[
  {
    "path": ".dockerignore",
    "content": "*~\n.dockerignore\n.hg\nDockerfile\n\n# Remove the git objects, logs, etc. to make final image smaller.\n# Some files still need to be in the .git directory, because Etherpad at\n# startup uses them to discover its version number.\n.git/branches\n.git/COMMIT_EDITMSG\n.git/config\n.git/description\n.git/FETCH_HEAD\n.git/hooks\n.git/index\n.git/info\n.git/logs\n.git/objects\n.git/ORIG_HEAD\n.git/packed-refs\n.git/refs/remotes/\n.git/rr-cache/\n.gitignore\n\nsettings.json\nsrc/node_modules\nadmin/node_modules\nui/node_modules\nnode_modules\n"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nindent_style = space\nindent_size = 2\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nend_of_line = lf\n# editorconfig-tools is unable to ignore longs strings or urls\nmax_line_length = off\n\n[CHANGELOG.md]\nindent_size = 4\n\n[*.bat]\nend_of_line = crlf\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: ether\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\n<!-- IMPORTANT: Please disable plugins prior to posting a bug report.  If you have a problem with a plugin please post on the plugin repository.  Thanks! -->\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\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**Server (please complete the following information):**\n - Etherpad version:\n - OS: [e.g., Ubuntu 20.04]\n - Node.js version (`node --version`):\n - npm version (`npm --version`):\n - Is the server free of plugins: \n\n**Desktop (please complete the following information):**\n - OS: [e.g. iOS]\n - Browser [e.g. chrome, safari]\n - Version [e.g. 22]\n\n**Smartphone (please complete the following information):**\n - Device: [e.g. iPhone6]\n - OS: [e.g. iOS8.1]\n - Browser [e.g. stock browser, safari]\n - Version [e.g. 22]\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: Feature Request\nassignees: ''\n\n---\n\n* * *\n\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: enhancement\nassignees: \n\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\n**Plugin?**\nMight this feature be better suited to being a plugin?  Usually features that can be plugins, should be.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/plugin-request-template.md",
    "content": "---\nname: Plugin request template\nabout: Suggest a plugin for Etherpad\ntitle: ''\nlabels: Plugin Request\nassignees: JohnMcLear\n\n---\n\n* * *\n\nname: Plugin request\nabout: Suggest a plugin for this project\ntitle: ''\nlabels: plugin request\nassignees: \n\n* * *\n\n**Is your plugin 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 plugin request here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/security-issue.md",
    "content": "---\nname: Security issue\nabout: Notify the Etherpad foundation of a Security issue\ntitle: ''\nlabels: security\nassignees: ''\n\n---\n\nPlease email contact@etherpad.org with details of the security issue prior to posting here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/security.md",
    "content": "* * *\n\nname: Security notification\nabout: Disclose a security issue in Etherpad\ntitle: ''\nlabels: security\nassignees: \n\n* * *\n\n**Our Security disclosure process**\n1. Please email contact@etherpad.org with detials of the exploit including steps to replicate.\n1. Once confirmed we will provide a confirmation, patch and CVE details.\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "<!--\n\n1. If you haven't already, please read https://github.com/ether/etherpad-lite/blob/master/CONTRIBUTING.md#pull-requests .\n2. Run all the tests, both front-end and back-end.  (see https://github.com/ether/etherpad-lite/blob/master/CONTRIBUTING.md#testing)\n3. Keep business logic and validation on the server-side.\n4. Update documentation.\n5. Write `fixes #XXXX` in your comment to auto-close an issue.\n\nIf you're making a big change, please explain what problem it solves:\n- Explain the purpose of the change.  When adding a way to do X, explain why it is important to be able to do X.\n- Show the current vs desired behavior with screenshots/GIFs.\n\n-->\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  # Maintain dependencies for GitHub Actions\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n  - package-ecosystem: \"docker\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n  - package-ecosystem: \"npm\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n    versioning-strategy: \"increase\"\n    open-pull-requests-limit: 30\n    groups:\n      dev-dependencies:\n        dependency-type: \"development\""
  },
  {
    "path": ".github/workflows/backend-tests.yml",
    "content": "name: \"Backend tests\"\n\n# any branch is useful for testing before a PR is submitted\non:\n  push:\n    paths-ignore:\n      - \"doc/**\"\n  pull_request:\n    paths-ignore:\n      - \"doc/**\"\n\npermissions:\n  contents: read\n\n\njobs:\n  withoutpluginsLinux:\n    env:\n      PNPM_HOME: ~/.pnpm-store\n    # run on pushes to any branch\n    # run on PRs from external forks\n    if: |\n      (github.event_name != 'pull_request')\n      || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)\n    name: Linux without plugins\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        node: [\">=20.0.0 <21.0.0\", \">=22.0.0 <23.0.0\", \">=24.0.0 <25.0.0\"]\n    steps:\n      -\n        name: Checkout repository\n        uses: actions/checkout@v6\n      - uses: actions/cache@v5\n        name: Setup gnpm cache\n        if: always()\n        with:\n          path: |\n             ${{ env.PNPM_HOME }}\n             ~/.local/share/gnpm\n             /usr/local/bin/gnpm\n             /usr/local/bin/gnpm-0.0.12\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n                  ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      -\n        name: Install libreoffice\n        uses: awalsh128/cache-apt-pkgs-action@v1.6.0\n        with:\n          packages: libreoffice libreoffice-pdfimport\n          version: 1.0\n      -\n        name: Install all dependencies and symlink for ep_etherpad-lite\n        run: gnpm i --frozen-lockfile --runtimeVersion=\"${{ matrix.node }}\"\n      - name: Install admin ui\n        working-directory: admin\n        run: gnpm install  --runtimeVersion=\"${{ matrix.node }}\"\n      - name: Build admin ui\n        working-directory: admin\n        run: gnpm build  --runtimeVersion=\"${{ matrix.node }}\"\n      -\n        name: Run the backend tests\n        run: gnpm test  --runtimeVersion=\"${{ matrix.node }}\"\n      - name: Run the new vitest tests\n        working-directory: src\n        run: gnpm run test:vitest  --runtimeVersion=\"${{ matrix.node }}\"\n\n  withpluginsLinux:\n    env:\n      PNPM_HOME: ~/.pnpm-store\n    # run on pushes to any branch\n    # run on PRs from external forks\n    if: |\n      (github.event_name != 'pull_request')\n      || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)\n    name: Linux with Plugins\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        node: [\">=20.0.0 <21.0.0\", \">=22.0.0 <23.0.0\", \">=24.0.0 <25.0.0\"]\n    steps:\n      -\n        name: Checkout repository\n        uses: actions/checkout@v6\n      - uses: actions/cache@v5\n        name: Setup pnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.PNPM_HOME }}\n            ~/.local/share/gnpm\n            /usr/local/bin/gnpm\n            /usr/local/bin/gnpm-0.0.12\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      -\n        name: Install libreoffice\n        uses: awalsh128/cache-apt-pkgs-action@v1.6.0\n        with:\n          packages: libreoffice libreoffice-pdfimport\n          version: 1.0\n      -\n        name: Install all dependencies and symlink for ep_etherpad-lite\n        run: gnpm install --frozen-lockfile --runtimeVersion=\"${{ matrix.node }}\"\n      - name: Build admin ui\n        working-directory: admin\n        run: gnpm build   --runtimeVersion=\"${{ matrix.node }}\"\n      -\n        name: Install Etherpad plugins\n        run: >\n          gnpm install --workspace-root\n          ep_align\n          ep_author_hover\n          ep_cursortrace\n          ep_font_size\n          ep_hash_auth\n          ep_headings2\n          ep_markdown\n          ep_readonly_guest\n          ep_set_title_on_pad\n          ep_spellcheck\n          ep_subscript_and_superscript\n          ep_table_of_contents   --runtimeVersion=\"${{ matrix.node }}\"\n      -\n        name: Run the backend tests\n        run: gnpm test   --runtimeVersion=\"${{ matrix.node }}\"\n      - name: Run the new vitest tests\n        working-directory: src\n        run: gnpm run test:vitest   --runtimeVersion=\"${{ matrix.node }}\"\n\n  withoutpluginsWindows:\n    env:\n      PNPM_HOME: ~\\\\.pnpm-store\n    # run on pushes to any branch\n    # run on PRs from external forks\n    if: |\n      (github.event_name != 'pull_request')\n      || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)\n    strategy:\n      fail-fast: false\n      matrix:\n        node: [\">=20.0.0 <21.0.0\", \">=22.0.0 <23.0.0\", \">=24.0.0 <25.0.0\"]\n    name: Windows without plugins\n    runs-on: windows-latest\n    steps:\n      -\n        name: Checkout repository\n        uses: actions/checkout@v6\n      - uses: actions/cache@v5\n        name: Setup pnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.PNPM_HOME }}\n            C:\\gnpm\\\n            C:\\Users\\runneradmin\\AppData\\Roaming\\gnpm\\\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      -\n        name: Install all dependencies and symlink for ep_etherpad-lite\n        run: gnpm install --frozen-lockfile   --runtimeVersion=\"${{ matrix.node }}\"\n      - name: Build admin ui\n        working-directory: admin\n        run: gnpm build   --runtimeVersion=\"${{ matrix.node }}\"\n      -\n        name:  Fix up the settings.json\n        run: |\n          powershell -Command \"(gc settings.json.template) -replace '\\\"max\\\": 10', '\\\"max\\\": 10000' | Out-File -encoding ASCII settings.json.holder\"\n          powershell -Command \"(gc settings.json.holder) -replace '\\\"points\\\": 10', '\\\"points\\\": 1000' | Out-File -encoding ASCII settings.json\"\n      -\n        name: Run the backend tests\n        working-directory: src\n        run: gnpm test  --runtimeVersion=\"${{ matrix.node }}\"\n      - name: Run the new vitest tests\n        working-directory: src\n        run: gnpm run test:vitest  --runtimeVersion=\"${{ matrix.node }}\"\n\n  withpluginsWindows:\n    env:\n      PNPM_HOME: ~\\\\.pnpm-store\n    # run on pushes to any branch\n    # run on PRs from external forks\n    if: |\n      (github.event_name != 'pull_request')\n      || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)\n    strategy:\n      fail-fast: false\n      matrix:\n        node: [\">=20.0.0 <21.0.0\", \">=22.0.0 <23.0.0\", \">=24.0.0 <25.0.0\"]\n    name: Windows with Plugins\n    runs-on: windows-latest\n\n    steps:\n      -\n        name: Checkout repository\n        uses: actions/checkout@v6\n      - uses: actions/cache@v5\n        name: Setup pnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.PNPM_HOME }}\n            C:\\gnpm\\\n            C:\\Users\\runneradmin\\AppData\\Roaming\\gnpm\\\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      - name: Install dependencies\n        run: gnpm install --runtimeVersion=\"${{ matrix.node }}\"\n      - name: Build admin ui\n        working-directory: admin\n        run: gnpm build --runtimeVersion=\"${{ matrix.node }}\"\n      -\n        name: Install Etherpad plugins\n        # The --legacy-peer-deps flag is required to work around a bug in npm\n        # v7: https://github.com/npm/cli/issues/2199\n        run: >\n          gnpm install --workspace-root\n          ep_align\n          ep_author_hover\n          ep_cursortrace\n          ep_font_size\n          ep_hash_auth\n          ep_headings2\n          ep_markdown\n          ep_readonly_guest\n          ep_set_title_on_pad\n          ep_spellcheck\n          ep_subscript_and_superscript\n          ep_table_of_contents   --runtimeVersion=\"${{ matrix.node }}\"\n      # Etherpad core dependencies must be installed after installing the\n      # plugin's dependencies, otherwise npm will try to hoist common\n      # dependencies by removing them from src/node_modules and installing them\n      # in the top-level node_modules. As of v6.14.10, npm's hoist logic appears\n      # to be buggy, because it sometimes removes dependencies from\n      # src/node_modules but fails to add them to the top-level node_modules.\n      # Even if npm correctly hoists the dependencies, the hoisting seems to\n      # confuse tools such as `npm outdated`, `npm update`, and some ESLint\n      # rules.\n      -\n        name: Install all dependencies and symlink for ep_etherpad-lite\n        run: gnpm install --frozen-lockfile   --runtimeVersion=\"${{ matrix.node }}\"\n      -\n        name:  Fix up the settings.json\n        run: |\n          powershell -Command \"(gc settings.json.template) -replace '\\\"max\\\": 10', '\\\"max\\\": 10000' | Out-File -encoding ASCII settings.json.holder\"\n          powershell -Command \"(gc settings.json.holder) -replace '\\\"points\\\": 10', '\\\"points\\\": 1000' | Out-File -encoding ASCII settings.json\"\n      -\n        name: Run the backend tests\n        working-directory: src\n        run: gnpm test   --runtimeVersion=\"${{ matrix.node }}\"\n      - name: Run the new vitest tests\n        working-directory: src\n        run: gnpm run test:vitest   --runtimeVersion=\"${{ matrix.node }}\"\n"
  },
  {
    "path": ".github/workflows/build-and-deploy-docs.yml",
    "content": "# Workflow for deploying static content to GitHub Pages\nname: Deploy Docs to GitHub Pages\n\non:\n  # Runs on pushes targeting the default branch\n  push:\n    branches: [\"develop\"]\n    paths:\n      - doc/** # Only run workflow when changes are made to the doc directory\n  # Allows you to run this workflow manually from the Actions tab\n  workflow_dispatch:\n\n# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n  packages: read\n\n# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.\n# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.\nconcurrency:\n  group: \"pages\"\n  cancel-in-progress: false\n\njobs:\n  # Single deploy job since we're just deploying\n  deploy:\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n      - uses: actions/cache@v5\n        name: Setup gnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.STORE_PATH }}\n            ~/.local/share/gnpm\n            /usr/local/bin/gnpm\n            /usr/local/bin/gnpm-0.0.12\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      - name: Setup Pages\n        uses: actions/configure-pages@v5\n      - name: Install dependencies\n        run: gnpm install\n      - name: Build app\n        working-directory: doc\n        run: gnpm run docs:build\n        env:\n          COMMIT_REF: ${{ github.sha }}\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@v4\n        with:\n          # Upload entire repository\n          path: './doc/.vitepress/dist'\n      - name: Deploy to GitHub Pages\n        id: deployment\n        uses: actions/deploy-pages@v4\n"
  },
  {
    "path": ".github/workflows/codeql-analysis.yml",
    "content": "name: \"CodeQL\"\n\non:\n  push:\n    branches: [develop, master]\n  pull_request:\n    # The branches below must be a subset of the branches above\n    branches: [develop]\n    paths-ignore:\n      - 'doc/**'\n  schedule:\n    - cron: '0 13 * * 1'\n\npermissions:\n  contents: read\n\njobs:\n  analyze:\n    permissions:\n      actions: read  # for github/codeql-action/init to get workflow details\n      contents: read  # for actions/checkout to fetch code\n      security-events: write  # for github/codeql-action/autobuild to send a status report\n    name: Analyze\n    runs-on: ubuntu-latest\n    steps:\n      -\n        name: Checkout repository\n        uses: actions/checkout@v6\n        with:\n          # We must fetch at least the immediate parents so that if this is\n          # a pull request then we can checkout the head.\n          fetch-depth: 2\n      # If this run was triggered by a pull request event, then checkout\n      # the head of the pull request instead of the merge commit.\n      -\n        run: git checkout HEAD^2\n        if: ${{ github.event_name == 'pull_request' }}\n      -\n        name: Initialize CodeQL\n        uses: github/codeql-action/init@v4\n      -\n        name: Autobuild\n        uses: github/codeql-action/autobuild@v4\n      -\n        name: Perform CodeQL Analysis\n        uses: github/codeql-action/analyze@v4\n"
  },
  {
    "path": ".github/workflows/dependency-review.yml",
    "content": "# Dependency Review Action\n#\n# This Action will scan dependency manifest files that change as part of a Pull Reqest, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging.\n#\n# Source repository: https://github.com/actions/dependency-review-action\n# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement\nname: 'Dependency Review'\non: [pull_request]\n\npermissions:\n  contents: read\n\njobs:\n  dependency-review:\n    runs-on: ubuntu-latest\n    steps:\n      - name: 'Checkout Repository'\n        uses: actions/checkout@v6\n      - name: 'Dependency Review'\n        uses: actions/dependency-review-action@v4\n"
  },
  {
    "path": ".github/workflows/docker.yml",
    "content": "name: \"Docker\"\non:\n  pull_request:\n    paths-ignore:\n      - 'doc/**'\n  push:\n    branches:\n      - 'develop'\n    paths-ignore:\n      - 'doc/**'\n    tags:\n      - 'v?[0-9]+.[0-9]+.[0-9]+'\nenv:\n  TEST_TAG: etherpad/etherpad:test\npermissions:\n  contents: read\n\njobs:\n  docker:\n    runs-on: ubuntu-latest\n    env:\n      PNPM_HOME: ~/.pnpm-store\n    steps:\n      -\n        name: Check out\n        uses: actions/checkout@v6\n        with:\n          path: etherpad\n\n      -\n        name: Set up QEMU\n        if: github.event_name == 'push'\n        uses: docker/setup-qemu-action@v4\n      -\n        name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n      -\n        name: Build and export to Docker\n        uses: docker/build-push-action@v7\n        with:\n          context: ./etherpad\n          target: production\n          load: true\n          tags: ${{ env.TEST_TAG }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n      - uses: actions/cache@v5\n        name: Setup gnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.PNPM_HOME }}\n            ~/.local/share/gnpm\n            /usr/local/bin/gnpm\n            /usr/local/bin/gnpm-0.0.12\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      -\n        name: Test\n        working-directory: etherpad\n        run: |\n          docker run --rm -d -p 9001:9001 --name test ${{ env.TEST_TAG }}\n          ./bin/installDeps.sh\n          docker logs -f test &\n          while true; do\n            echo \"Waiting for Docker container to start...\"\n            status=$(docker container inspect -f '{{.State.Health.Status}}' test) || exit 1\n            case ${status} in\n              healthy) break;;\n              starting) sleep 2;;\n              *) printf %s\\\\n \"unexpected status: ${status}\" >&2; exit 1;;\n            esac\n          done\n          (cd src && gnpm run test-container)\n          git clean -dxf .\n      -\n        name: Docker meta\n        if: github.event_name == 'push'\n        id: meta\n        uses: docker/metadata-action@v6\n        with:\n          images: etherpad/etherpad\n          tags: |\n            type=ref,event=branch\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}.{{minor}}\n            type=semver,pattern={{major}}\n      -\n        name: Log in to Docker Hub\n        if: github.event_name == 'push'\n        uses: docker/login-action@v4\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n      -\n        name: Build and push\n        id: build-docker\n        if: github.event_name == 'push'\n        uses: docker/build-push-action@v7\n        with:\n          context: ./etherpad\n          target: production\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n      - name: Update repo description\n        uses: peter-evans/dockerhub-description@v5\n        if: github.ref == 'refs/heads/master'\n        with:\n          readme-filepath: ./etherpad/README.md\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n          repository: etherpad/etherpad\n          enable-url-completion: true\n      - name: Check out\n        if: github.event_name == 'push' && github.ref == 'refs/heads/develop'\n        uses: actions/checkout@v6\n        with:\n          path: ether-charts\n          repository: ether/ether-charts\n          token: ${{ secrets.ETHER_CHART_TOKEN }}\n      - name: Update tag in values-dev.yaml\n        if: success() && github.ref == 'refs/heads/develop'\n        working-directory: ether-charts\n        run: |\n          sed -i 's/tag: \".*\"/tag: \"${{ steps.build-docker.outputs.digest }}\"/' values-dev.yaml\n      - name: Commit and push changes\n        working-directory: ether-charts\n        if: success() && github.ref == 'refs/heads/develop'\n        run: |\n          git config --global user.name 'github-actions[bot]'\n          git config --global user.email 'github-actions[bot]@users.noreply.github.com'\n          git add values-dev.yaml\n          git commit -m 'Update develop image tag'\n          git push\n"
  },
  {
    "path": ".github/workflows/frontend-admin-tests.yml",
    "content": "# Leave the powered by Sauce Labs bit in as this means we get additional concurrency\nname: \"Frontend admin tests\"\n\non:\n  push:\n    paths-ignore:\n      - 'doc/**'\n\npermissions:\n  contents: read # to fetch code (actions/checkout)\n\njobs:\n  withplugins:\n    env:\n      PNPM_HOME: ~/.pnpm-store\n    name: with plugins\n    runs-on: ubuntu-latest\n\n    strategy:\n      fail-fast: false\n      matrix:\n        node: [20, 22, 24]\n\n    steps:\n      -\n        name: Generate Sauce Labs strings\n        id: sauce_strings\n        run: |\n          printf %s\\\\n '::set-output name=name::${{ github.workflow }} - ${{ github.job }} - Node ${{ matrix.node }}'\n          printf %s\\\\n '::set-output name=tunnel_id::${{ github.run_id }}-${{ github.run_number }}-${{ github.job }}-node${{ matrix.node }}'\n      -\n        name: Checkout repository\n        uses: actions/checkout@v6\n      - uses: actions/cache@v5\n        name: Setup gnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.PNPM_HOME }}\n            ~/.local/share/gnpm\n            /usr/local/bin/gnpm\n            /usr/local/bin/gnpm-0.0.12\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      - name: Cache playwright binaries\n        uses: actions/cache@v5\n        id: playwright-cache\n        with:\n          path: |\n            ~/.cache/ms-playwright\n          key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}\n      #-\n      #  name: Install etherpad plugins\n      #  # We intentionally install an old ep_align version to test upgrades to\n      #  # the minor version number. The --legacy-peer-deps flag is required to\n      #  # work around a bug in npm v7: https://github.com/npm/cli/issues/2199\n      #  run: pnpm install --workspace-root ep_align@0.2.27\n      # Etherpad core dependencies must be installed after installing the\n      # plugin's dependencies, otherwise npm will try to hoist common\n      # dependencies by removing them from src/node_modules and installing them\n      # in the top-level node_modules. As of v6.14.10, npm's hoist logic appears\n      # to be buggy, because it sometimes removes dependencies from\n      # src/node_modules but fails to add them to the top-level node_modules.\n      # Even if npm correctly hoists the dependencies, the hoisting seems to\n      # confuse tools such as `npm outdated`, `npm update`, and some ESLint\n      # rules.\n      -\n        name: Install all dependencies and symlink for ep_etherpad-lite\n        run: gnpm i --runtimeVersion=\"${{ matrix.node }}\"\n      #-\n      #  name: Install etherpad plugins\n      #  run: rm -Rf node_modules/ep_align/static/tests/*\n      -\n        name: export GIT_HASH to env\n        id: environment\n        run: echo \"::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})\"\n      -\n        name: Create settings.json\n        run: cp settings.json.template settings.json\n      -\n        name: Write custom settings.json that enables the Admin UI tests\n        run: \"sed -i 's/\\\"enableAdminUITests\\\": false/\\\"enableAdminUITests\\\": true,\\\\n\\\"users\\\":{\\\"admin\\\":{\\\"password\\\":\\\"changeme1\\\",\\\"is_admin\\\":true}}/' settings.json\"\n      -\n        name: increase maxHttpBufferSize\n        run: \"sed -i 's/\\\"maxHttpBufferSize\\\": 50000/\\\"maxHttpBufferSize\\\": 10000000/' settings.json\"\n      -\n        name: Disable import/export rate limiting\n        run: |\n          sed -e '/^ *\"importExportRateLimiting\":/,/^ *\\}/ s/\"max\":.*/\"max\": 100000000/' -i settings.json\n      - name: Build admin frontend\n        working-directory: admin\n        run: |\n            gnpm run build --runtimeVersion=\"${{ matrix.node }}\"\n      #  name: Run the frontend admin tests\n      #  shell: bash\n      #  env:\n      #    SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }}\n      #    SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}\n      #    SAUCE_NAME: ${{ steps.sauce_strings.outputs.name }}\n      #    TRAVIS_JOB_NUMBER: ${{ steps.sauce_strings.outputs.tunnel_id }}\n      #   GIT_HASH: ${{ steps.environment.outputs.sha_short }}\n      #  run: |\n      #    src/tests/frontend/travis/adminrunner.sh\n      #-\n      #  uses: saucelabs/sauce-connect-action@v2.3.6\n      #  with:\n      #    username: ${{ secrets.SAUCE_USERNAME }}\n      #    accessKey: ${{ secrets.SAUCE_ACCESS_KEY }}\n      #    tunnelIdentifier: ${{ steps.sauce_strings.outputs.tunnel_id }}\n      #-\n      #  name: Run the frontend admin tests\n      #  shell: bash\n      #  env:\n      #    SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }}\n      #    SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}\n      #    SAUCE_NAME: ${{ steps.sauce_strings.outputs.name }}\n      #    TRAVIS_JOB_NUMBER: ${{ steps.sauce_strings.outputs.tunnel_id }}\n      #   GIT_HASH: ${{ steps.environment.outputs.sha_short }}\n      #  run: |\n      #    src/tests/frontend/travis/adminrunner.sh\n      - name: Run the frontend admin tests\n        shell: bash\n        run: |\n          gnpm run prod --runtimeVersion=\"${{ matrix.node }}\" &\n          connected=false\n          can_connect() {\n          curl -sSfo /dev/null http://localhost:9001/ || return 1\n          connected=true\n          }\n          now() { date +%s; }\n          start=$(now)\n          while [ $(($(now) - $start)) -le 15 ] && ! can_connect; do\n          sleep 1\n          done\n          cd src\n          gnpm exec playwright install --runtimeVersion=\"${{ matrix.node }}\"\n          gnpm exec playwright install-deps --runtimeVersion=\"${{ matrix.node }}\"\n          gnpm run test-admin --runtimeVersion=\"${{ matrix.node }}\"\n      - uses: actions/upload-artifact@v7\n        if: always()\n        with:\n          name: playwright-report-${{ matrix.node }}\n          path: src/playwright-report/\n          retention-days: 30\n"
  },
  {
    "path": ".github/workflows/frontend-tests.yml",
    "content": "# Leave the powered by Sauce Labs bit in as this means we get additional concurrency\nname: \"Frontend tests powered by Sauce Labs\"\n\non:\n  push:\n    paths-ignore:\n      - 'doc/**'\n\npermissions:\n  contents: read # to fetch code (actions/checkout)\n\njobs:\n  playwright-chrome:\n    env:\n      PNPM_HOME: ~/.pnpm-store\n    name: Playwright Chrome\n    runs-on: ubuntu-latest\n    steps:\n      -\n        name: Generate Sauce Labs strings\n        id: sauce_strings\n        run: |\n          printf %s\\\\n '::set-output name=name::${{ github.workflow }} - ${{ github.job }}'\n          printf %s\\\\n '::set-output name=tunnel_id::${{ github.run_id }}-${{ github.run_number }}-${{ github.job }}'\n      -\n        name: Checkout repository\n        uses: actions/checkout@v6\n      - uses: actions/cache@v5\n        name: Setup gnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.PNPM_HOME }}\n            ~/.cache/ms-playwright\n            ~/.local/share/gnpm\n            /usr/local/bin/gnpm\n            /usr/local/bin/gnpm-0.0.12\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      -\n        name: Install all dependencies and symlink for ep_etherpad-lite\n        run: gnpm install --frozen-lockfile\n      -\n        name: export GIT_HASH to env\n        id: environment\n        run: echo \"::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})\"\n      -\n        name: Create settings.json\n        run: cp ./src/tests/settings.json settings.json\n      - name: Run the frontend tests\n        shell: bash\n        run: |\n          gnpm run prod  &\n          connected=false\n          can_connect() {\n          curl -sSfo /dev/null http://localhost:9001/ || return 1\n          connected=true\n          }\n          now() { date +%s; }\n          start=$(now)\n          while [ $(($(now) - $start)) -le 15 ] && ! can_connect; do\n          sleep 1\n          done\n          cd src\n          gnpm exec playwright install chromium  --with-deps\n          gnpm run test-ui --project=chromium\n      - uses: actions/upload-artifact@v7\n        if: always()\n        with:\n          name: playwright-report-${{ matrix.node }}-chrome\n          path: src/playwright-report/\n          retention-days: 30\n  playwright-firefox:\n    env:\n        PNPM_HOME: ~/.pnpm-store\n    name: Playwright Firefox\n    runs-on: ubuntu-latest\n    steps:\n      - name: Generate Sauce Labs strings\n        id: sauce_strings\n        run: |\n          printf %s\\\\n '::set-output name=name::${{ github.workflow }} - ${{ github.job }}'\n          printf %s\\\\n '::set-output name=tunnel_id::${{ github.run_id }}-${{ github.run_number }}-${{ github.job }}'\n      - name: Checkout repository\n        uses: actions/checkout@v6\n      - uses: actions/cache@v5\n        name: Setup gnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.PNPM_HOME }}\n            ~/.local/share/gnpm\n            ~/.cache/ms-playwright\n            /usr/local/bin/gnpm\n            /usr/local/bin/gnpm-0.0.12\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      - name: Install all dependencies and symlink for ep_etherpad-lite\n        run: gnpm install --frozen-lockfile\n      - name: export GIT_HASH to env\n        id: environment\n        run: echo \"::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})\"\n      - name: Create settings.json\n        run: cp ./src/tests/settings.json settings.json\n      - name: Run the frontend tests\n        shell: bash\n        run: |\n          gnpm run prod &\n          connected=false\n          can_connect() {\n          curl -sSfo /dev/null http://localhost:9001/ || return 1\n          connected=true\n          }\n          now() { date +%s; }\n          start=$(now)\n          while [ $(($(now) - $start)) -le 15 ] && ! can_connect; do\n          sleep 1\n          done\n          cd src\n          gnpm exec playwright install firefox  --with-deps\n          gnpm run test-ui --project=firefox\n      - uses: actions/upload-artifact@v7\n        if: always()\n        with:\n          name: playwright-report-${{ matrix.node }}-firefox\n          path: src/playwright-report/\n          retention-days: 30\n  playwright-webkit:\n    name: Playwright Webkit\n    runs-on: ubuntu-latest\n    env:\n      PNPM_HOME: ~/.pnpm-store\n    steps:\n      -\n        name: Generate Sauce Labs strings\n        id: sauce_strings\n        run: |\n          printf %s\\\\n '::set-output name=name::${{ github.workflow }} - ${{ github.job }}'\n          printf %s\\\\n '::set-output name=tunnel_id::${{ github.run_id }}-${{ github.run_number }}-${{ github.job }}'\n      -\n        name: Checkout repository\n        uses: actions/checkout@v6\n      - uses: actions/cache@v5\n        name: Setup gnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.PNPM_HOME }}\n            ~/.local/share/gnpm\n            ~/.cache/ms-playwright\n            /usr/local/bin/gnpm\n            /usr/local/bin/gnpm-0.0.12\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      -\n        name: Install all dependencies and symlink for ep_etherpad-lite\n        run: gnpm install --frozen-lockfile\n      -\n        name: export GIT_HASH to env\n        id: environment\n        run: echo \"::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})\"\n      -\n        name: Create settings.json\n        run: cp ./src/tests/settings.json settings.json\n      - name: Run the frontend tests\n        shell: bash\n        run: |\n          gnpm run prod &\n          connected=false\n          can_connect() {\n          curl -sSfo /dev/null http://localhost:9001/ || return 1\n          connected=true\n          }\n          now() { date +%s; }\n          start=$(now)\n          while [ $(($(now) - $start)) -le 15 ] && ! can_connect; do\n          sleep 1\n          done\n          cd src\n          gnpm exec playwright install webkit --with-deps\n          gnpm run test-ui --project=webkit || true\n      - uses: actions/upload-artifact@v7\n        if: always()\n        with:\n          name: playwright-report-${{ matrix.node }}-webkit\n          path: src/playwright-report/\n          retention-days: 30\n\n\n\n"
  },
  {
    "path": ".github/workflows/handleRelease.yml",
    "content": "name: \"Handle release\"\n\n\non:\n  push:\n    tags:\n      - 'v*.*.*'\n  # allow manual triggering of the workflow\n  workflow_dispatch:\n\npermissions:\n  contents: read\n\nenv:\n  PNPM_HOME: ~/.pnpm-store\n\njobs:\n  create-release:\n    permissions: write-all\n    # run on pushes to any branch\n    # run on PRs from external forks\n    if: |\n      (github.event_name != 'pull_request')\n      || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)\n    name: Handle the release\n    runs-on: ubuntu-latest\n    steps:\n      -\n        name: Checkout repository\n        uses: actions/checkout@v6\n      - uses: actions/cache@v5\n        name: Setup gnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.STORE_PATH }}\n            ~/.local/share/gnpm\n            ~/.cache/ms-playwright\n            /usr/local/bin/gnpm\n            /usr/local/bin/gnpm-0.0.12\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      - name: Install all dependencies and symlink for ep_etherpad-lite\n        run: gnpm install --frozen-lockfile\n      - name: Build etherpad\n        run: gnpm run build:etherpad\n      # On release, create release\n      - name: Generate Changelog\n        working-directory: bin\n        run: gnpm run generateChangelog ${{ github.ref }} > ${{ github.workspace }}-CHANGELOG.txt\n      - name: Release\n        uses: softprops/action-gh-release@v2\n        if: ${{startsWith(github.ref, 'refs/tags/v') }}\n        with:\n          body_path: ${{ github.workspace }}-CHANGELOG.txt\n          make_latest: true\n"
  },
  {
    "path": ".github/workflows/load-test.yml",
    "content": "name: \"Loadtest\"\n\n# any branch is useful for testing before a PR is submitted\non:\n  push:\n    paths-ignore:\n      - \"doc/**\"\n  pull_request:\n    paths-ignore:\n      - \"doc/**\"\n\npermissions:\n  contents: read\n\nenv:\n  PNPM_HOME: ~/.pnpm-store\n  LOG_LEVEL: DEBUG\n\njobs:\n  withoutplugins:\n    # run on pushes to any branch\n    # run on PRs from external forks\n    if: |\n      (github.event_name != 'pull_request')\n      || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)\n    name: without plugins\n    runs-on: ubuntu-latest\n    steps:\n      -\n        name: Checkout repository\n        uses: actions/checkout@v6\n      - uses: actions/cache@v5\n        name: Setup gnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.STORE_PATH }}\n            ~/.local/share/gnpm\n            ~/.cache/ms-playwright\n            /usr/local/bin/gnpm\n            /usr/local/bin/gnpm-0.0.12\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      -\n        name: Install all dependencies and symlink for ep_etherpad-lite\n        run: gnpm install --frozen-lockfile\n      -\n        name: Install etherpad-load-test\n        run: sudo npm install -g etherpad-load-test-socket-io\n      -\n        name: Run load test\n        run: |\n          gnpm --gnpmEnv\n          eval \"$(gnpm --gnpmEnv)\"\n          echo $PATH\n          src/tests/frontend/travis/runnerLoadTest.sh 25 50\n\n  withplugins:\n    # run on pushes to any branch\n    # run on PRs from external forks\n    if: |\n      (github.event_name != 'pull_request')\n      || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)\n    name: with Plugins\n    runs-on: ubuntu-latest\n    steps:\n      -\n        name: Checkout repository\n        uses: actions/checkout@v6\n      - uses: actions/cache@v5\n        name: Setup gnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.STORE_PATH }}\n            ~/.local/share/gnpm\n            ~/.cache/ms-playwright\n            /usr/local/bin/gnpm\n            /usr/local/bin/gnpm-0.0.12\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      -\n        name: Install etherpad-load-test\n        run:  sudo npm install -g etherpad-load-test-socket-io\n      -\n        name: Install etherpad plugins\n        # The --legacy-peer-deps flag is required to work around a bug in npm v7:\n        # https://github.com/npm/cli/issues/2199\n        run: >\n          gnpm install --workspace-root\n          ep_align\n          ep_author_hover\n          ep_cursortrace\n          ep_font_size\n          ep_hash_auth\n          ep_headings2\n          ep_markdown\n          ep_readonly_guest\n          ep_set_title_on_pad\n          ep_spellcheck\n          ep_subscript_and_superscript\n          ep_table_of_contents\n      # Etherpad core dependencies must be installed after installing the\n      # plugin's dependencies, otherwise npm will try to hoist common\n      # dependencies by removing them from src/node_modules and installing them\n      # in the top-level node_modules. As of v6.14.10, npm's hoist logic appears\n      # to be buggy, because it sometimes removes dependencies from\n      # src/node_modules but fails to add them to the top-level node_modules.\n      # Even if npm correctly hoists the dependencies, the hoisting seems to\n      # confuse tools such as `npm outdated`, `npm update`, and some ESLint\n      # rules.\n      -\n        name: Install all dependencies and symlink for ep_etherpad-lite\n        run: gnpm install --frozen-lockfile\n      -\n        name: Run load test\n        run: |\n          eval \"$(gnpm --gnpmEnv)\"\n          src/tests/frontend/travis/runnerLoadTest.sh 25 50\n\n  long:\n    # run on pushes to any branch\n    # run on PRs from external forks\n    if: |\n      (github.event_name != 'pull_request')\n      || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)\n    name: long running\n    runs-on: ubuntu-latest\n    steps:\n      -\n        name: Checkout repository\n        uses: actions/checkout@v6\n      - uses: actions/cache@v5\n        name: Setup gnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.STORE_PATH }}\n            ~/.local/share/gnpm\n            ~/.cache/ms-playwright\n            /usr/local/bin/gnpm\n            /usr/local/bin/gnpm-0.0.12\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      -\n        name: Install all dependencies and symlink for ep_etherpad-lite\n        run: gnpm install --frozen-lockfile\n      -\n        name: Install etherpad-load-test\n        run: sudo npm install -g etherpad-load-test-socket-io\n      -\n        name: Run load test\n        run: |\n          gnpm --gnpmEnv\n          eval \"$(gnpm --gnpmEnv)\"\n          echo $PATH\n          src/tests/frontend/travis/runnerLoadTest.sh 5000 5\n"
  },
  {
    "path": ".github/workflows/perform-type-check.yml",
    "content": "name: \"Perform type checks\"\n\n# any branch is useful for testing before a PR is submitted\non:\n  push:\n    paths-ignore:\n      - \"doc/**\"\n  pull_request:\n    paths-ignore:\n      - \"doc/**\"\n\npermissions:\n  contents: read\n\nenv:\n  PNPM_HOME: ~/.pnpm-store\n\njobs:\n  performTypeCheck:\n    if: |\n      (github.event_name != 'pull_request')\n      || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)\n    name: perform type check\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n      - uses: actions/cache@v5\n        name: Setup gnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.STORE_PATH }}\n            ~/.local/share/gnpm\n            ~/.cache/ms-playwright\n            /usr/local/bin/gnpm\n            /usr/local/bin/gnpm-0.0.12\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      -\n        name: Install all dependencies and symlink for ep_etherpad-lite\n        run: gnpm install --frozen-lockfile\n      - name: Perform type check\n        working-directory: ./src\n        run: gnpm run ts-check\n"
  },
  {
    "path": ".github/workflows/rate-limit.yml",
    "content": "name: \"rate limit\"\n\n# any branch is useful for testing before a PR is submitted\non:\n  push:\n    paths-ignore:\n      - \"doc/**\"\n  pull_request:\n    paths-ignore:\n      - \"doc/**\"\n\npermissions:\n  contents: read\n\nenv:\n  PNPM_HOME: ~/.pnpm-store\n\njobs:\n  ratelimit:\n    # run on pushes to any branch\n    # run on PRs from external forks\n    if: |\n      (github.event_name != 'pull_request')\n      || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)\n    name: test\n    runs-on: ubuntu-latest\n    steps:\n      -\n        name: Checkout repository\n        uses: actions/checkout@v6\n      - uses: actions/cache@v5\n        name: Setup gnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.STORE_PATH }}\n            ~/.local/share/gnpm\n            ~/.cache/ms-playwright\n            /usr/local/bin/gnpm\n            /usr/local/bin/gnpm-0.0.12\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n\n      -\n        name: docker network\n        run: docker network create --subnet=172.23.0.0/16 ep_net\n      -\n        name: build docker image\n        run: |\n          docker build -f Dockerfile -t epl-debian-slim --build-arg NODE_ENV=develop .\n          docker build -f src/tests/ratelimit/Dockerfile.nginx -t nginx-latest .\n          docker build -f src/tests/ratelimit/Dockerfile.anotherip -t anotherip .\n      -\n        name: run docker images\n        run: |\n          docker run --name etherpad-docker -p 9000:9001 --rm --network ep_net --ip 172.23.42.2 -e 'TRUST_PROXY=true' epl-debian-slim &\n          docker run -p 8081:80 --rm --network ep_net --ip 172.23.42.1 -d nginx-latest\n          docker run --rm --network ep_net --ip 172.23.42.3 --name anotherip -dt anotherip\n      -\n        name: install dependencies and create symlink for ep_etherpad-lite\n        run: gnpm install --frozen-lockfile\n      -\n        name: run rate limit test\n        run: |\n          cd src/tests/ratelimit\n          ./testlimits.sh\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "permissions:\n  contents: read\nname: Release etherpad\non:\n  workflow_dispatch:\n    inputs:\n      release_type:\n        description: 'Choose the type of release to create'\n        required: true\n        default: 'patch'\n        type: choice\n        options:\n          - patch\n          - minor\n          - major\n\nenv:\n  PNPM_HOME: ~/.pnpm-store\n\njobs:\n  releases:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n        with:\n            repository: ether/etherpad-lite\n            path: etherpad\n            token: '${{ secrets.ETHER_RELEASE_TOKEN }}'\n            fetch-depth: '0'\n            fetch-tags: 'true'\n      - name: Checkout master\n        working-directory: etherpad\n        run: |\n          git fetch origin master\n          git checkout master\n          git reset --hard origin/master\n      - name: Checkout develop\n        working-directory: etherpad\n        run: |\n          git fetch origin develop\n          git checkout develop\n          git reset --hard origin/develop\n      - name: Checkout repository\n        uses: actions/checkout@v6\n        with:\n          repository: ether/ether.github.com\n          path: ether.github.com\n          token: '${{ secrets.ETHER_RELEASE_TOKEN }}'\n      - uses: actions/cache@v5\n        name: Setup gnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.STORE_PATH }}\n            ~/.local/share/gnpm\n            ~/.cache/ms-playwright\n            /usr/local/bin/gnpm\n            /usr/local/bin/gnpm-0.0.12\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      - name: Install dependencies ether.github.com\n        run: gnpm install --frozen-lockfile\n        working-directory: ether.github.com\n      - name: Set git user\n        run: |\n          git config --global user.name \"Etherpad Release Bot\"\n          git config --global user.email \"noreply@etherpad.org\"\n      - uses: ruby/setup-ruby@v1\n        with:\n          ruby-version: 2.7\n\n      - uses: reitzig/actions-asciidoctor@v2.0.4\n        with:\n          version: 2.0.18\n      - name: Prepare release\n        working-directory: etherpad\n        run: |\n          cd bin\n          gnpm install\n          gnpm run release ${{ inputs.release_type }}\n      - name: Push after release\n        working-directory: etherpad\n        run: |\n          ./bin/push-after-release.sh\n"
  },
  {
    "path": ".github/workflows/releaseEtherpad.yml",
    "content": "name: releaseEtherpad.yaml\npermissions:\n  contents: read\non:\n  workflow_dispatch:\n\nenv:\n  PNPM_HOME: ~/.pnpm-store\n\njobs:\n  release:\n    runs-on: ubuntu-latest\n    steps:\n        - name: Checkout repository\n          uses: actions/checkout@v6\n        - name: Get pnpm store directory\n          shell: bash\n          run: |\n            echo \"STORE_PATH=$(pnpm store path --silent)\" >> $GITHUB_ENV\n        - uses: actions/cache@v5\n          name: Setup pnpm cache\n          if: always()\n          with:\n            path: |\n              ${{ env.STORE_PATH }}\n              ~/.local/share/gnpm\n              /usr/local/bin/gnpm\n              /usr/local/bin/gnpm-0.0.12\n            key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n            restore-keys: |\n              ${{ runner.os }}-gnpm-store-\n        - name: Setup gnpm\n          uses: SamTV12345/gnpm-setup@main\n          with:\n            version: 0.0.12\n        - name: Install dependencies\n          run: gnpm install --frozen-lockfile\n        - name: Rename etherpad\n          working-directory: ./src\n          run: sed -i 's/ep_etherpad-lite/ep_etherpad/g' package.json\n        - name: Release to npm\n          run: gnpm publish --no-git-checks\n          working-directory: ./src\n          env:\n            NODE_AUTH_TOKEN: ${{ secrets.NPM_PRIVATE_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/stale.yml",
    "content": "name: 'Close stale issues and PRs'\non:\n  schedule:\n    - cron: '30 6 * * *'\npermissions:\n  issues: write\n  pull-requests: write\njobs:\n  stale:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/stale@v10\n        with:\n          close-issue-label: wontfix\n          close-pr-label: wontfix\n          days-before-close: -1\n          exempt-issue-labels: 'pinned,security,Bug,Serious Bug,Minor bug,Black hole bug,Special case Bug,Upstream bug,Feature Request'\n          exempt-pr-labels: 'pinned,security,Bug,Serious Bug,Minor bug,Black hole bug,Special case Bug,Upstream bug,Feature Request'\n"
  },
  {
    "path": ".github/workflows/upgrade-from-latest-release.yml",
    "content": "name: \"Upgrade from latest release\"\n\n# any branch is useful for testing before a PR is submitted\non:\n  push:\n    paths-ignore:\n      - \"doc/**\"\n  pull_request:\n    paths-ignore:\n      - \"doc/**\"\n\npermissions:\n  contents: read\n\nenv:\n  PNPM_HOME: ~/.pnpm-store\n\njobs:\n  withpluginsLinux:\n    # run on pushes to any branch\n    # run on PRs from external forks\n    if: |\n      (github.event_name != 'pull_request')\n      || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)\n    name: Linux with Plugins\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        node: [20, 22, 24]\n    steps:\n      -\n        name: Check out latest release\n        uses: actions/checkout@v6\n        with:\n          ref: develop #FIXME change to master when doing release\n      - uses: actions/cache@v5\n        name: Setup gnpm cache\n        if: always()\n        with:\n          path: |\n            ${{ env.STORE_PATH }}\n            ~/.local/share/gnpm\n            ~/.cache/ms-playwright\n            /usr/local/bin/gnpm\n            /usr/local/bin/gnpm-0.0.12\n          key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-gnpm-store-\n      - name: Setup gnpm\n        uses: SamTV12345/gnpm-setup@main\n        with:\n          version: 0.0.12\n      - name: Install libreoffice\n        uses: awalsh128/cache-apt-pkgs-action@v1.6.0\n        with:\n          packages: libreoffice libreoffice-pdfimport\n          version: 1.0\n      -\n        name: Install libreoffice\n        uses: awalsh128/cache-apt-pkgs-action@v1.6.0\n        with:\n          packages: libreoffice libreoffice-pdfimport\n          version: 1.0\n      -\n        name: Install all dependencies and symlink for ep_etherpad-lite\n        run: gnpm install --frozen-lockfile --runtimeVersion=\"${{ matrix.node }}\"\n      - name: Build admin ui\n        working-directory: admin\n        run: gnpm build --runtimeVersion=\"${{ matrix.node }}\"\n      -\n        name: Install Etherpad plugins\n        run: >\n          gnpm run install-plugins\n          ep_align\n          ep_author_hover\n          ep_cursortrace\n          ep_font_size\n          ep_hash_auth\n          ep_headings2\n          ep_markdown\n          ep_readonly_guest\n          ep_set_title_on_pad\n          ep_spellcheck\n          ep_subscript_and_superscript\n          ep_table_of_contents --runtimeVersion=\"${{ matrix.node }}\"\n      -\n        name: Run the backend tests\n        run: gnpm run test --runtimeVersion=\"${{ matrix.node }}\"\n      -\n        name: Install all dependencies and symlink for ep_etherpad-lite\n        run: gnpm install --frozen-lockfile --runtimeVersion=\"${{ matrix.node }}\"\n      # Because actions/checkout@v6 is called with \"ref: master\" and without\n      # \"fetch-depth: 0\", the local clone does not have the ${GITHUB_SHA}\n      # commit. Fetch ${GITHUB_REF} to get the ${GITHUB_SHA} commit. Note that a\n      # plain \"git fetch\" only fetches \"normal\" references (refs/heads/* and\n      # refs/tags/*), and for pull requests none of the normal references\n      # include ${GITHUB_SHA}, so we have to explicitly tell Git to fetch\n      # ${GITHUB_REF}.\n      -\n        name: Fetch the new Git commits\n        run: git fetch --depth=1 origin \"${GITHUB_REF}\"\n      -\n        name: Upgrade to the new Git revision\n        # For pull requests, ${GITHUB_SHA} is the automatically generated merge\n        # commit that merges the PR's source branch to its destination branch.\n        run: git checkout \"${GITHUB_SHA}\"\n      - name: Run the backend tests\n        run: gnpm run test --runtimeVersion=\"${{ matrix.node }}\"\n"
  },
  {
    "path": ".gitignore",
    "content": "/etherpad-win.exe\n/etherpad-win.zip\nnode_modules\n/settings.json\n!settings.json.template\nAPIKEY.txt\nSESSIONKEY.txt\nvar/dirty.db\n.env\n*~\n*.patch\nnpm-debug.log\n*.DS_Store\n*.crt\n*.key\ncredentials.json\nout/\n.nyc_output\n.idea\n/package-lock.json\n/src/bin/abiword.exe\n/src/bin/convertSettings.json\n/src/bin/etherpad-1.deb\n/src/bin/node.exe\nplugin_packages\n/src/templates/admin\n/src/test-results\nplaywright-report\nstate.json\n/src/static/oidc\n"
  },
  {
    "path": ".lgtm.yml",
    "content": "extraction:\n  javascript:\n    index:\n      exclude:\n        - src/static/js/vendors\n  python:\n    index:\n      exclude:\n        - /\n"
  },
  {
    "path": ".pr_agent.toml",
    "content": "[pr_reviewer]\nrun_on_pr_sync = true\n\n[pr_description]\nrun_on_pr_sync = true\n"
  },
  {
    "path": ".travis.yml",
    "content": "language: node_js\n\nnode_js:\n  - \"lts/*\"\n\nservices:\n  - docker\n\ncache: false\n\nenv:\n  global:\n    - secure: \"WMGxFkOeTTlhWB+ChMucRtIqVmMbwzYdNHuHQjKCcj8HBEPdZLfCuK/kf4rG\\nVLcLQiIsyllqzNhBGVHG1nyqWr0/LTm8JRqSCDDVIhpyzp9KpCJQQJG2Uwjk\\n6/HIJJh/wbxsEdLNV2crYU/EiVO3A4Bq0YTHUlbhUqG3mSCr5Ec=\"\n    - secure: \"gejXUAHYscbR6Bodw35XexpToqWkv2ifeECsbeEmjaLkYzXmUUNWJGknKSu7\\nEUsSfQV8w+hxApr1Z+jNqk9aX3K1I4btL3cwk2trnNI8XRAvu1c1Iv60eerI\\nkE82Rsd5lwUaMEh+/HoL8ztFCZamVndoNgX7HWp5J/NRZZMmh4g=\"\n\n_set_loglevel_warn: &set_loglevel_warn |\n  sed -e 's/\"loglevel\":[^,]*/\"loglevel\": \"WARN\"/' \\\n      settings.json.template >settings.json.template.new &&\n  mv settings.json.template.new settings.json.template\n\n_enable_admin_tests: &enable_admin_tests |\nsed -e 's/\"enableAdminUITests\": false/\"enableAdminUITests\": true,\\n\"users\":{\"admin\":{\"password\":\"changeme\",\"is_admin\":true}}/' \\\n      settings.json.template >settings.json.template.new &&\n  mv settings.json.template.new settings.json.template\n\n_install_libreoffice: &install_libreoffice >-\n  sudo add-apt-repository -y ppa:libreoffice/ppa &&\n  sudo apt-get update &&\n  sudo apt-get -y install libreoffice libreoffice-pdfimport\n\n# The --legacy-peer-deps flag is required to work around a bug in npm v7:\n# https://github.com/npm/cli/issues/2199\n_install_plugins: &install_plugins >-\n  npm install --no-save --legacy-peer-deps\n  ep_align\n  ep_author_hover\n  ep_cursortrace\n  ep_font_size\n  ep_hash_auth\n  ep_headings2\n  ep_markdown\n  ep_readonly_guest\n  ep_spellcheck\n  ep_subscript_and_superscript\n  ep_table_of_contents\n  ep_set_title_on_pad\n\njobs:\n  include:\n    # we can only frontend tests from the ether/ organization and not from forks.\n    # To request tests to be run ask a maintainer to fork your repo to ether/\n    - if: fork = false\n      name: \"Test the Frontend without Plugins\"\n      install:\n        - *set_loglevel_warn\n        - *enable_admin_tests\n        - \"src/tests/frontend/travis/sauce_tunnel.sh\"\n        - \"bin/installDeps.sh\"\n        - \"export GIT_HASH=$(git rev-parse --verify --short HEAD)\"\n      script:\n        - \"./src/tests/frontend/travis/runner.sh\"\n    - name: \"Run the Backend tests without Plugins\"\n      install:\n        - *install_libreoffice\n        - *set_loglevel_warn\n        - \"bin/installDeps.sh\"\n        - \"cd src && pnpm install && cd -\"\n      script:\n        - \"cd src && pnpm test\"\n    - name: \"Test the Dockerfile\"\n      install:\n        - \"cd src && pnpm install && cd -\"\n      script:\n        - \"docker build -t etherpad:test .\"\n        - \"docker run -d -p 9001:9001 etherpad:test && sleep 3\"\n        - \"cd src && pnpm run test-container\"\n    - name: \"Load test Etherpad without Plugins\"\n      install:\n        - *set_loglevel_warn\n        - \"bin/installDeps.sh\"\n        - \"cd src && pnpm install && cd -\"\n        - \"npm install -g etherpad-load-test\"\n      script:\n        - \"src/tests/frontend/travis/runnerLoadTest.sh\"\n    # we can only frontend tests from the ether/ organization and not from forks.\n    # To request tests to be run ask a maintainer to fork your repo to ether/\n    - if: fork = false\n      name: \"Test the Frontend Plugins only\"\n      install:\n        - *set_loglevel_warn\n        - *enable_admin_tests\n        - \"src/tests/frontend/travis/sauce_tunnel.sh\"\n        - \"bin/installDeps.sh\"\n        - \"rm src/tests/frontend/specs/*\"\n        - *install_plugins\n        - \"export GIT_HASH=$(git rev-parse --verify --short HEAD)\"\n      script:\n        - \"./src/tests/frontend/travis/runner.sh\"\n    - name: \"Lint test package-lock.json\"\n      install:\n        - \"npm install lockfile-lint\"\n      script:\n        - npx lockfile-lint --path src/package-lock.json --validate-https --allowed-hosts npm\n    - name: \"Run the Backend tests with Plugins\"\n      install:\n        - *install_libreoffice\n        - *set_loglevel_warn\n        - \"bin/installDeps.sh\"\n        - *install_plugins\n        - \"cd src && pnpm install && cd -\"\n      script:\n        - \"cd src && pnpm test\"\n    - name: \"Test the Dockerfile\"\n      install:\n        - \"cd src && pnpm install && cd -\"\n      script:\n        - \"docker build -t etherpad:test .\"\n        - \"docker run -d -p 9001:9001 etherpad:test && sleep 3\"\n        - \"cd src && pnpm run test-container\"\n    - name: \"Load test Etherpad with Plugins\"\n      install:\n        - *set_loglevel_warn\n        - \"bin/installDeps.sh\"\n        - *install_plugins\n        - \"cd src && npm install && cd -\"\n        - \"npm install -g etherpad-load-test\"\n      script:\n        - \"src/tests/frontend/travis/runnerLoadTest.sh\"\n    - name: \"Test rate limit\"\n      install:\n        - \"docker network create --subnet=172.23.42.0/16 ep_net\"\n        - \"docker build -f Dockerfile -t epl-debian-slim .\"\n        - \"docker build -f src/tests/ratelimit/Dockerfile.nginx -t nginx-latest .\"\n        - \"docker build -f src/tests/ratelimit/Dockerfile.anotherip -t anotherip .\"\n        - \"docker run -p 8081:80 --rm --network ep_net --ip 172.23.42.1 -d nginx-latest\"\n        - \"docker run --name etherpad-docker -p 9000:9001 --rm --network ep_net --ip 172.23.42.2 -e 'TRUST_PROXY=true' epl-debian-slim &\"\n        - \"docker run --rm --network ep_net --ip 172.23.42.3 --name anotherip -dt anotherip\"\n        - \"./bin/installDeps.sh\"\n      script:\n        - \"cd src/tests/ratelimit && bash testlimits.sh\"\n\nnotifications:\n  irc:\n    channels:\n      - \"irc.freenode.org#etherpad-lite-dev\"\n"
  },
  {
    "path": "AGENTS.MD",
    "content": "# Agent Guide - Etherpad\n\nWelcome to the Etherpad project. This guide provides essential context and instructions for AI agents and developers to effectively contribute to the codebase.\n\n## Project Overview\nEtherpad is a real-time collaborative editor designed to be lightweight, scalable, and highly extensible via plugins.\n\n## Technical Stack\n- **Runtime:** Node.js\n- **Package Manager:** pnpm\n- **Languages:** TypeScript (primary), JavaScript, CSS, HTML\n- **Backend:** Express.js, Socket.io\n- **Frontend:** Legacy core (`src/static`), New UI (`ui/`), Admin UI (`admin/`)\n- **Build Tools:** Vite (for `ui` and `admin`), custom scripts in `bin/`\n- **Testing:** Mocha (Backend), Vitest, Playwright, custom backend test suite\n\n## Directory Structure\n- `src/node/`: Backend logic, API, and database abstraction.\n- `src/static/`: Core frontend logic (Legacy).\n- `ui/`: Modern React-based user interface.\n- `admin/`: Modern React-based administration interface.\n- `bin/`: Utility and lifecycle scripts.\n- `doc/`: Documentation in Markdown and Adoc formats.\n- `src/tests/`: Test suites (backend, frontend, UI, admin).\n- `var/`: Runtime data (logs, dirtyDB, etc. - ignored by git).\n- `local_plugins/`: Directory for developing and testing plugins locally.\n\n## Core Mandates & Conventions\n\n### Coding Style\n- **Indentation:** 2 spaces for all files (JS/TS/CSS/HTML).\n- **No Tabs:** Use spaces only.\n- **Comments:** Provide clear comments for complex logic.\n- **Backward Compatibility:** Always ensure compatibility with older versions of the database and configuration files.\n\n### Development Workflow\n- **Branching:** Work in feature branches. Issue PRs against the `develop` branch.\n- **Commits:** Maintain a linear history (no merge commits). Use meaningful messages in the format: `submodule: description`.\n- **Feature Flags:** New features should be placed behind feature flags and disabled by default.\n- **Deprecation:** Never remove features abruptly; deprecate them first with a `WARN` log.\n\n### Testing & Validation\n- **Requirement:** Every bug fix MUST include a regression test in the same commit.\n- **Backend Tests:** Run `pnpm --filter ep_etherpad-lite run test` from the root.\n- **Frontend Tests:** Accessible at `/tests/frontend` on a running instance.\n- **Linting:** Run `pnpm run lint` to ensure style compliance.\n- **Build:** Run `pnpm run build:etherpad` before production deployment.\n\n## Key Concepts\n\n### Easysync\nThe real-time synchronization engine. It is complex; refer to `doc/public/easysync/` before modifying core synchronization logic.\n\n### Plugin Framework\nMost functionality should be implemented as plugins (`ep_...`). Avoid modifying the core unless absolutely necessary.\n\n### Settings\nConfigured via `settings.json`. A template is available at `settings.json.template`. Environment variables can override any setting using `\"${ENV_VAR}\"` or `\"${ENV_VAR:default_value}\"`.\n\n## AI-Specific Guidance\nAI/Agent contributions are explicitly welcomed by the maintainers, provided they strictly adhere to the guidelines in `CONTRIBUTING.md` and this guide. Always prioritize stability, readability, and compatibility.\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# 2.6.1\n\nFor those wondering where the new updates are and why it was very quite throughout the last 1 1/2 years – I've been working on a new implementation of Etherpad from scratch in Go. It's called Etherpad-Go and you can find it here: `https://github.com/ether/etherpad-go`\nand a short FAQ about it here: https://github.com/ether/etherpad-go/wiki/FAQ . I'd love to hear your feedback about it either on Discord or issue tracker. There is a README.md that explains how to get started and try it out and also the FAQ can be quite fruitful. Latest release can be found here: https://github.com/ether/etherpad-go/releases/tag/v0.0.4\n\n\n### Notable enhancements and fixes of this release\n\n- Minor fixes and improvements to the session transfer feature introduced in 2.6.0\n- Dependencies upgrades\n\n\n# 2.6.0\n\n### Notable enhancements and fixes\n\n- Added native option to transfer your Etherpad session between browsers. If you use multiple browsers or different PC for Etherpad they are different sessions. Meaning typing on one PC and then switching to another one in the same pad will result in different authorship colors. With this new feature you can now transfer your session to another browser or PC. To do so, open the home page and click on the wheel icon in the top right corner. After that click through the first dialog prompting you to copy a code to your clipboard. On your second browser open the same dialog and switch to \"Receive Session\" tab. There you can paste the code you copied before and click on \"Receive Session\". After that your session is transferred, and you can continue editing with the same authorship color as before. Just be aware that you can't have two active sessions at once in a pad.\n- Updated to oidc provider v2.6.0 after resolving compatibility issues.\n\n🎉 For all the people celebrating: Have a happy and awesome new year! 🎉 There is something big on the horizon for Etherpad in 2026. Stay tuned!\n\n# 2.5.3\n\n### Notable enhancements and fixes\n- Fixed an issue with the release script that caused the release to not be created correctly.\n\n# 2.5.2\n\n### Notable enhancements and fixes\n\n- Fixes the no skin theme having an overlapping\n- Adds a new setting to disable recent pads to be shown. By setting `showRecentPads` to false in the `settings.json` file you can disable the recent pads feature on the home screen.\n- Sets the oidc-provider version to 9.5.1 as 9.5.2 crashes Etherpad on startup.\n\n# 2.5.1\n\n### Notable enhancements and fixes\n\n- Added endpoint for prometheus scraping. You can now scrape the metrics endpoint with prometheus. It is available at /stats/prometheus if you have enableMetrics set to true in your settings.json\n\n- fixed exposeVersion causing the pad panel to not load correctly\n- fixed admin manage pad url to also take the base path into account\n\n# 2.5.0\n\n### Notable enhancements and fixes\n\n- Updated to express 5.0.0. This is a major update to express that brings a lot of improvements and fixes. Please update all your plugins to the latest version to ensure compatibility. A lot changed in the route matching, and thus old plugins will throw errors and crash Etherpad.\n- Fixed an issue with the no-skin theme with cookie recentPadList feature\n- Fixed layout issues with the no-skin theme\n\n# 2.4.2\n\n### Notable enhancements and fixes\n- Fixed a german translation in the english translation file.\n\n# 2.4.1\n\n### Notable enhancements and fixes\n\n- Added generating release through ci cd pipeline.\n- Readded temporarily disabled workflows after release generation works again\n\n# 2.4.0\n\n### Notable enhancements and fixes\n- Added home button to the pad panel. To show it in your current instance, please copy the updated \"toolbar\" settings from the `settings.json.template` file to your `settings.json` file.\n- Added more current design for the default collibri theme.\n- Added handling of recent visited pads in the collibri theme. You can now access the most recent three pads you visited in the pad panel.\n- Disable stats endpoints if enableMetrics is set to false. This allows you to disable the metrics endpoints if you don't want to use them.\n- Use Node LTS instead of always latest Node version.\n\n\n# 2.3.2\n\n### Notable enhancements and fixes\n\n- Fixed admin ui displaying incorrect text\n\n# 2.3.1\n\n### Notable enhancements and fixes\n\n- Dependency updates\n\n# 2.3.0\n\n### Notable enhancements and fixes\n\n- Added possibility to cluster Etherpads behind reverse proxy. There is now a new reverse proxy designed for Etherpads that handles multiple Etherpads and the created pads in them. It will assign the pad assignement to an Etherpad at random but once the choice was made it will always reverse proxy the same backend. This allows to host multiple concurrent Etherpads and benefit from multi core systems even though one Etherpad is singlethreaded.\n- Added reverse proxy configuration for replacing Nginx. In the past there were some issues with nginx and its configuration. This reverse proxy allows you to handle your configuration with ease.\n\nIf you want to find out more about the reverse proxy method check out the repository https://github.com/ether/etherpad-proxy . It also contains a sample docker-compose file with three Etherpads and one etherpad-proxy. Of course you need to adapt the settings.json.template to your liking and map it into the reverse proxy image before you are ready :).\n\n\n- Added client authorization to work with Etherpad. Before it would get blocked because it doesn't have the required claim. As this is now fixed etherpad-proxy can also work with your new OAuth2 configuration and retrieve a token via client credentials flow.\n\n\n\n\n# 2.2.7\n\n\n### Notable enhancements and fixes\n\n- We migrated all important pages to React 19 and React Router v7\n\nBesides that only dependency updates.\n\n\n -> Have a merry Christmas and a happy new year.  🎄 🎁\n\n\n# 2.2.6\n\n### Notable enhancements and fixes\n\n- Added option to delete a pad by the creator. This option can be found in the settings menu. When you click on it you get a confirm dialog and after that you have the chance to completely erase the pad.\n\n\n# 2.2.5\n\n### Notable enhancements and fixes\n\n- Fixed timeslider not scrolling when the revision count is a multiple of 100\n- Added new Restful API for version 2 of Etherpad. It is available at /api-docs\n\n\n# 2.2.4\n\n### Notable enhancements and fixes\n\n- Switched to new SQLite backend\n- Fixed rusty-store-kv module not found\n\n\n# 2.2.3\n\n### Notable enhancements and fixes\n\n- Introduced a new in process database `rustydb` that represents a fast key value store written in Rust.\n- Readded window._ as a shortcut for getting text\n- Added support for migrating any ueberdb database to another. You can now switch as you please. See here: https://docs.etherpad.org/cli.html\n- Further Typescript movements\n- A lot of security issues fixed and reviewed in this release. Please update.\n\n\n# 2.2.2\n\n### Notable enhancements and fixes\n\n- Removal of Etherpad require kernel: We finally managed to include esbuild to bundle our frontend code together. So no matter how many plugins your server has it is always one JavaScript file. This boosts performance dramatically.\n- Added log layoutType: This lets you print the log in either colored or basic (black and white text)\n- Introduced esbuild for bundling CSS files\n- Cache all files to be bundled in memory for faster load speed\n\n\n# 2.1.1\n\n\n### Notable enhancements and fixes\n\n- Fixed failing Docker build when checked out as git submodule. Thanks to @neurolabs\n- Fixed: Fallback to websocket and polling when unknown(old) config is present for socket io\n- Fixed: Next page disabled if zero page by @samyakj023\n- On CTRL+CLICK bring the window back to focus by Helder Sepulveda\n\n# 2.1.0\n\n### Notable enhancements and fixes\n\n- Added PWA support. You can now add your Etherpad instance to your home screen on your mobile device or desktop.\n- Fixed live plugin manager versions clashing. Thanks to @yacchin1205\n- Fixed a bug in the pad panel where pagination was not working correctly when sorting by pad name\n\n### Compatibility changes\n\n- Reintroduced APIKey.txt support. You can now switch between APIKey and OAuth2.0 authentication. This can be toggled with the setting authenticationMethod. The default is OAuth2. If you want to use the APIKey method you can set that to `apikey`.\n\n\n# 2.0.3\n\n### Notable enhancements and fixes\n\n- Added documentation for replacing apikeys with oauth2\n- Bumped live plugin manager to 0.20.0. Thanks to @fgreinacher\n- Added better documentation for using docker-compose with Etherpad\n\n\n\n# 2.0.2\n\n### Notable enhancements and fixes\n\n- Fixed the locale loading in the admin panel\n- Added OAuth2.0 support for the Etherpad API. You can now log in  into the Etherpad API with your admin user using OAuth2\n\n### Compatibility changes\n\n- The tests now require generating a token from the OAuth secret. You can find the `generateJWTToken` in the common.ts script for plugin endpoint updates.\n\n\n# 2.0.1\n\n### Notable enhancements and fixes\n\n- Fixed a bug where a plugin depending on a scoped dependency would not install successfully.\n\n\n# 2.0.0\n\n\n### Compatibility changes\n\n- Socket io has been updated to 4.7.5. This means that the json.send function won't work anymore and needs to be changed to .emit('message', myObj)\n- Deprecating npm version 6 in favor of pnpm: We have made the decision to switch to the well established pnpm (https://pnpm.io/). It works by symlinking dependencies into a global directory allowing you to have a cleaner and more reliable environment.\n- Introducing Typescript to the Etherpad core: Etherpad core logic has been rewritten in Typescript allowing for compiler checking of errors.\n- Rewritten Admin Panel: The Admin panel has been rewritten in React and now features a more pleasant user experience. It now also features an integrated pad searching with sorting functionality.\n\n### Notable enhancements and fixes\n\n* Bugfixes\n  - Live Plugin Manager: The live plugin manager caused problems when a plugin had depdendencies defined. This issue is now resolved.\n\n* Enhancements\n  - pnpm Workspaces: In addition to pnpm we introduced workspaces. A clean way to manage multiple bounded contexts like the admin panel or the bin folder.\n  - Bin folder: The bin folder has been moved from the src folder to the root folder. This change was necessary as the contained scripts do not represent core functionality of the user.\n  - Starting Etherpad: Etherpad can now be started with a single command: `pnpm run prod` in the root directory.\n  - Installing Etherpad: Etherpad no longer symlinks itself in the root directory. This is now also taken care by pnpm, and it just creates a node_modules folder with the src directory`s ep_etherpad-lite folder\n  - Plugins can now be installed simply via the command: `pnpm run plugins i first-plugin second-plugin` or if you want to install from path you can do:\n  `pnpm run plugins i --path ../path-to-plugin`\n\n\n# 1.9.7\n\n### Notable enhancements and fixes\n\n* Added Live Plugin Manager: Plugins are now installed into a separate folder on the host system. This folder is called `plugin_packages`.\nThat way the plugins are separated from the normal etherpad installation.\n* Make repairPad.js more verbose\n* Fixed favicon not being loaded correctly\n\n# 1.9.6\n\n### Notable enhancements and fixes\n\n* Prevent etherpad crash when update server is not reachable\n* Use npm@6 in Docker build\n* Fix setting the log level in settings.json\n\n\n# 1.9.5\n\n### Compatibility changes\n\n* This version deprecates NodeJS16 as it reached its end of life and won't receive any updates. So to get started with Etherpad v1.9.5 you need NodeJS 18 and above.\n* The bundled windows NodeJS version has been bumped to the current LTS version 20.\n\n### Notable enhancements and fixes\n\n* The support for the tidy program to tidy up HTML files has been removed. This decision was made because it hasn't been updated for years and also caused an incompability when exporting a pad with Abiword.\n\n\n# 1.9.4\n\n### Compatibility changes\n\n* Log4js has been updated to the latest version. As it involved a bump of 6 major version.\n  A lot has changed since then. Most notably the console appender has been deprecated. You can find out more about it [here](https://github.com/log4js-node/log4js-node)\n\n### Notable enhancements and fixes\n\n* Fix for MySQL: The logger calls were incorrectly configured leading to a crash when e.g. somebody uses a different encoding than standard MySQL encoding.\n\n# 1.9.3\n\n### Compability changes\n\n* express-rate-limit has been bumped to 7.0.0: This involves the breaking change that \"max: 0\"\nin the importExportRateLimiting is set to always trigger. So set it to your desired value.\nIf you haven't changed that value in the settings.json you are all set.\n\n### Notable enhancements and fixes\n\n* Bugfixes\n  * Fix etherpad crashing with mongodb database\n\n* Enhancements\n  * Add surrealdb database support. You can find out more about this database [here](https://surrealdb.com).\n  * Make sqlite faster: The sqlite library has been switched to better-sqlite3. This should lead to better performance.\n\n# 1.9.2\n\n### Notable enhancements and fixes\n\n* Security\n  * Enable session key rotation: This setting can be enabled in the settings.json. It changes the signing key for the cookie authentication in a fixed interval.\n\n* Bugfixes\n  * Fix appendRevision when creating a new pad via the API without a text.\n\n\n* Enhancements\n  * Bump JQuery to version 3.7\n  * Update elasticsearch connector to version 8\n\n### Compatibility changes\n\n* No compability changes as JQuery maintains excellent backwards compatibility.\n\n#### For plugin authors\n\n* Please update to JQuery 3.7. There is an excellent deprecation guide over [here](https://api.jquery.com/category/deprecated/). Version 3.1 to 3.7 are relevant for the upgrade.\n\n# 1.9.1\n\n### Notable enhancements and fixes\n\n* Security\n  * Limit requested revisions in timeslider and export to head revision. (affects v1.9.0)\n\n* Bugfixes\n  * revisions in `CHANGESET_REQ` (timeslider) and export (txt, html, custom)\n    are now checked to be numbers.\n  * bump sql for audit fix\n* Enhancements\n  * Add keybinding meta-backspace to delete to beginning of line\n  * Fix automatic Windows build via GitHub Actions\n  * Enable docs to be build cross platform thanks to asciidoctor\n\n### Compatibility changes\n* tests: drop windows 7 test coverage & use chrome latest for admin tests\n* Require Node 16 for Etherpad and target Node 20 for testing\n\n\n# 1.9.0\n\n### Notable enhancements and fixes\n\n* Windows build:\n  * The bundled `node.exe` was upgraded from v12 to v16.\n  * The bundled `node.exe` is now a 64-bit executable. If you need the 32-bit\n    version you must download and install Node.js yourself.\n* Improvements to login session management:\n  * `express_sid` cookies and `sessionstorage:*` database records are no longer\n    created unless `requireAuthentication` is `true` (or a plugin causes them to\n    be created).\n  * Login sessions now have a finite lifetime by default (10 days after\n    leaving).\n  * `sessionstorage:*` database records are automatically deleted when the login\n    session expires (with some exceptions that will be fixed in the future).\n  * Requests for static content (e.g., `/robots.txt`) and special pages (e.g.,\n    the HTTP API, `/stats`) no longer create login session state.\n  * The secret used to sign the `express_sid` cookie is now automatically\n    regenerated every day (called *key rotation*) by default. If key rotation is\n    enabled, the now-deprecated `SESSIONKEY.txt` file can be safely deleted\n    after Etherpad starts up (its content is read and saved to the database and\n    used to validate signatures from old cookies until they expire).\n* The following settings from `settings.json` are now applied as expected (they\n  were unintentionally ignored before):\n  * `padOptions.lang`\n  * `padOptions.showChat`\n  * `padOptions.userColor`\n  * `padOptions.userName`\n* HTTP API:\n  * Fixed the return value of `getText` when called with a specific revision.\n  * Fixed a potential attribute pool corruption bug with\n    `copyPadWithoutHistory`.\n  * Mappings created by `createGroupIfNotExistsFor` are now removed from the\n    database when the group is deleted.\n  * Fixed race conditions in the `setText`, `appendText`, and `restoreRevision`\n    functions.\n  * Added an optional `authorId` parameter to `appendText`,\n    `copyPadWithoutHistory`, `createGroupPad`, `createPad`, `restoreRevision`,\n    `setHTML`, and `setText`, and bumped the latest API version to 1.3.0.\n* Fixed a crash if the database is busy enough to cause a query timeout.\n* New `/health` endpoint for getting information about Etherpad's health (see\n  [draft-inadarei-api-health-check-06](https://www.ietf.org/archive/id/draft-inadarei-api-health-check-06.html)).\n* Docker now uses the new `/health` endpoint for health checks, which avoids\n  issues when authentication is enabled. It also avoids the unnecessary creation\n  of database records for managing browser sessions.\n* When copying a pad, the pad's records are copied in batches to avoid database\n  timeouts with large pads.\n* Exporting a large pad to `.etherpad` format should be faster thanks to bulk\n  database record fetches.\n* When importing an `.etherpad` file, records are now saved to the database in\n  batches to avoid database timeouts with large pads.\n\n#### For plugin authors\n\n* New `expressPreSession` server-side hook.\n* Pad server-side hook changes:\n  * `padCheck`: New hook.\n  * `padCopy`: New `srcPad` and `dstPad` context properties.\n  * `padDefaultContent`: New hook.\n  * `padRemove`: New `pad` context property.\n* The `db` property on Pad objects is now public.\n* New `getAuthorId` server-side hook.\n* New APIs for processing attributes: `ep_etherpad-lite/static/js/attributes`\n  (low-level API) and `ep_etherpad-lite/static/js/AttributeMap` (high-level\n  API).\n* The `import` server-side hook has a new `ImportError` context property.\n* New `exportEtherpad` and `importEtherpad` server-side hooks.\n* The `handleMessageSecurity` and `handleMessage` server-side hooks have a new\n  `sessionInfo` context property that includes the user's author ID, the pad ID,\n  and whether the user only has read-only access.\n* The `handleMessageSecurity` server-side hook can now be used to grant write\n  access for the current message only.\n* The `init_<pluginName>` server-side hooks have a new `logger` context\n  property that plugins can use to log messages.\n* Prevent infinite loop when exiting the server\n* Bump dependencies\n\n\n### Compatibility changes\n\n* Node.js v14.15.0 or later is now required.\n* The default login session expiration (applicable if `requireAuthentication` is\n  `true`) changed from never to 10 days after the user leaves.\n\n#### For plugin authors\n\n* The `client` context property for the `handleMessageSecurity` and\n  `handleMessage` server-side hooks is deprecated; use the `socket` context\n  property instead.\n* Pad server-side hook changes:\n  * `padCopy`:\n    * The `originalPad` context property is deprecated; use `srcPad` instead.\n    * The `destinationID` context property is deprecated; use `dstPad.id`\n      instead.\n  * `padCreate`: The `author` context property is deprecated; use the new\n    `authorId` context property instead. Also, the hook now runs asynchronously.\n  * `padLoad`: Now runs when a temporary Pad object is created during import.\n    Also, it now runs asynchronously.\n  * `padRemove`: The `padID` context property is deprecated; use `pad.id`\n    instead.\n  * `padUpdate`: The `author` context property is deprecated; use the new\n    `authorId` context property instead. Also, the hook now runs asynchronously.\n* Returning `true` from a `handleMessageSecurity` hook function is deprecated;\n  return `'permitOnce'` instead.\n* Changes to the `src/static/js/Changeset.js` library:\n  * The following attribute processing functions are deprecated (use the new\n    attribute APIs instead):\n    * `attribsAttributeValue()`\n    * `eachAttribNumber()`\n    * `makeAttribsString()`\n    * `opAttributeValue()`\n  * `opIterator()`: Deprecated in favor of the new `deserializeOps()` generator\n    function.\n  * `appendATextToAssembler()`: Deprecated in favor of the new `opsFromAText()`\n    generator function.\n  * `newOp()`: Deprecated in favor of the new `Op` class.\n* The `AuthorManager.getAuthor4Token()` function is deprecated; use the new\n  `AuthorManager.getAuthorId()` function instead.\n* The exported database records covered by the `exportEtherpadAdditionalContent`\n  server-side hook now include keys like `${customPrefix}:${padId}:*`, not just\n  `${customPrefix}:${padId}`.\n* Plugin locales should overwrite core's locales Stale\n* Plugin locales overwrite core locales\n\n# 1.8.18\n\nReleased: 2022-05-05\n\n### Notable enhancements and fixes\n\n  * Upgraded ueberDB to fix a regression with CouchDB.\n\n# 1.8.17\n\nReleased: 2022-02-23\n\n### Security fixes\n\n* Fixed a vunlerability in the `CHANGESET_REQ` message handler that allowed a\n  user with any access to read any pad if the pad ID is known.\n\n### Notable enhancements and fixes\n\n* Fixed a bug that caused all pad edit messages received at the server to go\n  through a single queue. Now there is a separate queue per pad as intended,\n  which should reduce message processing latency when many pads are active at\n  the same time.\n\n# 1.8.16\n\n### Security fixes\n\nIf you cannot upgrade to v1.8.16 for some reason, you are encouraged to try\ncherry-picking the fixes to the version you are running:\n\n```shell\ngit cherry-pick b7065eb9a0ec..77bcb507b30e\n```\n\n* Maliciously crafted `.etherpad` files can no longer overwrite arbitrary\n  non-pad database records when imported.\n* Imported `.etherpad` files are now subject to numerous consistency checks\n  before any records are written to the database. This should help avoid\n  denial-of-service attacks via imports of malformed `.etherpad` files.\n\n### Notable enhancements and fixes\n\n* Fixed several `.etherpad` import bugs.\n* Improved support for large `.etherpad` imports.\n\n# 1.8.15\n\n### Security fixes\n\n* Fixed leak of the writable pad ID when exporting from the pad's read-only ID.\n  This only matters if you treat the writeable pad IDs as secret (e.g., you are\n  not using [ep_padlist2](https://www.npmjs.com/package/ep_padlist2)) and you\n  share the pad's read-only ID with untrusted users. Instead of treating\n  writeable pad IDs as secret, you are encouraged to take advantage of\n  Etherpad's authentication and authorization mechanisms (e.g., use\n  [ep_openid_connect](https://www.npmjs.com/package/ep_openid_connect) with\n  [ep_readonly_guest](https://www.npmjs.com/package/ep_readonly_guest), or write\n  your own\n  [authentication](https://etherpad.org/doc/v1.8.14/#index_authenticate) and\n  [authorization](https://etherpad.org/doc/v1.8.14/#index_authorize) plugins).\n* Updated dependencies.\n\n### Compatibility changes\n\n* The `logconfig` setting is deprecated.\n\n#### For plugin authors\n\n* Etherpad now uses [jsdom](https://github.com/jsdom/jsdom) instead of\n  [cheerio](https://cheerio.js.org/) for processing HTML imports. There are two\n  consequences of this change:\n  * `require('ep_etherpad-lite/node_modules/cheerio')` no longer works. To fix,\n    your plugin should directly depend on `cheerio` and do `require('cheerio')`.\n  * The `collectContentImage` hook's `node` context property is now an\n    [`HTMLImageElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement)\n    object rather than a Cheerio Node-like object, so the API is slightly\n    different. See\n    [citizenos/ep_image_upload#49](https://github.com/citizenos/ep_image_upload/pull/49)\n    for an example fix.\n* The `clientReady` server-side hook is deprecated; use the new `userJoin` hook\n  instead.\n* The `init_<pluginName>` server-side hooks are now run every time Etherpad\n  starts up, not just the first time after the named plugin is installed.\n* The `userLeave` server-side hook's context properties have changed:\n  * `auth`: Deprecated.\n  * `author`: Deprecated; use the new `authorId` property instead.\n  * `readonly`: Deprecated; use the new `readOnly` property instead.\n  * `rev`: Deprecated.\n* Changes to the `src/static/js/Changeset.js` library:\n  * `opIterator()`: The unused start index parameter has been removed, as has\n    the unused `lastIndex()` method on the returned object.\n  * `smartOpAssembler()`: The returned object's `appendOpWithText()` method is\n    deprecated without a replacement available to plugins (if you need one, let\n    us know and we can make the private `opsFromText()` function public).\n  * Several functions that should have never been public are no longer exported:\n    `applyZip()`, `assert()`, `clearOp()`, `cloneOp()`, `copyOp()`, `error()`,\n    `followAttributes()`, `opString()`, `stringOp()`, `textLinesMutator()`,\n    `toBaseTen()`, `toSplices()`.\n\n### Notable enhancements and fixes\n\n* Accessibility fix for JAWS screen readers.\n* Fixed \"clear authorship\" error (see issue #5128).\n* Etherpad now considers square brackets to be valid URL characters.\n* The server no longer crashes if an exception is thrown while processing a\n  message from a client.\n* The `useMonospaceFontGlobal` setting now works (thanks @Lastpixl!).\n* Chat improvements:\n  * The message input field is now a text area, allowing multi-line messages\n    (use shift-enter to insert a newline).\n  * Whitespace in chat messages is now preserved.\n* Docker improvements:\n  * New `HEALTHCHECK` instruction (thanks @Gared!).\n  * New `settings.json` variables: `DB_COLLECTION`, `DB_URL`,\n    `SOCKETIO_MAX_HTTP_BUFFER_SIZE`, `DUMP_ON_UNCLEAN_EXIT` (thanks\n    @JustAnotherArchivist!).\n  * `.ep_initialized` files are no longer created.\n* Worked around a [Firefox Content Security Policy\n  bug](https://bugzilla.mozilla.org/show_bug.cgi?id=1721296) that caused CSP\n  failures when `'self'` was in the CSP header. See issue #4975 for details.\n* UeberDB upgraded from v1.4.10 to v1.4.18. For details, see the [ueberDB\n  changelog](https://github.com/ether/ueberDB/blob/master/CHANGELOG.md).\n  Highlights:\n  * The `postgrespool` driver was renamed to `postgres`, replacing the old\n    driver of that name. If you used the old `postgres` driver, you may see an\n    increase in the number of database connections.\n  * For `postgres`, you can now set the `dbSettings` value in `settings.json` to\n    a connection string (e.g., `\"postgres://user:password@host/dbname\"`) instead\n    of an object.\n  * For `mongodb`, the `dbName` setting was renamed to `database` (but `dbName`\n    still works for backwards compatibility) and is now optional (if unset, the\n    database name in `url` is used).\n* `/admin/settings` now honors the `--settings` command-line argument.\n* Fixed \"Author *X* tried to submit changes as author *Y*\" detection.\n* Error message display improvements.\n* Simplified pad reload after importing an `.etherpad` file.\n\n#### For plugin authors\n\n* `clientVars` was added to the context for the `postAceInit` client-side hook.\n  Plugins should use this instead of the `clientVars` global variable.\n* New `userJoin` server-side hook.\n* The `userLeave` server-side hook has a new `socket` context property.\n* The `helper.aNewPad()` function (accessible to client-side tests) now\n  accepts hook functions to inject when opening a pad. This can be used to\n  test any new client-side hooks your plugin provides.\n* Chat improvements:\n  * The `chatNewMessage` client-side hook context has new properties:\n    * `message`: Provides access to the raw message object so that plugins can\n      see the original unprocessed message text and any added metadata.\n    * `rendered`: Allows plugins to completely override how the message is\n      rendered in the UI.\n  * New `chatSendMessage` client-side hook that enables plugins to process the\n    text before sending it to the server or augment the message object with\n    custom metadata.\n  * New `chatNewMessage` server-side hook to process new chat messages before\n    they are saved to the database and relayed to users.\n* Readability improvements to browser-side error stack traces.\n* Added support for socket.io message acknowledgments.\n\n# 1.8.14\n\n### Security fixes\n\n* Fixed a persistent XSS vulnerability in the Chat component. In case you can't\n  update to 1.8.14 directly, we strongly recommend to cherry-pick\n  a7968115581e20ef47a533e030f59f830486bdfa. Thanks to sonarsource for the\n  professional disclosure.\n\n### Compatibility changes\n\n* Node.js v12.13.0 or later is now required.\n* The `favicon` setting is now interpreted as a pathname to a favicon file, not\n  a URL. Please see the documentation comment in `settings.json.template`.\n* The undocumented `faviconPad` and `faviconTimeslider` settings have been\n  removed.\n* MySQL/MariaDB now uses connection pooling, which means you will see up to 10\n  connections to the MySQL/MariaDB server (by default) instead of 1. This might\n  cause Etherpad to crash with a \"ER_CON_COUNT_ERROR: Too many connections\"\n  error if your server is configured with a low connection limit.\n* Changes to environment variable substitution in `settings.json` (see the\n  documentation comments in `settings.json.template` for details):\n  * An environment variable set to the string \"null\" now becomes `null` instead\n    of the string \"null\". Similarly, if the environment variable is unset and\n    the default value is \"null\" (e.g., `\"${UNSET_VAR:null}\"`), the value now\n    becomes `null` instead of the string \"null\". It is no longer possible to\n    produce the string \"null\" via environment variable substitution.\n  * An environment variable set to the string \"undefined\" now causes the setting\n    to be removed instead of set to the string \"undefined\". Similarly, if the\n    environment variable is unset and the default value is \"undefined\" (e.g.,\n    `\"${UNSET_VAR:undefined}\"`), the setting is now removed instead of set to\n    the string \"undefined\". It is no longer possible to produce the string\n    \"undefined\" via environment variable substitution.\n  * Support for unset variables without a default value is now deprecated.\n    Please change all instances of `\"${FOO}\"` in your `settings.json` to\n    `${FOO:null}` to keep the current behavior.\n  * The `DB_*` variable substitutions in `settings.json.docker` that previously\n    defaulted to `null` now default to \"undefined\".\n* Calling `next` without argument when using `Changeset.opIterator` does always\n  return a new Op. See b9753dcc7156d8471a5aa5b6c9b85af47f630aa8 for details.\n\n### Notable enhancements and fixes\n\n* MySQL/MariaDB now uses connection pooling, which should improve stability and\n  reduce latency.\n* Bulk database writes are now retried individually on write failure.\n* Minify: Avoid crash due to unhandled Promise rejection if stat fails.\n* padIds are now included in /socket.io query string, e.g.\n  `https://video.etherpad.com/socket.io/?padId=AWESOME&EIO=3&transport=websocket&t=...&sid=...`.\n  This is useful for directing pads to separate socket.io nodes.\n* <script> elements added via aceInitInnerdocbodyHead hook are now executed.\n* Fix read only pad access with authentication.\n* Await more db writes.\n* Disabled wtfnode dump by default.\n* Send `USER_NEWINFO` messages on reconnect.\n* Fixed loading in a hidden iframe.\n* Fixed a race condition with composition. (Thanks @ingoncalves for an\n  exceptionally detailed analysis and @rhansen for the fix.)\n\n# 1.8.13\n\n### Notable fixes\n\n* Fixed a bug in the safeRun.sh script (#4935)\n* Add more endpoints that do not need authentication/authorization (#4921)\n* Fixed issue with non-opening device keyboard on smartphones (#4929)\n* Add version string to iframe_editor.css to prevent stale cache entry (#4964)\n\n### Notable enhancements\n\n* Refactor pad loading (no document.write anymore) (#4960)\n* Improve import/export functionality, logging and tests (#4957)\n* Refactor CSS manager creation (#4963)\n* Better metrics\n* Add test for client height (#4965)\n\n### Dependencies\n\n* ueberDB2 1.3.2 -> 1.4.4\n* express-rate-limit 5.2.5 -> 5.2.6\n* etherpad-require-kernel 1.0.9 -> 1.0.11\n\n# 1.8.12\n\nSpecial mention: Thanks to Sauce Labs for additional testing tunnels to help us\ngrow! :)\n\n### Security patches\n\n* Fixed a regression in v1.8.11 which caused some pad names to cause Etherpad to\n  restart.\n\n### Notable fixes\n\n* Fixed a bug in the `dirty` database driver that sometimes caused Node.js to\n  crash during shutdown and lose buffered database writes.\n* Fixed a regression in v1.8.8 that caused \"Uncaught TypeError: Cannot read\n  property '0' of undefined\" with some plugins (#4885)\n* Less warnings in server console for supported element types on import.\n* Support Azure and other network share installations by using a more truthful\n  relative path.\n\n### Notable enhancements\n\n* Dependency updates\n* Various Docker deployment improvements\n* Various new translations\n* Improvement of rendering of plugin hook list and error message handling\n\n# 1.8.11\n\n### Notable fixes\n\n* Fix server crash issue within PadMessageHandler due to SocketIO handling\n* Fix editor issue with drop downs not being visible\n* Ensure correct version is passed when loading front end resources\n* Ensure underscore and jquery are available in original location for plugin comptability\n\n### Notable enhancements\n\n* Improved page load speeds\n\n# 1.8.10\n\n### Security Patches\n\n* Resolve potential ReDoS vulnerability in your project - GHSL-2020-359\n\n### Compatibility changes\n\n* JSONP API has been removed in favor of using the mature OpenAPI implementation.\n* Node 14 is now required for Docker Deployments\n\n### Notable fixes\n\n* Various performance and stability fixes\n\n### Notable enhancements\n\n* Improved line number alignment and user experience around line anchors\n* Notification to admin console if a plugin is missing during user file import\n* Beautiful loading and reconnecting animation\n* Additional code quality improvements\n* Dependency updates\n\n# 1.8.9\n\n### Notable fixes\n\n* Fixed HTTP 400 error when importing via the UI.\n* Fixed \"Error: spawn npm ENOENT\" crash on startup in Windows.\n\n### Notable enhancements\n\n* Removed some unnecessary arrow key handling logic.\n* Dependency updates.\n\n# 1.8.8\n\n### Security patches\n\n* EJS has been updated to 3.1.6 to mitigate an Arbitrary Code Injection\n\n### Compatibility changes\n\n* Node.js 10.17.0 or newer is now required.\n* The `bin/` and `tests/` directories were moved under `src/`. Symlinks were\n  added at the old locations to hopefully avoid breaking user scripts and other\n  tools.\n* Dependencies are now installed with the `--no-optional` flag to speed\n  installation. Optional dependencies such as `sqlite3` must now be manually\n  installed (e.g., `(cd src && npm i sqlite3)`).\n* Socket.IO messages are now limited to 10K bytes to make denial of service\n  attacks more difficult. This may cause issues when pasting large amounts of\n  text or with plugins that send large messages (e.g., `ep_image_upload`). You\n  can change the limit via `settings.json`; see `socketIo.maxHttpBufferSize`.\n* The top-level `package.json` file, added in v1.8.7, has been removed due to\n  problematic npm behavior. Whenever you install a plugin you will see the\n  following benign warnings that can be safely ignored:\n\n  ```\n  npm WARN saveError ENOENT: no such file or directory, open '.../package.json'\n  npm WARN enoent ENOENT: no such file or directory, open '.../package.json'\n  npm WARN develop No description\n  npm WARN develop No repository field.\n  npm WARN develop No README data\n  npm WARN develop No license field.\n  ```\n\n### Notable enhancements\n\n* You can now generate a link to a specific line number in a pad. Appending\n  `#L10` to a pad URL will cause your browser to scroll down to line 10.\n* Database performance is significantly improved.\n* Admin UI now has test coverage in CI. (The tests are not enabled by default;\n  see `settings.json`.)\n* New stats/metrics: `activePads`, `httpStartTime`, `lastDisconnected`,\n  `memoryUsageHeap`.\n* Improved import UX.\n* Browser caching improvements.\n* Users can now pick absolute white (`#fff`) as their color.\n* The `settings.json` template used for Docker images has new variables for\n  controlling rate limiting.\n* Admin UI now has test coverage in CI. (The tests are not enabled by default\n  because the admin password is required; see `settings.json`.)\n* For plugin authors:\n  * New `callAllSerial()` function that invokes hook functions like `callAll()`\n    except it supports asynchronous hook functions.\n  * `callFirst()` and `aCallFirst()` now support the same wide range of hook\n    function behaviors that `callAll()`, `aCallAll()`, and `callAllSerial()`\n    support. Also, they now warn when a hook function misbehaves.\n  * The following server-side hooks now support asynchronous hook functions:\n    `expressConfigure`, `expressCreateServer`, `padCopy`, `padRemove`\n  * Backend tests for plugins can now use the\n    [`ep_etherpad-lite/tests/backend/common`](src/tests/backend/common.js)\n    module to start the server and simplify API access.\n  * The `checkPlugins.js` script now automatically adds GitHub CI test coverage\n    badges for backend tests and npm publish.\n\n### Notable fixes\n\n* Enter key now stays in focus when inserted at bottom of viewport.\n* Numbering for ordered list items now properly increments when exported to\n  text.\n* Suppressed benign socket.io connection errors\n* Interface no longer loses color variants on disconnect/reconnect event.\n* General code quality is further significantly improved.\n* Restarting Etherpad via `/admin` actions is more robust.\n* Improved reliability of server shutdown and restart.\n* No longer error if no buttons are visible.\n* For plugin authors:\n  * Fixed `collectContentLineText` return value handling.\n\n# 1.8.7\n### Compatibility-breaking changes\n* **IMPORTANT:** It is no longer possible to protect a group pad with a\n  password. All API calls to `setPassword` or `isPasswordProtected` will fail.\n  Existing group pads that were previously password protected will no longer be\n  password protected. If you need fine-grained access control, you can restrict\n  API session creation in your frontend service, or you can use plugins.\n* All workarounds for Microsoft Internet Explorer have been removed. IE might\n  still work, but it is untested.\n* Plugin hook functions are now subject to new sanity checks. Buggy hook\n  functions will cause an error message to be logged\n* Authorization failures now return 403 by default instead of 401\n* The `authorize` hook is now only called after successful authentication. Use\n  the new `preAuthorize` hook if you need to bypass authentication\n* The `authFailure` hook is deprecated; use the new `authnFailure` and\n  `authzFailure` hooks instead\n* The `indexCustomInlineScripts` hook was removed\n* The `client` context property for the `handleMessage` and\n  `handleMessageSecurity` hooks has been renamed to `socket` (the old name is\n  still usable but deprecated)\n* The `aceAttribClasses` hook functions are now called synchronously\n* The format of `ENTER`, `CREATE`, and `LEAVE` log messages has changed\n* Strings passed to `$.gritter.add()` are now expected to be plain text, not\n  HTML. Use jQuery or DOM objects if you need formatting\n\n### Notable new features\n* Users can now import without creating and editing the pad first\n* Added a new `readOnly` user setting that makes it possible to create users in\n  `settings.json` that can read pads but not create or modify them\n* Added a new `canCreate` user setting that makes it possible to create users in\n  `settings.json` that can modify pads but not create them\n* The `authorize` hook now accepts `readOnly` to grant read-only access to a pad\n* The `authorize` hook now accepts `modify` to grant modify-only (creation\n  prohibited) access to a pad\n* All authentication successes and failures are now logged\n* Added a new `cookie.sameSite` setting that makes it possible to enable\n  authentication when Etherpad is embedded in an iframe from another site\n* New `exportHTMLAdditionalContent` hook to include additional HTML content\n* New `exportEtherpadAdditionalContent` hook to include additional database\n  content in `.etherpad` exports\n* New `expressCloseServer` hook to close Express when required\n* The `padUpdate` hook context now includes `revs` and `changeset`\n* `checkPlugin.js` has various improvements to help plugin developers\n* The HTTP request object (and therefore the express-session state) is now\n  accessible from within most `eejsBlock_*` hooks\n* Users without a `password` or `hash` property in `settings.json` are no longer\n  ignored, so they can now be used by authentication plugins\n* New permission denied modal and block ``permissionDenied``\n* Plugins are now updated to the latest version instead of minor or patches\n\n### Notable fixes\n* Fixed rate limit accounting when Etherpad is behind a reverse proxy\n* Fixed typos that prevented access to pads via an HTTP API session\n* Fixed authorization failures for pad URLs containing a percent-encoded\n  character\n* Fixed exporting of read-only pads\n* Passwords are no longer written to connection state database entries or logged\n  in debug logs\n* When using the keyboard to navigate through the toolbar buttons the button\n  with the focus is now highlighted\n* Fixed support for Node.js 10 by passing the `--experimental-worker` flag\n* Fixed export of HTML attributes within a line\n* Fixed occasional \"Cannot read property 'offsetTop' of undefined\" error in\n  timeslider when \"follow pad contents\" is checked\n* socket.io errors are now displayed instead of silently ignored\n* Pasting while the caret is in a link now works (except for middle-click paste\n  on X11 systems)\n* Removal of Microsoft Internet Explorer specific code\n* Import better handles line breaks and white space\n* Fix issue with ``createDiffHTML`` incorrect call of ``getInternalRevisionAText``\n* Allow additional characters in URLs\n* MySQL engine fix and various other UeberDB updates (See UeberDB changelog).\n* Admin UI improvements on search results (to remove duplicate items)\n* Removal of unused cruft from ``clientVars`` (``ip`` and ``userAgent``)\n\n\n### Minor changes\n* Temporary disconnections no longer force a full page refresh\n* Toolbar layout for narrow screens is improved\n* Fixed `SameSite` cookie attribute for the `language`, `token`, and `pref`\n  cookies\n* Fixed superfluous database accesses when deleting a pad\n* Expanded test coverage.\n* `package-lock.json` is now lint checked on commit\n* Various lint fixes/modernization of code\n\n# 1.8.6\n* IMPORTANT: This fixes a severe problem with postgresql in 1.8.5\n* SECURITY: Fix authentication and authorization bypass vulnerabilities\n* API: Update version to 1.2.15\n* FEATURE: Add copyPadWithoutHistory API (#4295)\n* FEATURE: Package more asset files to save http requests (#4286)\n* MINOR: Improve UI when reconnecting\n* TESTS: Improve tests\n\n# 1.8.5\n* IMPORTANT DROP OF SUPPORT: Drop support for IE.  Browsers now need async/await.\n* IMPORTANT SECURITY: Rate limit Commits when env=production\n* SECURITY: Non completed uploads no longer crash Etherpad\n* SECURITY: Log authentication requests\n* FEATURE: Support ES6 (migrate from Uglify-JS to Terser)\n* FEATURE: Improve support for non-cookie enabled browsers\n* FEATURE: New hooks for ``index.html``\n* FEATURE: New script to delete sessions.\n* FEATURE: New setting to allow import withing an author session on a pad\n* FEATURE: Checks Etherpad version on startup and notifies if update is available.  Also available in ``/admin`` interface.\n* FEATURE: Timeslider updates pad location to most recent edit\n* MINOR: Outdent UL/LI items on removal of list item\n* MINOR: Various UL/LI import/export bugs\n* MINOR: PDF export fix\n* MINOR: Front end tests no longer run (and subsequently error) on pull requests\n* MINOR: Fix issue with </li> closing a list before it opens\n* MINOR: Fix bug where large pads would fire a console error in timeslider\n* MINOR: Fix ?showChat URL param issue\n* MINOR: Issue where timeslider URI fails to be correct if padID is numeric\n* MINOR: Include prompt for clear authorship when entire document is selected\n* MINOR: Include full document aText every 100 revisions to make pad restoration on database corruption achievable\n* MINOR: Several Colibris CSS fixes\n* MINOR: Use mime library for mime types instead of hard-coded.\n* MINOR: Don't show \"new pad button\" if instance is read only\n* MINOR: Use latest NodeJS when doing Windows build\n* MINOR: Change disconnect logic to reconnect instead of silently failing\n* MINOR: Update SocketIO, async, jQuery and Mocha which were stuck due to stale code.\n* MINOR: Rewrite the majority of the ``bin`` scripts to use more modern syntax\n* MINOR: Improved CSS anomation through prefers-reduced-motion\n* PERFORMANCE: Use workers (where possible) to minify CSS/JS on first page request.  This improves initial startup times.\n* PERFORMANCE: Cache EJS files improving page load speed when maxAge > 0.\n* PERFORMANCE: Fix performance for large pads\n* TESTS: Additional test coverage for OL/LI/Import/Export\n* TESTS: Include Simulated Load Testing in CI.\n* TESTS: Include content collector tests to test contentcollector.js logic external to pad dependents.\n* TESTS: Include fuzzing import test.\n* TESTS: Ensure CI is no longer using any cache\n* TESTS: Fix various tests...\n* TESTS: Various additional Travis testing including libreoffice import/export\n\n# 1.8.4\n* FIX: fix a performance regression on MySQL introduced in 1.8.3\n* FIX: when running behind a reverse proxy and exposed in an inner directory, fonts and toolbar icons should now be visible. This is a regression introduced in 1.8.3\n* FIX: cleanups in the UI after the CSS rehaul of 1.8.3\n* MINOR: protect against bugged/stale UI elements after updates. An explicit cache busting via random query string is performed at each start. This needs to be replaced with hashed names in static assets.\n* MINOR: improved some tests\n* MINOR: fixed long-standing bugs in the maintenance tools in /bin (migrateDirtyDBtoRealDB, rebuildPad, convert, importSqlFile)\n\n# 1.8.3\n* FEATURE: colibris is now the default skin for new installs\n* FEATURE: improved colibris visuals, and migrated to Flexbox layout\n* FEATURE: skin variants: colibris skin colors can be easily customized. Visit http://127.0.0.1:9001/p/test#skinvariantsbuilder\n* REQUIREMENTS: minimum required Node version is **10.13.0 LTS**.\n* MINOR: stability fixes for the async migration in 1.8.0 (fixed many UnhandledPromiseRejectionWarning and the few remaining crashes)\n* MINOR: improved stability of import/export functionality\n* MINOR: fixed many small UI quirks (timeslider, import/export, chat)\n* MINOR: Docker images are now built & run in production mode by default\n* MINOR: reduced the size of the Docker images\n* MINOR: better documented cookies and configuration parameters of the Docker image\n* MINOR: better database support (especially MySQL)\n* MINOR: additional test coverage\n* MINOR: restored compatibility with ep_hash_auth\n* MINOR: migrate from swagger-node-express to openapi-backend\n* MINOR: honor the Accept-Language HTTP headers sent by browsers, eventually serving language variants\n* PERFORMANCE: correctly send HTTP/304 for minified files\n* SECURITY: bumped many dependencies. At the time of the release, this version has 0 reported vulnerabilities by npm audit\n* SECURITY: never send referrer when opening a link\n* SECURITY: rate limit imports and exports\n* SECURITY: do not allow pad import if a user never contributed to that pad\n* SECURITY: expose configuration parameter for limiting max import size\n\n*BREAKING CHANGE*: undoing the \"clear authorship colors\" command is no longer supported (see https://github.com/ether/etherpad-lite/issues/2802)\n*BREAKING CHANGE*: the visuals and CSS structure of the page was updated. Plugins may need a CSS rehaul\n\n# 1.8\n* SECURITY: change referrer policy so that Etherpad addresses aren't leaked when links are clicked (discussion: https://github.com/ether/etherpad-lite/pull/3636)\n* SECURITY: set the \"secure\" flag for the session cookies when served over SSL. From now on it will not be possible to serve the same instance both in cleartext and over SSL\n\n# 1.8-beta.1\n* FEATURE: code was migrated to `async`/`await`, getting rid of a lot of callbacks (see https://github.com/ether/etherpad-lite/issues/3540)\n* FEATURE: support configuration via environment variables\n* FEATURE: include an official Dockerfile in the main repository\n* FEATURE: support including plugins in custom Docker builds\n* FEATURE: conditional creation of users: when its password is null, a user is not created. This helps, for example, in advanced configuration of Docker images.\n* REQUIREMENTS: minimum required Node version is **8.9.0 LTS**. Release 1.8.3 will require at least Node **10.13.0** LTS\n* MINOR: in the HTTP API, allow URL parameters and POST bodies to co-exist\n* MINOR: fix Unicode bug in HTML export\n* MINOR: bugfixes to colibris chat window\n* MINOR: code simplification (avoided double negations, introduced early exits, ...)\n* MINOR: reduced the size of the Windows package\n* MINOR: upgraded the nodejs runtime to 10.16.3 in the Windows package\n* SECURITY: avoided XSS in IE11\n* SECURITY: the version is exposed in http header only when configured\n* SECURITY: updated vendored jQuery version\n* SECURITY: bumped dependencies\n\n# 1.7.5\n* FEATURE: introduced support for multiple skins. See https://etherpad.org/doc/v1.7.5/#index_skins\n* FEATURE: added a new, optional skin. It can be activated choosing `skinName: \"colibris\"` in `settings.json`\n* FEATURE: allow file import using LibreOffice\n* SECURITY: updated many dependencies. No known high or moderate risk dependencies remain.\n* SECURITY: generate better random pad names\n* FIX: don't nuke all installed plugins if `npm install` fails\n* FIX: improved LibreOffice export\n* FIX: allow debug mode on node versions >= 6.3\n* MINOR: started making Etherpad less dependent on current working directory when running\n* MINOR: started simplifying the code structure, flattening complex conditions\n* MINOR: simplified a bit the startup scripts\n\n*UPGRADE NOTES*: if you have custom files in `src/static/custom`, save them\nsomewhere else, revert the directory contents, update to Etherpad 1.7.5, and\nfinally put them back in their new location, uder `src/static/skins/no-skin`.\n\n# 1.7.0\n* FIX: `getLineHTMLForExport()` no longer produces multiple copies of a line. **WARNING**: this could potentially break some plugins\n* FIX: authorship of bullet points no longer changes when a second author edits them\n* FIX: improved Firefox compatibility (non printable keys)\n* FIX: `getPadPlainText()` was not working\n* REQUIREMENTS: minimum required Node version is 6.9.0 LTS. The next release will require at least Node 8.9.0 LTS\n* SECURITY: updated MySQL, Elasticsearch and PostgreSQL drivers\n* SECURITY: started updating deprecated code and packages\n* DOCS: documented --credentials, --apikey, --sessionkey. Better detailed contributors guidelines. Added a section on securing the installation\n\n# 1.6.6\n * FIX: line numbers are aligned with text again (broken in 1.6.4)\n * FIX: text entered between connection loss and reconnection was not saved\n * FIX: diagnostic call failed when etherpad was exposed in a subdirectory\n\n# 1.6.5\n * SECURITY: Escape data when listing available plugins\n * FIX: Fix typo in apicalls.js which prevented importing isValidJSONPName\n * FIX: fixed plugin dependency issue\n * FIX: Update iframe_editor.css\n * FIX: unbreak Safari iOS line wrapping\n\n# 1.6.4\n * SECURITY: Access Control bypass on /admin - CVE-2018-9845\n * SECURITY: Remote Code Execution through pad export - CVE-2018-9327\n * SECURITY: Remote Code Execution through JSONP handling - CVE-2018-9326\n * SECURITY: Pad data leak - CVE-2018-9325\n * Fix: Admin redirect URL\n * Fix: Various script Fixes\n * Fix: Various CSS/Style/Layout fixes\n * NEW: Improved Pad contents readability\n * NEW: Hook: onAccessCheck\n * NEW: SESSIONKEY and APIKey customizable path\n * NEW: checkPads script\n * NEW: Support \"cluster mode\"\n\n# 1.6.3\n * SECURITY: Update ejs\n * SECURITY: xss vulnerability when reading window.location.href\n * SECURITY: sanitize jsonp\n * NEW: Catch SIGTERM for graceful shutdown\n * NEW: Show actual applied text formatting for caret position\n * NEW: Add settings to improve scrolling of viewport on line changes\n\n# 1.6.2\n * NEW: Added pad shortcut disabling feature\n * NEW: Create option to automatically reconnect after a few seconds\n * Update: socket.io to 1.7.3\n * Update: l10n lib\n * Update: request to 2.83.0\n * Update: Node for windows to 8.9.0\n * Fix: minification of code\n\n# 1.6.1\n * NEW: Hook aceRegisterNonScrollableEditEvents to register events that shouldn't scroll\n * NEW: Added 'item' parameter to registerAceCommand Hook\n * NEW: Added LibreJS support\n * Fix: Crash on malformed export url\n * Fix: Re-enable editor after user is reconnected to server\n * Fix: minification\n * Other: Added 'no-referrer' for all pads\n * Other: Improved cookie security\n * Other: Fixed compatibility with nodejs 7\n * Other: Updates\n  - socket.io to 1.6.0\n  - express to 4.13.4\n  - express-session to 1.13.0\n  - clean-css to 3.4.12\n  - uglify-js to 2.6.2\n  - log4js to 0.6.35\n  - cheerio to 0.20.0\n  - ejs to 2.4.1\n  - graceful-fs to 4.1.3\n  - semver to 5.1.0\n  - unorm to 1.4.1\n  - jsonminify to 0.4.1\n  - measured to 1.1.0\n  - mocha to 2.4.5\n  - supertest to 1.2.0\n  - npm to 4.0.2\n  - Node.js for Windows to 6.9.2\n\n# 1.6.0\n * SECURITY: Fix a possible xss attack in iframe link\n * NEW: Add a aceSelectionChanged hook to allow plugins to react when the cursor location changes.\n * NEW: Accepting Arrays on 'exportHtmlAdditionalTags' to handle attributes stored as ['key', 'value']\n * NEW: Allow admin to run on a sub-directory\n * NEW: Support version 5 of node.js\n * NEW: Update windows build to node version 4.4.3\n * NEW: Create setting to control if a new line will be indented or not\n * NEW: Add an appendText API\n * NEW: Allow LibreOffice to be used when exporting a pad\n * NEW: Create hook exportHtmlAdditionalTagsWithData\n * NEW: Improve DB migration performance\n * NEW: allow settings to be applied from the filesystem\n * NEW: remove applySettings hook and allow credentials.json to be part of core\n * NEW: Use exec to switch to node process\n * NEW: Validate incoming color codes\n * Fix: Avoid space removal when pasting text from word processor.\n * Fix: Removing style that makes editor scroll to the top on iOS without any action from the user\n * Fix: Fix API call appendChatMessage to send new message to all connected clients\n * Fix: Timeslider \"Return to pad\" button\n * Fix: Generating pad HTML with tags like <span data-TAG=\"VALUE\"> instead of <TAG:VALUE>\n * Fix: Get git commit hash even if the repo only points to a bare repo.\n * Fix: Fix decode error if pad name contains special characters and is sanitized\n * Fix: Fix handleClientMessage_USER_* payloads not containing user info\n * Fix: Set language cookie on initial load\n * Fix: Timeslider Not Translated\n * Other: set charset for mysql connection in settings.json\n * Other: Dropped support for io.js\n * Other: Add support to store credentials in credentials.json\n * Other: Support node version 4 or higher\n * Other: Update uberDB to version 0.3.0\n\n# 1.5.7\n * NEW: Add support for intermediate CA certificates for ssl\n * NEW: Provide a script to clean up before running etherpad\n * NEW: Use ctrl+shift+1 to do a ordered list\n * NEW: Show versions of plugins on startup\n * NEW: Add author on padCreate and padUpdate hook\n * Fix: switchToPad method\n * Fix: Dead keys\n * Fix: Preserve new lines in copy-pasted text\n * Fix: Compatibility mode on IE\n * Fix: Content Collector to get the class of the DOM-node\n * Fix: Timeslider export links\n * Fix: Double prompt on file upload\n * Fix: setText() replaces the entire pad text\n * Fix: Accessibility features on embedded pads\n * Fix: Tidy HTML before abiword conversion\n * Fix: Remove edit buttons in read-only view\n * Fix: Disable user input in read-only view\n * Fix: Pads end with a single newline, rather than two newlines\n * Fix: Toolbar and chat for mobile devices\n\n# 1.5.6\n * Fix: Error on windows installations\n\n# 1.5.5\n * SECURITY: Also don't allow read files on directory traversal on minify paths\n * NEW: padOptions can be set in settings.json now\n * Fix: Add check for special characters in createPad API function\n * Fix: Middle click on a link in firefox don't paste text anymore\n * Fix: Made setPadRaw async to import larger etherpad files\n * Fix: rtl\n * Fix: Problem in older IEs\n * Other: Update to express 4.x\n * Other: Dropped support for node 0.8\n * Other: Update ejs to version 2.x\n * Other: Moved sessionKey from settings.json to a new auto-generated SESSIONKEY.txt file\n\n# 1.5.4\n * SECURITY: Also don't allow read files on directory traversal on frontend tests path\n\n# 1.5.3\n * NEW: Accessibility support for Screen readers, includes new fonts and keyboard shortcuts\n * NEW: API endpoint for Append Chat Message and Chat Backend Tests\n * NEW: Error messages displayed on load are included in Default Pad Text (can be suppressed)\n * NEW: Content Collector can handle key values\n * NEW: getAttributesOnPosition Method\n * FIX: Firefox keeps attributes (bold etc) on cut/copy -> paste\n * Fix: showControls=false now works\n * Fix: Cut and Paste works...\n * SECURITY: Don't allow read files on directory traversal\n\n# 1.5.2\n * NEW: Support for node version 0.12.x\n * NEW: API endpoint saveRevision, getSavedRevisionCount and listSavedRevisions\n * NEW: setting to allow load testing\n * Fix: Rare scroll issue\n * Fix: Handling of custom pad path\n * Fix: Better error handling of imports and exports of type \"etherpad\"\n * Fix: Walking caret in chrome\n * Fix: Better handling for changeset problems\n * SECURITY Fix: Information leak for etherpad exports (CVE-2015-2298)\n\n# 1.5.1\n * NEW: High resolution Icon\n * NEW: Use HTTPS for plugins.json download\n * NEW: Add 'last update' column\n * NEW: Show users and chat at the same time\n * NEW: Support io.js\n * Fix: removeAttributeOnLine now works properly\n * Fix: Plugin search and list\n * Fix: Issue where unauthed request could cause error\n * Fix: Privacy issue with .etherpad export\n * Fix: Freeze deps to improve bisectability\n * Fix: IE, everything. IE is so broken.\n * Fix: Timeslider proxy\n * Fix: All backend tests pass\n * Fix: Better support for Export into HTML\n * Fix: Timeslider stars\n * Fix: Translation update\n * Fix: Check filesystem if Abiword exists\n * Fix: Docs formatting\n * Fix: Move Save Revision notification to a gritter message\n * Fix: UeberDB MySQL Timeout issue\n * Fix: Indented +9 list items\n * Fix: Don't paste on middle click of link\n * SECURITY Fix: Issue where a malformed URL could cause EP to disclose installation location\n\n# 1.5.0\n * NEW: Lots of performance improvements for page load times\n * NEW: Hook for adding CSS to Exports\n * NEW: Allow shardable socket io\n * NEW: Allow UI to show when attr/prop is applied (CSS)\n * NEW: Various scripts\n * NEW: Export full fidelity pads (including authors etc.)\n * NEW: Various front end tests\n * NEW: Backend tests\n * NEW: switchPad hook to instantly switch between pads\n * NEW: Various translations\n * NEW: Icon sets instead of images to provide quality high DPI experience\n * Fix: HTML Import blocking / hanging server\n * Fix: Export Bullet / Numbered lists HTML\n * Fix: Swagger deprecated warning\n * Fix: Bad session from crashing server\n * Fix: Allow relative settings path\n * Fix: Stop attributes being improperly assigned between 2 lines\n * Fix: Copy / Move Pad API race condition\n * Fix: Save all user preferences\n * Fix: Upgrade majority of dependency inc upgrade to SocketIO1+\n * Fix: Provide UI button to restore maximized chat window\n * Fix: Timeslider UI Fix\n * Fix: Remove Dokuwiki\n * Fix: Remove long paths from windows build (stops error during extract)\n * Fix: Various globals removed\n * Fix: Move all scripts into bin/\n * Fix: Various CSS bugfixes for Mobile devices\n * Fix: Overflow Toolbar\n * Fix: Line Attribute management\n\n# 1.4.1\n * NEW: Translations\n * NEW: userLeave Hook\n * NEW: Script to reinsert all DB values of a Pad\n * NEW: Allow for absolute settings paths\n * NEW: API: Get Pad ID from read Only Pad ID\n * NEW: Huge improvement on MySQL database read/write (InnoDB to MyISAM)\n * NEW: Hook for Export File Name\n * NEW: Preprocessor Hook for DOMLine attributes (allows plugins to wrap entire line contents)\n * Fix: Exception on Plugin Search and fix for plugins not being fetched\n * Fix: Font on innerdoc body can be arial on paste\n * Fix: Fix Dropping of messages in handleMessage\n * Fix: Don't use Abiword for HTML exports\n * Fix: Color issues with user Icon\n * Fix: Timeslider Button\n * Fix: Session Deletion error\n * Fix: Allow browser tabs to be cycled when focus is in editor\n * Fix: Various Editor issues with Easysync potentially entering forever loop on bad changeset\n\n# 1.4\n * NEW: Disable toolbar items through settings.json\n * NEW: Internal stats/metrics engine\n * NEW: Copy/Move Pad API functions\n * NEW: getAttributeOnSelection method\n * NEW: CSS function when an attribute is active on caret location\n * NEW: Various new eejs blocks\n * NEW: Ace afterEditHook\n * NEW: Import hook to introduce alternative export methods\n * NEW: preProcessDomLine allows Domline attributes to be processed before native attributes\n * Fix: Allow for lighter author colors\n * Fix: Improved randomness of session tokens\n * Fix: Don't panic if an author2session/group2session no longer exists\n * Fix: Gracefully fallback to related languages if chosen language is unavailable\n * Fix: Various changeset/stability bugs\n * Fix: Re-enable import buttons after failed import\n * Fix: Allow browser tabs to be cycled when in editor\n * Fix: Better Protocol detection\n * Fix: padList API Fix\n * Fix: Caret walking issue\n * Fix: Better settings.json parsing\n * Fix: Improved import/export handling\n * Other: Various whitespace/code clean-up\n * Other: .deb packaging creator\n * Other: More API Documentation\n * Other: Lots more translations\n * Other: Support Node 0.11\n\n# 1.3\n * NEW: We now follow the semantic versioning scheme!\n * NEW: Option to disable IP logging\n * NEW: Localisation updates from https://translatewiki.net.\n * Fix: Fix readOnly group pads\n * Fix: don't fetch padList on every request\n\n# 1.2.12\n * NEW: Add explanations for more disconnect scenarios\n * NEW: export sessioninfos so plugins can access it\n * NEW: pass pad in postAceInit hook\n * NEW: Add trustProxy setting. ALlows to make ep use X-forwarded-for as remoteAddress\n * NEW: userLeave hook (UNDOCUMENTED)\n * NEW: Plural macro for translations\n * NEW: backlinks to main page in Admin pages\n * NEW: New translations from translatewiki.net\n * SECURITY FIX: Filter author data sent to clients\n * FIX: Never keep processing a changeset if it's corrupted\n * FIX: Some client-side performance fixes for webkit browsers\n * FIX: Only execute listAllPads query on demand (not on start-up)\n * FIX: HTML import (don't crash on malformed or blank HTML input; strip title out of html during import)\n * FIX: check if uploaded file only contains ascii chars when abiword disabled\n * FIX: Plugin search in /admin/plugins\n * FIX: Don't create new pad if a non-existent read-only pad is accessed\n * FIX: Drop messages from unknown connections (would lead to a crash after a restart)\n * FIX: API: fix createGroupFor endpoint, if mapped group is deleted\n * FIX: Import form for other locales\n * FIX: Don't stop processing changeset queue if there is an error\n * FIX: Caret movement. Chrome detects blank rows line heights as incorrect\n * FIX: allow colons in password\n * FIX: Polish logging of client-side errors on the server\n * FIX: Username url param\n * FIX: Make start script POSIX ompatible\n\n\n# 1.2.11\n * NEW: New Hook for outer_ace dynamic css manager and author style hook\n * NEW: Bump log4js for improved logging\n * Fix: Remove URL schemes which don't have RFC standard\n * Fix: Fix safeRun subsequent restarts issue\n * Fix: Allow safeRun to pass arguments to run.sh\n * Fix: Include script for more efficient import\n * Fix: Fix sysv comptibile script\n * Fix: Fix client side changeset spamming\n * Fix: Don't crash on no-auth\n * Fix: Fix some IE8 errors\n * Fix: Fix authorship sanitation\n\n# 1.2.10\n * NEW: Broadcast slider is exposed in timeslider so plugins can interact with it\n * Fix: IE issue where pads wouldn't load due to missing console from i18n\n * Fix: console issue in collab client would error on cross domain embeds in IE\n * Fix: Only Restart Etherpad once plugin is installed\n * Fix: Only redraw lines that exist after drag and drop\n * Fix: Pasting into ordered list\n * Fix: Import browser detection\n * Fix: 2 Part Locale Specs\n * Fix: Remove language string from chat element\n * Fix: Make Saved revision Star fade back out on non Top frames\n * Other: Remove some cruft legacy JS from old Etherpad\n * Other: Express 3.1.2 breaks sessions, set Express to 3.1.0\n\n# 1.2.91\n * NEW: Authors can now send custom object messages to other Authors making 3 way conversations possible.  This introduces WebRTC plugin support.\n * NEW: Hook for Chat Messages Allows for Desktop Notification support\n * NEW: FreeBSD installation docs\n * NEW: Ctrl S for save revision makes the Icon glow for a few sconds.\n * NEW: Various hooks and expose the document ACE object\n * NEW: Plugin page revamp makes finding and installing plugins more sane.\n * NEW: Icon to enable sticky chat from the Chat box\n * Fix: Cookies inside of plugins\n * Fix: Don't leak event emitters when accessing admin/plugins\n * Fix: Don't allow user to send messages after they have been \"kicked\" from a pad\n * Fix: Refactor Caret navigation with Arrow and Pageup/down keys stops cursor being lost\n * Fix: Long lines in Firefox now wrap properly\n * Fix: Session Disconnect limit is increased from 10 to 20 to support slower restarts\n * Fix: Support Node 0.10\n * Fix: Log HTTP on DEBUG log level\n * Fix: Server wont crash on import fails on 0 file import.\n * Fix: Import no longer fails consistently\n * Fix: Language support for non existing languages\n * Fix: Mobile support for chat notifications are now usable\n * Fix: Re-Enable Editbar buttons on reconnect\n * Fix: Clearing authorship colors no longer disconnects all clients\n * Other: New debug information for sessions\n\n# 1.2.9\n * Fix: MAJOR Security issue, where a hacker could submit content as another user\n * Fix: security issue due to unescaped user input\n * Fix: Admin page at /admin redirects to /admin/ now to prevent breaking relative links\n * Fix: indentation in chrome on linux\n * Fix: PadUsers API endpoint\n * NEW: A script to import data to all dbms\n * NEW: Add authorId to chat and userlist as a data attribute\n * NEW: Refactor and fix our frontend tests\n * NEW: Localisation updates\n\n\n# 1.2.81\n * Fix: CtrlZ-Y for Undo Redo\n * Fix: RTL functionality on contents & fix RTL/LTR tests and RTL in Safari\n * Fix: Various other tests fixed in Android\n\n# 1.2.8\n ! IMPORTANT: New setting.json value is required to automatically reconnect clients on disconnect\n * NEW: Use Socket IO for rooms (allows for pads to be load balanced with sticky rooms)\n * NEW: Plugins can now provide their own frontend tests\n * NEW: Improved server-side logging\n * NEW: Admin dashboard mobile device support and new hooks for Admin dashboard\n * NEW: Get current API version from API\n * NEW: CLI script to delete pads\n * Fix: Automatic client reconnection on disconnect\n * Fix: Text Export indentation now supports multiple indentations\n * Fix: Bugfix getChatHistory API method\n * Fix: Stop Chrome losing caret after paste is texted\n * Fix: Make colons on end of line create 4 spaces on indent\n * Fix: Stop the client disconnecting if a rev is in the wrong order\n * Fix: Various server crash issues based on rev in wrong order\n * Fix: Various tests\n * Fix: Make indent when on middle of the line stop creating list\n * Fix: Stop long strings breaking the UX by moving focus away from beginning of line\n * Fix: Redis findKeys support\n * Fix: padUsersCount no longer hangs server\n * Fix: Issue with two part locale specs not working\n * Fix: Make plugin search case insensitive\n * Fix: Indentation and bullets on text export\n * Fix: Resolve various warnings on dependencies during install\n * Fix: Page up / Page down now works in all browsers\n * Fix: Stop Opera browser inserting two new lines on enter keypress\n * Fix: Stop timeslider from showing NaN on pads with only one revision\n * Other: Allow timeslider tests to run and provide & fix various other frontend-tests\n * Other: Begin dropping reference to Lite.  Etherpad Lite is now named \"Etherpad\"\n * Other: Update to latest jQuery\n * Other: Change loading message asking user to please wait on first build\n * Other: Allow etherpad to use global npm installation (Safe since node 6.3)\n * Other: Better documentation for log rotation and log message handling\n\n\n\n# 1.2.7\n * NEW: notifications are now modularized and can be stacked\n * NEW: Visit a specific revision in the timeslider by suffixing #%revNumber% IE http://localhost/p/test/timeslider#12\n * NEW: Link to plugin on Admin page allows admins to easily see plugin details in a new window by clicking on the plugin name\n * NEW: Automatically see plugins that require update and be able to one click update\n * NEW: API endpoints for Chat .. getChatHistory, getChatHead\n * NEW: API endpoint to see a pad diff in HTML format from revision x to revision y .. createPadDiffHTML\n * NEW: Real time plugin search & unified menu UI for admin pages\n * Fix: MAJOR issue where server could be crashed by malformed client message\n * Fix: AuthorID is now included in padUsers API response\n * Fix: make docs\n * Fix: Timeslider UI bug with slider not being in position\n * Fix: IE8 language issue where it wouldn't load pads due to IE8 suckling on the bussum of hatrid\n * Fix: Import timeout issue\n * Fix: Import now works if Params are set in pad URL\n * Fix: Convert script\n * Other: Various new language strings and update/bugfixes of others\n * Other: Clean up the getParams functionality\n * Other: Various new EEJS blocks: index, timeslider, html etc.\n\n# 1.2.6\n * Fix: Package file UeberDB reference\n * New #users EEJS block for plugins\n\n# 1.2.5\n * Create timeslider EEJS blocks for plugins\n * Allow for \"more messages\" to be loaded in chat\n * Introduce better logging\n * API endpoint for \"listAllPads\"\n * Fix: Stop highlight of timeslider when dragging mouse\n * Fix: Time Delta on Timeslider make date update properly\n * Fix: Prevent empty chat messages from being sent\n * Fix: checkPad script\n * Fix: IE onLoad listener for i18n\n\n# 1.2.4\n * Fix IE console issue created in 1.2.3\n * Allow CI Tests to pass by ignoring timeslider test\n * Fix broken placeholders in locales\n * Fix extractPadData script\n * Fix documentation for checkToken\n * Fix hitting enter on form in admin/plugins\n\n# 1.2.3\n * Fix #1307: Chrome needs console.log to be called on console obj\n * Fix #1309: We had broken support for node v0.6 in the last release\n\n# 1.2.2\n * More translations and better language support.  See https://translatewiki.net/wiki/Translating:Etherpad_lite for more details\n * Add a checkToken Method to the API\n * Bugfix for Internal Caching issue that was causing some 404s on images.\n * Bugfix for IE Import\n * Bugfix for Node 0.6 compatibility\n * Bugfix for multiple cookie support\n * Bugfix for API when requireAuth is enabled.\n * Plugin page now shows plugin version #\n * Show color of Author in Chat messages\n * Allow plugin search by description\n * Allow for different socket IO transports\n * Allow for custom favicon path\n * Control S now does Create new Revision functionality\n * Focus on password when required\n * Frontend Timeslider test\n * Allow for basic HTML etc. import without abiword\n * Native HTTPS support\n\n# 1.2.1\n * Allow ! in urls inside the editor (Not Pad urls)\n * Allow comments in language files\n * More languages (Finish, Spanish, Bengali, Dutch) Thanks to TranslateWiki.net team.  See https://translatewiki.net/w/i.php?title=Special:MessageGroupStats&group=out-etherpad-lite for more details\n * Bugfix for IE7/8 issue with a JS error #1186\n * Bugfix windows package extraction issue and make the .zip file smaller\n * Bugfix group pad API export\n * Kristen Stewart is a terrible actress and Twilight sucks.\n\n# v1.2\n * Internationalization / Language / Translation support (i18n) with support for German/French\n * A frontend/client side testing framework and backend build tests\n * Customizable robots.txt\n * Customizable app title (finally you can name your epl instance!)\n * eejs render arguments are now passed on to eejs hooks through the newly introduced `renderContext` argument.\n * Plugin-specific settings in settings.json (finally allowing for things like a google analytics plugin)\n * Serve admin dashboard at /admin (still very limited, though)\n * Modify your settings.json through the newly created UI at /admin/settings\n * Fix: Import `<ol>` as `<ol>` and not as `<ul>`!\n * Added solaris compatibility (bin/installDeps.sh was broken on solaris)\n * Fix a bug with IE9 and Password Protected Pads using HTTPS\n\n# v1.1.5\n * We updated to express v3 (please [make sure](https://github.com/visionmedia/express/wiki/Migrating-from-2.x-to-3.x) your plugin works under express v3)\n * `userColor` URL parameter which sets the initial author color\n * Hooks for \"padCreate\", \"padRemove\", \"padUpdate\" and \"padLoad\" events\n * Security patches concerning the handling of messages originating from clients\n * Our database abstraction layer now natively supports couchDB, levelDB, mongoDB, postgres, and redis!\n * We now provide a script helping you to migrate from dirtyDB to MySQL\n * Support running Etherpad Lite behind IIS, using [iisnode](https://github.com/tjanczuk/iisnode/wiki)\n * LibreJS Licensing information in headers of HTML templates\n * Default port number to PORT env var, if port isn't specified in settings\n * Fix for `convert.js`\n * Raise upper char limit in chat to 999 characters\n * Fixes for mobile layout\n * Fixes for usage behind reverse proxy\n * Improved documentation\n * Fixed some opera style bugs\n * Update npm and fix some bugs, this introduces\n\n# v1.1\n* Introduced Plugin framework\n* Many bugfixes\n* Faster page loading\n* Various UI polishes\n* Saved Revisions\n* Read only Real time view\n* More API functionality\n\n# v 1.0.1\n\n* Updated MySQL driver, this fixes some problems with mysql\n* Fixed export,import and timeslider link when embed parameters are used\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributor Guidelines\n(Please talk to people on the mailing list before you change this page, see our section on [how to get in touch](https://github.com/ether/etherpad-lite#get-in-touch))\n\n**We have decided that LLM/Agent/AI contributions are fine as long as they are within the instructions set out by this document.**\n\n## Pull requests\n\n* the commit series in the PR should be _linear_ (it **should not contain merge commits**). This is necessary because we want to be able to [bisect](https://en.wikipedia.org/wiki/Bisection_(software_engineering)) bugs easily. Rewrite history/perform a rebase if necessary\n* PRs should be issued against the **develop** branch: we never pull directly into **master**\n* PRs **should not have conflicts** with develop. If there are, please resolve them rebasing and force-pushing\n* when preparing your PR, please make sure that you have included the relevant **changes to the documentation** (preferably with usage examples)\n* contain meaningful and detailed **commit messages** in the form:\n  ```\n  submodule: description\n\n  longer description of the change you have made, eventually mentioning the\n  number of the issue that is being fixed, in the form: Fixes #someIssueNumber\n  ```\n* if the PR is a **bug fix**:\n  * The commit that fixes the bug should **include a regression test** that\n    would fail if the bug fix was reverted. Adding the regression test in the\n    same commit as the bug fix makes it easier for a reviewer to verify that the\n    test is appropriate for the bug fix.\n  * If there is a bug report, **the pull request description should include the\n    text \"`Fixes #xxx`\"** so that the bug report is auto-closed when the PR is\n    merged. It is less useful to say the same thing in a commit message because\n    GitHub will spam the bug report every time the commit is rebased, and\n    because a bug number alone becomes meaningless in forks. (A full URL would\n    be better, but ideally each commit is readable on its own without the need\n    to examine an external reference to understand motivation or context.)\n* think about stability: code has to be backwards compatible as much as possible. Always **assume your code will be run with an older version of the DB/config file**\n* if you want to remove a feature, **deprecate it instead**:\n  * write an issue with your deprecation plan\n  * output a `WARN` in the log informing that the feature is going to be removed\n  * remove the feature in the next version\n* if you want to add a new feature, put it under a **feature flag**:\n  * once the new feature has reached a minimal level of stability, do a PR for it, so it can be integrated early\n  * expose a mechanism for enabling/disabling the feature\n  * the new feature should be **disabled** by default. With the feature disabled, the code path should be exactly the same as before your contribution. This is a __necessary condition__ for early integration\n* think of the PR not as something that __you wrote__, but as something that __someone else is going to read__. The commit series in the PR should tell a novice developer the story of your thoughts when developing it\n\n## How to write a bug report\n\n* Please be polite, we all are humans and problems can occur.\n* Please add as much information as possible, for example\n  * client os(s) and version(s)\n    * browser(s) and version(s), is the problem reproducible on different clients\n    * special environments like firewalls or antivirus\n  * host os and version\n    * npm and nodejs version\n    * Logfiles if available\n  * steps to reproduce\n  * what you expected to happen\n  * what actually happened\n* Please format logfiles and code examples with markdown see github Markdown help below the issue textarea for more information.\n\nIf you send logfiles, please set the loglevel switch DEBUG in your settings.json file:\n\n```\n/* The log level we are using, can be: DEBUG, INFO, WARN, ERROR */\n  \"loglevel\": \"DEBUG\",\n```\n\nThe logfile location is defined in startup script or the log is directly shown in the commandline after you have started etherpad.\n\n## General goals of Etherpad\nTo make sure everybody is going in the same direction:\n* easy to install for admins and easy to use for people\n* easy to integrate into other apps, but also usable as standalone\n* lightweight and scalable\n* extensible, as much functionality should be extendable with plugins so changes don't have to be done in core.\nAlso, keep it maintainable. We don't wanna end up as the monster Etherpad was!\n\n## How to work with git?\n* Don't work in your master branch.\n* Make a new branch for every feature you're working on. (This ensures that you can work you can do lots of small, independent pull requests instead of one big one with complete different features)\n* Don't use the online edit function of github (this only creates ugly and not working commits!)\n* Try to make clean commits that are easy readable (including descriptive commit messages!)\n* Test before you push. Sounds easy, it isn't!\n* Don't check in stuff that gets generated during build or runtime\n* Make small pull requests that are easy to review but make sure they do add value by themselves / individually\n\n## Coding style\n* Do write comments. (You don't have to comment every line, but if you come up with something that's a bit complex/weird, just leave a comment. Bear in mind that you will probably leave the project at some point and that other people will read your code. Undocumented huge amounts of code are worthless!)\n* Never ever use tabs\n* Indentation: 2 spaces\n* Don't overengineer. Don't try to solve any possible problem in one step, but try to solve problems as easy as possible and improve the solution over time!\n* Do generalize sooner or later! (if an old solution, quickly hacked together, poses more problems than it solves today, refactor it!)\n* Keep it compatible. Do not introduce changes to the public API, db schema or configurations too lightly. Don't make incompatible changes without good reasons!\n* If you do make changes, document them! (see below)\n* Use protocol independent urls \"//\"\n\n## Branching model / git workflow\nsee git flow http://nvie.com/posts/a-successful-git-branching-model/\n\n### `master` branch\n* the stable\n* This is the branch everyone should use for production stuff\n\n### `develop`branch\n* everything that is READY to go into master at some point in time\n* This stuff is tested and ready to go out\n\n### release branches\n* stuff that should go into master very soon\n* only bugfixes go into these (see http://nvie.com/posts/a-successful-git-branching-model/ for why)\n* we should not be blocking new features to develop, just because we feel that we should be releasing it to master soon. This is the situation that release branches solve/handle.\n\n### hotfix branches\n* fixes for bugs in master\n\n### feature branches (in your own repos)\n* these are the branches where you develop your features in\n* If it's ready to go out, it will be merged into develop\n\nOver the time we pull features from feature branches into the develop branch. Every month we pull from develop into master. Bugs in master get fixed in hotfix branches. These branches will get merged into master AND develop. There should never be commits in master that aren't in develop\n\n## Documentation\nThe docs are in the `doc/` folder in the git repository, so people can easily find the suitable docs for the current git revision.\n\nDocumentation should be kept up-to-date. This means, whenever you add a new API method, add a new hook or change the database model, pack the relevant changes to the docs in the same pull request.\n\nYou can build the docs e.g. produce html, using `make docs`. At some point in the future we will provide an online documentation. The current documentation in the github wiki should always reflect the state of `master` (!), since there are no docs in master, yet.\n\n## Testing\nFront-end tests are found in the `tests/frontend/` folder in the repository. Run them by pointing your browser to `<yourdomainhere>/tests/frontend`.\n\nBack-end tests can be run from the `src` directory, via `npm test`.\nYou can use `npm test -- --inspect-brk` and navigate to `edge://inspect` or `chrome://inspect` to debug the tests.\n\n## Things you can help with\nEtherpad is much more than software.  So if you aren't a developer then worry not, there is still a LOT you can do!  A big part of what we do is community engagement.  You can help in the following ways\n * Triage bugs (applying labels) and confirming their existence\n * Testing fixes (simply applying them and seeing if it fixes your issue or not) - Some git experience required\n * Notifying large site admins of new releases\n * Writing Changelogs for releases\n * Creating Windows packages\n * Creating releases\n * Bumping dependencies periodically and checking they don't break anything\n * Write proposals for grants\n * Co-Author and Publish CVEs\n * Work with SFC to maintain legal side of project\n * Maintain TODO page - https://github.com/ether/etherpad-lite/wiki/TODO#IMPORTANT_TODOS\n\n"
  },
  {
    "path": "Dockerfile",
    "content": "# Etherpad Lite Dockerfile\n#\n# https://github.com/ether/etherpad-lite\n#\n# Author: muxator\nARG BUILD_ENV=git\n\nARG PnpmVersion=10.28.2\n\nFROM node:lts-alpine AS adminbuild\nRUN npm install -g pnpm@${PnpmVersion}\nWORKDIR /opt/etherpad-lite\nCOPY . .\nRUN pnpm install\nRUN pnpm run build:ui\n\n\nFROM node:lts-alpine AS build\nLABEL maintainer=\"Etherpad team, https://github.com/ether/etherpad-lite\"\n\n# Set these arguments when building the image from behind a proxy\nARG http_proxy=\nARG https_proxy=\nARG no_proxy=\n\nARG TIMEZONE=\n\nRUN \\\n  [ -z \"${TIMEZONE}\" ] || { \\\n    apk add --no-cache tzdata && \\\n    cp /usr/share/zoneinfo/${TIMEZONE} /etc/localtime && \\\n    echo \"${TIMEZONE}\" > /etc/timezone; \\\n  }\nENV TIMEZONE=${TIMEZONE}\n\n# Control the configuration file to be copied into the container.\nARG SETTINGS=./settings.json.docker\n\n# plugins to install while building the container. By default no plugins are\n# installed.\n# If given a value, it has to be a space-separated, quoted list of plugin names.\n#\n# EXAMPLE:\n#   ETHERPAD_PLUGINS=\"ep_codepad ep_author_neat\"\nARG ETHERPAD_PLUGINS=\n\n# local plugins to install while building the container. By default no plugins are\n# installed.\n# If given a value, it has to be a space-separated, quoted list of plugin names.\n#\n# EXAMPLE:\n#   ETHERPAD_LOCAL_PLUGINS=\"../ep_my_plugin ../ep_another_plugin\"\nARG ETHERPAD_LOCAL_PLUGINS=\n\n# github plugins to install while building the container. By default no plugins are\n# installed.\n# If given a value, it has to be a space-separated, quoted list of plugin names.\n#\n# EXAMPLE:\n#   ETHERPAD_GITHUB_PLUGINS=\"ether/ep_plugin\"\nARG ETHERPAD_GITHUB_PLUGINS=\n\n# Control whether abiword will be installed, enabling exports to DOC/PDF/ODT formats.\n# By default, it is not installed.\n# If given any value, abiword will be installed.\n#\n# EXAMPLE:\n#   INSTALL_ABIWORD=true\nARG INSTALL_ABIWORD=\n\n# Control whether libreoffice will be installed, enabling exports to DOC/PDF/ODT formats.\n# By default, it is not installed.\n# If given any value, libreoffice will be installed.\n#\n# EXAMPLE:\n#   INSTALL_LIBREOFFICE=true\nARG INSTALL_SOFFICE=\n\n# Install dependencies required for modifying access.\nRUN apk add --no-cache shadow bash\n# Follow the principle of least privilege: run as unprivileged user.\n#\n# Running as non-root enables running this image in platforms like OpenShift\n# that do not allow images running as root.\n#\n# If any of the following args are set to the empty string, default\n# values will be chosen.\nARG EP_HOME=\nARG EP_UID=5001\nARG EP_GID=0\nARG EP_SHELL=\n\nRUN groupadd --system ${EP_GID:+--gid \"${EP_GID}\" --non-unique} etherpad && \\\n    useradd --system ${EP_UID:+--uid \"${EP_UID}\" --non-unique} --gid etherpad \\\n        ${EP_HOME:+--home-dir \"${EP_HOME}\"} --create-home \\\n        ${EP_SHELL:+--shell \"${EP_SHELL}\"} etherpad\n\nARG EP_DIR=/opt/etherpad-lite\nRUN mkdir -p \"${EP_DIR}\" && chown etherpad:etherpad \"${EP_DIR}\"\n\n# the mkdir is needed for configuration of openjdk-11-jre-headless, see\n# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=863199\nRUN  \\\n    mkdir -p /usr/share/man/man1 && \\\n    npm install pnpm@${PnpmVersion} -g  && \\\n    apk update && apk upgrade && \\\n    apk add --no-cache \\\n        ca-certificates \\\n        curl \\\n        git \\\n        ${INSTALL_ABIWORD:+abiword abiword-plugin-command} \\\n        ${INSTALL_SOFFICE:+libreoffice openjdk8-jre libreoffice-common} && \\\n    rm -rf /var/cache/apk/*\n\nUSER etherpad\n\nWORKDIR \"${EP_DIR}\"\n\n# etherpads version feature requires this. Only copy what is really needed\nCOPY --chown=etherpad:etherpad ${SETTINGS} ./settings.json\nCOPY --chown=etherpad:etherpad ./var ./var\nCOPY --chown=etherpad:etherpad ./bin ./bin\nCOPY --chown=etherpad:etherpad ./pnpm-workspace.yaml ./package.json ./\n\n\n\nFROM build AS build_git\nONBUILD COPY --chown=etherpad:etherpad ./.git/HEA[D] ./.git/HEAD\nONBUILD COPY --chown=etherpad:etherpad ./.git/ref[s] ./.git/refs\n\nFROM build AS build_copy\n\n\n\n\nFROM build_${BUILD_ENV} AS development\n\nARG ETHERPAD_PLUGINS=\nARG ETHERPAD_LOCAL_PLUGINS=\nARG ETHERPAD_LOCAL_PLUGINS_ENV=\nARG ETHERPAD_GITHUB_PLUGINS=\n\nCOPY --chown=etherpad:etherpad ./src/ ./src/\nCOPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/templates/admin ./src/templates/admin\nCOPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/static/oidc ./src/static/oidc\n\nCOPY --chown=etherpad:etherpad ./local_plugin[s] ./local_plugins/\n\nRUN bash -c ./bin/installLocalPlugins.sh\n\nRUN bin/installDeps.sh && \\\n  if [ ! -z \"${ETHERPAD_PLUGINS}\" ] || [ ! -z \"${ETHERPAD_GITHUB_PLUGINS}\" ]; then \\\n      pnpm run plugins i ${ETHERPAD_PLUGINS} ${ETHERPAD_GITHUB_PLUGINS:+--github ${ETHERPAD_GITHUB_PLUGINS}}; \\\n  fi\n\n\nFROM build_${BUILD_ENV} AS production\n\nARG ETHERPAD_PLUGINS=\nARG ETHERPAD_LOCAL_PLUGINS=\nARG ETHERPAD_LOCAL_PLUGINS_ENV=\nARG ETHERPAD_GITHUB_PLUGINS=\n\nENV NODE_ENV=production\nENV ETHERPAD_PRODUCTION=true\n\nCOPY --chown=etherpad:etherpad ./src ./src\nCOPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/templates/admin ./src/templates/admin\nCOPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/static/oidc ./src/static/oidc\n\nCOPY --chown=etherpad:etherpad ./local_plugin[s] ./local_plugins/\n\nRUN bash -c ./bin/installLocalPlugins.sh\n\nRUN bin/installDeps.sh && \\\n  if [ ! -z \"${ETHERPAD_PLUGINS}\" ] || [ ! -z \"${ETHERPAD_GITHUB_PLUGINS}\" ]; then \\\n      pnpm run plugins i ${ETHERPAD_PLUGINS} ${ETHERPAD_GITHUB_PLUGINS:+--github ${ETHERPAD_GITHUB_PLUGINS}}; \\\n  fi && \\\n    pnpm store prune\n\n# Copy the configuration file.\nCOPY --chown=etherpad:etherpad ${SETTINGS} \"${EP_DIR}\"/settings.json\n\n# Fix group permissions\n# Note: For some reason increases image size from 257 to 334.\n# RUN chmod -R g=u .\n\nUSER etherpad\n\nHEALTHCHECK --interval=5s --timeout=3s \\\n  CMD curl --silent http://localhost:9001/health | grep -E \"pass|ok|up\" > /dev/null || exit 1\n\nEXPOSE 9001\nCMD [\"pnpm\", \"run\", \"prod\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2013 THE ETHERPAD FOUNDATION\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "# Etherpad: A real-time collaborative editor for the web\n\n![Demo Etherpad Animated Jif](doc/public/etherpad_demo.gif \"Etherpad in action\")\n\n## About\n\nEtherpad is a real-time collaborative editor [scalable to thousands of\nsimultaneous real time users](http://scale.etherpad.org/). It provides [full\ndata\nexport](https://github.com/ether/etherpad-lite/wiki/Understanding-Etherpad's-Full-Data-Export-capabilities)\ncapabilities, and runs on _your_ server, under _your_ control.\n\n## Try it out\n\n[Try out a public Etherpad instance](https://github.com/ether/etherpad-lite/wiki/Sites-That-Run-Etherpad#sites-that-run-etherpad)\n\n## Project Status\n\nWe're looking for maintainers and have some funding available.  Please contact John McLear if you can help.\n\n### Code Quality\n\n[![Code Quality](https://github.com/ether/etherpad-lite/actions/workflows/codeql-analysis.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/codeql-analysis.yml)\n\n### Testing\n\n[![Backend tests](https://github.com/ether/etherpad-lite/actions/workflows/backend-tests.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/backend-tests.yml)\n[![Simulated Load](https://github.com/ether/etherpad-lite/actions/workflows/load-test.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/load-test.yml)\n[![Rate Limit](https://github.com/ether/etherpad-lite/actions/workflows/rate-limit.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/rate-limit.yml)\n[![Docker file](https://github.com/ether/etherpad-lite/actions/workflows/dockerfile.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/dockerfile.yml)\n[![Frontend admin tests powered by Sauce Labs](https://github.com/ether/etherpad-lite/actions/workflows/frontend-admin-tests.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/frontend-admin-tests.yml)\n[![Frontend tests powered by Sauce Labs](https://github.com/ether/etherpad-lite/actions/workflows/frontend-tests.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/frontend-tests.yml)\n[![Sauce Test Status](https://saucelabs.com/buildstatus/etherpad.svg)](https://saucelabs.com/u/etherpad)\n[![Windows Build](https://github.com/ether/etherpad-lite/actions/workflows/windows.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/windows.yml)\n\n### Engagement\n\n[![Docker Pulls](https://img.shields.io/docker/pulls/etherpad/etherpad?color=%2344b492)](https://hub.docker.com/r/etherpad/etherpad)\n[![Discord](https://img.shields.io/discord/741309013593030667?color=%2344b492)](https://discord.com/invite/daEjfhw)\n[![Etherpad plugins](https://img.shields.io/endpoint?url=https%3A%2F%2Fstatic.etherpad.org%2Fshields.json&color=%2344b492 \"Etherpad plugins\")](https://static.etherpad.org/index.html)\n![Languages](https://img.shields.io/static/v1?label=Languages&message=105&color=%2344b492)\n![Translation Coverage](https://img.shields.io/static/v1?label=Languages&message=98%&color=%2344b492)\n\n## Installation\n\n### Docker-Compose\n\n```yaml\nservices:\n  app:\n    user: \"0:0\"\n    image: etherpad/etherpad:latest\n    tty: true\n    stdin_open: true\n    volumes:\n      - plugins:/opt/etherpad-lite/src/plugin_packages\n      - etherpad-var:/opt/etherpad-lite/var\n    depends_on:\n      - postgres\n    environment:\n      NODE_ENV: production\n      ADMIN_PASSWORD: ${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:-admin}\n      DB_CHARSET: ${DOCKER_COMPOSE_APP_DB_CHARSET:-utf8mb4}\n      DB_HOST: postgres\n      DB_NAME: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad}\n      DB_PASS: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin}\n      DB_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432}\n      DB_TYPE: \"postgres\"\n      DB_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}\n      # For now, the env var DEFAULT_PAD_TEXT cannot be unset or empty; it seems to be mandatory in the latest version of etherpad\n      DEFAULT_PAD_TEXT: ${DOCKER_COMPOSE_APP_DEFAULT_PAD_TEXT:- }\n      DISABLE_IP_LOGGING: ${DOCKER_COMPOSE_APP_DISABLE_IP_LOGGING:-false}\n      SOFFICE: ${DOCKER_COMPOSE_APP_SOFFICE:-null}\n      TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-true}\n    restart: always\n    ports:\n      - \"${DOCKER_COMPOSE_APP_PORT_PUBLISHED:-9001}:${DOCKER_COMPOSE_APP_PORT_TARGET:-9001}\"\n\n  postgres:\n    image: postgres:15-alpine\n    environment:\n      POSTGRES_DB: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad}\n      POSTGRES_PASSWORD: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin}\n      POSTGRES_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432}\n      POSTGRES_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}\n      PGDATA: /var/lib/postgresql/data/pgdata\n    restart: always\n    # Exposing the port is not needed unless you want to access this database instance from the host.\n    # Be careful when other postgres docker container are running on the same port\n    # ports:\n    #   - \"5432:5432\"\n    volumes:\n      - postgres_data:/var/lib/postgresql/data\n\nvolumes:\n  postgres_data:\n  plugins:\n  etherpad-var:\n```\n\n### Requirements\n\n[Node.js](https://nodejs.org/).\n\n### Windows, macOS, Linux\n\n1. Download the latest Node.js runtime from [nodejs.org](https://nodejs.org/).\n2. Install pnpm: `npm install -g pnpm` (Administrator privileges may be required).\n3. Clone the repository: `git clone -b master`\n4. Run `pnpm i`\n5. Run `pnpm run build:etherpad`\n6. Run `pnpm run prod`\n7. Visit `http://localhost:9001` in your browser.\n\n### Docker container\n\nFind [here](doc/docker.adoc) information on running Etherpad in a container.\n\n## Plugins\n\nEtherpad is very customizable through plugins.\n\n![Basic install](doc/public/etherpad_basic.png \"Basic Installation\")\n\n![Full Features](doc/public/etherpad_full_features.png \"You can add a lot of plugins !\")\n\n### Available Plugins\n\nFor a list of available plugins, see the [plugins\nsite](https://static.etherpad.org).\n\n### Plugin Installation\n\nYou can install plugins from the admin web interface (e.g.,\nhttp://127.0.0.1:9001/admin/plugins).\n\nAlternatively, you can install plugins from the command line:\n\n```sh\ncd /path/to/etherpad-lite\npnpm run plugins i ep_${plugin_name}\n```\n\nAlso see [the plugin wiki\narticle](https://github.com/ether/etherpad-lite/wiki/Available-Plugins).\n\n### Suggested Plugins\n\nRun the following command in your Etherpad folder to get all of the features\nvisible in the above demo gif:\n\n```sh\npnpm run plugins i \\\n  ep_align \\\n  ep_comments_page \\\n  ep_embedded_hyperlinks2 \\\n  ep_font_color \\\n  ep_headings2 \\\n  ep_markdown \\\n  ep_webrtc\n```\n\nFor user authentication, you are encouraged to run an [OpenID\nConnect](https://openid.net/connect/) identity provider (OP) and install the\nfollowing plugins:\n\n  * [ep_openid_connect](https://github.com/ether/ep_openid_connect#readme) to\n    authenticate against your OP.\n  * [ep_guest](https://github.com/ether/ep_guest#readme) to create a\n    \"guest\" account that has limited access (e.g., read-only access).\n  * [ep_user_displayname](https://github.com/ether/ep_user_displayname#readme)\n    to automatically populate each user's displayed name from your OP.\n  * [ep_stable_authorid](https://github.com/ether/ep_stable_authorid#readme) so\n    that each user's chosen color, display name, comment ownership, etc. is\n    strongly linked to their account.\n\n### Upgrade Etherpad\n\nRun the following command in your Etherpad folder to upgrade\n\n1. Stop any running Etherpad (manual, systemd ...)\n2. Get present version\n```sh\ngit -P tag --contains\n```\n3. List versions available\n```sh\ngit -P tag --list \"v*\" --merged\n```\n4. Select the version\n```sh\ngit checkout v2.2.5\ngit switch -c v2.2.5\n```\n5. Upgrade Etherpad\n```sh\n./bin/run.sh\n```\n6. Stop with [CTRL-C]\n7. Restart your Etherpad service\n\n## Next Steps\n\n### Tweak the settings\n\nYou can modify the settings in `settings.json`. If you need to handle multiple\nsettings files, you can pass the path to a settings file to `bin/run.sh`\nusing the `-s|--settings` option: this allows you to run multiple Etherpad\ninstances from the same installation. Similarly, `--credentials` can be used to\ngive a settings override file, `--apikey` to give a different APIKEY.txt file\nand `--sessionkey` to give a non-default `SESSIONKEY.txt`. **Each configuration\nparameter can also be set via an environment variable**, using the syntax\n`\"${ENV_VAR}\"` or `\"${ENV_VAR:default_value}\"`. For details, refer to\n`settings.json.template`. Once you have access to your `/admin` section,\nsettings can be modified through the web browser.\n\nIf you are planning to use Etherpad in a production environment, you should use\na dedicated database such as `mysql`, since the `dirtyDB` database driver is\nonly for testing and/or development purposes.\n\n### Secure your installation\n\nIf you have enabled authentication in `users` section in `settings.json`, it is\na good security practice to **store hashes instead of plain text passwords** in\nthat file. This is _especially_ advised if you are running a production\ninstallation.\n\nPlease install [ep_hash_auth plugin](https://www.npmjs.com/package/ep_hash_auth)\nand configure it. If you prefer, `ep_hash_auth` also gives you the option of\nstoring the users in a custom directory in the file system, without having to\nedit `settings.json` and restart Etherpad each time.\n\n### Customize the style with skin variants\n\nOpen http://127.0.0.1:9001/p/test#skinvariantsbuilder in your browser and start\nplaying!\n\n![Skin Variant](doc/public/etherpad_skin_variants.gif \"Skin variants\")\n\n## Helpful resources\n\nThe [wiki](https://github.com/ether/etherpad-lite/wiki) is your one-stop\nresource for Tutorials and How-to's.\n\nDocumentation can be found in `doc/`.\n\n## Development\n\n### Things you should know\n\nYou can debug Etherpad using `bin/debugRun.sh`.\n\nYou can run Etherpad quickly launching `bin/fastRun.sh`. It's convenient for\ndevelopers and advanced users. Be aware that it will skip the dependencies\nupdate, so remember to run `bin/installDeps.sh` after installing a new\ndependency or upgrading version.\n\nIf you want to find out how Etherpad's `Easysync` works (the library that makes\nit really realtime), start with this\n[PDF](https://github.com/ether/etherpad-lite/raw/master/doc/easysync/easysync-full-description.pdf)\n(complex, but worth reading).\n\n### Contributing\n\nRead our [**Developer\nGuidelines**](https://github.com/ether/etherpad-lite/blob/master/CONTRIBUTING.md)\n\n### HTTP API\n\nEtherpad is designed to be easily embeddable and provides a [HTTP\nAPI](https://github.com/ether/etherpad-lite/wiki/HTTP-API) that allows your web\napplication to manage pads, users and groups. It is recommended to use the\n[available client\nimplementations](https://github.com/ether/etherpad-lite/wiki/HTTP-API-client-libraries)\nin order to interact with this API.\n\nOpenAPI (previously swagger) definitions for the API are exposed under\n`/api/openapi.json`.\n\n### jQuery plugin\n\nThere is a [jQuery plugin](https://github.com/ether/etherpad-lite-jquery-plugin)\nthat helps you to embed Pads into your website.\n\n### Plugin Framework\n\nEtherpad offers a plugin framework, allowing you to easily add your own\nfeatures. By default your Etherpad is extremely light-weight and it's up to you\nto customize your experience. Once you have Etherpad installed you should [visit\nthe plugin page](https://static.etherpad.org/) and take control.\n\n### Translations / Localizations  (i18n / l10n)\n\nEtherpad comes with translations into all languages thanks to the team at\n[TranslateWiki](https://translatewiki.net/).\n\nIf you require translations in [plugins](https://static.etherpad.org/) please\nsend pull request to each plugin individually.\n\n## FAQ\n\nVisit the **[FAQ](https://github.com/ether/etherpad-lite/wiki/FAQ)**.\n\n## Get in touch\n\nThe official channel for contacting the development team is via the [GitHub\nissues](https://github.com/ether/etherpad-lite/issues).\n\nFor **responsible disclosure of vulnerabilities**, please write a mail to the\nmaintainers (a.mux@inwind.it and contact@etherpad.org).\n\nJoin the official [Etherpad Discord\nChannel](https://discord.com/invite/daEjfhw).\n\n## License\n\n[Apache License v2](http://www.apache.org/licenses/LICENSE-2.0.html)\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Reporting a Vulnerability\n\nPlease email contact@etherpad.org to report security related issues.\n"
  },
  {
    "path": "admin/.eslintrc.cjs",
    "content": "module.exports = {\n  root: true,\n  env: { browser: true, es2020: true },\n  extends: [\n    'eslint:recommended',\n    'plugin:@typescript-eslint/recommended',\n    'plugin:react-hooks/recommended',\n  ],\n  ignorePatterns: ['dist', '.eslintrc.cjs'],\n  parser: '@typescript-eslint/parser',\n  plugins: ['react-refresh'],\n  rules: {\n    'react-refresh/only-export-components': [\n      'warn',\n      { allowConstantExport: true },\n    ],\n  },\n}\n"
  },
  {
    "path": "admin/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "admin/README.md",
    "content": "# React + TypeScript + Vite\n\nThis template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.\n\nCurrently, two official plugins are available:\n\n- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh\n- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh\n\n## Expanding the ESLint configuration\n\nIf you are developing a production application, we recommend updating the configuration to enable type aware lint rules:\n\n- Configure the top-level `parserOptions` property like this:\n\n```js\nexport default {\n  // other rules...\n  parserOptions: {\n    ecmaVersion: 'latest',\n    sourceType: 'module',\n    project: ['./tsconfig.json', './tsconfig.node.json'],\n    tsconfigRootDir: __dirname,\n  },\n}\n```\n\n- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`\n- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`\n- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list\n"
  },
  {
    "path": "admin/index.html",
    "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    <title>Etherpad Admin Dashboard</title>\n    <link rel=\"icon\" href=\"/favicon.ico\">\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <div id=\"loading\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "admin/package.json",
    "content": "{\n  \"name\": \"admin\",\n  \"private\": true,\n  \"version\": \"2.6.1\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"tsc && vite build\",\n    \"lint\": \"eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0\",\n    \"build-copy\": \"tsc && vite build --outDir ../src/templates/admin --emptyOutDir\",\n    \"preview\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"@radix-ui/react-switch\": \"^1.2.6\"\n  },\n  \"devDependencies\": {\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-toast\": \"^1.2.15\",\n    \"@types/react\": \"^19.2.14\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"@typescript-eslint/eslint-plugin\": \"^8.57.1\",\n    \"@typescript-eslint/parser\": \"^8.57.1\",\n    \"@vitejs/plugin-react\": \"^6.0.1\",\n    \"babel-plugin-react-compiler\": \"19.1.0-rc.3\",\n    \"eslint\": \"^10.0.3\",\n    \"eslint-plugin-react-hooks\": \"^7.0.1\",\n    \"eslint-plugin-react-refresh\": \"^0.5.2\",\n    \"i18next\": \"^25.8.19\",\n    \"i18next-browser-languagedetector\": \"^8.2.1\",\n    \"lucide-react\": \"^0.577.0\",\n    \"react\": \"^19.2.4\",\n    \"react-dom\": \"^19.2.4\",\n    \"react-hook-form\": \"^7.71.2\",\n    \"react-i18next\": \"^16.5.8\",\n    \"react-router-dom\": \"^7.13.1\",\n    \"socket.io-client\": \"^4.8.3\",\n    \"typescript\": \"^5.9.3\",\n    \"vite\": \"npm:rolldown-vite@7.2.10\",\n    \"vite-plugin-babel\": \"^1.6.0\",\n    \"vite-plugin-static-copy\": \"^3.3.0\",\n    \"zustand\": \"^5.0.12\"\n  },\n  \"overrides\": {\n    \"vite\": \"npm:rolldown-vite@7.2.10\"\n  }\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/ar.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Meno25\",\n\t\t\t\"محمد أحمد عبد الفتاح\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"فعل\",\n\t\"ep_adminpads2_autoupdate-label\": \"التحديث التلقائي على تغييرات الوسادة\",\n\t\"ep_adminpads2_autoupdate.title\": \"لتمكين أو تعطيل التحديثات التلقائية للاستعلام الحالي.\",\n\t\"ep_adminpads2_confirm\": \"هل تريد حقًا حذف الوسادة {{padID}}؟\",\n\t\"ep_adminpads2_delete.value\": \"حذف\",\n\t\"ep_adminpads2_last-edited\": \"آخر تعديل\",\n\t\"ep_adminpads2_loading\": \"جارٍ التحميل...\",\n\t\"ep_adminpads2_manage-pads\": \"إدارة الفوط\",\n\t\"ep_adminpads2_no-results\": \"لا توجد نتائج.\",\n\t\"ep_adminpads2_pad-user-count\": \"عدد المستخدمين الوسادة\",\n\t\"ep_adminpads2_padname\": \"بادنام\",\n\t\"ep_adminpads2_search-box.placeholder\": \"مصطلح البحث\",\n\t\"ep_adminpads2_search-button.value\": \"بحث\",\n\t\"ep_adminpads2_search-done\": \"اكتمل البحث\",\n\t\"ep_adminpads2_search-error-explanation\": \"واجه الخادم خطأً أثناء البحث عن منصات:\",\n\t\"ep_adminpads2_search-error-title\": \"فشل في الحصول على قائمة الوسادة\",\n\t\"ep_adminpads2_search-heading\": \"ابحث عن الفوط\",\n\t\"ep_adminpads2_title\": \"إدارة الوسادة\",\n\t\"ep_adminpads2_unknown-error\": \"خطأ غير معروف\",\n\t\"ep_adminpads2_unknown-status\": \"حالة غير معروفة\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/bn.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"আজিজ\",\n\t\t\t\"আফতাবুজ্জামান\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"কার্য\",\n\t\"ep_adminpads2_delete.value\": \"মুছে ফেলুন\",\n\t\"ep_adminpads2_last-edited\": \"সর্বশেষ সম্পাদিত\",\n\t\"ep_adminpads2_loading\": \"লোড হচ্ছে...\",\n\t\"ep_adminpads2_manage-pads\": \"প্যাড পরিচালনা করুন\",\n\t\"ep_adminpads2_no-results\": \"ফলাফল নেই\",\n\t\"ep_adminpads2_padname\": \"প্যাডের নাম\",\n\t\"ep_adminpads2_search-button.value\": \"অনুসন্ধান\",\n\t\"ep_adminpads2_search-done\": \"অনুসন্ধান সম্পূর্ণ\",\n\t\"ep_adminpads2_search-error-explanation\": \"প্যাড অনুসন্ধান করার সময় সার্ভার একটি ত্রুটির সম্মুখীন হয়েছে:\",\n\t\"ep_adminpads2_search-error-title\": \"প্যাডের তালিকা পেতে ব্যর্থ\",\n\t\"ep_adminpads2_search-heading\": \"প্যাড অনুসন্ধান করুন\",\n\t\"ep_adminpads2_title\": \"প্যাড প্রশাসন\",\n\t\"ep_adminpads2_unknown-error\": \"অজানা ত্রুটি\",\n\t\"ep_adminpads2_unknown-status\": \"অজানা অবস্থা\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/ca.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Mguix\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Acció\",\n\t\"ep_adminpads2_autoupdate-label\": \"Actualització automàtica en cas de canvis de pad\",\n\t\"ep_adminpads2_autoupdate.title\": \"Activa o desactiva les actualitzacions automàtiques per a la consulta actual.\",\n\t\"ep_adminpads2_confirm\": \"Esteu segur que voleu suprimir el pad {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Esborrar\",\n\t\"ep_adminpads2_last-edited\": \"Darrera modificació\",\n\t\"ep_adminpads2_loading\": \"S’està carregant…\",\n\t\"ep_adminpads2_manage-pads\": \"Gestiona els pads\",\n\t\"ep_adminpads2_no-results\": \"No hi ha cap resultat\",\n\t\"ep_adminpads2_pad-user-count\": \"Nombre d'usuaris de pads\",\n\t\"ep_adminpads2_padname\": \"Nom del pad\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Terme de cerca\",\n\t\"ep_adminpads2_search-button.value\": \"Cercar\",\n\t\"ep_adminpads2_search-done\": \"Cerca completa\",\n\t\"ep_adminpads2_search-error-explanation\": \"El servidor ha trobat un error mentre buscava pads:\",\n\t\"ep_adminpads2_search-error-title\": \"No s'ha pogut obtenir la llista del pad\",\n\t\"ep_adminpads2_search-heading\": \"Cerca pads\",\n\t\"ep_adminpads2_title\": \"Administració del pad\",\n\t\"ep_adminpads2_unknown-error\": \"Error desconegut\",\n\t\"ep_adminpads2_unknown-status\": \"Estat desconegut\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/cs.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Spotter\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Akce\",\n\t\"ep_adminpads2_autoupdate-label\": \"Automatická aktualizace změn Padu\",\n\t\"ep_adminpads2_autoupdate.title\": \"Povolí nebo zakáže automatické aktualizace pro aktuální dotaz.\",\n\t\"ep_adminpads2_confirm\": \"Opravdu chcete odstranit pad {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Smazat\",\n\t\"ep_adminpads2_last-edited\": \"Naposledy upraveno\",\n\t\"ep_adminpads2_loading\": \"Načítání…\",\n\t\"ep_adminpads2_manage-pads\": \"Spravovat pady\",\n\t\"ep_adminpads2_no-results\": \"Žádné výsledky\",\n\t\"ep_adminpads2_pad-user-count\": \"Počet uživatelů padu\",\n\t\"ep_adminpads2_padname\": \"Název padu\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Hledaný výraz\",\n\t\"ep_adminpads2_search-button.value\": \"Hledat\",\n\t\"ep_adminpads2_search-done\": \"Hledání dokončeno\",\n\t\"ep_adminpads2_search-error-explanation\": \"Při hledání padů došlo k chybě serveru:\",\n\t\"ep_adminpads2_search-error-title\": \"Seznam padů se nepodařilo získat\",\n\t\"ep_adminpads2_search-heading\": \"Hledat pady\",\n\t\"ep_adminpads2_title\": \"Správa Padu\",\n\t\"ep_adminpads2_unknown-error\": \"Neznámá chyba\",\n\t\"ep_adminpads2_unknown-status\": \"Neznámý stav\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/cy.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Robin Owain\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Gweithred\",\n\t\"ep_adminpads2_autoupdate-label\": \"Diweddaru newidiadau pad yn otomatig\",\n\t\"ep_adminpads2_autoupdate.title\": \"Galluogi neu analluogi diweddaru'r ymholiad cyfredol.\",\n\t\"ep_adminpads2_confirm\": \"Siwr eich bod am ddileu'r pad {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Dileu\",\n\t\"ep_adminpads2_last-edited\": \"Golygwyd ddiwethaf\",\n\t\"ep_adminpads2_loading\": \"Wrthi'n llwytho...\",\n\t\"ep_adminpads2_manage-pads\": \"Rheoli'r padiau\",\n\t\"ep_adminpads2_no-results\": \"Dim canlyniad\",\n\t\"ep_adminpads2_pad-user-count\": \"Cyfri defnyddiwr pad\",\n\t\"ep_adminpads2_padname\": \"Enwpad\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Term chwilio\",\n\t\"ep_adminpads2_search-button.value\": \"Chwilio\",\n\t\"ep_adminpads2_search-done\": \"Wedi gorffen\",\n\t\"ep_adminpads2_search-error-explanation\": \"Nam ar y gweinydd wrth chwilio'r padiau:\",\n\t\"ep_adminpads2_search-error-title\": \"Methwyd a chael y rhestr pad\",\n\t\"ep_adminpads2_search-heading\": \"Chwilio am badiau\",\n\t\"ep_adminpads2_title\": \"Gweinyddiaeth y pad\",\n\t\"ep_adminpads2_unknown-error\": \"Nam o ryw fath\",\n\t\"ep_adminpads2_unknown-status\": \"Statws anhysbys\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/da.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Saederup92\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Handling\",\n\t\"ep_adminpads2_delete.value\": \"Slet\",\n\t\"ep_adminpads2_last-edited\": \"Sidst redigeret\",\n\t\"ep_adminpads2_loading\": \"Indlæser...\",\n\t\"ep_adminpads2_no-results\": \"Ingen resultater\",\n\t\"ep_adminpads2_unknown-error\": \"Ukendt fejl\",\n\t\"ep_adminpads2_unknown-status\": \"Ukendt status\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/de.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Brettchenweber\",\n\t\t\t\"Justman10000\",\n\t\t\t\"Lorisobi\",\n\t\t\t\"SamTV\",\n\t\t\t\"Umlaut\",\n\t\t\t\"Zunkelty\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Aktion\",\n\t\"ep_adminpads2_autoupdate-label\": \"Automatisch bei Pad-Änderungen updaten\",\n\t\"ep_adminpads2_autoupdate.title\": \"Aktiviert oder deaktiviert automatische Aktualisierungen für die aktuelle Abfrage.\",\n\t\"ep_adminpads2_confirm\": \"Willst du das Pad {{padID}} wirklich löschen?\",\n\t\"ep_adminpads2_delete.value\": \"Löschen\",\n  \"ep_adminpads2_cleanup\": \"Historie aufräumen\",\n\t\"ep_adminpads2_last-edited\": \"Zuletzt bearbeitet\",\n\t\"ep_adminpads2_loading\": \"Lädt...\",\n\t\"ep_adminpads2_manage-pads\": \"Pads verwalten\",\n\t\"ep_adminpads2_no-results\": \"Keine Ergebnisse\",\n\t\"ep_adminpads2_pad-user-count\": \"Nutzerzahl des Pads\",\n\t\"ep_adminpads2_padname\": \"Padname\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Suchbegriff\",\n\t\"ep_adminpads2_search-button.value\": \"Suche\",\n\t\"ep_adminpads2_search-done\": \"Suche vollendet\",\n\t\"ep_adminpads2_search-error-explanation\": \"Der Server ist bei der Suche nach Pads auf einen Fehler gestoßen:\",\n\t\"ep_adminpads2_search-error-title\": \"Pad-Liste konnte nicht abgerufen werden\",\n\t\"ep_adminpads2_search-heading\": \"Nach Pads suchen\",\n\t\"ep_adminpads2_title\": \"Pad-Verwaltung\",\n\t\"ep_adminpads2_unknown-error\": \"Unbekannter Fehler\",\n\t\"ep_adminpads2_unknown-status\": \"Unbekannter Status\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/diq.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"1917 Ekim Devrimi\",\n\t\t\t\"Mirzali\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Hereketi\",\n\t\"ep_adminpads2_autoupdate-label\": \"Vurnayışanê pedi otomatik rocane kerê\",\n\t\"ep_adminpads2_autoupdate.title\": \"Persê mewcudi rê rocaneyışanê otomatika aktiv ke ya zi dewrê ra vecê\",\n\t\"ep_adminpads2_confirm\": \"Şıma qayılê pedê {{padID}} bıesternê?\",\n\t\"ep_adminpads2_delete.value\": \"Bestere\",\n\t\"ep_adminpads2_last-edited\": \"Vurnayışo peyên\",\n\t\"ep_adminpads2_loading\": \"Bar beno...\",\n\t\"ep_adminpads2_manage-pads\": \"Pedan idare kerê\",\n\t\"ep_adminpads2_no-results\": \"Netice çıniyo\",\n\t\"ep_adminpads2_pad-user-count\": \"Amarê karberanê pedi\",\n\t\"ep_adminpads2_padname\": \"Padname\",\n\t\"ep_adminpads2_search-box.placeholder\": \"termê cıgêrayış\",\n\t\"ep_adminpads2_search-button.value\": \"Cı geyre\",\n\t\"ep_adminpads2_search-done\": \"Cıgeyrayışi temam\",\n\t\"ep_adminpads2_search-error-explanation\": \"Server cıgeyrayışê pedan de yew xetaya raşt ame\",\n\t\"ep_adminpads2_search-error-title\": \"Lista pedi nêgêriye\",\n\t\"ep_adminpads2_search-heading\": \"Pedan cıgeyrayış\",\n\t\"ep_adminpads2_title\": \"İdarey pedi\",\n\t\"ep_adminpads2_unknown-error\": \"Xetaya nêzanıtiye\",\n\t\"ep_adminpads2_unknown-status\": \"Weziyeto nêzanaye\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/dsb.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Michawiki\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Akcija\",\n\t\"ep_adminpads2_autoupdate-label\": \"Pśi změnach na zapisniku awtomatiski aktualizěrowaś\",\n\t\"ep_adminpads2_autoupdate.title\": \"Zmóžnja abo znjemóžnja awtomatiske aktualizacije za aktualne wótpšašowanje.\",\n\t\"ep_adminpads2_confirm\": \"Cośo napšawdu zapisnik {{padID}} lašowaś?\",\n\t\"ep_adminpads2_delete.value\": \"Lašowaś\",\n\t\"ep_adminpads2_last-edited\": \"Slědna změna\",\n\t\"ep_adminpads2_loading\": \"Zacytujo se...\",\n\t\"ep_adminpads2_manage-pads\": \"Zapisniki zastojaś\",\n\t\"ep_adminpads2_no-results\": \"Žedne wuslědki\",\n\t\"ep_adminpads2_pad-user-count\": \"Licba wužywarjow zapisnika\",\n\t\"ep_adminpads2_padname\": \"Mě zapisnika\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Pytańske zapśimjeśe\",\n\t\"ep_adminpads2_search-button.value\": \"Pytaś\",\n\t\"ep_adminpads2_search-done\": \"Pytanje dokóńcone\",\n\t\"ep_adminpads2_search-error-explanation\": \"Serwer jo starcył na zmólku, mjaztym až jo pytał za zapisnikami:\",\n\t\"ep_adminpads2_search-error-title\": \"Lisćina zapisnikow njedajo se wobstaraś\",\n\t\"ep_adminpads2_search-heading\": \"Za zapisnikami pytaś\",\n\t\"ep_adminpads2_title\": \"Zapisnikowa administracija\",\n\t\"ep_adminpads2_unknown-error\": \"Njeznata zmólka\",\n\t\"ep_adminpads2_unknown-status\": \"Njeznaty status\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/el.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Norhorn\"\n\t\t]\n\t},\n\t\"ep_adminpads2_delete.value\": \"Διαγραφή\",\n\t\"ep_adminpads2_last-edited\": \"Τελευταία απεξεργασία\",\n\t\"ep_adminpads2_loading\": \"Φόρτωση…\",\n\t\"ep_adminpads2_no-results\": \"Κανένα αποτέλεσμα\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Αναζήτηση όρων\",\n\t\"ep_adminpads2_search-button.value\": \"Αναζήτηση\",\n\t\"ep_adminpads2_search-done\": \"Ολοκλήρωση αναζήτησης\",\n\t\"ep_adminpads2_unknown-error\": \"Άγνωστο σφάλμα\",\n\t\"ep_adminpads2_unknown-status\": \"Άγνωστη κατάσταση\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/en.json",
    "content": "{\n  \"ep_adminpads2_action\": \"Action\",\n  \"ep_adminpads2_autoupdate-label\": \"Auto-update on pad changes\",\n  \"ep_adminpads2_autoupdate.title\": \"Enables or disables automatic updates for the current query.\",\n  \"ep_adminpads2_confirm\": \"Do you really want to delete the pad {{padID}}?\",\n  \"ep_adminpads2_delete.value\": \"Delete\",\n  \"ep_adminpads2_cleanup\": \"Cleanup revisions\",\n  \"ep_adminpads2_last-edited\": \"Last edited\",\n  \"ep_adminpads2_loading\": \"Loading…\",\n  \"ep_adminpads2_manage-pads\": \"Manage pads\",\n  \"ep_adminpads2_no-results\": \"No results\",\n  \"ep_adminpads2_pad-user-count\": \"Pad user count\",\n  \"ep_adminpads2_padname\": \"Padname\",\n  \"ep_adminpads2_search-box.placeholder\": \"Search term\",\n  \"ep_adminpads2_search-button.value\": \"Search\",\n  \"ep_adminpads2_search-done\": \"Search complete\",\n  \"ep_adminpads2_search-error-explanation\": \"The server encountered an error while searching for pads:\",\n  \"ep_adminpads2_search-error-title\": \"Failed to get pad list\",\n  \"ep_adminpads2_search-heading\": \"Search for pads\",\n  \"ep_adminpads2_title\": \"Pad administration\",\n  \"ep_adminpads2_unknown-error\": \"Unknown error\",\n  \"ep_adminpads2_unknown-status\": \"Unknown status\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/eu.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Izendegi\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Ekintza\",\n\t\"ep_adminpads2_autoupdate-label\": \"Automatikoki eguneratu pad-aren aldaketak daudenean\",\n\t\"ep_adminpads2_autoupdate.title\": \"Oraingo kontsultarako eguneratze automatikoak gaitu edo desgaitzen du.\",\n\t\"ep_adminpads2_confirm\": \"Ziur zaude {{padID}} pad-a ezabatu nahi duzula?\",\n\t\"ep_adminpads2_delete.value\": \"Ezabatu\",\n\t\"ep_adminpads2_last-edited\": \"Azkenengoz editatua\",\n\t\"ep_adminpads2_loading\": \"Kargatzen...\",\n\t\"ep_adminpads2_manage-pads\": \"Kudeatu pad-ak\",\n\t\"ep_adminpads2_no-results\": \"Emaitzarik ez\",\n\t\"ep_adminpads2_pad-user-count\": \"Pad-erabiltzaile kopurua\",\n\t\"ep_adminpads2_padname\": \"Pad-izena\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Bilaketa testua\",\n\t\"ep_adminpads2_search-button.value\": \"Bilatu\",\n\t\"ep_adminpads2_search-done\": \"Bilaketa osatu da\",\n\t\"ep_adminpads2_search-error-explanation\": \"Zerbitzariak errore bat izan du pad-ak bilatzean:\",\n\t\"ep_adminpads2_search-error-title\": \"Pad-zerrenda eskuratzeak huts egin du\",\n\t\"ep_adminpads2_search-heading\": \"Bilatu pad-ak\",\n\t\"ep_adminpads2_title\": \"Pad-en kudeaketa\",\n\t\"ep_adminpads2_unknown-error\": \"Errore ezezaguna\",\n\t\"ep_adminpads2_unknown-status\": \"Egoera ezezaguna\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/ff.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Ibrahima Malal Sarr\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Baɗal\",\n\t\"ep_adminpads2_autoupdate-label\": \"Hesɗitin e jaajol tuma baylagol faɗo\",\n\t\"ep_adminpads2_autoupdate.title\": \"Hurminat walla daaƴa kesɗitine jaaje wonannde ɗaɓɓitannde wonaande.\",\n\t\"ep_adminpads2_confirm\": \"Aɗa yiɗi e jaati momtude faɗo {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Momtu\",\n\t\"ep_adminpads2_last-edited\": \"Taƴtaa sakket\",\n\t\"ep_adminpads2_loading\": \"Nana loowa…\",\n\t\"ep_adminpads2_manage-pads\": \"Toppito paɗe\",\n\t\"ep_adminpads2_no-results\": \"Alaa njaltudi\",\n\t\"ep_adminpads2_pad-user-count\": \"Limoore huutorɓe faɗo\",\n\t\"ep_adminpads2_padname\": \"Innde faɗo\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Helmere njiilaw\",\n\t\"ep_adminpads2_search-button.value\": \"Yiylo\",\n\t\"ep_adminpads2_search-done\": \"Njiylaw timmii\",\n\t\"ep_adminpads2_search-error-explanation\": \"Sarworde ndee hawrii e juumre tuma nde yiylotoo faɗo:\",\n\t\"ep_adminpads2_search-error-title\": \"Horiima heɓde doggol paɗe\",\n\t\"ep_adminpads2_search-heading\": \"Yiylo paɗe\",\n\t\"ep_adminpads2_title\": \"Yiylorde paɗe\",\n\t\"ep_adminpads2_unknown-error\": \"Juumre nde anndaaka\",\n\t\"ep_adminpads2_unknown-status\": \"Ngonka ka anndaaka\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/fi.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Artnay\",\n\t\t\t\"Kyykaarme\",\n\t\t\t\"MITO\",\n\t\t\t\"Maantietäjä\",\n\t\t\t\"Yupik\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Toiminto\",\n\t\"ep_adminpads2_delete.value\": \"Poista\",\n\t\"ep_adminpads2_last-edited\": \"Viimeksi muokattu\",\n\t\"ep_adminpads2_loading\": \"Ladataan...\",\n\t\"ep_adminpads2_manage-pads\": \"Hallitse muistioita\",\n\t\"ep_adminpads2_no-results\": \"Ei tuloksia\",\n\t\"ep_adminpads2_pad-user-count\": \"Pad-käyttäjien määrä\",\n\t\"ep_adminpads2_padname\": \"Muistion nimi\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Haettava teksti\",\n\t\"ep_adminpads2_search-button.value\": \"Etsi\",\n\t\"ep_adminpads2_search-done\": \"Haku valmis\",\n\t\"ep_adminpads2_search-error-explanation\": \"Palvelimessa tapahtui virhe etsiessään muistioita:\",\n\t\"ep_adminpads2_search-error-title\": \"Pad-luettelon hakeminen epäonnistui\",\n\t\"ep_adminpads2_search-heading\": \"Etsi sisältöä\",\n\t\"ep_adminpads2_unknown-error\": \"Tuntematon virhe\",\n\t\"ep_adminpads2_unknown-status\": \"Tuntematon tila\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/fr.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Verdy p\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Action\",\n\t\"ep_adminpads2_autoupdate-label\": \"Mise à jour automatique en cas de changements du bloc-notes\",\n\t\"ep_adminpads2_autoupdate.title\": \"Active ou désactive les mises à jour automatiques pour la requête actuelle.\",\n\t\"ep_adminpads2_confirm\": \"Voulez-vous vraiment supprimer le bloc-notes {{padID}} ?\",\n\t\"ep_adminpads2_delete.value\": \"Supprimer\",\n\t\"ep_adminpads2_last-edited\": \"Dernière modification\",\n\t\"ep_adminpads2_loading\": \"Chargement en cours...\",\n\t\"ep_adminpads2_manage-pads\": \"Gérer les bloc-notes\",\n\t\"ep_adminpads2_no-results\": \"Aucun résultat\",\n\t\"ep_adminpads2_pad-user-count\": \"Nombre d’utilisateurs du bloc-notes\",\n\t\"ep_adminpads2_padname\": \"Nom du bloc-notes\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Terme de recherche\",\n\t\"ep_adminpads2_search-button.value\": \"Rechercher\",\n\t\"ep_adminpads2_search-done\": \"Recherche terminée\",\n\t\"ep_adminpads2_search-error-explanation\": \"Le serveur a rencontré une erreur en cherchant des blocs-notes :\",\n\t\"ep_adminpads2_search-error-title\": \"Échec d’obtention de la liste de blocs-notes\",\n\t\"ep_adminpads2_search-heading\": \"Rechercher des blocs-notes\",\n\t\"ep_adminpads2_title\": \"Administration du bloc-notes\",\n\t\"ep_adminpads2_unknown-error\": \"Erreur inconnue\",\n\t\"ep_adminpads2_unknown-status\": \"État inconnu\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/gl.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Ghose\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Accións\",\n\t\"ep_adminpads2_autoupdate-label\": \"Actualización automática dos cambios\",\n\t\"ep_adminpads2_autoupdate.title\": \"Activa ou desactiva as actualizacións automáticas para a consulta actual.\",\n\t\"ep_adminpads2_confirm\": \"Tes a certeza de querer eliminar o pad {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Eliminar\",\n\t\"ep_adminpads2_last-edited\": \"Última edición\",\n\t\"ep_adminpads2_loading\": \"Cargando…\",\n\t\"ep_adminpads2_manage-pads\": \"Xestionar pads\",\n\t\"ep_adminpads2_no-results\": \"Sen resultados\",\n\t\"ep_adminpads2_pad-user-count\": \"Usuarias neste pad\",\n\t\"ep_adminpads2_padname\": \"Nome do pad\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Buscar termo\",\n\t\"ep_adminpads2_search-button.value\": \"Buscar\",\n\t\"ep_adminpads2_search-done\": \"Busca completa\",\n\t\"ep_adminpads2_search-error-explanation\": \"O servidor atopou un fallo cando buscaba pads:\",\n\t\"ep_adminpads2_search-error-title\": \"Non se obtivo a lista de pads\",\n\t\"ep_adminpads2_search-heading\": \"Buscar pads\",\n\t\"ep_adminpads2_title\": \"Administración do pad\",\n\t\"ep_adminpads2_unknown-error\": \"Erro descoñecido\",\n\t\"ep_adminpads2_unknown-status\": \"Estado descoñecido\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/he.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"YaronSh\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"פעולה\",\n\t\"ep_adminpads2_autoupdate-label\": \"לעדכן אוטומטית כשהמחברת נערכת\",\n\t\"ep_adminpads2_autoupdate.title\": \"הפעלה או השבתה של עדכונים אוטומטיים לשאילתה הנוכחית.\",\n\t\"ep_adminpads2_confirm\": \"למחוק את המחברת {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"מחיקה\",\n\t\"ep_adminpads2_last-edited\": \"עריכה אחרונה\",\n\t\"ep_adminpads2_loading\": \"בטעינה…\",\n\t\"ep_adminpads2_manage-pads\": \"ניהול מחברות\",\n\t\"ep_adminpads2_no-results\": \"אין תוצאות\",\n\t\"ep_adminpads2_pad-user-count\": \"ספירת משתמשים במחברת\",\n\t\"ep_adminpads2_padname\": \"שם המחברת\",\n\t\"ep_adminpads2_search-box.placeholder\": \"הביטוי לחיפוש\",\n\t\"ep_adminpads2_search-button.value\": \"חיפוש\",\n\t\"ep_adminpads2_search-done\": \"החיפוש הושלם\",\n\t\"ep_adminpads2_search-error-explanation\": \"השרת נתקל בשגיאה בעת חיפוש מחברות:\",\n\t\"ep_adminpads2_search-error-title\": \"קבלת רשימת המחברות נכשלה\",\n\t\"ep_adminpads2_search-heading\": \"חיפוש אחר מחברות\",\n\t\"ep_adminpads2_title\": \"ניהול מחברות\",\n\t\"ep_adminpads2_unknown-error\": \"שגיאה בלתי־ידועה\",\n\t\"ep_adminpads2_unknown-status\": \"מצב לא ידוע\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/hsb.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Michawiki\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Akcija\",\n\t\"ep_adminpads2_autoupdate-label\": \"Při změnach na zapisniku awtomatisce aktualizować\",\n\t\"ep_adminpads2_autoupdate.title\": \"Zmóžnja abo znjemóžnja awtomatiske aktualizacije za aktualne wotprašowanje.\",\n\t\"ep_adminpads2_confirm\": \"Chceće woprawdźe zapisnik {{padID}} zhašeć?\",\n\t\"ep_adminpads2_delete.value\": \"Zhašeć\",\n\t\"ep_adminpads2_last-edited\": \"Poslednja změna\",\n\t\"ep_adminpads2_loading\": \"Začituje so...\",\n\t\"ep_adminpads2_manage-pads\": \"Zapisniki rjadować\",\n\t\"ep_adminpads2_no-results\": \"Žane wuslědki.\",\n\t\"ep_adminpads2_pad-user-count\": \"Ličba wužiwarjow zapisnika\",\n\t\"ep_adminpads2_padname\": \"Mjeno zapisnika\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Pytanske zapřijeće\",\n\t\"ep_adminpads2_search-button.value\": \"Pytać\",\n\t\"ep_adminpads2_search-done\": \"Pytanje dokónčene\",\n\t\"ep_adminpads2_search-error-explanation\": \"Serwer je na zmylk storčił, mjeztym zo je za zapisnikami pytał:\",\n\t\"ep_adminpads2_search-error-title\": \"Lisćina zapisnikow njeda so wobstarać\",\n\t\"ep_adminpads2_search-heading\": \"Za zapisnikami pytać\",\n\t\"ep_adminpads2_title\": \"Zapisnikowa administracija\",\n\t\"ep_adminpads2_unknown-error\": \"Njeznaty zmylk\",\n\t\"ep_adminpads2_unknown-status\": \"Njeznaty status\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/hu.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": []\n\t},\n\t\"ep_adminpads2_action\": \"Művelet\",\n\t\"ep_adminpads2_autoupdate-label\": \"Változáskor jegyzetfüzet önműködő frissítése\",\n\t\"ep_adminpads2_autoupdate.title\": \"Önműködő frissítése az jelenlegi lekérdezéshez be- vagy kikapcsolása.\",\n\t\"ep_adminpads2_confirm\": \"Biztosan törölni szeretné a(z) {{padID}} jegyzetfüzetet?\",\n\t\"ep_adminpads2_delete.value\": \"Törlés\",\n\t\"ep_adminpads2_last-edited\": \"Utoljára szerkesztve\",\n\t\"ep_adminpads2_loading\": \"Betöltés folyamatban…\",\n\t\"ep_adminpads2_manage-pads\": \"Jegyzetfüzetek kezelése\",\n\t\"ep_adminpads2_no-results\": \"Nincs találat\",\n\t\"ep_adminpads2_pad-user-count\": \"Jegyzetfüzet felhasználók száma\",\n\t\"ep_adminpads2_padname\": \"Jegyzetfüzet név\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Keresési kifejezés\",\n\t\"ep_adminpads2_search-button.value\": \"Keresés\",\n\t\"ep_adminpads2_search-done\": \"Keresés befejezve\",\n\t\"ep_adminpads2_search-error-explanation\": \"A kiszolgáló hibát észlelt a jegyzetfüzetek keresésekor:\",\n\t\"ep_adminpads2_search-error-title\": \"Nem sikerült lekérni a jegyzetfüzet listát\",\n\t\"ep_adminpads2_search-heading\": \"Jegyzetfüzetek keresése\",\n\t\"ep_adminpads2_title\": \"Jegyzetfüzet felügyelete\",\n\t\"ep_adminpads2_unknown-error\": \"Ismeretlen hiba\",\n\t\"ep_adminpads2_unknown-status\": \"Ismeretlen állapot\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/ia.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"McDutchie\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Action\",\n\t\"ep_adminpads2_autoupdate-label\": \"Actualisar automaticamente le pad in caso de cambiamentos\",\n\t\"ep_adminpads2_autoupdate.title\": \"Activa o disactiva le actualisationes automatic pro le consulta actual.\",\n\t\"ep_adminpads2_confirm\": \"Es tu secur de voler deler le pad {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Deler\",\n\t\"ep_adminpads2_last-edited\": \"Ultime modification\",\n\t\"ep_adminpads2_loading\": \"Cargamento in curso…\",\n\t\"ep_adminpads2_manage-pads\": \"Gerer pads\",\n\t\"ep_adminpads2_no-results\": \"Nulle resultato\",\n\t\"ep_adminpads2_pad-user-count\": \"Numero de usatores del pad\",\n\t\"ep_adminpads2_padname\": \"Nomine del pad\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Termino de recerca\",\n\t\"ep_adminpads2_search-button.value\": \"Cercar\",\n\t\"ep_adminpads2_search-done\": \"Recerca terminate\",\n\t\"ep_adminpads2_search-error-explanation\": \"Le servitor ha incontrate un error durante le recerca de pads:\",\n\t\"ep_adminpads2_search-error-title\": \"Non poteva obtener le lista de pads\",\n\t\"ep_adminpads2_search-heading\": \"Cercar pads\",\n\t\"ep_adminpads2_title\": \"Administration de pads\",\n\t\"ep_adminpads2_unknown-error\": \"Error incognite\",\n\t\"ep_adminpads2_unknown-status\": \"Stato incognite\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/it.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Beta16\",\n\t\t\t\"Luca.favorido\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Azione\",\n\t\"ep_adminpads2_delete.value\": \"Cancella\",\n\t\"ep_adminpads2_last-edited\": \"Ultima modifica\",\n\t\"ep_adminpads2_loading\": \"Caricamento…\",\n\t\"ep_adminpads2_no-results\": \"Nessun risultato\",\n\t\"ep_adminpads2_search-button.value\": \"Cerca\",\n\t\"ep_adminpads2_unknown-error\": \"Errore sconosciuto\",\n\t\"ep_adminpads2_unknown-status\": \"Stato sconosciuto\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/kn.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"ಮಲ್ನಾಡಾಚ್ ಕೊಂಕ್ಣೊ\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"ಕ್ರಿಯೆ\",\n\t\"ep_adminpads2_delete.value\": \"ಅಳಿಸು\",\n\t\"ep_adminpads2_loading\": \"ತುಂಬಿಸಲಾಗುತ್ತಿದೆ…\",\n\t\"ep_adminpads2_no-results\": \"ಯಾವ ಫಲಿತಾಂಶಗಳೂ ಇಲ್ಲ\",\n\t\"ep_adminpads2_search-button.value\": \"ಹುಡುಕು\",\n\t\"ep_adminpads2_unknown-error\": \"ಅಪರಿಚಿತ ದೋಷ\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/ko.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Ykhwong\",\n\t\t\t\"그냥기여자\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"동작\",\n\t\"ep_adminpads2_autoupdate-label\": \"패드 변경 시 자동 업데이트\",\n\t\"ep_adminpads2_autoupdate.title\": \"현재 쿼리의 자동 업데이트를 활성화하거나 비활성화합니다.\",\n\t\"ep_adminpads2_confirm\": \"{{padID}} 패드를 삭제하시겠습니까?\",\n\t\"ep_adminpads2_delete.value\": \"삭제\",\n\t\"ep_adminpads2_last-edited\": \"최근 편집\",\n\t\"ep_adminpads2_loading\": \"불러오는 중...\",\n\t\"ep_adminpads2_manage-pads\": \"패드 관리\",\n\t\"ep_adminpads2_no-results\": \"결과 없음\",\n\t\"ep_adminpads2_pad-user-count\": \"패드 사용자 수\",\n\t\"ep_adminpads2_padname\": \"패드 이름\",\n\t\"ep_adminpads2_search-box.placeholder\": \"검색어\",\n\t\"ep_adminpads2_search-button.value\": \"검색\",\n\t\"ep_adminpads2_search-done\": \"검색 완료\",\n\t\"ep_adminpads2_search-error-explanation\": \"패드 검색 중 서버에 오류가 발생했습니다:\",\n\t\"ep_adminpads2_search-error-title\": \"패드 목록 가져오기 실패\",\n\t\"ep_adminpads2_search-heading\": \"패드 검색\",\n\t\"ep_adminpads2_title\": \"패드 관리\",\n\t\"ep_adminpads2_unknown-error\": \"알 수 없는 오류\",\n\t\"ep_adminpads2_unknown-status\": \"알 수 없는 상태\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/krc.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Къарачайлы\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Этиу\",\n\t\"ep_adminpads2_autoupdate-label\": \"Блокнот тюрлендириулеринде автомат халда джангыртыу\",\n\t\"ep_adminpads2_autoupdate.title\": \"Баргъан излем ючюн автомат халда джангыртыуланы джандын неда джукълат.\",\n\t\"ep_adminpads2_confirm\": \"{{padID}} блокнотну керти да кетерирге излеймисиз?\",\n\t\"ep_adminpads2_delete.value\": \"Кетер\",\n\t\"ep_adminpads2_last-edited\": \"Ахыр тюзетиу\",\n\t\"ep_adminpads2_loading\": \"Джюклениу…\",\n\t\"ep_adminpads2_manage-pads\": \"Блокнотланы оноуун эт\",\n\t\"ep_adminpads2_no-results\": \"Эсебле джокъдула\",\n\t\"ep_adminpads2_pad-user-count\": \"Блокнот хайырланыучуланы саны\",\n\t\"ep_adminpads2_padname\": \"Блокнот ат\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Терминни изле\",\n\t\"ep_adminpads2_search-button.value\": \"Изле\",\n\t\"ep_adminpads2_search-done\": \"Излеу тамамланды\",\n\t\"ep_adminpads2_search-error-explanation\": \"Сервер, блокнотланы излеген заманда халат  табды:\",\n\t\"ep_adminpads2_search-error-title\": \"Блокнот тизмеси алынамады\",\n\t\"ep_adminpads2_search-heading\": \"Блокнотла ючюн излеу\",\n\t\"ep_adminpads2_title\": \"Блокнот башчылыкъ\",\n\t\"ep_adminpads2_unknown-error\": \"Билинмеген халат\",\n\t\"ep_adminpads2_unknown-status\": \"Билинмеген турум\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/lb.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Robby\",\n\t\t\t\"Volvox\"\n\t\t]\n\t},\n\t\"ep_adminpads2_confirm\": \"Wëllt Dir de Pad {{padID}} wierklech läschen?\",\n\t\"ep_adminpads2_delete.value\": \"Läschen\",\n\t\"ep_adminpads2_loading\": \"Lueden...\",\n\t\"ep_adminpads2_no-results\": \"Keng Resultater\",\n\t\"ep_adminpads2_padname\": \"Padnumm\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Sichbegrëff\",\n\t\"ep_adminpads2_search-button.value\": \"Sichen\",\n\t\"ep_adminpads2_unknown-error\": \"Onbekannte Feeler\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/lt.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Nokeoo\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Veiksmas\",\n\t\"ep_adminpads2_autoupdate-label\": \"Automatinis bloknoto keitimų naujinimas\",\n\t\"ep_adminpads2_autoupdate.title\": \"Įjungia arba išjungia automatinius dabartinės užklausos atnaujinimus.\",\n\t\"ep_adminpads2_confirm\": \"Ar tikrai norite ištrinti bloknotą {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Ištrinti\",\n\t\"ep_adminpads2_last-edited\": \"Paskutinis pakeitimas\",\n\t\"ep_adminpads2_loading\": \"Įkeliama…\",\n\t\"ep_adminpads2_manage-pads\": \"Tvarkyti bloknotą\",\n\t\"ep_adminpads2_no-results\": \"Nėra rezultatų\",\n\t\"ep_adminpads2_pad-user-count\": \"Bloknoto naudotojų skaičius\",\n\t\"ep_adminpads2_padname\": \"Bloknoto pavadinimas\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Paieškos terminas\",\n\t\"ep_adminpads2_search-button.value\": \"Paieška\",\n\t\"ep_adminpads2_search-done\": \"Paieška baigta\",\n\t\"ep_adminpads2_search-error-explanation\": \"Serveris susidūrė su klaida ieškant bloknotų:\",\n\t\"ep_adminpads2_search-error-title\": \"Nepavyko gauti bloknotų sąrašo\",\n\t\"ep_adminpads2_search-heading\": \"Ieškokite bloknotų\",\n\t\"ep_adminpads2_title\": \"Bloknotų administravimas\",\n\t\"ep_adminpads2_unknown-error\": \"Nežinoma klaida\",\n\t\"ep_adminpads2_unknown-status\": \"Nežinoma būsena\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/mk.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Bjankuloski06\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Дејство\",\n\t\"ep_adminpads2_autoupdate-label\": \"Самоподнова при измени во тетратката\",\n\t\"ep_adminpads2_autoupdate.title\": \"Овозможува или оневозможува самоподнова на тековното барање.\",\n\t\"ep_adminpads2_confirm\": \"Дали навистина сакате да ја избришете тетратката {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Избриши\",\n\t\"ep_adminpads2_last-edited\": \"Последно уредување\",\n\t\"ep_adminpads2_loading\": \"Вчитувам…\",\n\t\"ep_adminpads2_manage-pads\": \"Раководење со тетратки\",\n\t\"ep_adminpads2_no-results\": \"Нема исход\",\n\t\"ep_adminpads2_pad-user-count\": \"Корисници на тетратката\",\n\t\"ep_adminpads2_padname\": \"Назив на тетратката\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Пребаран поим\",\n\t\"ep_adminpads2_search-button.value\": \"Пребарај\",\n\t\"ep_adminpads2_search-done\": \"Пребарувањето заврши\",\n\t\"ep_adminpads2_search-error-explanation\": \"Опслужувачот наиде на грешка при пребарувањето на тетратки:\",\n\t\"ep_adminpads2_search-error-title\": \"Не можев да го добијам списокот на тетратки\",\n\t\"ep_adminpads2_search-heading\": \"Пребарај по тетратките\",\n\t\"ep_adminpads2_title\": \"Администрација на тетратки\",\n\t\"ep_adminpads2_unknown-error\": \"Непозната грешка\",\n\t\"ep_adminpads2_unknown-status\": \"Непозната состојба\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/my.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Andibecker\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"လုပ်ဆောင်ချက်\",\n\t\"ep_adminpads2_autoupdate-label\": \"pad အပြောင်းအလဲများတွင်အလိုအလျောက်အပ်ဒိတ်လုပ်ပါ\",\n\t\"ep_adminpads2_autoupdate.title\": \"လက်ရှိမေးမြန်းမှုအတွက်အလိုအလျောက်အပ်ဒိတ်များကိုဖွင့်ပါသို့မဟုတ်ပိတ်ပါ။\",\n\t\"ep_adminpads2_confirm\": \"pad {{padID}} ကိုသင်တကယ်ဖျက်ချင်လား။\",\n\t\"ep_adminpads2_delete.value\": \"ဖျက်ပါ\",\n\t\"ep_adminpads2_last-edited\": \"နောက်ဆုံးတည်းဖြတ်သည်\",\n\t\"ep_adminpads2_loading\": \"ဖွင့်နေသည်…\",\n\t\"ep_adminpads2_manage-pads\": \"pads များကိုစီမံပါ\",\n\t\"ep_adminpads2_no-results\": \"ရလဒ်မရှိပါ\",\n\t\"ep_adminpads2_pad-user-count\": \"Pad အသုံးပြုသူအရေအတွက်\",\n\t\"ep_adminpads2_padname\": \"Padname\",\n\t\"ep_adminpads2_search-box.placeholder\": \"ဝေါဟာရရှာဖွေပါ\",\n\t\"ep_adminpads2_search-button.value\": \"ရှာဖွေပါ\",\n\t\"ep_adminpads2_search-done\": \"ရှာဖွေမှုပြီးပါပြီ\",\n\t\"ep_adminpads2_search-error-explanation\": \"pads များကိုရှာဖွေစဉ်ဆာဗာသည်အမှားတစ်ခုကြုံခဲ့သည်။\",\n\t\"ep_adminpads2_search-error-title\": \"pad စာရင်းရယူရန်မအောင်မြင်ပါ\",\n\t\"ep_adminpads2_search-heading\": \"pads များကိုရှာဖွေပါ\",\n\t\"ep_adminpads2_title\": \"Pad စီမံခန့်ခွဲမှု\",\n\t\"ep_adminpads2_unknown-error\": \"အမည်မသိအမှား\",\n\t\"ep_adminpads2_unknown-status\": \"အခြေအနေမသိ\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/nb.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"EdoAug\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Handling\",\n\t\"ep_adminpads2_last-edited\": \"Sist redigert\",\n\t\"ep_adminpads2_loading\": \"Laster …\",\n\t\"ep_adminpads2_no-results\": \"Ingen resultater\",\n\t\"ep_adminpads2_search-button.value\": \"Søk\",\n\t\"ep_adminpads2_search-done\": \"Søk fullført\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/nl.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Aranka\",\n\t\t\t\"McDutchie\",\n\t\t\t\"Spinster\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Handeling\",\n\t\"ep_adminpads2_autoupdate-label\": \"Automatisch bijwerken bij aanpassingen aan de pad\",\n\t\"ep_adminpads2_autoupdate.title\": \"Schakelt automatische updates voor de huidige query in of uit.\",\n\t\"ep_adminpads2_confirm\": \"Wil je de pad {{padID}} echt verwijderen?\",\n\t\"ep_adminpads2_delete.value\": \"Verwijderen\",\n\t\"ep_adminpads2_last-edited\": \"Laatst bewerkt\",\n\t\"ep_adminpads2_loading\": \"Bezig met laden...\",\n\t\"ep_adminpads2_manage-pads\": \"Pads beheren\",\n\t\"ep_adminpads2_no-results\": \"Geen resultaten\",\n\t\"ep_adminpads2_pad-user-count\": \"Aantal gebruikers van de pad\",\n\t\"ep_adminpads2_padname\": \"Naam van de pad\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Zoekterm\",\n\t\"ep_adminpads2_search-button.value\": \"Zoeken\",\n\t\"ep_adminpads2_search-done\": \"Zoekopdracht voltooid\",\n\t\"ep_adminpads2_search-error-explanation\": \"De server heeft een fout aangetroffen tijdens het zoeken naar pads:\",\n\t\"ep_adminpads2_search-error-title\": \"Kan lijst met pads niet ophalen\",\n\t\"ep_adminpads2_search-heading\": \"Pads zoeken\",\n\t\"ep_adminpads2_title\": \"Administratie van pad\",\n\t\"ep_adminpads2_unknown-error\": \"Onbekende fout\",\n\t\"ep_adminpads2_unknown-status\": \"Onbekende status\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/oc.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Quentí\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Accion\",\n\t\"ep_adminpads2_delete.value\": \"Suprimir\",\n\t\"ep_adminpads2_last-edited\": \"Darrièra edicion\",\n\t\"ep_adminpads2_loading\": \"Cargament…\",\n\t\"ep_adminpads2_manage-pads\": \"Gerir los pads\",\n\t\"ep_adminpads2_no-results\": \"Pas cap de resultat\",\n\t\"ep_adminpads2_padname\": \"Nom del pad\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Tèrme de recèrca\",\n\t\"ep_adminpads2_search-button.value\": \"Recercar\",\n\t\"ep_adminpads2_search-done\": \"Recèrca acabada\",\n\t\"ep_adminpads2_search-heading\": \"Cercar de pads\",\n\t\"ep_adminpads2_title\": \"Administracion de pad\",\n\t\"ep_adminpads2_unknown-error\": \"Error desconeguda\",\n\t\"ep_adminpads2_unknown-status\": \"Estat desconegut\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/pms.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Borichèt\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Assion\",\n\t\"ep_adminpads2_autoupdate-label\": \"Agiornament automàtich an sle modìfiche ëd plancia\",\n\t\"ep_adminpads2_autoupdate.title\": \"Abilité o disabilité j'agiornament automàtich për l'arcesta atual.\",\n\t\"ep_adminpads2_confirm\": \"Veul-lo për da bon dëscancelé la plancia {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Dëscancelé\",\n\t\"ep_adminpads2_last-edited\": \"Modificà l'ùltima vira\",\n\t\"ep_adminpads2_loading\": \"Cariament…\",\n\t\"ep_adminpads2_manage-pads\": \"Gestì le plance\",\n\t\"ep_adminpads2_no-results\": \"Gnun arzultà\",\n\t\"ep_adminpads2_pad-user-count\": \"Conteur ëd plancia dl'utent\",\n\t\"ep_adminpads2_padname\": \"Nòm ëd plancia\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Tèrmin d'arserca\",\n\t\"ep_adminpads2_search-button.value\": \"Arserca\",\n\t\"ep_adminpads2_search-done\": \"Arserca completà\",\n\t\"ep_adminpads2_search-error-explanation\": \"Ël servent a l'ha rancontrà n'eror an sërcand dle plance:\",\n\t\"ep_adminpads2_search-error-title\": \"Falì a oten-e la lista ëd plance\",\n\t\"ep_adminpads2_search-heading\": \"Arserca ëd plance\",\n\t\"ep_adminpads2_title\": \"Aministrassion ëd plance\",\n\t\"ep_adminpads2_unknown-error\": \"Eror nen conossù\",\n\t\"ep_adminpads2_unknown-status\": \"Statù nen conossù\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/pt-br.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Duke of Wikipädia\",\n\t\t\t\"Eduardo Addad de Oliveira\",\n\t\t\t\"Eduardoaddad\",\n\t\t\t\"YuriNikolai\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Ação\",\n\t\"ep_adminpads2_autoupdate-label\": \"Atualizar notas automaticamente\",\n\t\"ep_adminpads2_autoupdate.title\": \"Habilita ou desabilita atualizações automáticas para a consulta atual.\",\n\t\"ep_adminpads2_confirm\": \"Você realmente deseja excluir a nota {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Excluir\",\n\t\"ep_adminpads2_last-edited\": \"Última edição\",\n\t\"ep_adminpads2_loading\": \"Carregando…\",\n\t\"ep_adminpads2_manage-pads\": \"Gerenciar notas\",\n\t\"ep_adminpads2_no-results\": \"Sem resultados\",\n\t\"ep_adminpads2_pad-user-count\": \"Número de utilizadores na nota\",\n\t\"ep_adminpads2_padname\": \"Nome da nota\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Termo de pesquisa\",\n\t\"ep_adminpads2_search-button.value\": \"Pesquisar\",\n\t\"ep_adminpads2_search-done\": \"Busca completa\",\n\t\"ep_adminpads2_search-error-explanation\": \"O servidor encontrou um erro enquanto procurava por notas:\",\n\t\"ep_adminpads2_search-error-title\": \"Falha ao buscar lista de notas\",\n\t\"ep_adminpads2_search-heading\": \"Pesquisar por notas\",\n\t\"ep_adminpads2_title\": \"Administração de notas\",\n\t\"ep_adminpads2_unknown-error\": \"Erro desconhecido\",\n\t\"ep_adminpads2_unknown-status\": \"Status desconhecido\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/pt.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Guilha\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Ação\",\n\t\"ep_adminpads2_autoupdate-label\": \"Atualizar automaticamente as notas\",\n\t\"ep_adminpads2_autoupdate.title\": \"Ativa ou desativa atualizações automáticas na consulta atual.\",\n\t\"ep_adminpads2_confirm\": \"Tencionas mesmo eliminar a nota {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Eliminar\",\n\t\"ep_adminpads2_last-edited\": \"Última edição\",\n\t\"ep_adminpads2_loading\": \"A carregar...\",\n\t\"ep_adminpads2_manage-pads\": \"Gerir notas\",\n\t\"ep_adminpads2_no-results\": \"Sem resultados\",\n\t\"ep_adminpads2_pad-user-count\": \"Número de utilizadores na nota\",\n\t\"ep_adminpads2_padname\": \"Nome da nota\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Procurar termo\",\n\t\"ep_adminpads2_search-button.value\": \"Procurar\",\n\t\"ep_adminpads2_search-done\": \"Procura completa\",\n\t\"ep_adminpads2_search-error-explanation\": \"O servidor encontrou um erro enquanto procurava por notas:\",\n\t\"ep_adminpads2_search-error-title\": \"Falha ao obter lista de notas\",\n\t\"ep_adminpads2_search-heading\": \"Procurar por notas\",\n\t\"ep_adminpads2_title\": \"Administração da nota\",\n\t\"ep_adminpads2_unknown-error\": \"Erro desconhecido\",\n\t\"ep_adminpads2_unknown-status\": \"Estado desconhecido\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/qqq.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"BryanDavis\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"{{Identical|Action}}\",\n\t\"ep_adminpads2_delete.value\": \"{{Identical|Delete}}\",\n\t\"ep_adminpads2_search-button.value\": \"{{Identical|Search}}\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/ru.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"DDPAT\",\n\t\t\t\"Ice bulldog\",\n\t\t\t\"Megakott\",\n\t\t\t\"Okras\",\n\t\t\t\"Pacha Tchernof\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Действие\",\n\t\"ep_adminpads2_autoupdate-label\": \"Автообновление при изменении документа\",\n\t\"ep_adminpads2_autoupdate.title\": \"Включает или отключает автоматические обновления для текущего запроса.\",\n\t\"ep_adminpads2_confirm\": \"Вы действительно хотите удалить документ {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Удалить\",\n\t\"ep_adminpads2_last-edited\": \"Последнее изменение\",\n\t\"ep_adminpads2_loading\": \"Загружается…\",\n\t\"ep_adminpads2_manage-pads\": \"Управление документами\",\n\t\"ep_adminpads2_no-results\": \"Нет результатов\",\n\t\"ep_adminpads2_pad-user-count\": \"Количество пользователей документа\",\n\t\"ep_adminpads2_padname\": \"Название документа\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Искать термин\",\n\t\"ep_adminpads2_search-button.value\": \"Найти\",\n\t\"ep_adminpads2_search-done\": \"Поиск завершён\",\n\t\"ep_adminpads2_search-error-explanation\": \"Сервер обнаружил ошибку при поиске документов:\",\n\t\"ep_adminpads2_search-error-title\": \"Не удалось получить список документов\",\n\t\"ep_adminpads2_search-heading\": \"Поиск документов\",\n\t\"ep_adminpads2_title\": \"Администрирование документов\",\n\t\"ep_adminpads2_unknown-error\": \"Неизвестная ошибка\",\n\t\"ep_adminpads2_unknown-status\": \"Неизвестный статус\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/sc.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Adr mm\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Atzione\",\n\t\"ep_adminpads2_autoupdate-label\": \"Atualizatzione automàtica de is modìficas de su pad\",\n\t\"ep_adminpads2_autoupdate.title\": \"Ativat o disativat is atualizatziones automàticas pro sa chirca atuale.\",\n\t\"ep_adminpads2_confirm\": \"Seguru chi boles cantzellare su pad {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Cantzella\",\n\t\"ep_adminpads2_last-edited\": \"Ùrtima modìfica\",\n\t\"ep_adminpads2_loading\": \"Carrighende...\",\n\t\"ep_adminpads2_manage-pads\": \"Gesti is pads\",\n\t\"ep_adminpads2_no-results\": \"Nissunu resurtadu\",\n\t\"ep_adminpads2_pad-user-count\": \"Nùmeru de utentes de pads\",\n\t\"ep_adminpads2_padname\": \"Nòmine de su pad\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Tèrmine de chirca\",\n\t\"ep_adminpads2_search-button.value\": \"Chirca\",\n\t\"ep_adminpads2_search-done\": \"Chirca cumpleta\",\n\t\"ep_adminpads2_search-error-explanation\": \"Su serbidore at agatadu un'errore chirchende pads:\",\n\t\"ep_adminpads2_search-error-title\": \"Impossìbile otènnere sa lista de pads\",\n\t\"ep_adminpads2_search-heading\": \"Chirca pads\",\n\t\"ep_adminpads2_title\": \"Amministratzione de su pad\",\n\t\"ep_adminpads2_unknown-error\": \"Errore disconnotu\",\n\t\"ep_adminpads2_unknown-status\": \"Istadu disconnotu\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/sdc.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"F Samaritani\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Azioni\",\n\t\"ep_adminpads2_delete.value\": \"Canzella\",\n\t\"ep_adminpads2_loading\": \"carrigghendi...\",\n\t\"ep_adminpads2_no-results\": \"Nisciun risulthaddu\",\n\t\"ep_adminpads2_search-button.value\": \"Zercha\",\n\t\"ep_adminpads2_search-heading\": \"Zirchà dati\",\n\t\"ep_adminpads2_unknown-error\": \"Errori ischunisciddu\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/sk.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Yardom78\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Akcia\",\n\t\"ep_adminpads2_autoupdate-label\": \"Automatická aktualizácia zmien na poznámkovom bloku\",\n\t\"ep_adminpads2_autoupdate.title\": \"Zapne alebo vypne automatickú aktualizáciu.\",\n\t\"ep_adminpads2_confirm\": \"Skutočne chcete vymazať poznámkový blok {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Vymazať\",\n\t\"ep_adminpads2_last-edited\": \"Posledná úprava\",\n\t\"ep_adminpads2_loading\": \"Načítavanie...\",\n\t\"ep_adminpads2_manage-pads\": \"Spravovať poznámkové bloky\",\n\t\"ep_adminpads2_no-results\": \"Žiadne výsledky\",\n\t\"ep_adminpads2_pad-user-count\": \"Počet používateľov poznámkového bloku\",\n\t\"ep_adminpads2_padname\": \"Názov poznámkového bloku\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Hľadať výraz\",\n\t\"ep_adminpads2_search-button.value\": \"Hľadať\",\n\t\"ep_adminpads2_search-done\": \"Hľadanie dokončené\",\n\t\"ep_adminpads2_search-error-explanation\": \"Pri hľadaní poznámkového bloku došlo k chybe:\",\n\t\"ep_adminpads2_search-error-title\": \"Nepodarilo sa získať zoznam poznámkových blokov\",\n\t\"ep_adminpads2_search-heading\": \"Hľadať poznámkový blok\",\n\t\"ep_adminpads2_title\": \"Správa poznámkového bloku\",\n\t\"ep_adminpads2_unknown-error\": \"Neznáma chyba\",\n\t\"ep_adminpads2_unknown-status\": \"Neznámy stav\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/skr-arab.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Saraiki\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"عمل\",\n\t\"ep_adminpads2_delete.value\": \"مٹاؤ\",\n\t\"ep_adminpads2_last-edited\": \"چھیکڑی تبدیلی\",\n\t\"ep_adminpads2_loading\": \"لوڈ تھین٘دا پئے۔۔۔\",\n\t\"ep_adminpads2_manage-pads\": \"پیڈ منیج کرو\",\n\t\"ep_adminpads2_no-results\": \"کوئی نتیجہ کائنی\",\n\t\"ep_adminpads2_padname\": \"پیڈ ناں\",\n\t\"ep_adminpads2_search-box.placeholder\": \"ٹرم ڳولو\",\n\t\"ep_adminpads2_search-button.value\": \"ڳولو\",\n\t\"ep_adminpads2_search-done\": \"ڳولݨ پورا تھیا\",\n\t\"ep_adminpads2_search-heading\": \"پیڈاں دی ڳول\",\n\t\"ep_adminpads2_unknown-error\": \"نامعلوم غلطی\",\n\t\"ep_adminpads2_unknown-status\": \"نامعلوم حالت\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/sl.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Eleassar\",\n\t\t\t\"HairyFotr\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Dejanje\",\n\t\"ep_adminpads2_autoupdate-label\": \"Samodejno posodabljanje ob spremembah blokcev\",\n\t\"ep_adminpads2_autoupdate.title\": \"Omogoči ali onemogoči samodejne posodobitve za trenutno poizvedbo.\",\n\t\"ep_adminpads2_confirm\": \"Ali res želite izbrisati blokec {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Izbriši\",\n\t\"ep_adminpads2_last-edited\": \"Zadnje urejanje\",\n\t\"ep_adminpads2_loading\": \"Nalaganje ...\",\n\t\"ep_adminpads2_manage-pads\": \"Upravljanje blokcev\",\n\t\"ep_adminpads2_no-results\": \"Ni zadetkov\",\n\t\"ep_adminpads2_pad-user-count\": \"Število urejevalcev blokca\",\n\t\"ep_adminpads2_padname\": \"Ime blokca\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Iskalni izraz\",\n\t\"ep_adminpads2_search-button.value\": \"Išči\",\n\t\"ep_adminpads2_search-done\": \"Iskanje končano\",\n\t\"ep_adminpads2_search-error-explanation\": \"Strežnik je med iskanjem blokcev naletel na napako:\",\n\t\"ep_adminpads2_search-error-title\": \"Ni bilo mogoče pridobiti seznama blokcev\",\n\t\"ep_adminpads2_search-heading\": \"Iskanje blokcev\",\n\t\"ep_adminpads2_title\": \"Upravljanje blokcev\",\n\t\"ep_adminpads2_unknown-error\": \"Neznana napaka\",\n\t\"ep_adminpads2_unknown-status\": \"Neznano stanje\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/smn.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Yupik\"\n\t\t]\n\t},\n\t\"ep_adminpads2_delete.value\": \"Siho\",\n\t\"ep_adminpads2_last-edited\": \"Majemustáá nubástittum\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Uuccâmsääni\",\n\t\"ep_adminpads2_search-button.value\": \"Uusâ\",\n\t\"ep_adminpads2_unknown-error\": \"Tubdâmettum feilâ\",\n\t\"ep_adminpads2_unknown-status\": \"Tubdâmettum tile\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/sms.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Yupik\"\n\t\t]\n\t},\n\t\"ep_adminpads2_delete.value\": \"Jaukkâd\",\n\t\"ep_adminpads2_last-edited\": \"Mââimõssân muttum\",\n\t\"ep_adminpads2_no-results\": \"Ij käunnʼjam ni mii\",\n\t\"ep_adminpads2_padname\": \"Mošttʼtõspõʹmmai nõmm\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Ooccâmsääʹnn\",\n\t\"ep_adminpads2_search-button.value\": \"Ooʒʒ\",\n\t\"ep_adminpads2_search-heading\": \"Ooʒʒ mošttʼtõspõʹmmjid\",\n\t\"ep_adminpads2_unknown-error\": \"Toobdteʹmes vââʹǩǩ\",\n\t\"ep_adminpads2_unknown-status\": \"Toobdteʹmes status\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/sq.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Besnik b\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Veprim\",\n\t\"ep_adminpads2_autoupdate-label\": \"Vetëpërditësohu, kur nga ndryshime blloku\",\n\t\"ep_adminpads2_autoupdate.title\": \"Aktivizon ose çaktivizon përditësim të automatizuara për kërkesën e tanishme.\",\n\t\"ep_adminpads2_confirm\": \"Doni vërtet të fshihet blloku {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Fshije\",\n\t\"ep_adminpads2_last-edited\": \"Përpunuar së fundi më\",\n\t\"ep_adminpads2_loading\": \"Po ngarkohet…\",\n\t\"ep_adminpads2_manage-pads\": \"Administroni blloqe\",\n\t\"ep_adminpads2_no-results\": \"S’ka përfundime\",\n\t\"ep_adminpads2_pad-user-count\": \"Numër përdoruesish blloku\",\n\t\"ep_adminpads2_padname\": \"Emër blloku\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Term kërkimi\",\n\t\"ep_adminpads2_search-button.value\": \"Kërko\",\n\t\"ep_adminpads2_search-done\": \"Kërkim i plotë\",\n\t\"ep_adminpads2_search-error-explanation\": \"Shërbyesi hasi një gabim teksa kërkohej për blloqe:\",\n\t\"ep_adminpads2_search-error-title\": \"S’u arrit të merrej listë blloqesh\",\n\t\"ep_adminpads2_search-heading\": \"Kërkoni për blloqe\",\n\t\"ep_adminpads2_title\": \"Administrim blloku\",\n\t\"ep_adminpads2_unknown-error\": \"Gabim i panjohur\",\n\t\"ep_adminpads2_unknown-status\": \"Gjendje e panjohur\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/sv.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Bengtsson96\",\n\t\t\t\"WikiPhoenix\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Åtgärd\",\n\t\"ep_adminpads2_autoupdate-label\": \"Uppdatera automatiskt när blocket ändras\",\n\t\"ep_adminpads2_autoupdate.title\": \"Aktivera eller inaktivera automatiska uppdatering för nuvarande förfrågan.\",\n\t\"ep_adminpads2_confirm\": \"Vill du verkligen radera blocket {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Radera\",\n\t\"ep_adminpads2_last-edited\": \"Senast redigerad\",\n\t\"ep_adminpads2_loading\": \"Läser in …\",\n\t\"ep_adminpads2_manage-pads\": \"Hantera block\",\n\t\"ep_adminpads2_no-results\": \"Inga resultat\",\n\t\"ep_adminpads2_pad-user-count\": \"Antal blockanvändare\",\n\t\"ep_adminpads2_padname\": \"Blocknamn\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Sökord\",\n\t\"ep_adminpads2_search-button.value\": \"Sök\",\n\t\"ep_adminpads2_search-done\": \"Sökning slutförd\",\n\t\"ep_adminpads2_search-error-explanation\": \"Servern stötte på ett fel vid sökning efter block:\",\n\t\"ep_adminpads2_search-error-title\": \"Misslyckades att hämta blocklista\",\n\t\"ep_adminpads2_search-heading\": \"Sök efter block\",\n\t\"ep_adminpads2_title\": \"Blockadministration\",\n\t\"ep_adminpads2_unknown-error\": \"Okänt fel\",\n\t\"ep_adminpads2_unknown-status\": \"Okänd status\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/sw.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Andibecker\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Hatua\",\n\t\"ep_adminpads2_autoupdate-label\": \"Sasisha kiotomatiki kwenye mabadiliko ya pedi\",\n\t\"ep_adminpads2_autoupdate.title\": \"Huwasha au kulemaza sasisho otomatiki kwa hoja ya sasa.\",\n\t\"ep_adminpads2_confirm\": \"Je! Kweli unataka kufuta pedi {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Futa\",\n\t\"ep_adminpads2_last-edited\": \"Ilihaririwa mwisho\",\n\t\"ep_adminpads2_loading\": \"Inapakia...\",\n\t\"ep_adminpads2_manage-pads\": \"Dhibiti pedi\",\n\t\"ep_adminpads2_no-results\": \"Hakuna matokeo\",\n\t\"ep_adminpads2_pad-user-count\": \"Hesabu ya mtumiaji wa pedi\",\n\t\"ep_adminpads2_padname\": \"Jina la utani\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Neno la utaftaji\",\n\t\"ep_adminpads2_search-button.value\": \"Tafuta\",\n\t\"ep_adminpads2_search-done\": \"Utafutaji umekamilika\",\n\t\"ep_adminpads2_search-error-explanation\": \"Seva ilipata hitilafu wakati wa kutafuta pedi:\",\n\t\"ep_adminpads2_search-error-title\": \"Imeshindwa kupata orodha ya pedi\",\n\t\"ep_adminpads2_search-heading\": \"Tafuta pedi\",\n\t\"ep_adminpads2_title\": \"Usimamizi wa pedi\",\n\t\"ep_adminpads2_unknown-error\": \"Hitilafu isiyojulikana\",\n\t\"ep_adminpads2_unknown-status\": \"Hali isiyojulikana\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/th.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Andibecker\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"การกระทำ\",\n\t\"ep_adminpads2_autoupdate-label\": \"อัปเดตอัตโนมัติเมื่อเปลี่ยนแผ่น\",\n\t\"ep_adminpads2_autoupdate.title\": \"เปิดหรือปิดการอัปเดตอัตโนมัติสำหรับคิวรีปัจจุบัน\",\n\t\"ep_adminpads2_confirm\": \"คุณต้องการลบแพด {{padID}} จริงหรือไม่\",\n\t\"ep_adminpads2_delete.value\": \"ลบ\",\n\t\"ep_adminpads2_last-edited\": \"แก้ไขล่าสุด\",\n\t\"ep_adminpads2_loading\": \"กำลังโหลด…\",\n\t\"ep_adminpads2_manage-pads\": \"จัดการแผ่นรอง\",\n\t\"ep_adminpads2_no-results\": \"ไม่มีผลลัพธ์\",\n\t\"ep_adminpads2_pad-user-count\": \"จำนวนผู้ใช้แพด\",\n\t\"ep_adminpads2_padname\": \"นามแฝง\",\n\t\"ep_adminpads2_search-box.placeholder\": \"คำที่ต้องการค้นหา\",\n\t\"ep_adminpads2_search-button.value\": \"ค้นหา\",\n\t\"ep_adminpads2_search-done\": \"ค้นหาเสร็จสมบูรณ์\",\n\t\"ep_adminpads2_search-error-explanation\": \"เซิร์ฟเวอร์พบข้อผิดพลาดขณะค้นหาแผ่นอิเล็กโทรด:\",\n\t\"ep_adminpads2_search-error-title\": \"ไม่สามารถรับรายการแผ่นรอง\",\n\t\"ep_adminpads2_search-heading\": \"ค้นหาแผ่นรอง\",\n\t\"ep_adminpads2_title\": \"การบริหารแผ่น\",\n\t\"ep_adminpads2_unknown-error\": \"ข้อผิดพลาดที่ไม่รู้จัก\",\n\t\"ep_adminpads2_unknown-status\": \"ไม่ทราบสถานะ\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/tl.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Mrkczr\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Kilos\",\n\t\"ep_adminpads2_delete.value\": \"Burahin\",\n\t\"ep_adminpads2_last-edited\": \"Huling binago\",\n\t\"ep_adminpads2_loading\": \"Naglo-load...\",\n\t\"ep_adminpads2_no-results\": \"Walang mga resulta\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Mga katagang hahanapin:\",\n\t\"ep_adminpads2_search-button.value\": \"Hanapin\",\n\t\"ep_adminpads2_search-done\": \"Natapos na ang paghahanap\",\n\t\"ep_adminpads2_unknown-error\": \"Hindi nalalamang kamalian\",\n\t\"ep_adminpads2_unknown-status\": \"Hindi alam na katayuan\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/tr.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Hedda\",\n\t\t\t\"MuratTheTurkish\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Eylem\",\n\t\"ep_adminpads2_autoupdate-label\": \"Bloknot değişikliklerinde otomatik güncelleme\",\n\t\"ep_adminpads2_autoupdate.title\": \"Mevcut sorgu için otomatik güncellemeleri etkinleştirir veya devre dışı bırakır.\",\n\t\"ep_adminpads2_confirm\": \"{{padID}} bloknotunu gerçekten silmek istiyor musunuz?\",\n\t\"ep_adminpads2_delete.value\": \"Sil\",\n\t\"ep_adminpads2_last-edited\": \"Son düzenleme\",\n\t\"ep_adminpads2_loading\": \"Yükleniyor...\",\n\t\"ep_adminpads2_manage-pads\": \"Bloknotları yönet\",\n\t\"ep_adminpads2_no-results\": \"Sonuç yok\",\n\t\"ep_adminpads2_pad-user-count\": \"Bloknot kullanıcı sayısı\",\n\t\"ep_adminpads2_padname\": \"Bloknot adı\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Terimi ara\",\n\t\"ep_adminpads2_search-button.value\": \"Ara\",\n\t\"ep_adminpads2_search-done\": \"Arama tamamlandı\",\n\t\"ep_adminpads2_search-error-explanation\": \"Sunucu, bloknotları ararken bir hatayla karşılaştı:\",\n\t\"ep_adminpads2_search-error-title\": \"Bloknot listesi alınamadı\",\n\t\"ep_adminpads2_search-heading\": \"Bloknotları ara\",\n\t\"ep_adminpads2_title\": \"Bloknot yönetimi\",\n\t\"ep_adminpads2_unknown-error\": \"Bilinmeyen hata\",\n\t\"ep_adminpads2_unknown-status\": \"Bilinmeyen durum\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/uk.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"DDPAT\",\n\t\t\t\"Ice bulldog\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"Дія\",\n\t\"ep_adminpads2_autoupdate-label\": \"Автоматичне оновлення при зміні майданчика\",\n\t\"ep_adminpads2_autoupdate.title\": \"Вмикає або вимикає автоматичне оновлення поточного запиту.\",\n\t\"ep_adminpads2_confirm\": \"Ви дійсно хочете видалити панель {{padID}}?\",\n\t\"ep_adminpads2_delete.value\": \"Видалити\",\n\t\"ep_adminpads2_last-edited\": \"Останнє редагування\",\n\t\"ep_adminpads2_loading\": \"Завантаження…\",\n\t\"ep_adminpads2_manage-pads\": \"Управління майданчиками\",\n\t\"ep_adminpads2_no-results\": \"Немає результатів\",\n\t\"ep_adminpads2_pad-user-count\": \"Кількість майданчиків користувача\",\n\t\"ep_adminpads2_padname\": \"Назва майданчика\",\n\t\"ep_adminpads2_search-box.placeholder\": \"Пошуковий термін\",\n\t\"ep_adminpads2_search-button.value\": \"Пошук\",\n\t\"ep_adminpads2_search-done\": \"Пошук завершено\",\n\t\"ep_adminpads2_search-error-explanation\": \"Під час пошуку педів сервер виявив помилку:\",\n\t\"ep_adminpads2_search-error-title\": \"Не вдалося отримати список панелей\",\n\t\"ep_adminpads2_search-heading\": \"Пошук майданчиків\",\n\t\"ep_adminpads2_title\": \"Введення майданчиків\",\n\t\"ep_adminpads2_unknown-error\": \"Невідома помилка\",\n\t\"ep_adminpads2_unknown-status\": \"Невідомий статус\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/zh-hans.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"GuoPC\",\n\t\t\t\"Lakejason0\",\n\t\t\t\"沈澄心\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"操作\",\n\t\"ep_adminpads2_autoupdate-label\": \"在记事本更改时自动更新\",\n\t\"ep_adminpads2_autoupdate.title\": \"启用或禁用目前查询的自动更新\",\n\t\"ep_adminpads2_confirm\": \"您确定要删除记事本 {{padID}}？\",\n\t\"ep_adminpads2_delete.value\": \"删除\",\n\t\"ep_adminpads2_last-edited\": \"上次编辑于\",\n\t\"ep_adminpads2_loading\": \"正在加载…\",\n\t\"ep_adminpads2_manage-pads\": \"管理记事本\",\n\t\"ep_adminpads2_no-results\": \"没有结果\",\n\t\"ep_adminpads2_pad-user-count\": \"记事本用户数\",\n\t\"ep_adminpads2_padname\": \"记事本名称\",\n\t\"ep_adminpads2_search-box.placeholder\": \"搜索关键词\",\n\t\"ep_adminpads2_search-button.value\": \"搜索\",\n\t\"ep_adminpads2_search-done\": \"搜索完成\",\n\t\"ep_adminpads2_search-error-explanation\": \"搜索记事本时服务器发生错误：\",\n\t\"ep_adminpads2_search-error-title\": \"获取记事本列表失败\",\n\t\"ep_adminpads2_search-heading\": \"搜索记事本\",\n\t\"ep_adminpads2_title\": \"记事本管理\",\n\t\"ep_adminpads2_unknown-error\": \"未知错误\",\n\t\"ep_adminpads2_unknown-status\": \"未知状态\"\n}\n"
  },
  {
    "path": "admin/public/ep_admin_pads/zh-hant.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"HellojoeAoPS\",\n\t\t\t\"Kly\"\n\t\t]\n\t},\n\t\"ep_adminpads2_action\": \"操作\",\n\t\"ep_adminpads2_autoupdate-label\": \"在記事本更改時自動更新\",\n\t\"ep_adminpads2_autoupdate.title\": \"啟用或停用目前查詢的自動更新。\",\n\t\"ep_adminpads2_confirm\": \"您確定要刪除記事本 {{padID}}？\",\n\t\"ep_adminpads2_delete.value\": \"刪除\",\n\t\"ep_adminpads2_last-edited\": \"上一次編輯\",\n\t\"ep_adminpads2_loading\": \"載入中…\",\n\t\"ep_adminpads2_manage-pads\": \"管理記事本\",\n\t\"ep_adminpads2_no-results\": \"沒有結果\",\n\t\"ep_adminpads2_pad-user-count\": \"記事本使用者數\",\n\t\"ep_adminpads2_padname\": \"記事本名稱\",\n\t\"ep_adminpads2_search-box.placeholder\": \"搜尋關鍵字\",\n\t\"ep_adminpads2_search-button.value\": \"搜尋\",\n\t\"ep_adminpads2_search-done\": \"搜尋完成\",\n\t\"ep_adminpads2_search-error-explanation\": \"當搜尋記事本時伺服器發生錯誤：\",\n\t\"ep_adminpads2_search-error-title\": \"取得記事本清單失敗\",\n\t\"ep_adminpads2_search-heading\": \"搜尋記事本\",\n\t\"ep_adminpads2_title\": \"記事本管理\",\n\t\"ep_adminpads2_unknown-error\": \"不明錯誤\",\n\t\"ep_adminpads2_unknown-status\": \"不明狀態\"\n}\n"
  },
  {
    "path": "admin/src/App.css",
    "content": ""
  },
  {
    "path": "admin/src/App.tsx",
    "content": "import {useEffect, useState} from 'react'\nimport './App.css'\nimport {connect} from 'socket.io-client'\nimport {isJSONClean} from './utils/utils.ts'\nimport {NavLink, Outlet, useNavigate} from \"react-router-dom\";\nimport {useStore} from \"./store/store.ts\";\nimport {LoadingScreen} from \"./utils/LoadingScreen.tsx\";\nimport {Trans, useTranslation} from \"react-i18next\";\nimport {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu} from \"lucide-react\";\n\nconst WS_URL = import.meta.env.DEV ? 'http://localhost:9001' : ''\nexport const App = () => {\n  const setSettings = useStore(state => state.setSettings);\n  const {t} = useTranslation()\n  const navigate = useNavigate()\n  const [sidebarOpen, setSidebarOpen] = useState<boolean>(true)\n\n  useEffect(() => {\n    fetch('/admin-auth/', {\n      method: 'POST'\n    }).then((value) => {\n      if (!value.ok) {\n        navigate('/login')\n      }\n    }).catch(() => {\n      navigate('/login')\n    })\n  }, []);\n\n  useEffect(() => {\n    document.title = t('admin.page-title')\n\n    useStore.getState().setShowLoading(true);\n    const settingSocket = connect(`${WS_URL}/settings`, {\n      transports: ['websocket'],\n    });\n\n    const pluginsSocket = connect(`${WS_URL}/pluginfw/installer`, {\n      transports: ['websocket'],\n    })\n\n    pluginsSocket.on('connect', () => {\n      useStore.getState().setPluginsSocket(pluginsSocket);\n    });\n\n\n    settingSocket.on('connect', () => {\n      useStore.getState().setSettingsSocket(settingSocket);\n      useStore.getState().setShowLoading(false)\n      settingSocket.emit('load');\n      console.log('connected');\n    });\n\n    settingSocket.on('disconnect', (reason) => {\n      // The settingSocket.io client will automatically try to reconnect for all reasons other than \"io\n      // server disconnect\".\n      useStore.getState().setShowLoading(true)\n      if (reason === 'io server disconnect') {\n        settingSocket.connect();\n      }\n    });\n\n    settingSocket.on('settings', (settings) => {\n      /* Check whether the settings.json is authorized to be viewed */\n      if (settings.results === 'NOT_ALLOWED') {\n        console.log('Not allowed to view settings.json')\n        return;\n      }\n\n      /* Check to make sure the JSON is clean before proceeding */\n      if (isJSONClean(settings.results)) {\n        setSettings(settings.results);\n      } else {\n        alert('Invalid JSON');\n      }\n      useStore.getState().setShowLoading(false);\n    });\n\n    settingSocket.on('saveprogress', (status) => {\n      console.log(status)\n    })\n\n    return () => {\n      settingSocket.disconnect();\n      pluginsSocket.disconnect()\n    }\n  }, []);\n\n  return <div id=\"wrapper\" className={`${sidebarOpen ? '': 'closed' }`}>\n    <LoadingScreen/>\n    <div className=\"menu\">\n      <div className=\"inner-menu\">\n        <span>\n                    <Crown width={40} height={40}/>\n                    <h1>Etherpad</h1>\n                </span>\n        <ul onClick={()=>{\n          if (window.innerWidth < 768) {\n            setSidebarOpen(false)\n          }\n        }}>\n          <li><NavLink to=\"/plugins\"><Cable/><Trans i18nKey=\"admin_plugins\"/></NavLink></li>\n          <li><NavLink to={\"/settings\"}><Wrench/><Trans i18nKey=\"admin_settings\"/></NavLink></li>\n          <li><NavLink to={\"/help\"}> <Construction/> <Trans i18nKey=\"admin_plugins_info\"/></NavLink></li>\n          <li><NavLink to={\"/pads\"}><NotepadText/><Trans\n            i18nKey=\"ep_admin_pads:ep_adminpads2_manage-pads\"/></NavLink></li>\n          <li><NavLink to={\"/shout\"}><PhoneCall/>Communication</NavLink></li>\n        </ul>\n      </div>\n    </div>\n      <button id=\"icon-button\" onClick={() => {\n        setSidebarOpen(!sidebarOpen)\n      }}><LucideMenu/></button>\n    <div className=\"innerwrapper\">\n      <Outlet/>\n    </div>\n  </div>\n}\n\nexport default App\n"
  },
  {
    "path": "admin/src/components/IconButton.tsx",
    "content": "import {FC, JSX, ReactElement} from \"react\";\n\nexport type IconButtonProps = {\n    style?: React.CSSProperties,\n    icon: JSX.Element,\n    title: string|ReactElement,\n    onClick: ()=>void,\n    className?: string,\n    disabled?: boolean\n}\n\nexport const IconButton:FC<IconButtonProps> = ({icon,className,onClick,title, disabled, style})=>{\n    return <button style={style}  onClick={onClick} className={\"icon-button \"+ className} disabled={disabled}>\n        {icon}\n        <span>{title}</span>\n        </button>\n}\n"
  },
  {
    "path": "admin/src/components/SearchField.tsx",
    "content": "import {ChangeEventHandler, FC} from \"react\";\nimport {Search} from 'lucide-react'\nexport type SearchFieldProps = {\n    value: string,\n    onChange:  ChangeEventHandler<HTMLInputElement>,\n    placeholder?: string\n}\n\nexport const SearchField:FC<SearchFieldProps> = ({onChange,value, placeholder})=>{\n    return <span className=\"search-field\">\n        <input value={value} onChange={onChange} placeholder={placeholder}/>\n        <Search/>\n    </span>\n}\n"
  },
  {
    "path": "admin/src/components/ShoutType.ts",
    "content": "export type ShoutType = {\n    type: string,\n    data:{\n        type: string,\n        payload: {\n            message: {\n                message: string,\n                sticky: boolean\n            },\n            timestamp: number\n        }\n    }\n}\n"
  },
  {
    "path": "admin/src/index.css",
    "content": ":root {\n  --etherpad-color: #0f775b;\n  --etherpad-comp: #9C8840;\n  --etherpad-light: #99FF99;\n  --sidebar-width: 20em;\n}\n\n@font-face {\n  font-family: Karla;\n  src: url(/Karla-Regular.ttf);\n}\n\nhtml, body, #root {\n  box-sizing: border-box;\n  height: 100%;\n  font-family: \"Karla\", sans-serif;\n}\n\n*, *:before, *:after {\n  box-sizing: inherit;\n  font-size: 16px;\n}\n\nbody {\n  margin: 0;\n  color: #333;\n  font: 14px helvetica, sans-serif;\n  background: #eee;\n}\n\ndiv.menu {\n  left: 0;\n  transition: left .3s;\n  height: 100vh;\n  font-size: 16px;\n  font-weight: bolder;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: var(--sidebar-width);\n  z-index: 99;\n  position: fixed;\n}\n\n[role=\"dialog\"] h2 {\n  color: var(--etherpad-color);\n}\n\n.icon-button {\n  display: flex;\n  gap: 10px;\n  background-color: var(--etherpad-color);\n  color: white;\n  border: none;\n  padding: 10px 20px;\n  border-radius: 5px;\n  cursor: pointer;\n}\n\n.icon-button:hover {\n  background-color: #13a37c;\n}\n\n\n.dialog-close-button {\n  position: absolute;\n  top: 10px;\n  right: 10px;\n  background: none;\n  border: none;\n  cursor: pointer;\n  color: var(--etherpad-color);\n}\n\n.icon-button:active {\n  background-color: #13a37c;\n  transform: scale(0.98);\n}\n\n.icon-button svg {\n  align-self: center;\n}\n\n.icon-button span {\n  align-self: center;\n}\n\n\ndiv.menu span:first-child {\n  display: flex;\n  justify-content: center;\n}\n\ndiv.menu span:first-child svg {\n  margin-right: 10px;\n  align-self: center;\n}\n\n\ndiv.menu h1 {\n  font-size: 50px;\n  text-align: center;\n}\n\n.inner-menu {\n  border-radius: 0 20px 20px 0;\n  padding: 10px;\n  flex-grow: 100;\n  background-color: var(--etherpad-comp);\n  color: white;\n  height: 100vh;\n}\n\ndiv.menu ul {\n  color: white;\n  padding: 0;\n}\n\ndiv.menu li a {\n  display: flex;\n  gap: 10px;\n  margin-bottom: 20px;\n}\n\ndiv.menu svg {\n  align-self: center;\n}\n\ndiv.menu li {\n  padding: 10px;\n  color: white;\n  list-style: none;\n  margin-left: 3px;\n  line-height: 3;\n}\n\n\ndiv.menu li:has(.active) {\n  background-color: #9C885C;\n}\n\ndiv.menu li a {\n  color: lightgray;\n}\n\n\ndiv.innerwrapper {\n  transition: margin-left .3s;\n  isolation: isolate;\n  background-color: #F0F0F0;\n  overflow: auto;\n  height: 100vh;\n  flex-grow: 100;\n  margin-left: var(--sidebar-width);\n  padding: 20px 20px 20px;\n}\n\ndiv.innerwrapper-err {\n  display: none;\n}\n\n#wrapper {\n  background: none repeat scroll 0px 0px #FFFFFF;\n  box-shadow: 0px 1px 10px rgba(0, 0, 0, 0.2);\n  min-height: 100%; /*always display a scrollbar*/\n}\n\nh1 {\n  font-size: 29px;\n}\n\nh2 {\n  font-size: 24px;\n}\n\n.separator {\n  margin: 10px 0;\n  height: 1px;\n  background: #aaa;\n  background: -webkit-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff);\n  background: -moz-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff);\n  background: -ms-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff);\n  background: -o-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff);\n}\n\nform {\n  margin-bottom: 0;\n}\n\n#inner {\n  width: 300px;\n  margin: 0 auto;\n}\n\ninput {\n  font-weight: bold;\n  font-size: 15px;\n}\n\n\n.sort {\n  cursor: pointer;\n}\n\n.sort:after {\n  content: '▲▼'\n}\n\n.sort.up:after {\n  content: '▲'\n}\n\n.sort.down:after {\n  content: '▼'\n}\n\n\n#installed-plugins thead tr th:nth-child(3) {\n  width: 15%;\n}\n\ntable {\n  border: 1px solid #ddd;\n  border-radius: 3px;\n  border-spacing: 0;\n  width: 100%;\n  margin: 20px 0;\n}\n\n.table-container {\n  width: 100%;\n  overflow: auto;\n  max-height: 90vh;\n}\n\n\n#available-plugins th:first-child, #available-plugins th:nth-child(2) {\n  text-align: center;\n}\n\ntd, th {\n  padding: 5px;\n}\n\n.template {\n  display: none;\n}\n\n#installed-plugins td > div {\n  position: relative; /* Allows us to position the loading indicator relative to this row */\n  display: inline-block; /*make this fill the whole cell*/\n  width: 100%;\n}\n\n.messages {\n  height: 5em;\n}\n\n.messages * {\n  display: none;\n  text-align: center;\n}\n\n.messages .fetching {\n  display: block;\n}\n\n.progress {\n  position: absolute;\n  top: 0;\n  left: 0;\n  bottom: 0;\n  right: 0;\n  padding: auto;\n\n  background: rgb(255, 255, 255);\n  display: none;\n}\n\n#search-progress.progress {\n  padding-top: 20%;\n  background: rgba(255, 255, 255, 0.3);\n}\n\n.progress * {\n  display: block;\n  margin: 0 auto;\n  text-align: center;\n  color: #666;\n}\n\n\n.settings-page {\n  display: flex;\n  flex-direction: column;\n  gap: 20px;\n  height: 100%;\n}\n\n.settings {\n  flex-grow: max(1, 1);\n  outline: none;\n  width: 100%;\n  resize: none;\n  font-family: monospace;\n}\n\n#response {\n  display: inline;\n}\n\na:link, a:visited, a:hover, a:focus {\n  color: #333333;\n  text-decoration: none;\n}\n\na:focus, a:hover {\n  text-decoration: underline;\n}\n\n.installed-results a:link,\n.search-results a:link,\n.installed-results a:visited,\n.search-results a:visited,\n.installed-results a:hover,\n.search-results a:hover,\n.installed-results a:focus,\n.search-results a:focus {\n  text-decoration: underline;\n}\n\n.installed-results a:focus,\n.search-results a:focus,\n.installed-results a:hover,\n.search-results a:hover {\n  text-decoration: none;\n}\n\npre {\n  white-space: pre-wrap;\n  word-wrap: break-word;\n}\n\n\n#icon-button {\n  color: var(--etherpad-color);\n  top: 10px;\n  background-color: transparent;\n  border: none;\n  z-index: 99;\n  position: absolute;\n  left: 10px;\n}\n\n\n.inner-menu span:nth-child(2) {\n  display: flex;\n  margin-top: 30px;\n}\n\n#wrapper.closed .menu {\n  left: calc(-1 * var(--sidebar-width));\n}\n\n#wrapper.closed .innerwrapper {\n  margin-left: 0;\n}\n\n@media (max-width: 800px) {\n\n  div.innerwrapper {\n    margin-left: 0;\n  }\n\n  .inner-menu {\n    border-radius: 0;\n  }\n\n  div.menu {\n    height: auto;\n    border-right: none;\n    --sidebar-width: 100%;\n    float: left;\n  }\n\n  table {\n    border: none;\n  }\n\n  table, thead, tbody, td, tr {\n    display: block;\n  }\n\n  thead tr {\n    display: none;\n  }\n\n  tr {\n    border: 1px solid #ccc;\n    margin-bottom: 5px;\n    border-radius: 3px;\n  }\n\n  td {\n    border: none;\n    border-bottom: 1px solid #eee;\n    position: relative;\n    padding-left: 50%;\n    white-space: normal;\n    text-align: left;\n  }\n\n  td.name {\n    word-wrap: break-word;\n  }\n\n  td:before {\n    position: absolute;\n    top: 6px;\n    left: 6px;\n    text-align: left;\n    padding-right: 10px;\n    white-space: nowrap;\n    font-weight: bold;\n    content: attr(data-label);\n  }\n\n  td:last-child {\n    border-bottom: none;\n  }\n\n  table input[type=\"button\"] {\n    float: none;\n  }\n}\n\n\n.settings-button-bar {\n  margin-top: 10px;\n  display: flex;\n  gap: 10px;\n}\n\n.login-background {\n  background-image: url(\"/fond.jpg\");\n  background-position: center;\n  background-repeat: no-repeat;\n  background-size: cover;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  height: 100vh;\n  background-color: #f0f0f0;\n}\n\n.login-inner-box div {\n  margin-top: 1rem;\n}\n\n.login-inner-box [type=submit] {\n  margin-top: 2rem;\n}\n\n\n.login-textinput {\n  width: 100%;\n  padding: 10px;\n  background-color: #fffacc;\n  border-radius: 5px;\n  border: 1px solid #ccc;\n  margin-bottom: 10px;\n}\n\n.login-box {\n  padding: 20px;\n  border-radius: 40px;\n  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);\n  background-color: #fff;\n}\n\n\n@media (max-width: 900px) {\n  .login-box {\n    width: 90%\n  }\n}\n\n.login-inner-box {\n  position: relative;\n  padding: 20px;\n}\n\n.login-title {\n  padding: 0;\n  margin: 0;\n  text-align: center;\n  color: var(--etherpad-color);\n  font-size: 4rem;\n  font-weight: 1000;\n}\n\n.login-button {\n  padding: 10px;\n  background-color: var(--etherpad-color);\n  color: white;\n  border: none;\n  border-radius: 5px;\n  cursor: pointer;\n  width: 100%;\n  height: 40px;\n}\n\n.dialog-overlay {\n  position: fixed;\n  inset: 0;\n  background-color: white;\n  z-index: 100;\n}\n\n\n.dialog-confirm-overlay {\n  position: fixed;\n  inset: 0;\n  background-color: rgba(0, 0, 0, 0.5);\n  z-index: 100;\n}\n\n\n.dialog-confirm-content {\n  position: fixed;\n  top: 50%;\n  left: 50%;\n  background-color: white;\n  transform: translate(-50%, -50%);\n  padding: 20px;\n  z-index: 101;\n}\n\n\n.dialog-content {\n  position: fixed;\n  top: 50%;\n  left: 50%;\n  transform: translate(-50%, -50%);\n  padding: 20px;\n  z-index: 101;\n}\n\n.dialog-title {\n  color: var(--etherpad-color);\n  font-size: 2em;\n  margin-bottom: 20px;\n}\n\n\n.ToastViewport {\n  position: fixed;\n  top: 10px;\n  right: 20px;\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n  width: 390px;\n  max-width: 100vw;\n  margin: 0;\n  list-style: none;\n  z-index: 2147483647;\n  outline: none;\n}\n\n.ToastRootSuccess {\n  background-color: lawngreen;\n}\n\n.ToastRootFailure {\n  background-color: red;\n}\n\n.ToastRootFailure > .ToastTitle {\n  color: white;\n}\n\n.ToastRoot {\n  border-radius: 20px;\n  box-shadow: hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px;\n  padding: 15px;\n  display: grid;\n  grid-template-areas: 'title action' 'description action';\n  grid-template-columns: auto max-content;\n  column-gap: 15px;\n  align-items: center;\n}\n\n.ToastRoot[data-state='open'] {\n  animation: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n.ToastRoot[data-state='closed'] {\n  animation: hide 100ms ease-in;\n}\n\n.ToastRoot[data-swipe='move'] {\n  transform: translateX(var(--radix-toast-swipe-move-x));\n}\n\n.ToastRoot[data-swipe='cancel'] {\n  transform: translateX(0);\n  transition: transform 200ms ease-out;\n}\n\n.ToastRoot[data-swipe='end'] {\n  animation: swipeOut 100ms ease-out;\n}\n\n@keyframes hide {\n  from {\n    opacity: 1;\n  }\n  to {\n    opacity: 0;\n  }\n}\n\n@keyframes slideIn {\n  from {\n    transform: translateX(calc(100% + var(--viewport-padding)));\n  }\n  to {\n    transform: translateX(0);\n  }\n}\n\n@keyframes swipeOut {\n  from {\n    transform: translateX(var(--radix-toast-swipe-end-x));\n  }\n  to {\n    transform: translateX(calc(100% + var(--viewport-padding)));\n  }\n}\n\n.ToastTitle {\n  grid-area: title;\n  margin-bottom: 5px;\n  font-weight: 500;\n  color: var(--slate-12);\n  padding: 10px;\n  font-size: 15px;\n}\n\n.ToastDescription {\n  grid-area: description;\n  margin: 0;\n  color: var(--slate-11);\n  font-size: 13px;\n  line-height: 1.3;\n}\n\n.ToastAction {\n  grid-area: action;\n}\n\n.help-block {\n  display: grid;\n  grid-template-columns: repeat(2, minmax(0, 1fr));\n  gap: 20px\n}\n\n.search-field {\n  position: relative;\n}\n\n.search-field input {\n  border-color: transparent;\n  border-radius: 20px;\n  height: 2.5rem;\n  width: 100%;\n  padding: 5px 5px 5px 30px;\n}\n\n.search-field input:focus {\n  outline: none;\n}\n\n\n.send-message {\n  position: relative;\n}\n\n.send-message input {\n  width: auto;\n}\n\n.send-message {\n}\n\n.send-message svg {\n  position: absolute;\n  right: 3px;\n  bottom: -3px;\n  left: auto !important;\n}\n\n.search-field svg {\n  position: absolute;\n  left: 3px;\n  bottom: -3px;\n}\n\n\n.search-field svg {\n  color: gray\n}\n\ntable {\n  margin: 25px 0;\n  font-size: 0.9em;\n  font-family: sans-serif;\n  min-width: 400px;\n  box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);\n}\n\nth:first-child {\n  border-top-left-radius: 10px;\n}\n\nth:last-child {\n  border-top-right-radius: 10px;\n}\n\ntable thead tr {\n  font-size: 25px;\n  background-color: var(--etherpad-color);\n  color: #ffffff;\n  text-align: left;\n}\n\ntable tbody tr {\n  border-bottom: 1px solid #dddddd;\n}\n\ntable tr:nth-child(even) td {\n  background-color: lightgray;\n}\n\ntable tr td {\n  padding: 12px 15px;\n}\n\ntable tbody tr:nth-of-type(even) {\n  background-color: #f3f3f3;\n}\n\ntable tbody tr:last-of-type {\n  border-bottom: 2px solid #009879;\n}\n\ntable tbody tr.active-row {\n  font-weight: bold;\n  color: #009879;\n}\n\n\n.pad-pagination {\n  display: flex;\n  justify-content: center;\n  gap: 10px;\n  margin-top: 20px;\n}\n\n.pad-pagination button {\n  display: flex;\n  padding: 10px 20px;\n  border-radius: 5px;\n  border: none;\n  color: black;\n  cursor: pointer;\n}\n\n\n.pad-pagination button:disabled {\n  background: transparent;\n  color: lightgrey;\n  cursor: not-allowed;\n}\n\n.pad-pagination span {\n  align-self: center;\n}\n\n.pad-pagination > span {\n  font-size: 20px;\n}\n\n\n.login-page .login-form .input-control input[type=text], .login-page .login-form .input-control input[type=email], .login-page .login-form .input-control input[type=password], .login-page .signup-form .input-control input[type=text], .login-page .signup-form .input-control input[type=email], .login-page .signup-form .input-control input[type=password], .login-page .forgot-form .input-control input[type=text], .login-page .forgot-form .input-control input[type=email], .login-page .forgot-form .input-control input[type=password] {\n  width: 100%;\n  padding: 12px 20px;\n  margin: 8px 0;\n  display: inline-block;\n  border-bottom: 2px solid #ccc;\n  border-top: 0;\n  border-left: 0;\n  border-right: 0;\n  -webkit-box-sizing: border-box;\n  box-sizing: border-box;\n  border-radius: 5px;\n  font-size: 14px;\n  color: #666;\n  background-color: #f8f8f8;\n  -webkit-transition: all 0.3s ease-in-out;\n  transition: all 0.3s ease-in-out;\n}\n\ninput, button, select, optgroup, textarea {\n  margin: 0;\n  font-family: inherit;\n  font-size: inherit;\n  line-height: inherit;\n}\n\n.icon-input {\n  position: relative;\n}\n\n.icon-input svg {\n  position: absolute;\n  top: 50%;\n  transform: translateY(-50%);\n  right: 10px;\n  color: #666;\n}\n\n\n.SwitchRoot {\n  align-self: center;\n  width: 60px;\n  height: 30px;\n  background-color: black;\n  border-radius: 9999px;\n  position: relative;\n  box-shadow: 0 2px 10px var(--black-a7);\n  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\n\n.SwitchRoot:focus {\n  box-shadow: 0 0 0 2px black;\n}\n\n.SwitchRoot[data-state='checked'] {\n  background-color: var(--etherpad-color);\n}\n\n.SwitchThumb {\n  display: block;\n  width: 20px;\n  height: 20px;\n  background-color: white;\n  border-radius: 9999px;\n  box-shadow: 0 2px 2px var(--black-a7);\n  transition: transform 100ms;\n  transform: translateX(2px);\n  will-change: transform;\n}\n\n.SwitchThumb[data-state='checked'] {\n  transform: translateX(25px);\n}\n\n.Label {\n  color: white;\n  font-size: 15px;\n  line-height: 1;\n}\n\n.message {\n  position: relative;\n  padding: 10px;\n  border: 1px solid #e0e0e0;\n  margin: 10px 20px 10px 10px;\n  border-radius: 10px 0 10px 10px;\n  background-color: var(--etherpad-color);\n  color: white\n}\n\n.search-pads {\n  text-align: center;\n}\n\n.search-pads-body tr td:last-child {\n  display: flex;\n  justify-content: center;\n}\n\n.manage-pads-header {\n  display: flex;\n}\n"
  },
  {
    "path": "admin/src/localization/i18n.ts",
    "content": "import i18n from 'i18next'\nimport {initReactI18next} from \"react-i18next\";\nimport LanguageDetector from 'i18next-browser-languagedetector'\n\n\nimport { BackendModule } from 'i18next';\n\nconst LazyImportPlugin: BackendModule = {\n    type: 'backend',\n    init: function () {\n    },\n    read: async function (language, namespace, callback) {\n\n        let baseURL = import.meta.env.BASE_URL\n        if(namespace === \"translation\") {\n            // If default we load the translation file\n            baseURL+=`/locales/${language}.json`\n        } else {\n            // Else we load the former plugin translation file\n            baseURL+=`/${namespace}/${language}.json`\n        }\n\n        const localeJSON = await fetch(baseURL, {\n            cache: \"force-cache\"\n        })\n        let json;\n\n        try {\n            json = JSON.parse(await localeJSON.text())\n        } catch(e) {\n             callback(new Error(\"Error loading\"), null);\n        }\n\n\n        callback(null, json);\n    },\n\n    save: function () {\n    },\n\n    create: function () {\n        /* save the missing translation */\n    },\n};\n\ni18n\n    .use(LanguageDetector)\n    .use(LazyImportPlugin)\n    .use(initReactI18next)\n    .init(\n        {\n            ns: ['translation','ep_admin_pads'],\n            fallbackLng: 'en'\n        }\n    )\n\nexport default i18n\n"
  },
  {
    "path": "admin/src/main.tsx",
    "content": "import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport App from './App.tsx'\nimport './index.css'\nimport {createBrowserRouter, createRoutesFromElements, Route, RouterProvider} from \"react-router-dom\";\nimport {HomePage} from \"./pages/HomePage.tsx\";\nimport {SettingsPage} from \"./pages/SettingsPage.tsx\";\nimport {LoginScreen} from \"./pages/LoginScreen.tsx\";\nimport {HelpPage} from \"./pages/HelpPage.tsx\";\nimport * as Toast from '@radix-ui/react-toast'\nimport {I18nextProvider} from \"react-i18next\";\nimport i18n from \"./localization/i18n.ts\";\nimport {PadPage} from \"./pages/PadPage.tsx\";\nimport {ToastDialog} from \"./utils/Toast.tsx\";\nimport {ShoutPage} from \"./pages/ShoutPage.tsx\";\n\nconst router = createBrowserRouter(createRoutesFromElements(\n    <><Route element={<App/>}>\n        <Route index element={<HomePage/>}/>\n        <Route path=\"/plugins\" element={<HomePage/>}/>\n        <Route path=\"/settings\" element={<SettingsPage/>}/>\n        <Route path=\"/help\" element={<HelpPage/>}/>\n        <Route path=\"/pads\" element={<PadPage/>}/>\n        <Route path=\"/shout\" element={<ShoutPage/>}/>\n    </Route><Route path=\"/login\">\n        <Route index element={<LoginScreen/>}/>\n    </Route></>\n), {\n    basename: import.meta.env.BASE_URL\n})\n\n\nReactDOM.createRoot(document.getElementById('root')!).render(\n  <React.StrictMode>\n      <I18nextProvider i18n={i18n}>\n      <Toast.Provider>\n          <ToastDialog/>\n          <RouterProvider router={router}/>\n      </Toast.Provider>\n      </I18nextProvider>\n  </React.StrictMode>,\n)\n"
  },
  {
    "path": "admin/src/pages/HelpPage.tsx",
    "content": "import {Trans} from \"react-i18next\";\nimport {useStore} from \"../store/store.ts\";\nimport {useEffect, useState} from \"react\";\nimport {HelpObj} from \"./Plugin.ts\";\n\nexport const HelpPage = () => {\n    const settingsSocket = useStore(state=>state.settingsSocket)\n    const [helpData, setHelpData] = useState<HelpObj>();\n\n    useEffect(() => {\n        if(!settingsSocket) return;\n        settingsSocket?.on('reply:help', (data) => {\n            setHelpData(data)\n        });\n\n        settingsSocket?.emit('help');\n    }, [settingsSocket]);\n\n    const renderHooks = (hooks:Record<string, Record<string, string>>) => {\n        return Object.keys(hooks).map((hookName, i) => {\n            return <div key={hookName+i}>\n                <h3>{hookName}</h3>\n                <ul>\n                    {Object.keys(hooks[hookName]).map((hook, i) => <li key={hook+i}>{hook}\n                        <ul key={hookName+hook+i}>\n                            {Object.keys(hooks[hookName][hook]).map((subHook, i) => <li key={i}>{subHook}</li>)}\n                        </ul>\n                    </li>)}\n                </ul>\n            </div>\n        })\n    }\n\n\n    if (!helpData) return <div></div>\n\n    return <div>\n        <h1><Trans i18nKey=\"admin_plugins_info.version\"/></h1>\n        <div className=\"help-block\">\n            <div><Trans i18nKey=\"admin_plugins_info.version_number\"/></div>\n            <div>{helpData?.epVersion}</div>\n            <div><Trans i18nKey=\"admin_plugins_info.version_latest\"/></div>\n            <div>{helpData.latestVersion}</div>\n            <div>Git sha</div>\n            <div>{helpData.gitCommit}</div>\n        </div>\n        <h2><Trans i18nKey=\"admin_plugins.installed\"/></h2>\n        <ul>\n            {helpData.installedPlugins.map((plugin, i) => <li key={plugin+i}>{plugin}</li>)}\n        </ul>\n\n        <h2><Trans i18nKey=\"admin_plugins_info.parts\"/></h2>\n        <ul>\n            {helpData.installedParts.map((part, i) => <li key={part+i}>{part}</li>)}\n        </ul>\n\n        <h2><Trans i18nKey=\"admin_plugins_info.hooks\"/></h2>\n        {\n            renderHooks(helpData.installedServerHooks)\n        }\n\n        <h2>\n            <Trans i18nKey=\"admin_plugins_info.hooks_client\"/>\n            {\n                renderHooks(helpData.installedClientHooks)\n            }\n        </h2>\n\n    </div>\n}\n"
  },
  {
    "path": "admin/src/pages/HomePage.tsx",
    "content": "import {useStore} from \"../store/store.ts\";\nimport {useEffect, useMemo, useState} from \"react\";\nimport {InstalledPlugin, PluginDef, SearchParams} from \"./Plugin.ts\";\nimport {useDebounce} from \"../utils/useDebounce.ts\";\nimport {Trans, useTranslation} from \"react-i18next\";\nimport {SearchField} from \"../components/SearchField.tsx\";\nimport {ArrowUpFromDot, Download, Trash} from \"lucide-react\";\nimport {IconButton} from \"../components/IconButton.tsx\";\nimport {determineSorting} from \"../utils/sorting.ts\";\n\n\nexport const HomePage = () => {\n    const pluginsSocket = useStore(state=>state.pluginsSocket)\n    const [plugins,setPlugins] = useState<PluginDef[]>([])\n  const installedPlugins = useStore(state=>state.installedPlugins)\n  const setInstalledPlugins = useStore(state=>state.setInstalledPlugins)\n  const [searchParams, setSearchParams] = useState<SearchParams>({\n    offset: 0,\n    limit: 99999,\n    sortBy: 'name',\n    sortDir: 'asc',\n    searchTerm: ''\n  })\n\n  const filteredInstallablePlugins = useMemo(()=>{\n    return plugins.sort((a, b)=>{\n      if(searchParams.sortBy === \"version\"){\n        if(searchParams.sortDir === \"asc\"){\n          return a.version.localeCompare(b.version)\n        }\n        return b.version.localeCompare(a.version)\n      }\n\n      if(searchParams.sortBy === \"last-updated\"){\n        if(searchParams.sortDir === \"asc\"){\n          return a.time.localeCompare(b.time)\n        }\n        return b.time.localeCompare(a.time)\n      }\n\n\n      if (searchParams.sortBy === \"name\") {\n        if(searchParams.sortDir === \"asc\"){\n          return a.name.localeCompare(b.name)\n        }\n        return b.name.localeCompare(a.name)\n      }\n      return 0\n    })\n  }, [plugins, searchParams])\n\n    const sortedInstalledPlugins = useMemo(()=>{\n        return useStore.getState().installedPlugins.sort((a, b)=>{\n\n            if(a.name < b.name){\n                return -1\n            }\n            if(a.name > b.name){\n                return 1\n            }\n            return 0\n        })\n\n    } ,[installedPlugins, searchParams])\n\n    const [searchTerm, setSearchTerm] = useState<string>('')\n    const {t} = useTranslation()\n\n\n    useEffect(() => {\n        if(!pluginsSocket){\n            return\n        }\n\n        pluginsSocket.on('results:installed', (data:{\n            installed: InstalledPlugin[]\n        })=>{\n            setInstalledPlugins(data.installed)\n        })\n\n        pluginsSocket.on('results:updatable', (data) => {\n          const newInstalledPlugins = useStore.getState().installedPlugins.map(plugin => {\n            if (data.updatable.includes(plugin.name)) {\n              return {\n                ...plugin,\n                updatable: true\n              }\n            }\n            return plugin\n          })\n         setInstalledPlugins(newInstalledPlugins)\n        })\n\n        pluginsSocket.on('finished:install', () => {\n            pluginsSocket!.emit('getInstalled');\n        })\n\n        pluginsSocket.on('finished:uninstall', () => {\n            console.log(\"Finished uninstall\")\n        })\n\n\n        // Reload on reconnect\n        pluginsSocket.on('connect', ()=>{\n            // Initial retrieval of installed plugins\n            pluginsSocket.emit('getInstalled');\n            pluginsSocket.emit('search', searchParams)\n        })\n\n        pluginsSocket.emit('getInstalled');\n\n        // check for updates every 5mins\n        const interval = setInterval(() => {\n            pluginsSocket.emit('checkUpdates');\n        }, 1000 * 60 * 5);\n\n        return ()=>{\n            clearInterval(interval)\n        }\n        }, [pluginsSocket]);\n\n\n    useEffect(() => {\n        if (!pluginsSocket) {\n            return\n        }\n        pluginsSocket?.emit('search', searchParams)\n        pluginsSocket!.on('results:search', (data: {\n            results: PluginDef[]\n        }) => {\n            setPlugins(data.results)\n        })\n        pluginsSocket!.on('results:searcherror', (data: {error: string}) => {\n            console.log(data.error)\n            useStore.getState().setToastState({\n                open: true,\n                title: \"Error retrieving plugins\",\n                success: false\n            })\n        })\n    }, [searchParams, pluginsSocket]);\n\n    const uninstallPlugin  = (pluginName: string)=>{\n        pluginsSocket!.emit('uninstall', pluginName);\n        // Remove plugin\n        setInstalledPlugins(installedPlugins.filter(i=>i.name !== pluginName))\n    }\n\n    const installPlugin = (pluginName: string)=>{\n        pluginsSocket!.emit('install', pluginName);\n        setPlugins(plugins.filter(plugin=>plugin.name !== pluginName))\n    }\n\n    useDebounce(()=>{\n        setSearchParams({\n            ...searchParams,\n            offset: 0,\n            searchTerm: searchTerm\n        })\n    }, 500, [searchTerm])\n\n\n    return <div>\n        <h1><Trans i18nKey=\"admin_plugins\"/></h1>\n\n        <h2><Trans i18nKey=\"admin_plugins.installed\"/></h2>\n\n        <table id=\"installed-plugins\">\n            <thead>\n            <tr>\n                <th><Trans i18nKey=\"admin_plugins.name\"/></th>\n                <th><Trans i18nKey=\"admin_plugins.version\"/></th>\n                <th><Trans i18nKey=\"ep_admin_pads:ep_adminpads2_action\"/></th>\n            </tr>\n            </thead>\n            <tbody style={{overflow: 'auto'}}>\n            {sortedInstalledPlugins.map((plugin, index) => {\n                return <tr key={index}>\n                    <td><a rel=\"noopener noreferrer\" href={`https://npmjs.com/${plugin.name}`} target=\"_blank\">{plugin.name}</a></td>\n                    <td>{plugin.version}</td>\n                    <td>\n                    {\n                        plugin.updatable ?\n                            <IconButton onClick={() => installPlugin(plugin.name)} icon={<ArrowUpFromDot/>} title=\"Update\"></IconButton>\n                            : <IconButton disabled={plugin.name == \"ep_etherpad-lite\"} icon={<Trash/>} title={<Trans i18nKey=\"admin_plugins.installed_uninstall.value\"/>} onClick={() => uninstallPlugin(plugin.name)}/>\n                    }\n                    </td>\n                        </tr>\n                    })}\n            </tbody>\n        </table>\n\n\n        <h2><Trans i18nKey=\"admin_plugins.available\"/></h2>\n        <SearchField onChange={v=>{setSearchTerm(v.target.value)}} placeholder={t('admin_plugins.available_search.placeholder')} value={searchTerm}/>\n\n      <div className=\"table-container\">\n        <table id=\"available-plugins\">\n            <thead>\n            <tr>\n                <th className={determineSorting(searchParams.sortBy, searchParams.sortDir == \"asc\", 'name')} onClick={()=>{\n                  setSearchParams({\n                    ...searchParams,\n                    sortBy: 'name',\n                    sortDir: searchParams.sortDir === \"asc\"? \"desc\": \"asc\"\n                  })\n                }}>\n                  <Trans i18nKey=\"admin_plugins.name\" /></th>\n                <th style={{width: '30%'}}><Trans i18nKey=\"admin_plugins.description\"/></th>\n                <th className={determineSorting(searchParams.sortBy, searchParams.sortDir == \"asc\", 'version')} onClick={()=>{\n                  setSearchParams({\n                    ...searchParams,\n                    sortBy: 'version',\n                    sortDir: searchParams.sortDir === \"asc\"? \"desc\": \"asc\"\n                  })\n                }}><Trans i18nKey=\"admin_plugins.version\"/></th>\n                <th className={determineSorting(searchParams.sortBy, searchParams.sortDir == \"asc\", 'last-updated')} onClick={()=>{\n                  setSearchParams({\n                    ...searchParams,\n                    sortBy: 'last-updated',\n                    sortDir: searchParams.sortDir === \"asc\"? \"desc\": \"asc\"\n                  })\n                }}><Trans i18nKey=\"admin_plugins.last-update\"/></th>\n                <th><Trans i18nKey=\"ep_admin_pads:ep_adminpads2_action\"/></th>\n            </tr>\n            </thead>\n            <tbody style={{overflow: 'auto'}}>\n            {(filteredInstallablePlugins.length > 0) ?\n              filteredInstallablePlugins.map((plugin) => {\n                        return <tr key={plugin.name}>\n                            <td><a rel=\"noopener noreferrer\" href={`https://npmjs.com/${plugin.name}`} target=\"_blank\">{plugin.name}</a></td>\n                            <td>{plugin.description}</td>\n                            <td>{plugin.version}</td>\n                            <td>{plugin.time}</td>\n                            <td>\n                                <IconButton icon={<Download/>} onClick={() => installPlugin(plugin.name)} title={<Trans i18nKey=\"admin_plugins.available_install.value\"/>}/>\n                            </td>\n                        </tr>\n                    })\n                :\n                <tr><td colSpan={5}>{searchTerm == '' ? <Trans i18nKey=\"pad.loading\"/>: <Trans i18nKey=\"admin_plugins.available_not-found\"/>}</td></tr>\n            }\n            </tbody>\n        </table>\n      </div>\n    </div>\n}\n"
  },
  {
    "path": "admin/src/pages/LoginScreen.tsx",
    "content": "import {useStore} from \"../store/store.ts\";\nimport {useNavigate} from \"react-router-dom\";\nimport {SubmitHandler, useForm} from \"react-hook-form\";\nimport {Eye, EyeOff} from \"lucide-react\";\nimport {useState} from \"react\";\n\ntype Inputs = {\n    username: string\n    password: string\n}\n\nexport const LoginScreen = ()=>{\n    const navigate = useNavigate()\n    const [passwordVisible, setPasswordVisible] = useState<boolean>(false)\n\n    const {\n        register,\n        handleSubmit} = useForm<Inputs>()\n\n    const login: SubmitHandler<Inputs> = ({username,password})=>{\n        fetch('/admin-auth/', {\n            method: 'POST',\n            headers:{\n                Authorization: `Basic ${btoa(`${username}:${password}`)}`\n            }\n        }).then(r=>{\n            if(!r.ok) {\n                useStore.getState().setToastState({\n                    open: true,\n                    title: \"Login failed\",\n                    success: false\n                })\n            } else {\n                navigate('/')\n            }\n        }).catch(e=>{\n            console.error(e)\n        })\n    }\n\n    return <div className=\"login-background login-page\">\n        <div className=\"login-box login-form\">\n            <h1 className=\"login-title\">Etherpad</h1>\n            <form className=\"login-inner-box input-control\" onSubmit={handleSubmit(login)}>\n                <div>Username</div>\n                <input {...register('username', {\n                    required: true\n                })} className=\"login-textinput input-control\" type=\"text\" placeholder=\"Username\"/>\n                <div>Password</div>\n                <span className=\"icon-input\">\n                        <input {...register('password', {\n                            required: true\n                        })} className=\"login-textinput\" type={passwordVisible?\"text\":\"password\"} placeholder=\"Password\"/>\n                    {passwordVisible? <Eye onClick={()=>setPasswordVisible(!passwordVisible)}/> :\n                        <EyeOff onClick={()=>setPasswordVisible(!passwordVisible)}/>}\n                    </span>\n                <input type=\"submit\" value=\"Login\" className=\"login-button\"/>\n            </form>\n        </div>\n    </div>\n}\n"
  },
  {
    "path": "admin/src/pages/PadPage.tsx",
    "content": "import {Trans, useTranslation} from \"react-i18next\";\nimport {useEffect, useMemo, useState} from \"react\";\nimport {useStore} from \"../store/store.ts\";\nimport {PadSearchQuery, PadSearchResult} from \"../utils/PadSearch.ts\";\nimport {useDebounce} from \"../utils/useDebounce.ts\";\nimport {determineSorting} from \"../utils/sorting.ts\";\nimport * as Dialog from \"@radix-ui/react-dialog\";\nimport {IconButton} from \"../components/IconButton.tsx\";\nimport {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon} from \"lucide-react\";\nimport {SearchField} from \"../components/SearchField.tsx\";\nimport {useForm} from \"react-hook-form\";\n\ntype PadCreateProps = {\n    padName: string\n}\n\nexport const PadPage = ()=>{\n    const settingsSocket = useStore(state=>state.settingsSocket)\n    const [searchParams, setSearchParams] = useState<PadSearchQuery>({\n        offset: 0,\n        limit: 12,\n        pattern: '',\n        sortBy: 'padName',\n        ascending: true\n    })\n    const {t} = useTranslation()\n    const [searchTerm, setSearchTerm] = useState<string>('')\n    const pads = useStore(state=>state.pads)\n    const [currentPage, setCurrentPage] = useState<number>(0)\n    const [deleteDialog, setDeleteDialog] = useState<boolean>(false)\n    const [errorText, setErrorText] = useState<string|null>(null)\n    const [padToDelete, setPadToDelete] = useState<string>('')\n    const [createPadDialogOpen, setCreatePadDialogOpen] = useState<boolean>(false)\n    const {register, handleSubmit} = useForm<PadCreateProps>()\n    const pages = useMemo(()=>{\n        if(!pads){\n            return 0;\n        }\n\n        return Math.ceil(pads!.total / searchParams.limit)\n    },[pads, searchParams.limit])\n\n    useDebounce(()=>{\n        setSearchParams({\n            ...searchParams,\n            pattern: searchTerm\n        })\n\n    }, 500, [searchTerm])\n\n    useEffect(() => {\n        if(!settingsSocket){\n            return\n        }\n\n        settingsSocket.emit('padLoad', searchParams)\n\n    }, [settingsSocket, searchParams]);\n\n    useEffect(() => {\n        if(!settingsSocket){\n            return\n        }\n\n        settingsSocket.on('results:padLoad', (data: PadSearchResult)=>{\n            useStore.getState().setPads(data);\n        })\n\n\n        settingsSocket.on('results:deletePad', (padID: string)=>{\n            const newPads = useStore.getState().pads?.results?.filter((pad)=>{\n                return pad.padName !== padID\n            })\n            useStore.getState().setPads({\n                total: useStore.getState().pads!.total-1,\n                results: newPads\n            })\n        })\n\n      type SettingsSocketCreateReponse = {\n          error: string\n      } | {\n        success: string\n      }\n\n      settingsSocket.on('results:createPad', (rep: SettingsSocketCreateReponse)=>{\n        if ('error' in rep) {\n          useStore.getState().setToastState({\n            open: true,\n            title: rep.error,\n            success: false\n          })\n        } else {\n          useStore.getState().setToastState({\n            open: true,\n            title: rep.success,\n            success: true\n          })\n          setCreatePadDialogOpen(false)\n          // reload pads\n          settingsSocket.emit('padLoad', searchParams)\n        }\n      })\n\n        settingsSocket.on('results:cleanupPadRevisions', (data)=>{\n          const newPads = useStore.getState().pads?.results ?? []\n\n          if (data.error) {\n            setErrorText(data.error)\n            return\n          }\n\n          newPads.forEach((pad)=>{\n            if (pad.padName === data.padId) {\n              pad.revisionNumber = data.keepRevisions\n            }\n          })\n\n          useStore.getState().setPads({\n            results: newPads,\n            total: useStore.getState().pads!.total\n          })\n        })\n    }, [settingsSocket, pads]);\n\n    const deletePad = (padID: string)=>{\n        settingsSocket?.emit('deletePad', padID)\n    }\n\n    const cleanupPad = (padID: string)=>{\n        settingsSocket?.emit('cleanupPadRevisions', padID)\n    }\n\n    const onPadCreate = (data: PadCreateProps)=>{\n      settingsSocket?.emit('createPad', {\n        padName: data.padName\n      })\n    }\n\n\n    return <div>\n        <Dialog.Root open={deleteDialog}><Dialog.Portal>\n            <Dialog.Overlay className=\"dialog-confirm-overlay\" />\n            <Dialog.Content  className=\"dialog-confirm-content\">\n                <div className=\"\">\n                    <div className=\"\"></div>\n                    <div className=\"\">\n                        {t(\"ep_admin_pads:ep_adminpads2_confirm\", {\n                        padID: padToDelete,\n                        })}\n                    </div>\n                    <div className=\"settings-button-bar\">\n                        <button onClick={()=>{\n                            setDeleteDialog(false)\n                        }}>Cancel</button>\n                        <button onClick={()=>{\n                            deletePad(padToDelete)\n                            setDeleteDialog(false)\n                        }}>Ok</button>\n                    </div>\n                </div>\n            </Dialog.Content>\n        </Dialog.Portal>\n        </Dialog.Root>\n        <Dialog.Root open={errorText !== null}>\n          <Dialog.Portal>\n            <Dialog.Overlay className=\"dialog-confirm-overlay\"/>\n            <Dialog.Content className=\"dialog-confirm-content\">\n              <div>\n                <div>Error occured: {errorText}</div>\n                <div className=\"settings-button-bar\">\n                  <button onClick={() => {\n                    setErrorText(null)\n                  }}>OK</button>\n                </div>\n              </div>\n            </Dialog.Content>\n          </Dialog.Portal>\n        </Dialog.Root>\n      <Dialog.Root open={createPadDialogOpen}>\n        <Dialog.Portal>\n        <Dialog.Overlay className=\"dialog-confirm-overlay\" />\n        <Dialog.Content  className=\"dialog-confirm-content\">\n          <Dialog.Title className=\"dialog-confirm-title\"><Trans i18nKey=\"index.newPad\"/></Dialog.Title>\n          <form  onSubmit={handleSubmit(onPadCreate)}>\n            <button className=\"dialog-close-button\" onClick={()=>{\n              setCreatePadDialogOpen(false);\n            }}>x</button>\n              <div style={{display: 'grid', gap: '10px', gridTemplateColumns: 'auto auto', marginBottom: '1rem'}}>\n                <label><Trans i18nKey=\"ep_admin_pads:ep_adminpads2_padname\"/></label>\n                <input {...register('padName', {\n                  required: true\n                })}/>\n              </div>\n            <input type=\"submit\" value={t('admin_settings.createPad')} className=\"login-button\" />\n          </form>\n        </Dialog.Content>\n      </Dialog.Portal>\n      </Dialog.Root>\n      <span className=\"manage-pads-header\">\n                <h1><Trans i18nKey=\"ep_admin_pads:ep_adminpads2_manage-pads\"/></h1>\n        <span style={{width: '29px', marginBottom: 'auto', marginTop: 'auto', flexGrow: 1}}><IconButton style={{float: 'right'}} icon={<PlusIcon/>} title={<Trans i18nKey=\"index.newPad\"/>} onClick={()=>{\n          setCreatePadDialogOpen(true)\n        }}/></span>\n      </span>\n        <SearchField value={searchTerm} onChange={v=>setSearchTerm(v.target.value)} placeholder={t('ep_admin_pads:ep_adminpads2_search-heading')}/>\n        <table>\n            <thead>\n            <tr className=\"search-pads\">\n                <th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'padName')} onClick={()=>{\n                    setSearchParams({\n                        ...searchParams,\n                        sortBy: 'padName',\n                        ascending: !searchParams.ascending\n                    })\n                }}><Trans i18nKey=\"ep_admin_pads:ep_adminpads2_padname\"/></th>\n                <th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'userCount')} onClick={()=>{\n                    setSearchParams({\n                        ...searchParams,\n                        sortBy: 'userCount',\n                        ascending: !searchParams.ascending\n                    })\n                }}><Trans i18nKey=\"ep_admin_pads:ep_adminpads2_pad-user-count\"/></th>\n                <th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'lastEdited')} onClick={()=>{\n                    setSearchParams({\n                        ...searchParams,\n                        sortBy: 'lastEdited',\n                        ascending: !searchParams.ascending\n                    })\n                }}><Trans i18nKey=\"ep_admin_pads:ep_adminpads2_last-edited\"/></th>\n                <th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'revisionNumber')} onClick={()=>{\n                    setSearchParams({\n                        ...searchParams,\n                        sortBy: 'revisionNumber',\n                        ascending: !searchParams.ascending\n                    })\n                }}>Revision number</th>\n                <th><Trans i18nKey=\"ep_admin_pads:ep_adminpads2_action\"/></th>\n            </tr>\n            </thead>\n            <tbody className=\"search-pads-body\">\n            {\n                pads?.results?.map((pad)=>{\n                    return <tr key={pad.padName}>\n                        <td style={{textAlign: 'center'}}>{pad.padName}</td>\n                        <td style={{textAlign: 'center'}}>{pad.userCount}</td>\n                        <td style={{textAlign: 'center'}}>{new Date(pad.lastEdited).toLocaleString()}</td>\n                        <td style={{textAlign: 'center'}}>{pad.revisionNumber}</td>\n                        <td>\n                            <div className=\"settings-button-bar\">\n                                <IconButton icon={<Trash2/>} title={<Trans i18nKey=\"ep_admin_pads:ep_adminpads2_delete.value\"/>} onClick={()=>{\n                                    setPadToDelete(pad.padName)\n                                    setDeleteDialog(true)\n                                }}/>\n                                <IconButton icon={<FileStack/>} title={<Trans i18nKey=\"ep_admin_pads:ep_adminpads2_cleanup\"/>} onClick={()=>{\n                                  cleanupPad(pad.padName)\n                                }}/>\n                                <IconButton icon={<Eye/>} title={<Trans i18nKey=\"index.createOpenPad\"/>} onClick={()=>window.open(`../../p/${pad.padName}`, '_blank')}/>\n                            </div>\n                        </td>\n                    </tr>\n                })\n            }\n            </tbody>\n        </table>\n        <div className=\"settings-button-bar pad-pagination\">\n            <button disabled={currentPage == 0} onClick={()=>{\n                setCurrentPage(currentPage-1)\n                    setSearchParams({\n                        ...searchParams,\n                        offset: (Number(currentPage)-1)*searchParams.limit})\n            }}><ChevronLeft/><span>Previous Page</span></button>\n            <span>{currentPage+1} out of {pages}</span>\n            <button disabled={pages == 0 || pages == currentPage+1} onClick={()=>{\n              const newCurrentPage = currentPage+1\n                setCurrentPage(newCurrentPage)\n                setSearchParams({\n                    ...searchParams,\n                    offset: (Number(newCurrentPage))*searchParams.limit\n                })\n            }}><span>Next Page</span><ChevronRight/></button>\n        </div>\n    </div>\n}\n"
  },
  {
    "path": "admin/src/pages/Plugin.ts",
    "content": "export type PluginDef = {\n    name: string,\n    description: string,\n    version: string,\n    time: string,\n    official: boolean,\n}\n\n\nexport type InstalledPlugin = {\n    name: string,\n    path: string,\n    realPath: string,\n    version:string,\n    updatable?: boolean\n}\n\n\nexport type SearchParams = {\n    searchTerm: string,\n    offset: number,\n    limit: number,\n    sortBy: 'name'|'version'|'last-updated',\n    sortDir: 'asc'|'desc'\n}\n\n\nexport type HelpObj = {\n    epVersion: string\n    gitCommit: string\n    installedClientHooks: Record<string, Record<string, string>>,\n    installedParts: string[],\n    installedPlugins: string[],\n    installedServerHooks: Record<string, never>,\n    latestVersion: string\n}\n"
  },
  {
    "path": "admin/src/pages/SettingsPage.tsx",
    "content": "import {useStore} from \"../store/store.ts\";\nimport {isJSONClean, cleanComments} from \"../utils/utils.ts\";\nimport {Trans} from \"react-i18next\";\nimport {IconButton} from \"../components/IconButton.tsx\";\nimport {RotateCw, Save} from \"lucide-react\";\n\nexport const SettingsPage = ()=>{\n    const settingsSocket = useStore(state=>state.settingsSocket)\n    const settings = cleanComments(useStore(state=>state.settings))\n\n    return <div className=\"settings-page\">\n        <h1><Trans i18nKey=\"admin_settings.current\"/></h1>\n        <textarea value={settings} className=\"settings\" onChange={v => {\n            useStore.getState().setSettings(v.target.value)\n        }}/>\n        <div className=\"settings-button-bar\">\n            <IconButton className=\"settingsButton\" icon={<Save/>}\n                        title={<Trans i18nKey=\"admin_settings.current_save.value\"/>} onClick={() => {\n                if (isJSONClean(settings!)) {\n                    // JSON is clean so emit it to the server\n                    settingsSocket!.emit('saveSettings', settings!);\n                    useStore.getState().setToastState({\n                        open: true,\n                        title: \"Successfully saved settings\",\n                        success: true\n                    })\n                } else {\n                    useStore.getState().setToastState({\n                        open: true,\n                        title: \"Error saving settings\",\n                        success: false\n                    })\n                }\n            }}/>\n            <IconButton className=\"settingsButton\" icon={<RotateCw/>}\n                        title={<Trans i18nKey=\"admin_settings.current_restart.value\"/>} onClick={() => {\n                settingsSocket!.emit('restartServer');\n            }}/>\n        </div>\n        <div className=\"separator\"/>\n        <div className=\"settings-button-bar\">\n            <a rel=\"noopener noreferrer\" target=\"_blank\"\n               href=\"https://github.com/ether/etherpad-lite/wiki/Example-Production-Settings.JSON\"><Trans\n                i18nKey=\"admin_settings.current_example-prod\"/></a>\n            <a rel=\"noopener noreferrer\" target=\"_blank\"\n               href=\"https://github.com/ether/etherpad-lite/wiki/Example-Development-Settings.JSON\"><Trans\n                i18nKey=\"admin_settings.current_example-devel\"/></a>\n        </div>\n    </div>\n}\n"
  },
  {
    "path": "admin/src/pages/ShoutPage.tsx",
    "content": "import {useEffect, useState} from \"react\";\nimport {SendHorizonal} from 'lucide-react'\nimport {useStore} from \"../store/store.ts\";\nimport * as Switch from '@radix-ui/react-switch';\nimport {ShoutType} from \"../components/ShoutType.ts\";\n\nexport const ShoutPage = ()=>{\n    const [totalUsers, setTotalUsers] = useState(0);\n    const [message, setMessage] = useState<string>(\"\");\n    const [sticky, setSticky] = useState<boolean>(false);\n    const socket = useStore(state => state.settingsSocket);\n    const pluginSocket = useStore(state => state.pluginsSocket);\n    const [shouts, setShouts] = useState<ShoutType[]>([]);\n\n\n    useEffect(() => {\n        if(socket && pluginSocket) {\n          console.log('Socket connected', socket.id);\n            socket.on('shout', (shout) => {\n                setShouts([...shouts, shout])\n            })\n          pluginSocket.on('results:stats', (statData) => {\n            setTotalUsers(statData.totalUsers);\n          })\n        }\n    }, [socket, shouts, pluginSocket])\n\n\n  useEffect(() => {\n    if (pluginSocket) {\n      pluginSocket.emit('getStats', {});\n    }\n  }, [pluginSocket]);\n\n    const sendMessage = () => {\n        socket?.emit('shout', {\n            message,\n            sticky\n        });\n        setMessage('')\n    }\n\n    return (\n        <div>\n            <h1>Communication</h1>\n            {totalUsers > 0 && <p>There  {totalUsers>1?\"are\":\"is\"} currently {totalUsers} user{totalUsers>1?\"s\":\"\"} online</p>}\n            <div style={{height: '80vh', display: 'flex', flexDirection: 'column'}}>\n                <div style={{flexGrow: 1, backgroundColor: 'white', overflowY: \"auto\"}}>\n                    {\n                        shouts.map((shout) => {\n                            return (\n                                <div key={shout.data.payload.timestamp} className=\"message\">\n                                    <div>{shout.data.payload.message.message}</div>\n                                    <div style={{display: 'flex'}}>\n                                        <div style={{flexGrow: 1}}></div>\n                                        <div\n                                            style={{color: \"lightgray\"}}>{new Date(shout.data.payload.timestamp).toLocaleTimeString()\n                                            + \" \" + new Date(shout.data.payload.timestamp).toLocaleDateString()}</div>\n                                    </div>\n                                </div>\n                            )\n                        })\n                    }\n                </div>\n                <form onSubmit={(e) => {\n                    e.preventDefault()\n                    sendMessage()\n                }} className=\"send-message search-field\" style={{display: 'flex', gap: '10px'}}>\n                    <Switch.Root title=\"Change sticky message\" className=\"SwitchRoot\" checked={sticky}\n                                 onCheckedChange={() => {\n                                     setSticky(!sticky);\n             }}>\n                 <Switch.Thumb className=\"SwitchThumb\"/>\n             </Switch.Root>\n                    <input required value={message} onChange={v=>setMessage(v.target.value)}\n                           style={{width: '100%', paddingRight: '55px', backgroundColor: '#e0e0e0', flexGrow: 1}}/>\n                    <SendHorizonal style={{bottom: '5px', right: '9px', color: '#0f775b'}} onClick={()=>sendMessage()}/>\n                </form>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "admin/src/store/store.ts",
    "content": "import {create} from \"zustand\";\nimport {Socket} from \"socket.io-client\";\nimport {PadSearchResult} from \"../utils/PadSearch.ts\";\nimport {InstalledPlugin} from \"../pages/Plugin.ts\";\n\ntype ToastState = {\n    description?:string,\n    title: string,\n    open: boolean,\n    success: boolean\n}\n\n\ntype StoreState = {\n    settings: string|undefined,\n    setSettings: (settings: string) => void,\n    settingsSocket: Socket|undefined,\n    setSettingsSocket: (socket: Socket) => void,\n    showLoading: boolean,\n    setShowLoading: (show: boolean) => void,\n    setPluginsSocket: (socket: Socket) => void\n    pluginsSocket: Socket|undefined,\n    toastState: ToastState,\n    setToastState: (val: ToastState)=>void,\n    pads: PadSearchResult|undefined,\n    setPads: (pads: PadSearchResult)=>void,\n    installedPlugins: InstalledPlugin[],\n    setInstalledPlugins: (plugins: InstalledPlugin[])=>void\n}\n\n\nexport const useStore = create<StoreState>()((set) => ({\n    settings: undefined,\n    setSettings: (settings: string) => set({settings}),\n    settingsSocket: undefined,\n    setSettingsSocket: (socket: Socket) => set({settingsSocket: socket}),\n    showLoading: false,\n    setShowLoading: (show: boolean) => set({showLoading: show}),\n    pluginsSocket: undefined,\n    setPluginsSocket: (socket: Socket) => set({pluginsSocket: socket}),\n    setToastState: (val )=>set({toastState: val}),\n    toastState: {\n        open: false,\n        title: '',\n        description:'',\n        success: false\n    },\n    pads: undefined,\n    setPads: (pads)=>set({pads}),\n    installedPlugins: [],\n    setInstalledPlugins: (plugins)=>set({installedPlugins: plugins})\n}));\n"
  },
  {
    "path": "admin/src/utils/AnimationFrameHook.ts",
    "content": "import {useCallback, useEffect, useRef} from \"react\";\n\ntype Args = any[]\n\nexport const useAnimationFrame = <Fn extends (...args: Args)=>void>(\n    callback: Fn,\n    wait = 0\n): ((...args: Parameters<Fn>)=>void)=>{\n    const rafId = useRef(0)\n    const render = useCallback(\n        (...args: Parameters<Fn>)=>{\n            cancelAnimationFrame(rafId.current)\n            const timeStart = performance.now()\n\n            const renderFrame = (timeNow: number)=>{\n                if(timeNow-timeStart<wait){\n                    rafId.current = requestAnimationFrame(renderFrame)\n                    return\n                }\n                callback(...args)\n            }\n            rafId.current = requestAnimationFrame(renderFrame)\n        }, [callback, wait]\n    )\n\n\n    useEffect(()=>cancelAnimationFrame(rafId.current),[])\n    return render\n}"
  },
  {
    "path": "admin/src/utils/LoadingScreen.tsx",
    "content": "import {useStore} from \"../store/store.ts\";\nimport * as Dialog from '@radix-ui/react-dialog';\nimport brand from './brand.svg'\n\nexport const LoadingScreen = ()=>{\n    const showLoading = useStore(state => state.showLoading)\n\n    return <Dialog.Root open={showLoading}><Dialog.Portal>\n        <Dialog.Overlay className=\"loading-screen fixed inset-0 bg-black bg-opacity-50 z-50 dialog-overlay\" />\n        <Dialog.Content  className=\"fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 dialog-content\">\n            <div className=\"flex flex-col items-center\">\n                <div className=\"animate-spin w-16 h-16 border-t-2 border-b-2 border-[--fg-color] rounded-full\"></div>\n                <div className=\"mt-4 text-[--fg-color]\">\n                    <img src={brand}/>\n                </div>\n            </div>\n        </Dialog.Content>\n    </Dialog.Portal>\n    </Dialog.Root>\n}\n"
  },
  {
    "path": "admin/src/utils/PadSearch.ts",
    "content": "export type PadSearchQuery = {\n    pattern: string;\n    offset: number;\n    limit: number;\n    ascending: boolean;\n    sortBy: string;\n}\n\n\nexport type PadSearchResult = {\n    total: number;\n    results?: PadType[]\n}\n\nexport type PadType = {\n    padName: string;\n    lastEdited: number;\n    userCount: number;\n    revisionNumber: number;\n}\n"
  },
  {
    "path": "admin/src/utils/Toast.tsx",
    "content": "import * as Toast from '@radix-ui/react-toast'\nimport {useStore} from \"../store/store.ts\";\nimport {useMemo} from \"react\";\n\nexport const ToastDialog = ()=>{\n    const toastState = useStore(state => state.toastState)\n    const resultingClass = useMemo(()=> {\n        return toastState.success?'ToastRootSuccess':'ToastRootFailure'\n    },    [toastState.success])\n\n    console.log()\n    return <>\n        <Toast.Root className={\"ToastRoot \"+resultingClass} open={toastState && toastState.open} onOpenChange={()=>{\n          useStore.getState().setToastState({\n              ...toastState!,\n              open: !toastState?.open\n          })\n        }}>\n        <Toast.Title className=\"ToastTitle\">{toastState.title}</Toast.Title>\n        <Toast.Description asChild>\n            {toastState.description}\n        </Toast.Description>\n    </Toast.Root>\n        <Toast.Viewport className=\"ToastViewport\"/>\n    </>\n}\n"
  },
  {
    "path": "admin/src/utils/sorting.ts",
    "content": "export const determineSorting = (sortBy: string, ascending: boolean, currentSymbol: string) => {\n    if (sortBy === currentSymbol) {\n        return ascending ? 'sort up' : 'sort down';\n    }\n    return 'sort none';\n}\n"
  },
  {
    "path": "admin/src/utils/useDebounce.ts",
    "content": "import {DependencyList, EffectCallback, useMemo, useRef} from \"react\";\nimport {useAnimationFrame} from \"./AnimationFrameHook\";\n\nconst defaultDeps: DependencyList = []\n\nexport const useDebounce = (\n    fn:EffectCallback,\n    wait = 0,\n    deps = defaultDeps\n):void => {\n    const isFirstRender = useRef(true)\n    const render = useAnimationFrame(fn, wait)\n\n    useMemo(()=>{\n        if(isFirstRender.current){\n            isFirstRender.current = false\n            return\n        }\n\n        render()\n    }, deps)\n}"
  },
  {
    "path": "admin/src/utils/utils.ts",
    "content": "export const cleanComments = (json: string|undefined)=>{\n    if (json !== undefined){\n        json = json.replace(/\\/\\*.*?\\*\\//g, \"\");          // remove single line comments\n        json = json.replace(/ *\\/\\*.*(.|\\n)*?\\*\\//g, \"\"); // remove multi line comments\n        json = json.replace(/[ \\t]+$/gm, \"\");             // trim trailing spaces\n        json = json.replace(/^(\\n)/gm, \"\");               // remove empty lines\n    }\n    return json;\n}\n\nexport const minify = (json: string)=>{\n    let tokenizer = /\"|(\\/\\*)|(\\*\\/)|(\\/\\/)|\\n|\\r/g,\n        in_string = false,\n        in_multiline_comment = false,\n        in_singleline_comment = false,\n        tmp, tmp2, new_str = [], ns = 0, from = 0, lc, rc\n    ;\n\n    tokenizer.lastIndex = 0;\n\n    while (tmp = tokenizer.exec(json)) {\n        lc = RegExp.leftContext;\n        rc = RegExp.rightContext;\n        if (!in_multiline_comment && !in_singleline_comment) {\n            tmp2 = lc.substring(from);\n            if (!in_string) {\n                tmp2 = tmp2.replace(/(\\n|\\r|\\s)*/g,\"\");\n            }\n            new_str[ns++] = tmp2;\n        }\n        from = tokenizer.lastIndex;\n\n        if (tmp[0] == \"\\\"\" && !in_multiline_comment && !in_singleline_comment) {\n            tmp2 = lc.match(/(\\\\)*$/);\n            if (!in_string || !tmp2 || (tmp2[0].length % 2) == 0) {\t// start of string with \", or unescaped \" character found to end string\n                in_string = !in_string;\n            }\n            from--; // include \" character in next catch\n            rc = json.substring(from);\n        }\n        else if (tmp[0] == \"/*\" && !in_string && !in_multiline_comment && !in_singleline_comment) {\n            in_multiline_comment = true;\n        }\n        else if (tmp[0] == \"*/\" && !in_string && in_multiline_comment && !in_singleline_comment) {\n            in_multiline_comment = false;\n        }\n        else if (tmp[0] == \"//\" && !in_string && !in_multiline_comment && !in_singleline_comment) {\n            in_singleline_comment = true;\n        }\n        else if ((tmp[0] == \"\\n\" || tmp[0] == \"\\r\") && !in_string && !in_multiline_comment && in_singleline_comment) {\n            in_singleline_comment = false;\n        }\n        else if (!in_multiline_comment && !in_singleline_comment && !(/\\n|\\r|\\s/.test(tmp[0]))) {\n            new_str[ns++] = tmp[0];\n        }\n    }\n    new_str[ns++] = rc;\n    return new_str.join(\"\");\n}\n\nexport const isJSONClean = (data: string) => {\n    let cleanSettings = minify(data);\n    // this is a bit naive. In theory some key/value might contain the sequences ',]' or ',}'\n    cleanSettings = cleanSettings.replace(',]', ']').replace(',}', '}');\n    try {\n        return typeof JSON.parse(cleanSettings) === 'object';\n    } catch (e) {\n        return false; // the JSON failed to be parsed\n    }\n};\n"
  },
  {
    "path": "admin/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n/// <reference types=\"vite-plugin-svgr/client\" />\n"
  },
  {
    "path": "admin/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true\n  },\n  \"include\": [\"src\"],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }]\n}\n"
  },
  {
    "path": "admin/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "admin/vite.config.ts",
    "content": "import { defineConfig } from 'vite'\nimport {viteStaticCopy} from \"vite-plugin-static-copy\";\nimport react from '@vitejs/plugin-react';\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  plugins: [viteStaticCopy({\n      targets: [\n          {\n              src: '../src/locales',\n              dest: ''\n          }\n      ]\n  }),   react({\n    babel: {\n      plugins: ['babel-plugin-react-compiler'],\n    }})],\n    base: '/admin',\n    build:{\n      outDir: '../src/templates/admin',\n        emptyOutDir: true,\n    },\n  server:{\n    proxy: {\n      '/socket.io/*': {\n        target: 'http://localhost:9001',\n        changeOrigin: true,\n        rewrite: (path) => path.replace(/^\\/api/, '')\n      },\n        '/admin-auth/': {\n            target: 'http://localhost:9001',\n            changeOrigin: true,\n        }\n        }\n  }\n})\n"
  },
  {
    "path": "best_practices.md",
    "content": "# Contributor Guidelines\n(Please talk to people on the mailing list before you change this page, see our section on [how to get in touch](https://github.com/ether/etherpad-lite#get-in-touch))\n\n**We have decided that LLM/Agent/AI contributions are fine as long as they are within the instructions set out by this document.**\n\n## Pull requests\n\n* PRs MUST include a non-empty description explaining what the change does and why\n* PRs without a description should be flagged as incomplete\n* the commit series in the PR should be _linear_ (it **should not contain merge commits**). This is necessary because we want to be able to [bisect](https://en.wikipedia.org/wiki/Bisection_(software_engineering)) bugs easily. Rewrite history/perform a rebase if necessary\n* PRs should be issued against the **develop** branch: we never pull directly into **master**\n* PRs **should not have conflicts** with develop. If there are, please resolve them rebasing and force-pushing\n* when preparing your PR, please make sure that you have included the relevant **changes to the documentation** (preferably with usage examples)\n* contain meaningful and detailed **commit messages** in the form:\n  ```\n  submodule: description\n\n  longer description of the change you have made, eventually mentioning the\n  number of the issue that is being fixed, in the form: Fixes #someIssueNumber\n  ```\n* if the PR is a **bug fix**:\n  * The commit that fixes the bug should **include a regression test** that\n    would fail if the bug fix was reverted. Adding the regression test in the\n    same commit as the bug fix makes it easier for a reviewer to verify that the\n    test is appropriate for the bug fix.\n  * If there is a bug report, **the pull request description should include the\n    text \"`Fixes #xxx`\"** so that the bug report is auto-closed when the PR is\n    merged. It is less useful to say the same thing in a commit message because\n    GitHub will spam the bug report every time the commit is rebased, and\n    because a bug number alone becomes meaningless in forks. (A full URL would\n    be better, but ideally each commit is readable on its own without the need\n    to examine an external reference to understand motivation or context.)\n* think about stability: code has to be backwards compatible as much as possible. Always **assume your code will be run with an older version of the DB/config file**\n* if you want to remove a feature, **deprecate it instead**:\n  * write an issue with your deprecation plan\n  * output a `WARN` in the log informing that the feature is going to be removed\n  * remove the feature in the next version\n* if you want to add a new feature, put it under a **feature flag**:\n  * once the new feature has reached a minimal level of stability, do a PR for it, so it can be integrated early\n  * expose a mechanism for enabling/disabling the feature\n  * the new feature should be **disabled** by default. With the feature disabled, the code path should be exactly the same as before your contribution. This is a __necessary condition__ for early integration\n* think of the PR not as something that __you wrote__, but as something that __someone else is going to read__. The commit series in the PR should tell a novice developer the story of your thoughts when developing it\n\n## How to write a bug report\n\n* Please be polite, we all are humans and problems can occur.\n* Please add as much information as possible, for example\n  * client os(s) and version(s)\n    * browser(s) and version(s), is the problem reproducible on different clients\n    * special environments like firewalls or antivirus\n  * host os and version\n    * npm and nodejs version\n    * Logfiles if available\n  * steps to reproduce\n  * what you expected to happen\n  * what actually happened\n* Please format logfiles and code examples with markdown see github Markdown help below the issue textarea for more information.\n\nIf you send logfiles, please set the loglevel switch DEBUG in your settings.json file:\n\n```\n/* The log level we are using, can be: DEBUG, INFO, WARN, ERROR */\n  \"loglevel\": \"DEBUG\",\n```\n\nThe logfile location is defined in startup script or the log is directly shown in the commandline after you have started etherpad.\n\n## General goals of Etherpad\nTo make sure everybody is going in the same direction:\n* easy to install for admins and easy to use for people\n* easy to integrate into other apps, but also usable as standalone\n* lightweight and scalable\n* extensible, as much functionality should be extendable with plugins so changes don't have to be done in core.\nAlso, keep it maintainable. We don't wanna end up as the monster Etherpad was!\n\n## How to work with git?\n* Don't work in your master branch.\n* Make a new branch for every feature you're working on. (This ensures that you can work you can do lots of small, independent pull requests instead of one big one with complete different features)\n* Don't use the online edit function of github (this only creates ugly and not working commits!)\n* Try to make clean commits that are easy readable (including descriptive commit messages!)\n* Test before you push. Sounds easy, it isn't!\n* Don't check in stuff that gets generated during build or runtime\n* Make small pull requests that are easy to review but make sure they do add value by themselves / individually\n\n## Coding style\n* Do write comments. (You don't have to comment every line, but if you come up with something that's a bit complex/weird, just leave a comment. Bear in mind that you will probably leave the project at some point and that other people will read your code. Undocumented huge amounts of code are worthless!)\n* Never ever use tabs\n* Indentation: 2 spaces\n* Don't overengineer. Don't try to solve any possible problem in one step, but try to solve problems as easy as possible and improve the solution over time!\n* Do generalize sooner or later! (if an old solution, quickly hacked together, poses more problems than it solves today, refactor it!)\n* Keep it compatible. Do not introduce changes to the public API, db schema or configurations too lightly. Don't make incompatible changes without good reasons!\n* If you do make changes, document them! (see below)\n* Use protocol independent urls \"//\"\n\n## Branching model / git workflow\nsee git flow http://nvie.com/posts/a-successful-git-branching-model/\n\n### `master` branch\n* the stable\n* This is the branch everyone should use for production stuff\n\n### `develop`branch\n* everything that is READY to go into master at some point in time\n* This stuff is tested and ready to go out\n\n### release branches\n* stuff that should go into master very soon\n* only bugfixes go into these (see http://nvie.com/posts/a-successful-git-branching-model/ for why)\n* we should not be blocking new features to develop, just because we feel that we should be releasing it to master soon. This is the situation that release branches solve/handle.\n\n### hotfix branches\n* fixes for bugs in master\n\n### feature branches (in your own repos)\n* these are the branches where you develop your features in\n* If it's ready to go out, it will be merged into develop\n\nOver the time we pull features from feature branches into the develop branch. Every month we pull from develop into master. Bugs in master get fixed in hotfix branches. These branches will get merged into master AND develop. There should never be commits in master that aren't in develop\n\n## Documentation\nThe docs are in the `doc/` folder in the git repository, so people can easily find the suitable docs for the current git revision.\n\nDocumentation should be kept up-to-date. This means, whenever you add a new API method, add a new hook or change the database model, pack the relevant changes to the docs in the same pull request.\n\nYou can build the docs e.g. produce html, using `make docs`. At some point in the future we will provide an online documentation. The current documentation in the github wiki should always reflect the state of `master` (!), since there are no docs in master, yet.\n\n## Testing\nFront-end tests are found in the `tests/frontend/` folder in the repository. Run them by pointing your browser to `<yourdomainhere>/tests/frontend`.\n\nBack-end tests can be run from the `src` directory, via `npm test`.\nYou can use `npm test -- --inspect-brk` and navigate to `edge://inspect` or `chrome://inspect` to debug the tests.\n\n"
  },
  {
    "path": "bin/buildDebian.sh",
    "content": "#!/usr/bin/env bash\n\n# IMPORTANT\n# Protect against misspelling a var and rm -rf /\nset -u\nset -e\n\nSRC=/tmp/etherpad-deb-src\nDIST=/tmp/etherpad-deb-dist\nSYSROOT=${SRC}/sysroot\nDEBIAN=${SRC}/DEBIAN\n\nrm -rf ${DIST}\nmkdir -p ${DIST}/\n\nrm -rf ${SRC}\nrsync -a bin/deb-src/ ${SRC}/\nmkdir -p ${SYSROOT}/opt/\n\nrsync --exclude '.git' -a . ${SYSROOT}/opt/etherpad/ --delete\nmkdir -p ${SYSROOT}/usr/share/doc\ncp README.md ${SYSROOT}/usr/share/doc/etherpad\nfind ${SRC}/ -type d -exec chmod 0755 {} \\;\nfind ${SRC}/ -type f -exec chmod go-w {} \\;\nchown -R root:root ${SRC}/\n\nlet SIZE=$(du -s ${SYSROOT} | sed s'/\\s\\+.*//')+8\npushd ${SYSROOT}/\ntar czf ${DIST}/data.tar.gz [a-z]*\npopd\nsed s\"/SIZE/${SIZE}/\" -i ${DEBIAN}/control\npushd ${DEBIAN}\ntar czf ${DIST}/control.tar.gz *\npopd\n\npushd ${DIST}/\necho 2.0 > ./debian-binary\n\nfind ${DIST}/ -type d -exec chmod 0755 {} \\;\nfind ${DIST}/ -type f -exec chmod go-w {} \\;\nchown -R root:root ${DIST}/\nar r ${DIST}/etherpad-1.deb debian-binary control.tar.gz data.tar.gz\npopd\nrsync -a ${DIST}/etherpad-1.deb ./\n"
  },
  {
    "path": "bin/buildForWindows.sh",
    "content": "#!/bin/sh\n\nset -e\n\npecho() { printf %s\\\\n \"$*\"; }\nlog() { pecho \"$@\"; }\nerror() { log \"ERROR: $@\" >&2; }\nfatal() { error \"$@\"; exit 1; }\ntry() { \"$@\" || fatal \"'$@' failed\"; }\nis_cmd() { command -v \"$@\" >/dev/null 2>&1; }\n\nfor x in git unzip wget zip; do\n  is_cmd \"${x}\" || fatal \"Please install ${x}\"\ndone\n\n# Move to the folder where Etherpad is checked out\ntry cd \"${0%/*}\"\nworkdir=$(try git rev-parse --show-toplevel) || exit 1\ntry cd \"${workdir}\"\n[ -f src/package.json ] || fatal \"failed to cd to etherpad root directory\"\n\n# See https://github.com/msys2/MSYS2-packages/issues/1216\nexport MSYSTEM=winsymlinks:lnk\n\nOUTPUT=${workdir}/etherpad-win.zip\n\nTMP_FOLDER=$(try mktemp -d) || exit 1\ntrap 'exit 1' HUP INT TERM\ntrap 'log \"cleaning up...\"; try cd / && try rm -rf \"${TMP_FOLDER}\"' EXIT\n\nlog \"create a clean environment in $TMP_FOLDER...\"\ntry export GIT_WORK_TREE=${TMP_FOLDER}; git checkout HEAD -f \\\n    || fatal \"failed to copy etherpad to temporary folder\"\ntry mkdir \"${TMP_FOLDER}\"/.git\ntry git rev-parse HEAD >${TMP_FOLDER}/.git/HEAD\n# Disable symlinks to avoid problems with Windows\n#try pnpm i \"${TMP_FOLDER}\"/src/node_modules\n\ntry cd \"${TMP_FOLDER}\"\n[ -f src/package.json ] || fatal \"failed to copy etherpad to temporary folder\"\n\n# setting NODE_ENV=production ensures that dev dependencies are not installed,\n# making the windows package smaller\nexport NODE_ENV=development\n\nrm -rf node_modules || true\nrm -rf src/node_modules || true\n\n#log \"do a normal unix install first...\"\n#$(try cd ./bin/installDeps.sh)\n\n# Install admin frontend\ntry pnpm install\ntry pnpm run build:etherpad\n\n# Nuke the admin folder as it is not needed anymore :D\nrm -rf admin\nrm -rf oidc\nrm -rf src/node_modules\n\nlog \"copy the windows settings template...\"\ntry cp settings.json.template settings.json\n\n#log \"resolve symbolic links...\"\n#try cp -rL node_modules node_modules_resolved\n#try rm -rf node_modules\n#try mv node_modules_resolved node_modules\n\nlog \"download windows node...\"\ntry wget \"https://nodejs.org/dist/latest-v20.x/win-x64/node.exe\" -O node.exe\n\nlog \"create the zip...\"\ntry zip -9 -r \"${OUTPUT}\" ./*\n\nlog \"Finished. You can find the zip at ${OUTPUT}\"\n"
  },
  {
    "path": "bin/checkAllPads.ts",
    "content": "'use strict';\n/*\n * This is a debug tool. It checks all revisions for data corruption\n */\nimport process from \"node:process\";\n\n// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an\n// unhandled rejection into an uncaught exception, which does cause Node.js to exit.\nprocess.on('unhandledRejection', (err) => { throw err; });\n\nif (process.argv.length !== 2) throw new Error('Use: node bin/checkAllPads.js');\n\n(async () => {\n  const db = require('ep_etherpad-lite/node/db/DB');\n  await db.init();\n  const padManager = require('ep_etherpad-lite/node/db/PadManager');\n  await Promise.all((await padManager.listAllPads()).padIDs.map(async (padId: string) => {\n    const pad = await padManager.getPad(padId);\n    try {\n      await pad.check();\n    } catch (err:any) {\n      console.error(`Error in pad ${padId}: ${err.stack || err}`);\n      return;\n    }\n    console.log(`Pad ${padId}: OK`);\n  }));\n  console.log('Finished.');\n  process.exit(0)\n})();\n"
  },
  {
    "path": "bin/checkPad.ts",
    "content": "'use strict';\n/*\n * This is a debug tool. It checks all revisions for data corruption\n */\nimport process from \"node:process\";\n\n// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an\n// unhandled rejection into an uncaught exception, which does cause Node.js to exit.\nprocess.on('unhandledRejection', (err) => { throw err; });\n\nif (process.argv.length !== 3) throw new Error('Use: node bin/checkPad.js $PADID');\n// @ts-ignore\nconst padId = process.argv[2];\n\nconst performCheck = async () => {\n  const db = require('ep_etherpad-lite/node/db/DB');\n  await db.init();\n  console.log(\"Checking if \" + padId + \" exists\")\n  const padManager = require('ep_etherpad-lite/node/db/PadManager');\n  if (!await padManager.doesPadExists(padId)) throw new Error('Pad does not exist');\n  const pad = await padManager.getPad(padId);\n  await pad.check();\n  console.log('Finished checking pad.');\n  process.exit(0)\n}\n\nperformCheck()\n  .then(e=>console.log(\"Finished\"))\n  .catch(e=>console.log(\"Finished with errors\"))\n"
  },
  {
    "path": "bin/cleanRun.sh",
    "content": "#!/bin/sh\n\n# Move to the Etherpad base directory.\nMY_DIR=$(cd \"${0%/*}\" && pwd -P) || exit 1\ncd \"${MY_DIR}/..\" || exit 1\n\n# Source constants and useful functions\n. bin/functions.sh\n\nignoreRoot=0\nfor ARG in \"$@\"\ndo\n  if [ \"$ARG\" = \"--root\" ]; then\n    ignoreRoot=1\n  fi\ndone\n\n#Stop the script if it's started as root\nif [ \"$(id -u)\" -eq 0 ] && [ $ignoreRoot -eq 0 ]; then\n   echo \"You shouldn't start Etherpad as root!\"\n   echo \"Please type 'Etherpad rocks my socks' or supply the '--root' argument if you still want to start it as root\"\n   read rocks\n   if [ ! $rocks = \"Etherpad rocks my socks\" ]\n   then\n     echo \"Your input was incorrect\"\n     exit 1\n   fi\nfi\n\n#Clean the current environment\nrm -rf src/node_modules\n\n#Prepare the environment\nbin/installDeps.sh \"$@\" || exit 1\n\n#Move to the node folder and start\necho \"Starting Etherpad...\"\ncd src\nexec node --import tsx ./node/server.ts \"$@\"\n"
  },
  {
    "path": "bin/commonPlugins.ts",
    "content": "import {PackageData} from \"ep_etherpad-lite/node/types/PackageInfo\";\nimport {writeFileSync} from \"fs\";\nimport {installedPluginsPath} from \"ep_etherpad-lite/static/js/pluginfw/installer\";\nconst pluginsModule = require('ep_etherpad-lite/static/js/pluginfw/plugins');\n\nexport const persistInstalledPlugins = async () => {\n    const plugins:PackageData[] = []\n    const installedPlugins = {plugins: plugins};\n    for (const pkg of Object.values(await pluginsModule.getPackages()) as PackageData[]) {\n        installedPlugins.plugins.push({\n            name: pkg.name,\n            version: pkg.version,\n        });\n    }\n    installedPlugins.plugins = [...new Set(installedPlugins.plugins)];\n    writeFileSync(installedPluginsPath, JSON.stringify(installedPlugins));\n};\n"
  },
  {
    "path": "bin/convertSettings.json.template",
    "content": "{\n  \"etherpadDB\":\n  {\n    \"host\": \"localhost\",\n    \"port\": 3306,\n    \"database\": \"etherpad\",\n    \"user\": \"etherpaduser\",\n    \"password\": \"yourpassword\"\n  }\n}\n"
  },
  {
    "path": "bin/createRelease.sh",
    "content": "#!/bin/bash\n#\n# WARNING: since Etherpad 1.7.0 (2018-08-17), this script is DEPRECATED, and\n#          will be removed/modified in a future version.\n#          It's left here just for documentation.\n#          The branching policies for releases have been changed.\n#\n# This script is used to publish a new release/version of etherpad on github\n#\n# Work that is done by this script:\n# ETHER_REPO:\n# - Add text to CHANGELOG.md\n# - Replace version of etherpad in src/package.json\n# - Create a release branch and push it to github\n# - Merges this release branch into master branch\n# - Creating the windows build and the docs\n# ETHER_WEB_REPO:\n# - Creating a new branch with the docs and the windows build\n# - Replacing the version numbers in the index.html\n# - Push this branch and merge it to master\n# ETHER_REPO:\n# - Create a new release on github\n\nprintf \"WARNING: since Etherpad 1.7.0 this script is DEPRECATED, and will be removed/modified in a future version.\\n\\n\"\nwhile true; do\n    read -p \"Do you want to continue? This is discouraged. [y/N]\" yn\n    case $yn in\n        [Yy]* ) break;;\n        [Nn]* ) exit;;\n        * ) printf \"Please answer yes or no.\\n\\n\";;\n    esac\ndone\n\nETHER_REPO=\"https://github.com/ether/etherpad-lite.git\"\nETHER_WEB_REPO=\"https://github.com/ether/ether.github.com.git\"\nTMP_DIR=\"/tmp/\"\n\necho \"WARNING: You can only run this script if your github api token is allowed to create and merge branches on $ETHER_REPO and $ETHER_WEB_REPO.\"\necho \"This script automatically changes the version number in package.json and adds a text to CHANGELOG.md.\"\necho \"When you use this script you should be in the branch that you want to release (develop probably) on latest version. Any changes that are currently not committed will be committed.\"\necho \"-----\"\n\n# Get the latest version\nLATEST_GIT_TAG=$(git tag | tail -n 1)\n\n# Current environment\necho \"Current environment: \"\necho \"- branch: $(git branch | grep '* ')\"\necho \"- last commit date: $(git show --quiet --pretty=format:%ad)\"\necho \"- current version: $LATEST_GIT_TAG\"\necho \"- temp dir: $TMP_DIR\"\n\n# Get new version number\n# format: x.x.x\necho -n \"Enter new version (x.x.x): \"\nread VERSION\n\n# Get the message for the changelogs\nread -p \"Enter new changelog entries (press enter): \"\ntmp=$(mktemp)\n\"${EDITOR:-vi}\" $tmp\nchangelogText=$(<$tmp)\necho \"$changelogText\"\nrm $tmp\n\nif [ \"$changelogText\" != \"\" ]; then\n  changelogText=\"# $VERSION\\n$changelogText\"\nfi\n\n# get the token for the github api\necho -n \"Enter your github api token: \"\nread API_TOKEN\n\nfunction check_api_token {\n  echo \"Checking if github api token is valid...\"\n  CURL_RESPONSE=$(curl --silent -i https://api.github.com/user?access_token=$API_TOKEN | iconv -f utf8)\n  HTTP_STATUS=$(echo $CURL_RESPONSE | head -1 | sed -r 's/.* ([0-9]{3}) .*/\\1/')\n  [[ $HTTP_STATUS != \"200\" ]] && echo \"Aborting: Invalid github api token\" && exit 1\n}\n\nfunction modify_files {\n  # Add changelog text to first line of CHANGELOG.md\n\n  msg=\"\"\n  # source: https://unix.stackexchange.com/questions/9784/how-can-i-read-line-by-line-from-a-variable-in-bash#9789\n  while IFS= read -r line\n  do\n    # replace newlines with literal \"\\n\" for using with sed\n    msg+=\"$line\\n\"\n  done < <(printf '%s\\n' \"${changelogText}\")\n\n  sed -i \"1s/^/${msg}\\n/\" CHANGELOG.md\n  [[ $? != 0 ]] && echo \"Aborting: Error modifying CHANGELOG.md\" && exit 1\n\n  # Replace version number of etherpad in package.json\n  sed -i -r \"s/(\\\"version\\\"[ ]*: \\\").*(\\\")/\\1$VERSION\\2/\" src/package.json\n  [[ $? != 0 ]] && echo \"Aborting: Error modifying package.json\" && exit 1\n}\n\nfunction create_release_branch {\n  echo \"Creating new release branch...\"\n  git rev-parse --verify release/$VERSION 2>/dev/null\n  if [ $? == 0 ]; then\n    echo \"Aborting: Release branch already present\"\n    exit 1\n  fi\n  git checkout -b release/$VERSION\n  [[ $? != 0 ]] && echo \"Aborting: Error creating release branch\" && exit 1\n\n  echo \"Committing CHANGELOG.md and package.json\"\n  git add CHANGELOG.md\n  git add src/package.json\n  git commit -m \"Release version $VERSION\"\n\n  echo \"Pushing release branch to github...\"\n  git push -u $ETHER_REPO release/$VERSION\n  [[ $? != 0 ]] && echo \"Aborting: Error pushing release branch to github\" && exit 1\n}\n\nfunction merge_release_branch {\n  echo \"Merging release to master branch on github...\"\n  API_JSON=$(printf '{\"base\": \"master\",\"head\": \"release/%s\",\"commit_message\": \"Merge new release into master branch!\"}' $VERSION)\n  CURL_RESPONSE=$(curl --silent -i -N --data \"$API_JSON\" https://api.github.com/repos/ether/etherpad-lite/merges?access_token=$API_TOKEN  | iconv -f utf8)\n  echo $CURL_RESPONSE\n  HTTP_STATUS=$(echo $CURL_RESPONSE | head -1 | sed -r 's/.* ([0-9]{3}) .*/\\1/')\n  [[ $HTTP_STATUS != \"200\" ]] && echo \"Aborting: Error merging release branch on github\" && exit 1\n}\n\nfunction create_builds {\n  echo \"Cloning etherpad-lite repo and ether.github.com repo...\"\n  cd $TMP_DIR\n  rm -rf etherpad-lite ether.github.com\n  git clone $ETHER_REPO --branch master\n  git clone $ETHER_WEB_REPO\n  echo \"Creating windows build...\"\n  cd etherpad-lite\n  bin/buildForWindows.sh\n  [[ $? != 0 ]] && echo \"Aborting: Error creating build for windows\" && exit 1\n  echo \"Creating docs...\"\n  make docs\n  [[ $? != 0 ]] && echo \"Aborting: Error generating docs\" && exit 1\n}\n\nfunction push_builds {\n  cd $TMP_DIR/etherpad-lite/\n  echo \"Copying windows build and docs to website repo...\"\n  GIT_SHA=$(git rev-parse HEAD | cut -c1-10)\n  mv etherpad-win.zip $TMP_DIR/ether.github.com/downloads/etherpad-win-$VERSION-$GIT_SHA.zip\n\n  mv out/doc $TMP_DIR/ether.github.com/doc/v$VERSION\n\n  cd $TMP_DIR/ether.github.com/\n  sed -i \"s/etherpad-win.*\\.zip/etherpad-win-$VERSION-$GIT_SHA.zip/\" index.html\n  sed -i \"s/$LATEST_GIT_TAG/$VERSION/g\" index.html\n  git checkout -b release_$VERSION\n  [[ $? != 0 ]] && echo \"Aborting: Error creating new release branch\" && exit 1\n  git add doc/\n  git add downloads/\n  git commit -a -m \"Release version $VERSION\"\n  git push -u $ETHER_WEB_REPO release_$VERSION\n  [[ $? != 0 ]] && echo \"Aborting: Error pushing release branch to github\" && exit 1\n}\n\nfunction merge_web_branch {\n        echo \"Merging release to master branch on github...\"\n        API_JSON=$(printf '{\"base\": \"master\",\"head\": \"release_%s\",\"commit_message\": \"Release version %s\"}' $VERSION $VERSION)\n         CURL_RESPONSE=$(curl --silent -i -N --data \"$API_JSON\" https://api.github.com/repos/ether/ether.github.com/merges?access_token=$API_TOKEN | iconv -f utf8)\n  echo $CURL_RESPONSE\n  HTTP_STATUS=$(echo $CURL_RESPONSE | head -1 | sed -r 's/.* ([0-9]{3}) .*/\\1/')\n  [[ $HTTP_STATUS != \"200\" ]] && echo \"Aborting: Error merging release branch\" && exit 1\n}\n\nfunction publish_release {\n  echo -n \"Do you want to publish a new release on github (y/n)? \"\n  read PUBLISH_RELEASE\n  if [ $PUBLISH_RELEASE = \"y\" ]; then\n    # create a new release on github\n    API_JSON=$(printf '{\"tag_name\": \"%s\",\"target_commitish\": \"master\",\"name\": \"Release %s\",\"body\": \"%s\",\"draft\": false,\"prerelease\": false}' $VERSION $VERSION $changelogText)\n    CURL_RESPONSE=$(curl --silent -i -N --data \"$API_JSON\" https://api.github.com/repos/ether/etherpad-lite/releases?access_token=$API_TOKEN | iconv -f utf8)\n    HTTP_STATUS=$(echo $CURL_RESPONSE | head -1 | sed -r 's/.* ([0-9]{3}) .*/\\1/')\n    [[ $HTTP_STATUS != \"201\" ]] && echo \"Aborting: Error publishing release on github\" && exit 1\n  else\n    echo \"No release published on github!\"\n  fi\n}\n\nfunction todo_notification {\n  echo \"Release procedure was successful, but you have to do some steps manually:\"\n  echo \"- Update the wiki at https://github.com/ether/etherpad-lite/wiki\"\n  echo \"- Create a pull request on github to merge the master branch back to develop\"\n  echo \"- Announce the new release on the mailing list, blog.etherpad.org and Twitter\"\n}\n\n# Call functions\ncheck_api_token\nmodify_files\ncreate_release_branch\nmerge_release_branch\ncreate_builds\npush_builds\nmerge_web_branch\npublish_release\ntodo_notification\n"
  },
  {
    "path": "bin/createUserSession.ts",
    "content": "'use strict';\n\n/*\n * A tool for generating a test user session which can be used for debugging configs\n * that require sessions.\n */\n\n// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an\n// unhandled rejection into an uncaught exception, which does cause Node.js to exit.\nimport fs from \"node:fs\";\n\nimport path from \"node:path\";\n\nimport querystring from \"node:querystring\";\n\nimport axios from 'axios'\nimport process from \"node:process\";\n\n\nprocess.on('unhandledRejection', (err) => { throw err; });\nimport settings from 'ep_etherpad-lite/node/utils/Settings';\n(async () => {\n  axios.defaults.baseURL = `http://${settings.ip}:${settings.port}`;\n  const api = axios;\n\n  const filePath = path.join(__dirname, '../APIKEY.txt');\n  const apikey = fs.readFileSync(filePath, {encoding: 'utf-8'});\n\n  let res;\n\n  res = await api.get('/api/');\n  const apiVersion = res.data.currentVersion;\n  if (!apiVersion) throw new Error('No version set in API');\n  console.log('apiVersion', apiVersion);\n  const uri = (cmd: string, args: querystring.ParsedUrlQueryInput ) => `/api/${apiVersion}/${cmd}?${querystring.stringify(args)}`;\n\n  res = await api.post(uri('createGroup', {apikey}));\n  if (res.data.code === 1) throw new Error(`Error creating group: ${res.data}`);\n  const groupID = res.data.data.groupID;\n  console.log('groupID', groupID);\n\n  res = await api.post(uri('createGroupPad', {apikey, groupID}));\n  if (res.data.code === 1) throw new Error(`Error creating group pad: ${res.data}`);\n  console.log('Test Pad ID ====> ', res.data.data.padID);\n\n  res = await api.post(uri('createAuthor', {apikey}));\n  if (res.data.code === 1) throw new Error(`Error creating author: ${res.data}`);\n  const authorID = res.data.data.authorID;\n  console.log('authorID', authorID);\n\n  const validUntil = Math.floor(new Date().getTime()  / 1000) + 60000;\n  console.log('validUntil', validUntil);\n  res = await api.post(uri('createSession', {apikey, groupID, authorID, validUntil}));\n  if (res.data.code === 1) throw new Error(`Error creating session: ${JSON.stringify(res.data)}`);\n  console.log('Session made: ====> create a cookie named sessionID and set the value to',\n      res.data.data.sessionID);\n  process.exit(0)\n})();\n"
  },
  {
    "path": "bin/deb-src/DEBIAN/control",
    "content": "Package: etherpad\nVersion: 1.3\nSection: base\nPriority: optional\nArchitecture: i386\nInstalled-Size: SIZE\nDepends:\nMaintainer: John McLear <john@mclear.co.uk>\nDescription: Etherpad is a collaborative editor.  \n"
  },
  {
    "path": "bin/deb-src/DEBIAN/postinst",
    "content": "#!/bin/bash\n# Start the services!\n\nservice etherpad start\necho \"Give Etherpad about 3 minutes to install dependencies then visit http://localhost:9001 in your web browser\"\necho \"To stop etherpad type 'service etherpad stop', To restart type 'service etherpad restart'\".\nrm -f /tmp/etherpad.log /tmp/etherpad.err\n"
  },
  {
    "path": "bin/deb-src/DEBIAN/preinst",
    "content": "#!/bin/bash\n\n# Installs node if it isn't already installed\n#\n# Don't steamroll over a previously installed node version\n# TODO provide a local version of node?\n\nVER=\"0.10.4\"\nARCH=\"x86\"\nif [ `arch | grep 64` ]\nthen\n  ARCH=\"x64\"\nfi\n\n# TODO test version\nif [ ! -f /usr/local/bin/node ]\nthen\n  pushd /tmp\n  wget -c \"http://nodejs.org/dist/v${VER}/node-v${VER}-linux-${ARCH}.tar.gz\"\n  rm -rf /tmp/node-v${VER}-linux-${ARCH}\n  tar xf node-v${VER}-linux-${ARCH}.tar.gz -C /tmp/\n  cp -a /tmp/node-v${VER}-linux-${ARCH}/* /usr/local/\nfi\n\n# Create Etherpad user\nadduser --system etherpad\n"
  },
  {
    "path": "bin/deb-src/DEBIAN/prerm",
    "content": "#!/bin/bash\n\n# Stop the appserver:\nservice etherpad stop || true\n"
  },
  {
    "path": "bin/deb-src/sysroot/etc/init/etherpad.conf",
    "content": "description \"etherpad\"\n\nstart on started networking\nstop on runlevel [!2345]\n\nenv EPHOME=/opt/etherpad\nenv EPLOGS=/var/log/etherpad\nenv EPUSER=etherpad\n\nrespawn\n\npre-start script\n    cd $EPHOME\n    mkdir $EPLOGS                        ||true\n    chown $EPUSER $EPLOGS                ||true\n    chmod 0755 $EPLOGS                   ||true\n    chown -R $EPUSER $EPHOME/var         ||true\n    $EPHOME/bin/installDeps.sh >> $EPLOGS/error.log || { stop; exit 1; }\nend script\n\nscript\n  cd $EPHOME/\n  exec su -s /bin/sh -c 'exec \"$0\" \"$@\"' $EPUSER -- node --import tsx src/node/server.ts \\\n                        >> $EPLOGS/access.log \\\n                        2>> $EPLOGS/error.log\n  echo \"Etherpad is running on http://localhost:9001 - To change settings edit /opt/etherpad/settings.json\"\n\nend script\n"
  },
  {
    "path": "bin/debugRun.sh",
    "content": "#!/bin/sh\n\n# Move to the Etherpad base directory.\nMY_DIR=$(cd \"${0%/*}\" && pwd -P) || exit 1\ncd \"${MY_DIR}/..\" || exit 1\n\n# Source constants and useful functions\n. bin/functions.sh\n\n# Prepare the environment\nbin/installDeps.sh || exit 1\n\necho \"If you are new to debugging Node.js with Chrome DevTools, take a look at this page:\"\necho \"https://medium.com/@paul_irish/debugging-node-js-nightlies-with-chrome-devtools-7c4a1b95ae27\"\necho \"Open 'chrome://inspect' on Chrome to start debugging.\"\n\ncd src\n# Use 0.0.0.0 to allow external connections to the debugger\n# (ex: running Etherpad on a docker container). Use default port # (9229)\nexec node --import tsx --inspect=0.0.0.0:9229 ./node/server.ts \"$@\"\n"
  },
  {
    "path": "bin/deleteAllGroupSessions.ts",
    "content": "/*\n* A tool for deleting ALL GROUP sessions Etherpad user sessions from the CLI,\n* because sometimes a brick is required to fix a face.\n*/\n\n// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an\n// unhandled rejection into an uncaught exception, which does cause Node.js to exit.\nimport path from \"node:path\";\n\nimport fs from \"node:fs\";\nimport process from \"node:process\";\n\nprocess.on('unhandledRejection', (err) => { throw err; });\nimport axios from 'axios'\n// Set a delete counter which will increment on each delete attempt\n// TODO: Check delete is successful before incrementing\nlet deleteCount = 0;\n\n// get the API Key\nconst filePath = path.join(__dirname, '../APIKEY.txt');\nconsole.log('Deleting all group sessions, please be patient.');\nconst settings = require('ep_etherpad-lite/tests/container/loadSettings').loadSettings();\n\n(async () => {\n  const apikey = fs.readFileSync(filePath, {encoding: 'utf-8'});\n  axios.defaults.baseURL = `http://${settings.ip}:${settings.port}`;\n\n  const apiVersionResponse = await axios.get('/api/');\n  const apiVersion = apiVersionResponse.data.currentVersion; // 1.12.5\n  console.log('apiVersion', apiVersion);\n\n  const groupsResponse = await axios.get(`/api/${apiVersion}/listAllGroups?apikey=${apikey}`);\n  const groups = groupsResponse.data.data.groupIDs; // ['whateverGroupID']\n\n  for (const groupID of groups) {\n    const sessionURI = `/api/${apiVersion}/listSessionsOfGroup?apikey=${apikey}&groupID=${groupID}`;\n    const sessionsResponse = await axios.get(sessionURI);\n    const sessions = sessionsResponse.data.data;\n\n    if(sessions == null) continue;\n\n    for (const [sessionID, val] of Object.entries(sessions)) {\n      if(val == null) continue;\n      const deleteURI = `/api/${apiVersion}/deleteSession?apikey=${apikey}&sessionID=${sessionID}`;\n      await axios.post(deleteURI).then(c=>{\n        console.log(c.data)\n        deleteCount++;\n      }); // delete\n    }\n  }\n  console.log(`Deleted ${deleteCount} sessions`);\n  process.exit(0)\n})();\n"
  },
  {
    "path": "bin/deletePad.ts",
    "content": "'use strict';\n\n/*\n * A tool for deleting pads from the CLI, because sometimes a brick is required\n * to fix a window.\n */\n\n// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an\n// unhandled rejection into an uncaught exception, which does cause Node.js to exit.\nimport path from \"node:path\";\n\nimport fs from \"node:fs\";\nimport process from \"node:process\";\nimport axios from \"axios\";\n\nprocess.on('unhandledRejection', (err) => { throw err; });\n\nconst settings = require('ep_etherpad-lite/tests/container/loadSettings').loadSettings();\n\naxios.defaults.baseURL = `http://${settings.ip}:${settings.port}`;\n\nif (process.argv.length !== 3) throw new Error('Use: node deletePad.js $PADID');\n\n// get the padID\nconst padId = process.argv[2];\n\n// get the API Key\nconst filePath = path.join(__dirname, '../APIKEY.txt');\nconst apikey = fs.readFileSync(filePath, {encoding: 'utf-8'});\n\n(async () => {\n  let apiVersion = await axios.get('/api/');\n  apiVersion = apiVersion.data.currentVersion;\n  if (!apiVersion) throw new Error('No version set in API');\n\n  // Now we know the latest API version, let's delete pad\n  const uri = `/api/${apiVersion}/deletePad?apikey=${apikey}&padID=${padId}`;\n  const deleteAttempt = await axios.post(uri);\n  if (deleteAttempt.data.code === 1) throw new Error(`Error deleting pad ${deleteAttempt.data}`);\n  console.log('Deleted pad', deleteAttempt.data);\n  process.exit(0)\n})();\n"
  },
  {
    "path": "bin/extractPadData.ts",
    "content": "'use strict';\n\n/*\n * This is a debug tool. It helps to extract all datas of a pad and move it from\n * a productive environment and to a develop environment to reproduce bugs\n * there. It outputs a dirtydb file\n */\n\n// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an\n// unhandled rejection into an uncaught exception, which does cause Node.js to exit.\nimport util from \"node:util\";\nimport process from \"node:process\";\nprocess.on('unhandledRejection', (err) => { throw err; });\nif (process.argv.length !== 3) throw new Error('Use: node extractPadData.js $PADID');\n\n// get the padID\nconst padId = process.argv[2];\n\n(async () => {\n  // initialize database\n  require('ep_etherpad-lite/node/utils/Settings');\n  const db = require('ep_etherpad-lite/node/db/DB');\n  await db.init();\n\n  // load extra modules\n  const dirtyDB = require('dirty');\n  const padManager = require('ep_etherpad-lite/node/db/PadManager');\n\n  // initialize output database\n  const dirty = dirtyDB(`${padId}.db`);\n\n  // Promise set function\n  const set = util.promisify(dirty.set.bind(dirty));\n\n  // array in which required key values will be accumulated\n  const neededDBValues = [`pad:${padId}`];\n\n  // get the actual pad object\n  const pad = await padManager.getPad(padId);\n\n  // add all authors\n  neededDBValues.push(...pad.getAllAuthors().map((author: string) => `globalAuthor:${author}`));\n\n  // add all revisions\n  for (let rev = 0; rev <= pad.head; ++rev) {\n    neededDBValues.push(`pad:${padId}:revs:${rev}`);\n  }\n\n  // add all chat values\n  for (let chat = 0; chat <= pad.chatHead; ++chat) {\n    neededDBValues.push(`pad:${padId}:chat:${chat}`);\n  }\n\n  for (const dbkey of neededDBValues) {\n    let dbvalue = await db.get(dbkey);\n    if (dbvalue && typeof dbvalue !== 'object') {\n      dbvalue = JSON.parse(dbvalue);\n    }\n    await set(dbkey, dbvalue);\n  }\n\n  console.log('finished');\n  process.exit(0)\n})();\n"
  },
  {
    "path": "bin/fastRun.sh",
    "content": "#!/bin/bash\n#\n# Run Etherpad directly, assuming all the dependencies are already installed.\n#\n# Useful for developers, or users that know what they are doing. If you just\n# upgraded Etherpad version, installed a new dependency, or are simply unsure\n# of what to do, please execute bin/installDeps.sh once before running this\n# script.\n\nset -eu\n\n# Move to the Etherpad base directory.\nMY_DIR=$(cd \"${0%/*}\" && pwd -P) || exit 1\ncd \"${MY_DIR}/..\" || exit 1\n\n# Source constants and useful functions\n. bin/functions.sh\n\necho \"Running directly, without checking/installing dependencies\"\n\n# run Etherpad main class\nexec pnpm run prod \"$@\"\n"
  },
  {
    "path": "bin/functions.sh",
    "content": "# minimum required node version\nREQUIRED_NODE_MAJOR=12\nREQUIRED_NODE_MINOR=13\n\n# minimum required npm version\nREQUIRED_NPM_MAJOR=5\nREQUIRED_NPM_MINOR=5\n\npecho() { printf %s\\\\n \"$*\"; }\nlog() { pecho \"$@\"; }\nerror() { log \"ERROR: $@\" >&2; }\nfatal() { error \"$@\"; exit 1; }\nis_cmd() { command -v \"$@\" >/dev/null 2>&1; }\n\n\nget_program_version() {\n  PROGRAM=\"$1\"\n  KIND=\"${2:-full}\"\n  PROGRAM_VERSION_STRING=$($PROGRAM --version)\n  PROGRAM_VERSION_STRING=${PROGRAM_VERSION_STRING#\"v\"}\n\n  DETECTED_MAJOR=$(pecho \"$PROGRAM_VERSION_STRING\" | cut -s -d \".\" -f 1)\n  [ -n \"$DETECTED_MAJOR\" ] || fatal \"Cannot extract $PROGRAM major version from version string \\\"$PROGRAM_VERSION_STRING\\\"\"\n  case \"$DETECTED_MAJOR\" in\n      ''|*[!0-9]*)\n        fatal \"$PROGRAM_LABEL major version from \\\"$VERSION_STRING\\\" is not a number. Detected: \\\"$DETECTED_MAJOR\\\"\"\n        ;;\n  esac\n\n  DETECTED_MINOR=$(pecho \"$PROGRAM_VERSION_STRING\" | cut -s -d \".\" -f 2)\n  [ -n \"$DETECTED_MINOR\" ] || fatal \"Cannot extract $PROGRAM minor version from version string \\\"$PROGRAM_VERSION_STRING\\\"\"\n  case \"$DETECTED_MINOR\" in\n      ''|*[!0-9]*)\n        fatal \"$PROGRAM_LABEL minor version from \\\"$VERSION_STRING\\\" is not a number. Detected: \\\"$DETECTED_MINOR\\\"\"\n  esac\n\n  case $KIND in\n    major)\n      echo $DETECTED_MAJOR\n      exit;;\n    minor)\n      echo $DETECTED_MINOR\n      exit;;\n    *)\n      echo $DETECTED_MAJOR.$DETECTED_MINOR\n      exit;;\n  esac\n\n  echo $VERSION\n}\n\n\nrequire_minimal_version() {\n  PROGRAM_LABEL=\"$1\"\n  VERSION=\"$2\"\n  REQUIRED_MAJOR=\"$3\"\n  REQUIRED_MINOR=\"$4\"\n\n  VERSION_MAJOR=$(pecho \"$VERSION\" | cut -s -d \".\" -f 1)\n  VERSION_MINOR=$(pecho \"$VERSION\" | cut -s -d \".\" -f 2)\n\n  [ \"$VERSION_MAJOR\" -gt \"$REQUIRED_MAJOR\" ] || ([ \"$VERSION_MAJOR\" -eq \"$REQUIRED_MAJOR\" ] && [ \"$VERSION_MINOR\" -ge \"$REQUIRED_MINOR\" ]) \\\n    || fatal \"Your $PROGRAM_LABEL version \\\"$VERSION_MAJOR.$VERSION_MINOR\\\" is too old. $PROGRAM_LABEL $REQUIRED_MAJOR.$REQUIRED_MINOR.x or higher is required.\"\n}\n"
  },
  {
    "path": "bin/generateReleaseNotes.ts",
    "content": "import {readFileSync} from \"node:fs\";\n\nconst changelog = readFileSync('../CHANGELOG.md')\nconst changelogText = changelog.toString()\nconst changelogLines = changelogText.split('\\n')\n\n\nlet cliArgs = process.argv.slice(2)\n\nlet tagVar = cliArgs[0]\n\nif (!tagVar) {\n  console.error(\"No tag provided\")\n  process.exit(1)\n}\n\ntagVar = tagVar.replace(\"refs/tags/v\", \"\")\n\nlet startNum = -1\nlet endline = 0\n\nlet counter = 0\nfor (const line of changelogLines) {\n    if (line.trim().startsWith(\"#\") && (line.match(new RegExp(\"#\", \"g\"))||[]).length === 1) {\n      if (startNum !== -1) {\n        endline = counter-1\n        break\n      }\n\n      const sanitizedLine = line.replace(\"#\",\"\").trim()\n      if(sanitizedLine.includes(tagVar)) {\n        startNum = counter\n      }\n    }\n    counter++\n}\n\nlet currentReleaseNotes = changelogLines.slice(startNum, endline).join('\\n')\nconsole.log(currentReleaseNotes)\n"
  },
  {
    "path": "bin/importSqlFile.ts",
    "content": "'use strict';\n\n// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an\n// unhandled rejection into an uncaught exception, which does cause Node.js to exit.\nimport util from \"node:util\";\nimport fs from 'node:fs';\nimport log4js from 'log4js';\nimport readline from 'readline';\nimport {Database, DatabaseType} from \"ueberdb2\";\nimport process from \"node:process\";\n\nimport settings from 'ep_etherpad-lite/node/utils/Settings';\nprocess.on('unhandledRejection', (err) => { throw err; });\nconst startTime = Date.now();\n\nconst log = (str:string) => {\n  console.log(`${(Date.now() - startTime) / 1000}\\t${str}`);\n};\n\nconst unescape = (val: string) => {\n  // value is a string\n  if (val.substring(0, 1) === \"'\") {\n    val = val.substring(0, val.length - 1).substring(1);\n\n    return val.replace(/\\\\[0nrbtZ\\\\'\"]/g, (s) => {\n      switch (s) {\n        case '\\\\0': return '\\0';\n        case '\\\\n': return '\\n';\n        case '\\\\r': return '\\r';\n        case '\\\\b': return '\\b';\n        case '\\\\t': return '\\t';\n        case '\\\\Z': return '\\x1a';\n        default: return s.substring(1);\n      }\n    });\n  }\n\n  // value is a boolean or NULL\n  if (val === 'NULL') {\n    return null;\n  }\n  if (val === 'true') {\n    return true;\n  }\n  if (val === 'false') {\n    return false;\n  }\n\n  // value is a number\n  return val;\n};\n\n(async () => {\n\n  const dbWrapperSettings = {\n    cache: 0,\n    writeInterval: 100,\n    json: false, // data is already json encoded\n  };\n  const db = new Database( // eslint-disable-line new-cap\n      settings.dbType as DatabaseType,\n      settings.dbSettings,\n      dbWrapperSettings,\n      log4js.getLogger('ueberDB'));\n\n  const sqlFile = process.argv[2];\n\n  // stop if the settings file is not set\n  if (!sqlFile) throw new Error('Use: node importSqlFile.js $SQLFILE');\n\n  log('initializing db');\n  const initDb = await util.promisify(db.init.bind(db));\n  await initDb(null);\n  log('done');\n\n  log(`Opening ${sqlFile}...`);\n  const stream = fs.createReadStream(sqlFile, {encoding: 'utf8'});\n\n  log(`Reading ${sqlFile}...`);\n  let keyNo = 0;\n  for await (const l of readline.createInterface({input: stream, crlfDelay: Infinity})) {\n    if (l.substring(0, 27) === 'REPLACE INTO store VALUES (') {\n      const pos = l.indexOf(\"', '\");\n      const key = l.substring(28, pos - 28);\n      let value = l.substring(pos + 3);\n      value = value.substring(0, value.length - 2);\n      console.log(`key: ${key} val: ${value}`);\n      console.log(`unval: ${unescape(value)}`);\n      // @ts-ignore\n      db.set(key, unescape(value), null);\n      keyNo++;\n      if (keyNo % 1000 === 0) log(` ${keyNo}`);\n    }\n  }\n  process.stdout.write('\\n');\n  process.stdout.write('done. waiting for db to finish transaction. ' +\n                       'depended on dbms this may take some time..\\n');\n\n  const closeDB = util.promisify(db.close.bind(db));\n  // @ts-ignore\n  await closeDB(null);\n  log(`finished, imported ${keyNo} keys.`);\n  process.exit(0)\n})();\n"
  },
  {
    "path": "bin/installDeps.sh",
    "content": "#!/bin/sh\n\n\n# Move to the Etherpad base directory.\nMY_DIR=$(cd \"${0%/*}\" && pwd -P) || exit 1\ncd \"${MY_DIR}/..\" || exit 1\n\n# Source constants and useful functions\n. bin/functions.sh\n\nis_cmd pnpm || npm install pnpm -g\n\n\n# Is node installed?\n# Not checking io.js, default installation creates a symbolic link to node\nis_cmd node || fatal \"Please install node.js ( https://nodejs.org )\"\n\n# Check node version\nrequire_minimal_version \"nodejs\" \"$(get_program_version \"node\")\" \\\n    \"$REQUIRED_NODE_MAJOR\" \"$REQUIRED_NODE_MINOR\"\n\n# Get the name of the settings file\nsettings=\"settings.json\"\na='';\nfor arg in \"$@\"; do\n  if [ \"$a\" = \"--settings\" ] || [ \"$a\" = \"-s\" ]; then settings=$arg; fi\n  a=$arg\ndone\n\n# Does a $settings exist? if not copy the template\nif [ ! -f \"$settings\" ]; then\n  log \"Copy the settings template to $settings...\"\n  cp settings.json.template \"$settings\" || exit 1\nfi\n\nlog \"Installing dependencies...\"\nif [ -z \"${ETHERPAD_PRODUCTION}\" ]; then\n  log \"Installing dev dependencies with pnpm\"\n  pnpm --recursive i  || exit 1\nelse\n  log \"Installing production dependencies with pnpm\"\n  pnpm --recursive i --production || exit 1\nfi\n\n# Remove all minified data to force node creating it new\nlog \"Clearing minified cache...\"\nrm -f var/minified*\n\nexit 0\n"
  },
  {
    "path": "bin/installLocalPlugins.sh",
    "content": "#!/bin/bash\nset -euo pipefail\nIFS=$'\\n\\t'\n\ntrim() {\n    local var=\"$*\"\n    # remove leading whitespace characters\n    var=\"${var#\"${var%%[![:space:]]*}\"}\"\n    # remove trailing whitespace characters\n    var=\"${var%\"${var##*[![:space:]]}\"}\"\n    printf '%s' \"$var\"\n}\n\n# Move to the Etherpad base directory.\nMY_DIR=$(cd \"${0%/*}\" && pwd -P) || exit 1\ncd \"${MY_DIR}/..\" || exit 1\n\n# Source constants and useful functions\n. bin/functions.sh\n\nPNPM_OPTIONS=\nif [ ! -z \"${NODE_ENV-}\"  ]; then\n  if [ \"$NODE_ENV\" == 'production' ]; then\n    PNPM_OPTIONS='--prod'\n  fi\nfi\n\nif [ ! -z \"${ETHERPAD_LOCAL_PLUGINS_ENV-}\"  ]; then\n  if [ \"$ETHERPAD_LOCAL_PLUGINS_ENV\" == 'production' ]; then\n    PNPM_OPTIONS='--prod'\n  elif [ \"$ETHERPAD_LOCAL_PLUGINS_ENV\" == 'development' ]; then\n    PNPM_OPTIONS='-D'\n  fi\nfi\n\nif [ ! -z \"${ETHERPAD_LOCAL_PLUGINS}\" ]; then\n  readarray -d ' ' plugins <<< \"${ETHERPAD_LOCAL_PLUGINS}\"\n  for plugin in \"${plugins[@]}\"; do\n    plugin=$(trim \"$plugin\")\n    if [ -d \"local_plugins/${plugin}\" ]; then\n      echo \"Installing plugin: '${plugin}'\"\n      pnpm install -w ${PNPM_OPTIONS:-} \"local_plugins/${plugin}/\"\n    else\n      ( echo \"Error. Directory 'local_plugins/${plugin}' for local plugin \" \\\n             \"'${plugin}' missing\" >&2 )\n      exit 1\n    fi\n  done\nelse\n  echo 'No local plugings to install.'\nfi\n"
  },
  {
    "path": "bin/installOnWindows.bat",
    "content": "@echo off\n\n:: Change directory to etherpad-lite root\ncd /D \"%~dp0\\..\"\n\n:: Is node installed?\ncmd /C node -e \"\" || ( echo \"Please install node.js ( https://nodejs.org )\" && exit /B 1 )\n\necho _\necho Ensure that all dependencies are up to date...  If this is the first time you have run Etherpad please be patient.\n\n\n:: Install admin ui only if available\nIF EXIST admin (\n cd /D .\\admin\n dir\n cmd /C pnpm i || exit /B 1\n cmd /C pnpm run build || exit /B 1\n cd /D ..\n)\n\n:: Install ui only if available\nIF EXIST ui (\n cd /D .\\ui\n dir\n cmd /C pnpm i || exit /B 1\n cmd /C pnpm run build || exit /B 1\n cd /D ..\n)\n\n\ncmd /C pnpm i || exit /B 1\n\ncd /D \"%~dp0\\..\"\n\necho _\necho Clearing cache...\ndel /S var\\minified*\n\necho _\necho Setting up settings.json...\nIF NOT EXIST settings.json (\n  echo Can't find settings.json.\n  echo Copying settings.json.template...\n  cmd /C copy settings.json.template settings.json || exit /B 1\n)\n\necho _\necho Installed Etherpad!  To run Etherpad type start.bat\n"
  },
  {
    "path": "bin/make_docs.ts",
    "content": "import {exec} from 'child_process'\nimport fs from 'fs'\nimport path from 'path'\n\nimport pjson from '../src/package.json'\n\nconst VERSION=pjson.version\nconsole.log(`Building docs for version ${VERSION}`)\n\nconst createDirIfNotExists = (dir: fs.PathLike) => {\n    if (!fs.existsSync(dir)){\n        fs.mkdirSync(dir)\n    }\n}\n\n\nfunction copyFolderSync(from: fs.PathLike, to: fs.PathLike) {\n    if(fs.existsSync(to)){\n        const stat = fs.lstatSync(to)\n        if (stat.isDirectory()){\n            fs.rmSync(to, { recursive: true })\n        }\n        else{\n            fs.rmSync(to)\n        }\n    }\n    fs.mkdirSync(to);\n    fs.readdirSync(from).forEach(element => {\n        if (fs.lstatSync(path.join(<string>from, element)).isFile()) {\n          if (typeof from === \"string\") {\n            if (typeof to === \"string\") {\n              fs.copyFileSync(path.join(from, element), path.join(to, element))\n            }\n          }\n        } else {\n          if (typeof from === \"string\") {\n            if (typeof to === \"string\") {\n              copyFolderSync(path.join(from, element), path.join(to, element))\n            }\n          }\n        }\n    });\n}\n\nexec('asciidoctor -v', (err,stdout)=>{\n    if (err){\n        console.log('Please install asciidoctor')\n        console.log('https://asciidoctor.org/docs/install-toolchain/')\n        process.exit(1)\n    }\n});\n\n\ncreateDirIfNotExists('../out')\ncreateDirIfNotExists('../out/doc')\ncreateDirIfNotExists('../out/doc/api')\n\n\n\nexec(`asciidoctor -D ../out/doc ../doc/index.adoc ../*/**.adoc -a VERSION=${VERSION}`)\nexec(`asciidoctor -D ../out/doc/api  ../doc/api/*.adoc -a VERSION=${VERSION}`)\n\ncopyFolderSync('../doc/public/', '../out/doc/')\n"
  },
  {
    "path": "bin/migrateDB.ts",
    "content": "// DB migration\nimport {readFileSync} from 'node:fs'\nimport {Database, DatabaseType} from \"ueberdb2\";\nimport path from \"node:path\";\nimport settings from 'ep_etherpad-lite/node/utils/Settings';\n\n\n// file1 = source, file2 = target\n// pnpm run --filter bin migrateDB --file1 <db1.json> --file2 <db2.json>\nconst arg = process.argv.slice(2);\n\nif (arg.length != 4) {\n  console.error('Wrong number of arguments!. Call with pnpm run --filter bin migrateDB --file1 source.json target.json')\n  process.exit(1)\n}\n\ntype SettingsConfig = {\n  dbType: string,\n  dbSettings: any\n}\n\n/*\n  {\n    \"dbType\": \"<your-db-type>\",\n    \"dbSettings\": {\n      <your-db-settings>\n     }\n  }\n */\n\nlet firstDBSettingsFile: string\nlet secondDBSettingsFile: string\n\n\nif (arg[0] == \"--file1\") {\n    firstDBSettingsFile = arg[1]\n} else if (arg[0] === \"--file2\") {\n  secondDBSettingsFile = arg[1]\n}\n\nif (arg[2] == \"--file1\") {\n  firstDBSettingsFile = arg[3]\n} else if (arg[2] === \"--file2\") {\n  secondDBSettingsFile = arg[3]\n}\n\n\n\nconst settingsfile = JSON.parse(readFileSync(path.join(settings.root,firstDBSettingsFile!)).toString()) as SettingsConfig\nconst settingsfile2 = JSON.parse(readFileSync(path.join(settings.root,secondDBSettingsFile!)).toString()) as SettingsConfig\n\nconsole.log(settingsfile2)\nif (\"filename\" in settingsfile.dbSettings) {\n  settingsfile.dbSettings.filename = path.join(settings.root, settingsfile.dbSettings.filename)\n  console.log(settingsfile.dbType + \" location is \"+ settingsfile.dbSettings.filename)\n}\n\nif (\"filename\" in settingsfile2.dbSettings) {\n  settingsfile2.dbSettings.filename = path.join(settings.root, settingsfile2.dbSettings.filename)\n  console.log(settingsfile2.dbType + \" location is \"+ settingsfile2.dbSettings.filename)\n}\n\nconst ueberdb1 = new Database(settingsfile.dbType as DatabaseType, settingsfile.dbSettings)\nconst ueberdb2 = new Database(settingsfile2.dbType as DatabaseType, settingsfile2.dbSettings)\n\nconst handleSync = async ()=>{\n  await ueberdb1.init()\n  await ueberdb2.init()\n\n  const allKeys = await ueberdb1.findKeys('*','')\n  for (const key of allKeys) {\n    const foundVal = await ueberdb1.get(key)!\n    await ueberdb2.set(key, foundVal)\n  }\n}\n\nhandleSync().then(()=>{\n  console.log(\"Done syncing dbs\")\n}).catch(e=>{\n  console.log(`Error syncing db ${e}`)\n})\n\n\n"
  },
  {
    "path": "bin/migrateDirtyDBtoRealDB.ts",
    "content": "'use strict';\n\nimport process from 'node:process';\nimport {Database, DatabaseType} from \"ueberdb2\";\nimport log4js from 'log4js';\nimport settings from 'ep_etherpad-lite/node/utils/Settings';\n\n// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an\n// unhandled rejection into an uncaught exception, which does cause Node.js to exit.\nprocess.on('unhandledRejection', (err) => { throw err; });\n\n(async () => {\n  // This script requires that you have modified your settings.json file\n  // to work with a real database.  Please make a backup of your dirty.db\n  // file before using this script, just to be safe.\n\n  // It might be necessary to run the script using more memory:\n  // `node --max-old-space-size=4096 src/bin/migrateDirtyDBtoRealDB.js`\n\n\n  const dbWrapperSettings = {\n    cache: '0', // The cache slows things down when you're mostly writing.\n    writeInterval: 0, // Write directly to the database, don't buffer\n  };\n  const db = new Database( // eslint-disable-line new-cap\n      settings.dbType as DatabaseType,\n      settings.dbSettings,\n      dbWrapperSettings,\n      log4js.getLogger('ueberDB'));\n  await db.init();\n\n  console.log('Waiting for dirtyDB to parse its file.');\n  const dirty = new Database('dirty', `${__dirname}/../var/dirty.db`);\n    await dirty.init();\n  const keys = await dirty.findKeys('*', '')\n\n  console.log(`Found ${keys.length} records, processing now.`);\n  const p: Promise<void>[] = [];\n  let numWritten = 0;\n  for (const key of keys) {\n    let value = await dirty.get(key);\n    let bcb, wcb;\n    p.push(new Promise((resolve, reject) => {\n      bcb = (err:any) => { if (err != null) return reject(err); };\n      wcb = (err:any) => {\n        if (err != null) return reject(err);\n        if (++numWritten % 100 === 0) console.log(`Wrote record ${numWritten} of ${length}`);\n        resolve();\n      };\n    }));\n    db.set(key, value, bcb, wcb);\n  }\n  await Promise.all(p);\n  console.log(`Wrote all ${numWritten} records`);\n\n  await db.close(null);\n  await dirty.close(null);\n  console.log('Finished.');\n  process.exit(0)\n})();\n"
  },
  {
    "path": "bin/nsis/README.md",
    "content": "A simple NSIS script to Install Etherpad (Server) on Windows and start it.\n\n# TODO\n1. i18n\n1. Run as Service\n1. Display messages during install\n\n# License\nApache 2\n"
  },
  {
    "path": "bin/nsis/etherpad.nsi",
    "content": ";Include Modern UI\n!include \"MUI2.nsh\"\n!include x64.nsh\n\n;--------------------------------\n;Styling\n!define MUI_ICON \"brand.ico\"\nIcon \"brand.ico\"\nBrandingText \"Etherpad Foundation\"\nName \"Etherpad Server\"\nOutFile \"..\\..\\..\\etherpad-win.exe\"\n\n!insertmacro MUI_LANGUAGE \"English\"\n\nPage directory\nPage instfiles\n\n; The default installation directory\nInstallDir \"$PROGRAMFILES64\\Etherpad Foundation\\Etherpad Server\"\n\nSection\n  SectionIn RO\n\n  ${If} ${RunningX64}\n    DetailPrint \"Installer running on x64 host\"\n  ${Else}\n    Abort \"Unsupported CPU architecture (only x64 is supported)\"\n  ${Endif}\n\n  ; Set output path to the installation directory.\n  SetOutPath $INSTDIR\n\n  ; Put files there\n  File /r \"..\\..\\..\\..\\etherpad-zip\\*\"\n\nSectionEnd\n\nSection\n  CreateDirectory \"$SMPROGRAMS\\Etherpad Foundation\"\n  CreateShortCut \"$SMPROGRAMS\\Etherpad Foundation\\Etherpad Server.lnk\" \"$INSTDIR\\start.bat\" \"brand.ico\" \"Etherpad Server\"\n  CreateShortCut \"$SMPROGRAMS\\Etherpad Foundation\\Etherpad.lnk\" \"http://127.0.0.1:9001\" \"brand.ico\" \"Etherpad\"\n  CreateShortCut \"$SMPROGRAMS\\Etherpad Foundation\\Etherpad Admin.lnk\" \"http://127.0.0.1:9001/admin\" \"brand.ico\" \"Etherpad Admin\"\n  CreateShortCut \"$SMPROGRAMS\\Etherpad Foundation\\Uninstall Etherpad Server.lnk\" \"$INSTDIR\\uninstall.exe\"\n  WriteUninstaller \"$INSTDIR\\uninstall.exe\"\n  Exec '$INSTDIR\\start.bat'\nSectionEnd\n\nUninstPage instfiles\n\nSection Uninstall\n  Delete \"$INSTDIR\\*\"\n  Delete \"$INSTDIR\\uninstall.exe\"\n  RMDir \"$INSTDIR\"\n  SetAutoClose false\nSectionEnd\n"
  },
  {
    "path": "bin/package.json",
    "content": "{\n  \"name\": \"bin\",\n  \"version\": \"2.6.1\",\n  \"description\": \"\",\n  \"main\": \"checkAllPads.js\",\n  \"directories\": {\n    \"doc\": \"doc\"\n  },\n  \"dependencies\": {\n    \"axios\": \"^1.13.6\",\n    \"ep_etherpad-lite\": \"workspace:../src\",\n    \"log4js\": \"^6.9.1\",\n    \"semver\": \"^7.7.4\",\n    \"tsx\": \"^4.21.0\",\n    \"ueberdb2\": \"^5.0.23\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^25.5.0\",\n    \"@types/semver\": \"^7.7.1\",\n    \"typescript\": \"^5.9.3\"\n  },\n  \"scripts\": {\n    \"makeDocs\": \"node --import tsx make_docs.ts\",\n    \"checkPad\": \"node --import tsx checkPad.ts\",\n    \"checkAllPads\": \"node --import tsx checkAllPads.ts\",\n    \"createUserSession\": \"node --import tsx createUserSession.ts\",\n    \"deletePad\": \"node --import tsx deletePad.ts\",\n    \"repairPad\": \"node --import tsx repairPad.ts\",\n    \"release\": \"node --import tsx release.ts\",\n    \"deleteAllGroupSessions\": \"node --import tsx deleteAllGroupSessions.ts\",\n    \"importSqlFile\": \"node --import tsx importSqlFile.ts\",\n    \"migrateDirtyDBtoRealDB\": \"node --import tsx migrateDirtyDBtoRealDB.ts\",\n    \"rebuildPad\": \"node --import tsx rebuildPad.ts\",\n    \"stalePlugins\": \"node --import tsx ./plugins/stalePlugins.ts\",\n    \"checkPlugin\": \"node --import tsx ./plugins/checkPlugin.ts\",\n    \"plugins\": \"node --import tsx ./plugins.ts\",\n    \"generateChangelog\": \"node --import tsx generateReleaseNotes.ts\",\n    \"migrateDB\": \"node --import tsx migrateDB.ts\"\n  },\n  \"author\": \"\",\n  \"license\": \"ISC\"\n}\n"
  },
  {
    "path": "bin/plugins/README.md",
    "content": "The files in this folder are for Plugin developers.\n\n# Get suggestions to improve your Plugin\n\nThis code will check your plugin for known usual issues and some suggestions for\nimprovements. No changes will be made to your project.\n\n```\nnode src/bin/plugins/checkPlugin.js $PLUGIN_NAME$\n```\n\n# Basic Example:\n\n```\nnode src/bin/plugins/checkPlugin.js ep_webrtc\n```\n\n## Autofixing - will autofix any issues it can\n\n```\nnode src/bin/plugins/checkPlugin.js ep_whatever autofix\n```\n\n## Autocommitting - fix issues and commit\n\n```\nnode src/bin/plugins/checkPlugin.js ep_whatever autocommit\n```\n\n## Autopush - fix issues, commit, push, and publish (highly dangerous)\n\n```\nnode src/bin/plugins/checkPlugin.js ep_whatever autopush\n```\n\n# All the plugins\n\nReplace johnmclear with your github username\n\n```\n# Clones\ncd node_modules\nGHUSER=johnmclear; curl \"https://api.github.com/users/$GHUSER/repos?per_page=1000\" | grep -o 'git@[^\"]*' | grep /ep_ | xargs -L1 git clone\ncd ..\n\n# autofixes and autocommits /pushes & npm publishes\nfor dir in node_modules/ep_*; do\n  dir=${dir#node_modules/}\n  [ \"$dir\" != ep_etherpad-lite ] || continue\n  node src/bin/plugins/checkPlugin.js \"$dir\" autocommit\ndone\n```\n\n# Automating update of ether organization plugins\n\n```\ngetCorePlugins.sh\nupdateCorePlugins.sh\n```\n"
  },
  {
    "path": "bin/plugins/checkPlugin.ts",
    "content": "/*\n * Usage -- see README.md\n *\n * Normal usage:                node bin/plugins/checkPlugin.js ep_whatever\n * Auto fix the things it can:  node bin/plugins/checkPlugin.js ep_whatever autofix\n * Auto fix and commit:         node bin/plugins/checkPlugin.js ep_whatever autocommit\n * Auto fix, commit, push and publish to npm (highly dangerous):\n *                              node bin/plugins/checkPlugin.js ep_whatever autopush\n */\n\nimport process from 'node:process';\n\n// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an\n// unhandled rejection into an uncaught exception, which does cause Node.js to exit.\nprocess.on('unhandledRejection', (err) => { throw err; });\n\nimport {strict as assert} from 'assert';\nimport fs from 'node:fs';\nconst fsp = fs.promises;\nimport childProcess from 'node:child_process';\nimport log4js from 'log4js';\nimport path from 'node:path';\nimport semver from \"semver\";\n\nconst logger = log4js.getLogger('checkPlugin');\nlog4js.configure({\n  appenders: { console: { type: \"console\" } },\n  categories: { default: { appenders: [\"console\"], level: \"info\" } },\n});\n(async () => {\n  // get plugin name & path from user input\n  const pluginName = process.argv[2];\n\n  if (!pluginName) throw new Error('no plugin name specified');\n  logger.info(`Checking the plugin: ${pluginName}`);\n\n  const epRootDir = await fsp.realpath(path.join(await fsp.realpath(__dirname), '../..'));\n  logger.info(`Etherpad root directory: ${epRootDir}`);\n  process.chdir(epRootDir);\n  const pluginPath = await fsp.realpath(`../${pluginName}`);\n  logger.info(`Plugin directory: ${pluginPath}`);\n  const epSrcDir = await fsp.realpath(path.join(epRootDir, 'src'));\n\n  const optArgs = process.argv.slice(3);\n  const autoPush = optArgs.includes('autopush');\n  const autoCommit = autoPush || optArgs.includes('autocommit');\n  const autoFix = autoCommit || optArgs.includes('autofix');\n\n  const execSync = (cmd:string, opts = {}) => (childProcess.execSync(cmd, {\n    cwd: `${pluginPath}/`,\n    ...opts,\n  }) || '').toString().replace(/\\n+$/, '');\n\n  const writePackageJson = async (obj: object) => {\n    console.log(\"writing package.json\",obj)\n    let s = JSON.stringify(obj, null, 2);\n    if (s.length && s.slice(s.length - 1) !== '\\n') s += '\\n';\n    return await fsp.writeFile(`${pluginPath}/package.json`, s);\n  };\n\n  const checkEntries = (got: any, want:any) => {\n    let changed = false;\n    for (const [key, val] of Object.entries(want)) {\n      try {\n        assert.deepEqual(got[key], val);\n      } catch (err:any) {\n        logger.warn(`${key} possibly outdated.`);\n        logger.warn(err.message);\n        if (autoFix) {\n          got[key] = val;\n          changed = true;\n        }\n      }\n    }\n    return changed;\n  };\n\n  const updateDeps = async (parsedPackageJson: any, key: string, wantDeps: {\n    [key: string]: string | {ver?: string, overwrite?: boolean}|null\n  }|string) => {\n    const {[key]: deps = {}} = parsedPackageJson;\n    let changed = false;\n\n    if (typeof wantDeps === 'string') {\n      if (deps !== wantDeps) {\n        logger.warn(`Dependency mismatch in ${key}: '${wantDeps}' (current: ${deps})`);\n        if (autoFix) {\n          parsedPackageJson[key] = wantDeps;\n          await writePackageJson(parsedPackageJson);\n        }\n      }\n      return;\n    }\n    for (const [pkg, verInfo] of Object.entries(wantDeps)) {\n      const {ver, overwrite = true} =\n          typeof verInfo === 'string' || verInfo == null ? {ver: verInfo} : verInfo;\n      if (deps[pkg] === ver || (deps[pkg] == null && ver == null)) continue;\n      if (deps[pkg] == null) {\n        logger.warn(`Missing dependency in ${key}: '${pkg}': '${ver}'`);\n      } else {\n        if (!overwrite) continue;\n        logger.warn(`Dependency mismatch in ${key}: '${pkg}': '${ver}' (current: ${deps[pkg]})`);\n      }\n      if (autoFix) {\n        if (ver == null) delete deps[pkg];\n        else deps[pkg] = ver;\n        changed = true;\n      }\n    }\n    if (changed) {\n      parsedPackageJson[key] = deps;\n      await writePackageJson(parsedPackageJson);\n    }\n  };\n\n  const prepareRepo = () => {\n    const modified = execSync('git diff-files --name-status');\n    if (modified !== '') {\n      logger.warn('working directory has modifications');\n      if (autoFix)\n      execSync('git stash', {stdio: 'inherit'})\n      //throw new Error(`working directory has modifications:\\n${modified}`);\n    }\n    const untracked = execSync('git ls-files -o --exclude-standard');\n    if (untracked !== '') throw new Error(`working directory has untracked files:\\n${untracked}`);\n    const indexStatus = execSync('git diff-index --cached --name-status HEAD');\n    if (indexStatus !== '') throw new Error(`uncommitted staged changes to files:\\n${indexStatus}`);\n    let br;\n    if (autoCommit) {\n      br = execSync('git symbolic-ref HEAD');\n      if (!br.startsWith('refs/heads/')) throw new Error('detached HEAD');\n      br = br.replace(/^refs\\/heads\\//, '');\n      execSync('git rev-parse --verify -q HEAD || ' +\n               `{ echo \"Error: no commits on ${br}\" >&2; exit 1; }`);\n      execSync('git config --get user.name');\n      execSync('git config --get user.email');\n    }\n    if (autoPush) {\n      if (!['master', 'main'].includes(br!)) throw new Error('master/main not checked out');\n      execSync('git rev-parse --verify @{u}');\n      execSync('git pull --ff-only', {stdio: 'inherit'});\n      if (execSync('git rev-list @{u}...') !== '') throw new Error('repo contains unpushed commits');\n    }\n  };\n\n  const checkFile = async (srcFn: string, dstFn:string, overwrite = true) => {\n    const outFn = path.join(pluginPath, dstFn);\n    const wantContents = await fsp.readFile(srcFn, {encoding: 'utf8'});\n    let gotContents = null;\n    try {\n      gotContents = await fsp.readFile(outFn, {encoding: 'utf8'});\n    } catch (err) { /* treat as if the file doesn't exist */ }\n    try {\n      assert.equal(gotContents, wantContents);\n    } catch (err:any) {\n      logger.warn(`File ${dstFn} does not match the default`);\n      logger.warn(err.message);\n      if (!overwrite && gotContents != null) {\n        logger.warn('Leaving existing contents alone.');\n        return;\n      }\n      if (autoFix) {\n        await fsp.mkdir(path.dirname(outFn), {recursive: true});\n        await fsp.writeFile(outFn, wantContents);\n      }\n    }\n  };\n\n  if (autoPush) {\n    logger.warn('Auto push is enabled, I hope you know what you are doing...');\n  }\n\n  const files = await fsp.readdir(pluginPath);\n\n  // some files we need to know the actual file name.  Not compulsory but might help in the future.\n  const readMeFileName = files.filter((f) => f === 'README' || f === 'README.md')[0];\n\n  if (!files.includes('.git')) throw new Error('No .git folder, aborting');\n  prepareRepo();\n\n  const workflows = ['backend-tests.yml', 'frontend-tests.yml', 'npmpublish.yml', 'test-and-release.yml'];\n  await Promise.all(workflows.map(async (fn) => {\n    await checkFile(`bin/plugins/lib/${fn}`, `.github/workflows/${fn}`);\n  }));\n  await checkFile('bin/plugins/lib/dependabot.yml', '.github/dependabot.yml');\n\n  if (!files.includes('package.json')) {\n    logger.warn('no package.json, please create');\n  } else {\n    const packageJSON =\n        await fsp.readFile(`${pluginPath}/package.json`, {encoding: 'utf8', flag: 'r'});\n    const parsedPackageJSON = JSON.parse(packageJSON);\n\n    await updateDeps(parsedPackageJSON, 'devDependencies', {\n      'eslint': '^8.57.0',\n      'eslint-config-etherpad': '^4.0.4',\n      // Changing the TypeScript version can break plugin code, so leave it alone if present.\n      'typescript': {ver: '^5.4.2', overwrite: true},\n      // These were moved to eslint-config-etherpad's dependencies so they can be removed:\n      '@typescript-eslint/eslint-plugin': null,\n      '@typescript-eslint/parser': null,\n      'eslint-import-resolver-typescript': null,\n      'eslint-plugin-cypress': null,\n      'eslint-plugin-eslint-comments': null,\n      'eslint-plugin-import': null,\n      'eslint-plugin-mocha': null,\n      'eslint-plugin-node': null,\n      'eslint-plugin-prefer-arrow': null,\n      'eslint-plugin-promise': null,\n      'eslint-plugin-you-dont-need-lodash-underscore': null,\n    });\n\n    const currentVersion = semver.parse(parsedPackageJSON.version)!;\n    const newVersion = currentVersion.inc('patch');\n\n    await updateDeps(parsedPackageJSON, 'version', newVersion.version)\n\n\n    await updateDeps(parsedPackageJSON, 'peerDependencies', {\n      // These were moved to eslint-config-etherpad's dependencies so they can be removed:\n      'ep_etherpad-lite': null,\n    });\n\n    /*await updateDeps(parsedPackageJSON, 'peerDependencies', {\n      // Some plugins require a newer version of Etherpad so don't overwrite if already set.\n      'ep_etherpad-lite': {ver: '>=1.8.6', overwrite: false},\n    });*/\n\n    delete parsedPackageJSON.peerDependencies;\n\n    await updateDeps(parsedPackageJSON, 'engines', {\n      node: '>=18.0.0',\n    });\n\n    if (parsedPackageJSON.eslintConfig != null && autoFix) {\n      delete parsedPackageJSON.eslintConfig;\n      await writePackageJson(parsedPackageJSON);\n    }\n    if (files.includes('.eslintrc.js')) {\n      const [from, to] = [`${pluginPath}/.eslintrc.js`, `${pluginPath}/.eslintrc.cjs`];\n      if (!files.includes('.eslintrc.cjs')) {\n        if (autoFix) {\n          await fsp.rename(from, to);\n        } else {\n          logger.warn(`please rename ${from} to ${to}`);\n        }\n      } else {\n        logger.error(`both ${from} and ${to} exist; delete ${from}`);\n      }\n    } else {\n      await checkFile('bin/plugins/lib/eslintrc.cjs', '.eslintrc.cjs', false);\n    }\n\n    if (checkEntries(parsedPackageJSON, {\n      funding: {\n        type: 'individual',\n        url: 'https://etherpad.org/',\n      },\n    })) await writePackageJson(parsedPackageJSON);\n\n    if (parsedPackageJSON.scripts == null) parsedPackageJSON.scripts = {};\n    if (checkEntries(parsedPackageJSON.scripts, {\n      'lint': 'eslint .',\n      'lint:fix': 'eslint --fix .',\n    }))\n      await writePackageJson(parsedPackageJSON);\n  }\n\n  if (!files.includes('pnpm-lock.yaml')) {\n    logger.warn('pnpm-lock.yaml not found');\n    if (!autoFix) {\n      logger.warn('Run pnpm install in the plugin folder and commit the package-lock.json file.');\n    } else {\n        logger.info('Autofixing missing package-lock.json file');\n        try {\n          fs.statfsSync(`${pluginPath}/package-lock.json`)\n          fs.rmSync(`${pluginPath}/package-lock.json`)\n        } catch (e) {\n          // Nothing to do\n        }\n        execSync('pnpm install', {\n            cwd: `${pluginPath}/`,\n            stdio: 'inherit',\n        });\n    }\n  }\n\n  const fillTemplate = async (templateFilename: string, outputFilename: string) => {\n    const contents = (await fsp.readFile(templateFilename, 'utf8'))\n        .replace(/\\[name of copyright owner\\]/g, execSync('git config user.name'))\n        .replace(/\\[plugin_name\\]/g, pluginName)\n        .replace(/\\[yyyy\\]/g, new Date().getFullYear().toString());\n    await fsp.writeFile(outputFilename, contents);\n  };\n\n  if (!readMeFileName) {\n    logger.warn('README.md file not found, please create');\n    if (autoFix) {\n      logger.info('Autofixing missing README.md file');\n      logger.info('please edit the README.md file further to include plugin specific details.');\n      await fillTemplate('bin/plugins/lib/README.md', `${pluginPath}/README.md`);\n    }\n  }\n\n  if (!files.includes('CONTRIBUTING') && !files.includes('CONTRIBUTING.md')) {\n    logger.warn('CONTRIBUTING.md file not found, please create');\n    if (autoFix) {\n      logger.info('Autofixing missing CONTRIBUTING.md file, please edit the CONTRIBUTING.md ' +\n                  'file further to include plugin specific details.');\n      await fillTemplate('bin/plugins/lib/CONTRIBUTING.md', `${pluginPath}/CONTRIBUTING.md`);\n    }\n  }\n\n\n  if (readMeFileName) {\n    let readme =\n        await fsp.readFile(`${pluginPath}/${readMeFileName}`, {encoding: 'utf8', flag: 'r'});\n    if (!readme.toLowerCase().includes('license')) {\n      logger.warn('No license section in README');\n      if (autoFix) {\n        logger.warn('Please add License section to README manually.');\n      }\n    }\n    // eslint-disable-next-line max-len\n    const publishBadge = `![Publish Status](https://github.com/ether/${pluginName}/workflows/Node.js%20Package/badge.svg)`;\n    // eslint-disable-next-line max-len\n    const testBadge = `![Backend Tests Status](https://github.com/ether/${pluginName}/workflows/Backend%20tests/badge.svg)`;\n    if (readme.toLowerCase().includes('travis')) {\n      logger.warn('Remove Travis badges');\n    }\n    if (!readme.includes('workflows/Node.js%20Package/badge.svg')) {\n      logger.warn('No Github workflow badge detected');\n      if (autoFix) {\n        readme = `${publishBadge} ${testBadge}\\n\\n${readme}`;\n        // write readme to file system\n        await fsp.writeFile(`${pluginPath}/${readMeFileName}`, readme);\n        logger.info('Wrote Github workflow badges to README');\n      }\n    }\n  }\n\n  if (!files.includes('LICENSE') && !files.includes('LICENSE.md')) {\n    logger.warn('LICENSE file not found, please create');\n    if (autoFix) {\n      logger.info('Autofixing missing LICENSE file (Apache 2.0).');\n      await fsp.copyFile('bin/plugins/lib/LICENSE', `${pluginPath}/LICENSE`);\n    }\n  }\n\n  if (!files.includes('.gitignore')) {\n    logger.warn('.gitignore file not found, please create.  .gitignore files are useful to ' +\n                 \"ensure files aren't incorrectly commited to a repository.\");\n    if (autoFix) {\n      logger.info('Autofixing missing .gitignore file');\n      const gitignore =\n          await fsp.readFile('bin/plugins/lib/gitignore', {encoding: 'utf8', flag: 'r'});\n      await fsp.writeFile(`${pluginPath}/.gitignore`, gitignore);\n    }\n  } else {\n    let gitignore =\n        await fsp.readFile(`${pluginPath}/.gitignore`, {encoding: 'utf8', flag: 'r'});\n    if (!gitignore.includes('node_modules/')) {\n      logger.warn('node_modules/ missing from .gitignore');\n      if (autoFix) {\n        gitignore += 'node_modules/';\n        await fsp.writeFile(`${pluginPath}/.gitignore`, gitignore);\n      }\n    }\n  }\n\n  // if we include templates but don't have translations...\n  if (files.includes('templates') && !files.includes('locales')) {\n    logger.warn('Translations not found, please create.  ' +\n                 'Translation files help with Etherpad accessibility.');\n  }\n\n\n  if (files.includes('.ep_initialized')) {\n    logger.warn(\n        '.ep_initialized found, please remove.  .ep_initialized should never be commited to git ' +\n        'and should only exist once the plugin has been executed one time.');\n    if (autoFix) {\n      logger.info('Autofixing incorrectly existing .ep_initialized file');\n      await fsp.unlink(`${pluginPath}/.ep_initialized`);\n    }\n  }\n\n  if (files.includes('npm-debug.log')) {\n    logger.warn('npm-debug.log found, please remove.  npm-debug.log should never be commited to ' +\n                 'your repository.');\n    if (autoFix) {\n      logger.info('Autofixing incorrectly existing npm-debug.log file');\n      await fsp.unlink(`${pluginPath}/npm-debug.log`);\n    }\n  }\n\n  if (files.includes('static')) {\n    const staticFiles = await fsp.readdir(`${pluginPath}/static`);\n    if (!staticFiles.includes('tests')) {\n      logger.warn('Test files not found, please create tests.  https://github.com/ether/etherpad-lite/wiki/Creating-a-plugin#writing-and-running-front-end-tests-for-your-plugin');\n    }\n  } else {\n    logger.warn('Test files not found, please create tests.  https://github.com/ether/etherpad-lite/wiki/Creating-a-plugin#writing-and-running-front-end-tests-for-your-plugin');\n  }\n\n  // Install dependencies so we can run ESLint. This should also create or update package-lock.json\n  // if autoFix is enabled.\n  const npmInstall = `pnpm install`;\n  execSync(npmInstall, {stdio: 'inherit'});\n  // Create the ep_etherpad-lite symlink if necessary. This must be done after running `npm install`\n  // because that command nukes the symlink.\n  /*try {\n    const d = await fsp.realpath(path.join(pluginPath, 'node_modules/ep_etherpad-lite'));\n    assert.equal(d, epSrcDir);\n  } catch (err) {\n    execSync(`${npmInstall} --no-save ep_etherpad-lite@file:${epSrcDir}`, {stdio: 'inherit'});\n  }*/\n  // linting begins\n  try {\n    logger.info('Linting...');\n    const lintCmd = autoFix ? 'pnpm exec eslint --fix .' : 'npx eslint';\n    execSync(lintCmd, {stdio: 'inherit'});\n  } catch (e) {\n    // it is gonna throw an error anyway\n    logger.info('Manual linting probably required, check with: pnpm run lint');\n  }\n  // linting ends.\n\n  if (autoFix) {\n    /*const unchanged = JSON.parse(execSync(\n        'untracked=$(git ls-files -o --exclude-standard) || exit 1; ' +\n        'git diff-files --quiet && [ -z \"$untracked\" ] && echo true || echo false'));*/\n\n    if (true) {\n      // Display a diff of changes. Git doesn't diff untracked files, so they must be added to the\n      // index. Use a temporary index file to avoid modifying Git's default index file.\n      execSync('git read-tree HEAD', {\n        env: {...process.env, GIT_INDEX_FILE: '.git/checkPlugin.index'},\n        stdio: 'inherit',\n      });\n      execSync('git add -A', {\n        env: {...process.env, GIT_INDEX_FILE: '.git/checkPlugin.index'},\n        stdio: 'inherit',\n      });\n      execSync('git diff-index -p --cached HEAD', {\n        env: {...process.env, GIT_INDEX_FILE: '.git/checkPlugin.index'},\n        stdio: 'inherit',\n      });\n\n      await fsp.unlink(`${pluginPath}/.git/checkPlugin.index`);\n\n      const commitCmd = [\n        'git add -A',\n        'git commit -m \"autofixes from Etherpad checkPlugin.js\"',\n      ]\n\n      if (autoCommit) {\n        logger.info('Committing changes...');\n        execSync(commitCmd[0], {stdio: 'inherit'});\n        execSync(commitCmd[1], {stdio: 'inherit'});\n      } else {\n        logger.info('Fixes applied. Check the above git diff then run the following command:');\n        logger.info(`(cd node_modules/${pluginName} && ${commitCmd.join(' && ')})`);\n      }\n      const pushCmd = 'git push';\n      if (autoPush) {\n        logger.info('Pushing new commit...');\n        execSync(pushCmd, {stdio: 'inherit'});\n      } else {\n        logger.info('Changes committed. To push, run the following command:');\n        logger.info(`(cd node_modules/${pluginName} && ${pushCmd})`);\n      }\n    } else {\n      logger.info('No changes.');\n    }\n  }\n  logger.info('Finished');\n  process.exit(0)\n})();\n"
  },
  {
    "path": "bin/plugins/getCorePlugins.sh",
    "content": "#!/bin/sh\n\nset -e\n\nnewline='\n'\n\npecho () { printf %s\\\\n \"$*\"; }\nlog () { pecho \"$@\"; }\nerror () { log \"ERROR: $@\" >&2; }\nfatal () { error \"$@\"; exit 1; }\n\nmydir=$(cd \"${0%/*}\" && pwd -P) || exit 1\ncd \"${mydir}/../..\"\npdir=$(cd .. && pwd -P) || exit 1\n\nplugins=$(\"${mydir}/listOfficialPlugins\") || exit 1\necho $plugins\nfor d in ${plugins}; do\n  echo $d\n  log \"============================================================\"\n  log \"${d}\"\n  log \"============================================================\"\n  fd=${pdir}/${d}\n  repo=https://github.com/ether/${d}.git\n  [ -d \"${fd}\" ] || {\n    log \"Cloning ${repo} to ${fd}...\"\n    (cd \"${pdir}\" && git clone \"${repo}\" \"${d}\") || continue\n  } || exit 1\n  log \"Fetching latest commits...\"\n  (cd \"${fd}\" && git pull --ff-only) || exit 1\n  #log \"Getting plugin name...\"\n  #pn=$(cd \"${fd}\" && npx -c 'printf %s\\\\n \"${npm_package_name}\"') || exit 1\n  #[ -n \"${pn}\" ] || fatal \"Unable to determine plugin name for ${d}\"\n  #md=node_modules/${pn}\n  #[ -d \"${md}\" ] || {\n  #  log \"Installing plugin to ${md}...\"\n  #  ln -s ../../\"${d}\" \"${md}\"\n  #} || exit 1\n  #[ \"${md}\" -ef \"${fd}\" ] || fatal \"${md} is not a symlink to ${fd}\"\ndone\n"
  },
  {
    "path": "bin/plugins/lib/CONTRIBUTING.md",
    "content": "# Contributor Guidelines\n(Please talk to people on the mailing list before you change this page, see our section on [how to get in touch](https://github.com/ether/etherpad-lite#get-in-touch))\n\n## Pull requests\n\n* the commit series in the PR should be _linear_ (it **should not contain merge commits**). This is necessary because we want to be able to [bisect](https://en.wikipedia.org/wiki/Bisection_(software_engineering)) bugs easily. Rewrite history/perform a rebase if necessary\n* PRs should be issued against the **develop** branch: we never pull directly into **master**\n* PRs **should not have conflicts** with develop. If there are, please resolve them rebasing and force-pushing\n* when preparing your PR, please make sure that you have included the relevant **changes to the documentation** (preferably with usage examples)\n* contain meaningful and detailed **commit messages** in the form:\n  ```\n  submodule: description\n\n  longer description of the change you have made, eventually mentioning the\n  number of the issue that is being fixed, in the form: Fixes #someIssueNumber\n  ```\n* if the PR is a **bug fix**:\n  * the first commit in the series must be a test that shows the failure\n  * subsequent commits will fix the bug and make the test pass\n  * the final commit message should include the text `Fixes: #xxx` to link it to its bug report\n* think about stability: code has to be backwards compatible as much as possible. Always **assume your code will be run with an older version of the DB/config file**\n* if you want to remove a feature, **deprecate it instead**:\n  * write an issue with your deprecation plan\n  * output a `WARN` in the log informing that the feature is going to be removed\n  * remove the feature in the next version\n* if you want to add a new feature, put it under a **feature flag**:\n  * once the new feature has reached a minimal level of stability, do a PR for it, so it can be integrated early\n  * expose a mechanism for enabling/disabling the feature\n  * the new feature should be **disabled** by default. With the feature disabled, the code path should be exactly the same as before your contribution. This is a __necessary condition__ for early integration\n* think of the PR not as something that __you wrote__, but as something that __someone else is going to read__. The commit series in the PR should tell a novice developer the story of your thoughts when developing it\n\n## How to write a bug report\n\n* Please be polite, we all are humans and problems can occur.\n* Please add as much information as possible, for example\n  * client os(s) and version(s)\n    * browser(s) and version(s), is the problem reproducible on different clients\n    * special environments like firewalls or antivirus\n  * host os and version\n    * npm and nodejs version\n    * Logfiles if available\n  * steps to reproduce\n  * what you expected to happen\n  * what actually happened\n* Please format logfiles and code examples with markdown see github Markdown help below the issue textarea for more information.\n\nIf you send logfiles, please set the loglevel switch DEBUG in your settings.json file:\n\n```\n/* The log level we are using, can be: DEBUG, INFO, WARN, ERROR */\n  \"loglevel\": \"DEBUG\",\n```\n\nThe logfile location is defined in startup script or the log is directly shown in the commandline after you have started etherpad.\n\n## General goals of Etherpad\nTo make sure everybody is going in the same direction:\n* easy to install for admins and easy to use for people\n* easy to integrate into other apps, but also usable as standalone\n* lightweight and scalable\n* extensible, as much functionality should be extendable with plugins so changes don't have to be done in core.\nAlso, keep it maintainable. We don't wanna end up as the monster Etherpad was!\n\n## How to work with git?\n* Don't work in your master branch.\n* Make a new branch for every feature you're working on. (This ensures that you can work you can do lots of small, independent pull requests instead of one big one with complete different features)\n* Don't use the online edit function of github (this only creates ugly and not working commits!)\n* Try to make clean commits that are easy readable (including descriptive commit messages!)\n* Test before you push. Sounds easy, it isn't!\n* Don't check in stuff that gets generated during build or runtime\n* Make small pull requests that are easy to review but make sure they do add value by themselves / individually\n\n## Coding style\n* Do write comments. (You don't have to comment every line, but if you come up with something that's a bit complex/weird, just leave a comment. Bear in mind that you will probably leave the project at some point and that other people will read your code. Undocumented huge amounts of code are worthless!)\n* Never ever use tabs\n* Indentation: JS/CSS: 2 spaces; HTML: 4 spaces\n* Don't overengineer. Don't try to solve any possible problem in one step, but try to solve problems as easy as possible and improve the solution over time!\n* Do generalize sooner or later! (if an old solution, quickly hacked together, poses more problems than it solves today, refactor it!)\n* Keep it compatible. Do not introduce changes to the public API, db schema or configurations too lightly. Don't make incompatible changes without good reasons!\n* If you do make changes, document them! (see below)\n* Use protocol independent urls \"//\"\n\n## Branching model / git workflow\nsee git flow http://nvie.com/posts/a-successful-git-branching-model/\n\n### `master` branch\n* the stable\n* This is the branch everyone should use for production stuff\n\n### `develop`branch\n* everything that is READY to go into master at some point in time\n* This stuff is tested and ready to go out\n\n### release branches\n* stuff that should go into master very soon\n* only bugfixes go into these (see http://nvie.com/posts/a-successful-git-branching-model/ for why)\n* we should not be blocking new features to develop, just because we feel that we should be releasing it to master soon. This is the situation that release branches solve/handle.\n\n### hotfix branches\n* fixes for bugs in master\n\n### feature branches (in your own repos)\n* these are the branches where you develop your features in\n* If it's ready to go out, it will be merged into develop\n\nOver the time we pull features from feature branches into the develop branch. Every month we pull from develop into master. Bugs in master get fixed in hotfix branches. These branches will get merged into master AND develop. There should never be commits in master that aren't in develop\n\n## Documentation\nThe docs are in the `doc/` folder in the git repository, so people can easily find the suitable docs for the current git revision.\n\nDocumentation should be kept up-to-date. This means, whenever you add a new API method, add a new hook or change the database model, pack the relevant changes to the docs in the same pull request.\n\nYou can build the docs e.g. produce html, using `make docs`. At some point in the future we will provide an online documentation. The current documentation in the github wiki should always reflect the state of `master` (!), since there are no docs in master, yet.\n\n## Testing\n\nFront-end tests are found in the `src/tests/frontend/` folder in the repository.\nRun them by pointing your browser to `<yourdomainhere>/tests/frontend`.\n\nBack-end tests can be run from the `src` directory, via `npm test`.\n\n## Things you can help with\nEtherpad is much more than software.  So if you aren't a developer then worry not, there is still a LOT you can do!  A big part of what we do is community engagement.  You can help in the following ways\n * Triage bugs (applying labels) and confirming their existence\n * Testing fixes (simply applying them and seeing if it fixes your issue or not) - Some git experience required\n * Notifying large site admins of new releases\n * Writing Changelogs for releases\n * Creating Windows packages\n * Creating releases\n * Bumping dependencies periodically and checking they don't break anything\n * Write proposals for grants\n * Co-Author and Publish CVEs\n * Work with SFC to maintain legal side of project\n * Maintain TODO page - https://github.com/ether/etherpad-lite/wiki/TODO#IMPORTANT_TODOS\n\n"
  },
  {
    "path": "bin/plugins/lib/LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "bin/plugins/lib/README.md",
    "content": "# [plugin_name]\n\nTODO: Describe the plugin.\n\n## Example animated gif of usage if appropriate\n\n![screenshot](https://user-images.githubusercontent.com/220864/99979953-97841d80-2d9f-11eb-9782-5f65817c58f4.PNG)\n\n## Installation\n\nFrom the Etherpad working directory, run:\n\n```shell\nnpm install --no-save --legacy-peer-deps [plugin_name]\n```\n\nOr, install from Etherpad's `/admin/plugins` page.\n\n## Configuration\n\nTODO\n\n## Testing\n\nTo run the backend tests, run the following from the Etherpad working directory:\n\n```shell\n(cd src && pnpm test)\n```\n\nTo run the frontend tests, visit: http://localhost:9001/tests/frontend/\n\n## Copyright and License\n\nCopyright © [yyyy] [name of copyright owner]\nand the [plugin_name] authors and contributors\n\nLicensed under the [Apache License, Version 2.0](LICENSE) (the \"License\"); you\nmay not use this file except in compliance with the License. You may obtain a\ncopy of the License at\n\nhttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software distributed\nunder the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR\nCONDITIONS OF ANY KIND, either express or implied. See the License for the\nspecific language governing permissions and limitations under the License.\n"
  },
  {
    "path": "bin/plugins/lib/backend-tests.yml",
    "content": "name: Backend Tests\n\n# any branch is useful for testing before a PR is submitted\non:\n  workflow_call:\n\njobs:\n  withplugins:\n    # run on pushes to any branch\n    # run on PRs from external forks\n    if: |\n      (github.event_name != 'pull_request')\n      || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)\n    name: with Plugins\n    runs-on: ubuntu-latest\n    steps:\n      -\n        name: Install libreoffice\n        uses: awalsh128/cache-apt-pkgs-action@v1.4.2\n        with:\n          packages: libreoffice libreoffice-pdfimport\n          version: 1.0\n      -\n        name: Install etherpad core\n        uses: actions/checkout@v3\n        with:\n          repository: ether/etherpad-lite\n          path: etherpad-lite\n      - uses: pnpm/action-setup@v3\n        name: Install pnpm\n        with:\n          version: 10\n          run_install: false\n      - name: Get pnpm store directory\n        shell: bash\n        run: |\n          echo \"STORE_PATH=$(pnpm store path --silent)\" >> $GITHUB_ENV\n      - uses: actions/cache@v4\n        name: Setup pnpm cache\n        with:\n          path: ${{ env.STORE_PATH }}\n          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-pnpm-store-\n      -\n        name: Checkout plugin repository\n        uses: actions/checkout@v3\n        with:\n          path: plugin\n      -\n        name: Determine plugin name\n        id: plugin_name\n        working-directory: ./plugin\n        run: |\n          npx -c 'printf %s\\\\n \"::set-output name=plugin_name::${npm_package_name}\"'\n      -\n        name: Link plugin directory\n        working-directory: ./plugin\n        run: |\n          pnpm link --global\n      - name: Remove tests\n        working-directory: ./etherpad-lite\n        run: rm -rf ./src/tests/backend/specs\n      -\n        name: Install Etherpad core dependencies\n        working-directory: ./etherpad-lite\n        run: bin/installDeps.sh\n      - name: Link plugin to etherpad-lite\n        working-directory: ./etherpad-lite\n        run: |\n          pnpm link --global $PLUGIN_NAME\n          pnpm run plugins i --path  ../../plugin\n        env:\n          PLUGIN_NAME: ${{ steps.plugin_name.outputs.plugin_name }}\n      - name: Link ep_etherpad-lite\n        working-directory: ./etherpad-lite/src\n        run: |\n          pnpm link --global\n      - name: Link etherpad to plugin\n        working-directory: ./plugin\n        run: |\n          pnpm link --global ep_etherpad-lite\n      -\n        name: Run the backend tests\n        working-directory: ./etherpad-lite\n        run: |\n          res=$(find .. -path \"./node_modules/ep_*/static/tests/backend/specs/**\" | wc -l)\n          if [ $res -eq 0 ]; then\n          echo \"No backend tests found\"\n          else\n          pnpm run test\n          fi\n"
  },
  {
    "path": "bin/plugins/lib/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n  - package-ecosystem: \"npm\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n    versioning-strategy: \"increase\"\n"
  },
  {
    "path": "bin/plugins/lib/eslintrc.cjs",
    "content": "'use strict';\n\n// This is a workaround for https://github.com/eslint/eslint/issues/3458\nrequire('eslint-config-etherpad/patch/modern-module-resolution');\n\nmodule.exports = {\n  root: true,\n  extends: 'etherpad/plugin',\n};\n"
  },
  {
    "path": "bin/plugins/lib/frontend-tests.yml",
    "content": "# Publicly credit Sauce Labs because they generously support open source\n# projects.\nname: Frontend Tests\n\non:\n  workflow_call:\n\njobs:\n  test-frontend:\n    runs-on: ubuntu-latest\n\n    steps:\n      -\n        name: Check out Etherpad core\n        uses: actions/checkout@v3\n        with:\n          repository: ether/etherpad-lite\n      - uses: pnpm/action-setup@v3\n        name: Install pnpm\n        with:\n          version: 10\n          run_install: false\n      - name: Get pnpm store directory\n        shell: bash\n        run: |\n          echo \"STORE_PATH=$(pnpm store path --silent)\" >> $GITHUB_ENV\n      - uses: actions/cache@v4\n        name: Setup pnpm cache\n        with:\n          path: ${{ env.STORE_PATH }}\n          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-pnpm-store-\n      -\n        name: Check out the plugin\n        uses: actions/checkout@v3\n        with:\n          path: ./node_modules/__tmp\n      -\n        name: export GIT_HASH to env\n        id: environment\n        run: |\n          cd ./node_modules/__tmp\n          echo \"::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})\"\n      -\n        name: Determine plugin name\n        id: plugin_name\n        run: |\n          cd ./node_modules/__tmp\n          npx -c 'printf %s\\\\n \"::set-output name=plugin_name::${npm_package_name}\"'\n      -\n        name: Rename plugin directory\n        env:\n          PLUGIN_NAME: ${{ steps.plugin_name.outputs.plugin_name }}\n        run: |\n          mv ./node_modules/__tmp ./node_modules/\"${PLUGIN_NAME}\"\n      -\n        name: Install plugin dependencies\n        env:\n          PLUGIN_NAME: ${{ steps.plugin_name.outputs.plugin_name }}\n        run: |\n          cd ./node_modules/\"${PLUGIN_NAME}\"\n          pnpm i\n      # Etherpad core dependencies must be installed after installing the\n      # plugin's dependencies, otherwise npm will try to hoist common\n      # dependencies by removing them from src/node_modules and installing them\n      # in the top-level node_modules. As of v6.14.10, npm's hoist logic appears\n      # to be buggy, because it sometimes removes dependencies from\n      # src/node_modules but fails to add them to the top-level node_modules.\n      # Even if npm correctly hoists the dependencies, the hoisting seems to\n      # confuse tools such as `npm outdated`, `npm update`, and some ESLint\n      # rules.\n      -\n        name: Install Etherpad core dependencies\n        run: bin/installDeps.sh\n      - name: Create settings.json\n        run: cp ./src/tests/settings.json settings.json\n      - name: Run the frontend tests\n        shell: bash\n        run: |\n          pnpm run prod &\n          connected=false\n          can_connect() {\n          curl -sSfo /dev/null http://localhost:9001/ || return 1\n          connected=true\n          }\n          now() { date +%s; }\n          start=$(now)\n          while [ $(($(now) - $start)) -le 15 ] && ! can_connect; do\n          sleep 1\n          done\n          cd src\n          pnpm exec playwright install chromium  --with-deps\n          pnpm run test-ui --project=chromium\n"
  },
  {
    "path": "bin/plugins/lib/gitignore",
    "content": ".DS_Store\nnode_modules/\nnpm-debug.log\n"
  },
  {
    "path": "bin/plugins/lib/npmpublish.yml",
    "content": "# This workflow will run tests using node and then publish a package to the npm registry when a release is created\n# For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages\n\nname: Node.js Package\n\non:\n  workflow_call:\n\njobs:\n  publish-npm:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          registry-url: https://registry.npmjs.org/\n      - name: Check out Etherpad core\n        uses: actions/checkout@v3\n        with:\n          repository: ether/etherpad-lite\n      - uses: pnpm/action-setup@v3\n        name: Install pnpm\n        with:\n          version: 10\n          run_install: false\n      - name: Get pnpm store directory\n        shell: bash\n        run: |\n          echo \"STORE_PATH=$(pnpm store path --silent)\" >> $GITHUB_ENV\n      - uses: actions/cache@v4\n        name: Setup pnpm cache\n        with:\n          path: ${{ env.STORE_PATH }}\n          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-pnpm-store-\n      -\n        uses: actions/checkout@v3\n        with:\n          fetch-depth: 0\n      -\n        name: Bump version (patch)\n        run: |\n          LATEST_TAG=$(git describe --tags --abbrev=0) || exit 1\n          NEW_COMMITS=$(git rev-list --count \"${LATEST_TAG}\"..) || exit 1\n          [ \"${NEW_COMMITS}\" -gt 0 ] || exit 0\n          git config user.name 'github-actions[bot]'\n          git config user.email '41898282+github-actions[bot]@users.noreply.github.com'\n          pnpm i\n          pnpm version patch\n          git push --follow-tags\n      # This is required if the package has a prepare script that uses something\n      # in dependencies or devDependencies.\n      -\n        run: pnpm i\n      # `npm publish` must come after `git push` otherwise there is a race\n      # condition: If two PRs are merged back-to-back then master/main will be\n      # updated with the commits from the second PR before the first PR's\n      # workflow has a chance to push the commit generated by `npm version\n      # patch`. This causes the first PR's `git push` step to fail after the\n      # package has already been published, which in turn will cause all future\n      # workflow runs to fail because they will all attempt to use the same\n      # already-used version number. By running `npm publish` after `git push`,\n      # back-to-back merges will cause the first merge's workflow to fail but\n      # the second's will succeed.\n      -\n        run: pnpm publish\n        env:\n          NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}\n      #-\n      #  name: Add package to etherpad organization\n      #  run: pnpm access grant read-write etherpad:developers\n      #  env:\n      #    NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}\n"
  },
  {
    "path": "bin/plugins/lib/test-and-release.yml",
    "content": "name: Node.js Package\non: [push]\n\n\njobs:\n  backend:\n    uses: ./.github/workflows/backend-tests.yml\n    secrets: inherit\n  frontend:\n    uses: ./.github/workflows/frontend-tests.yml\n    secrets: inherit\n  release:\n    if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }}\n    needs:\n      - backend\n      - frontend\n    uses: ./.github/workflows/npmpublish.yml\n    secrets: inherit\n"
  },
  {
    "path": "bin/plugins/listOfficialPlugins",
    "content": "#!/bin/sh\nset -e\nnewline='\n'\nmydir=$(cd \"${0%/*}\" && pwd -P) || exit 1\ncd \"${mydir}/../..\"\npdir=$(cd .. && pwd -P) || exit 1\nplugins=\nfor p in \"\" \"&page=2\" \"&page=3\"; do\n  curlOut=$(curl \"https://api.github.com/users/ether/repos?per_page=100${p}\") || exit 1\n  plugins=${plugins}${newline}$(printf %s\\\\n \"${curlOut}\" \\\n        | sed -n -e 's;.*git@github.com:ether/\\(ep_[^\"]*\\)\\.git.*;\\1;p');\ndone\nprintf %s\\\\n \"${plugins}\" | sort -u | grep -v '^[[:space:]]*$'\n"
  },
  {
    "path": "bin/plugins/reTestAllPlugins.sh",
    "content": "echo \"herp\";\nfor dir in `ls node_modules`;\ndo\n  echo $dir\n  if [[ $dir == *\"ep_\"* ]]; then\n    if [[ $dir != \"ep_etherpad-lite\" ]]; then\n      # node bin/plugins/checkPlugin.js $dir autopush\n      cd node_modules/$dir\n      git commit -m \"Automatic update: bump update to re-run latest Etherpad tests\" --allow-empty\n      git push origin master\n      cd ../..\n    fi\n  fi\ndone\n"
  },
  {
    "path": "bin/plugins/stalePlugins.ts",
    "content": "'use strict';\n\n// Returns a list of stale plugins and their authors email\n\nimport axios from 'axios'\nimport process from \"node:process\";\nconst currentTime = new Date();\n\n(async () => {\n  const res = await axios.get<string>('https://static.etherpad.org/plugins.full.json');\n  for (const plugin of Object.keys(res.data)) {\n    // @ts-ignore\n    const name = res.data[plugin].data.name;\n    // @ts-ignore\n    const date = new Date(res.data[plugin].time);\n    const diffTime = Math.abs(currentTime.getTime() - date.getTime());\n    const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n    if (diffDays > (365 * 2)) {\n      // @ts-ignore\n      console.log(`${name}, ${res.data[plugin].data.maintainers[0].email}`);\n    }\n  }\n  process.exit(0)\n})();\n"
  },
  {
    "path": "bin/plugins/updateAllPluginsScript.sh",
    "content": "cd node_modules\nGHUSER=johnmclear; curl \"https://api.github.com/users/$GHUSER/repos?per_page=1000\" | grep -o 'git@[^\"]*' | grep /ep_ | xargs -L1 git clone\nGHUSER=johnmclear; curl \"https://api.github.com/users/$GHUSER/repos?per_page=1000&page=2\" | grep -o 'git@[^\"]*' | grep /ep_ | xargs -L1 git clone\nGHUSER=johnmclear; curl \"https://api.github.com/users/$GHUSER/repos?per_page=1000&page=3\" | grep -o 'git@[^\"]*' | grep /ep_ | xargs -L1 git clone\nGHUSER=johnmclear; curl \"https://api.github.com/users/$GHUSER/repos?per_page=1000&page=4\" | grep -o 'git@[^\"]*' | grep /ep_ | xargs -L1 git clone\ncd ..\n\nfor dir in `ls node_modules`;\ndo\n  # echo $0\n  if [[ $dir == *\"ep_\"* ]]; then\n    if [[ $dir != \"ep_etherpad-lite\" ]]; then\n      pnpm run checkPlugins $dir autopush\n    fi\n  fi\n  # echo $dir\ndone\n"
  },
  {
    "path": "bin/plugins/updateCorePlugins.sh",
    "content": "#!/bin/sh\n\nset -e\n\nfor dir in node_modules/ep_*; do\n  dir=${dir#node_modules/}\n  [ \"$dir\" != ep_etherpad-lite ] || continue\n  pnpm run checkPlugins \"$dir\" autopush\ndone\n"
  },
  {
    "path": "bin/plugins.ts",
    "content": "'use strict';\n\nimport {linkInstaller, checkForMigration} from \"ep_etherpad-lite/static/js/pluginfw/installer\";\nimport {persistInstalledPlugins} from \"./commonPlugins\";\nimport fs from \"node:fs\";\nconst settings = require('ep_etherpad-lite/node/utils/Settings');\n\nif (process.argv.length === 2) {\n  console.error('Expected at least one argument!');\n  process.exit(1);\n}\n\nlet args = process.argv.slice(2)\n\n\nconst possibleActions = [\n  \"i\",\n  \"install\",\n  \"rm\",\n  \"remove\",\n  \"ls\",\n  \"list\"\n]\n\nconst install = ()=> {\n  const argsAsString: string = args.join(\" \");\n  const regexRegistryPlugins = /(?<=(?:i|install)\\s)(.*?)(?=--github|--path|$)/;\n  const regexLocalPlugins = /(?<=--path\\s)(.*?)(?=--github|$)/;\n  const regexGithubPlugins = /(?<=--github\\s)(.*?)(?=--path|$)/;\n  const registryPlugins = argsAsString.match(regexRegistryPlugins)?.[0]?.split(\" \")?.filter(s => s) || [];\n  const localPlugins = argsAsString.match(regexLocalPlugins)?.[0]?.split(\" \")?.filter(s => s) || [];\n  const githubPlugins = argsAsString.match(regexGithubPlugins)?.[0]?.split(\" \")?.filter(s => s) || [];\n\n  async function run() {\n    for (const plugin of registryPlugins) {\n      if (possibleActions.includes(plugin)){\n        continue\n      }\n      console.log(`Installing plugin from registry: ${plugin}`)\n      if (plugin.includes('@')) {\n        const [name, version] = plugin.split('@');\n        await linkInstaller.installPlugin(name, version);\n        continue;\n      }\n      await linkInstaller.installPlugin(plugin);\n    }\n\n    for (const plugin of localPlugins) {\n      console.log(`Installing plugin from path: ${plugin}`);\n      await linkInstaller.installFromPath(plugin);\n    }\n\n    for (const plugin of githubPlugins) {\n      console.log(`Installing plugin from github: ${plugin}`);\n      await linkInstaller.installFromGitHub(plugin);\n    }\n  }\n\n  (async () => {\n    await checkForMigration();\n    await run();\n    await persistInstalledPlugins();\n  })();\n}\n\nconst list = ()=>{\n  const walk =  async () => {\n    const plugins = fs.readFileSync(settings.root+\"/var/installed_plugins.json\", \"utf-8\")\n    const pluginNames = JSON.parse(plugins).plugins.map((plugin: any) => plugin.name).join(\", \")\n\n    console.log(\"Installed plugins are:\", pluginNames)\n  }\n\n  (async () => {\n    await walk();\n  })();\n}\n\nconst remove = (plugins: string[])=>{\n  const walk =  async () => {\n    for (const plugin of plugins) {\n      console.log(`Uninstalling plugin: ${plugin}`)\n      await linkInstaller.uninstallPlugin(plugin);\n    }\n    await persistInstalledPlugins();\n  }\n\n  (async () => {\n    await checkForMigration();\n    await walk();\n  })();\n}\n\nlet action = args[0];\n\nswitch (action) {\n  case \"install\":\n    install();\n    break;\n  case \"i\":\n    install();\n    break;\n  case \"ls\":\n    list();\n    break;\n  case \"list\":\n    list();\n    break;\n  case \"rm\":\n    remove(args.slice(1));\n    break;\n  case \"remove\":\n    remove(args.slice(1));\n    break;\n  default:\n    console.error('Expected at least one argument!');\n    process.exit(1);\n}\n\n\n"
  },
  {
    "path": "bin/push-after-release.sh",
    "content": "#!/bin/bash\n\n# Specify the path to your package.json file\nPACKAGE_JSON_PATH=\"./src/package.json\"\n\n# Check if the file exists\nif [ ! -f \"$PACKAGE_JSON_PATH\" ]; then\n    echo \"Error: package.json not found in the specified path.\"\n    exit 1\nfi\n\n# Read the version from package.json into a variable\nVERSION=$(jq -r '.version' \"$PACKAGE_JSON_PATH\")\ngit push origin master develop $VERSION\ngit push --tags\n(cd ../ether.github.com && git push)"
  },
  {
    "path": "bin/rebuildPad.ts",
    "content": "/*\n  This is a repair tool. It rebuilds an old pad at a new pad location up to a\n  known \"good\" revision.\n*/\n\n// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an\n// unhandled rejection into an uncaught exception, which does cause Node.js to exit.\nimport process from \"node:process\";\n\nprocess.on('unhandledRejection', (err) => { throw err; });\n\nif (process.argv.length !== 4 && process.argv.length !== 5) {\n  throw new Error('Use: node bin/repairPad.js $PADID $REV [$NEWPADID]');\n}\n\n// @ts-ignore\nconst padId = process.argv[2];\nconst newRevHead = Number(process.argv[3]);\nconst newPadId = process.argv[4] || `${padId}-rebuilt`;\n\n(async () => {\n  const db = require('ep_etherpad-lite/node/db/DB');\n  await db.init();\n\n  const PadManager = require('ep_etherpad-lite/node/db/PadManager');\n  const Pad = require('ep_etherpad-lite/node/db/Pad').Pad;\n  // Validate the newPadId if specified and that a pad with that ID does\n  // not already exist to avoid overwriting it.\n  if (!PadManager.isValidPadId(newPadId)) {\n    throw new Error('Cannot create a pad with that id as it is invalid');\n  }\n  const exists = await PadManager.doesPadExist(newPadId);\n  if (exists) throw new Error('Cannot create a pad with that id as it already exists');\n\n  const oldPad = await PadManager.getPad(padId);\n  const newPad = new Pad(newPadId);\n\n  // Clone all Chat revisions\n  const chatHead = oldPad.chatHead;\n  await Promise.all([...Array(chatHead + 1).keys()].map(async (i) => {\n    const chat = await db.get(`pad:${padId}:chat:${i}`);\n    await db.set(`pad:${newPadId}:chat:${i}`, chat);\n    console.log(`Created: Chat Revision: pad:${newPadId}:chat:${i}`);\n  }));\n\n  // Rebuild Pad from revisions up to and including the new revision head\n  const AuthorManager = require('ep_etherpad-lite/node/db/AuthorManager');\n  const Changeset = require('ep_etherpad-lite/static/js/Changeset');\n  // Author attributes are derived from changesets, but there can also be\n  // non-author attributes with specific mappings that changesets depend on\n  // and, AFAICT, cannot be recreated any other way\n  newPad.pool.numToAttrib = oldPad.pool.numToAttrib;\n  for (let curRevNum = 0; curRevNum <= newRevHead; curRevNum++) {\n    const rev = await db.get(`pad:${padId}:revs:${curRevNum}`);\n    if (!rev || !rev.meta) throw new Error('The specified revision number could not be found.');\n    const newRevNum = ++newPad.head;\n    const newRevId = `pad:${newPad.id}:revs:${newRevNum}`;\n    await Promise.all([\n      db.set(newRevId, rev),\n      AuthorManager.addPad(rev.meta.author, newPad.id),\n    ]);\n    newPad.atext = Changeset.applyToAText(rev.changeset, newPad.atext, newPad.pool);\n    console.log(`Created: Revision: pad:${newPad.id}:revs:${newRevNum}`);\n  }\n\n  // Add saved revisions up to the new revision head\n  console.log(newPad.head);\n  const newSavedRevisions = [];\n  for (const savedRev of oldPad.savedRevisions) {\n    if (savedRev.revNum <= newRevHead) {\n      newSavedRevisions.push(savedRev);\n      console.log(`Added: Saved Revision: ${savedRev.revNum}`);\n    }\n  }\n  newPad.savedRevisions = newSavedRevisions;\n\n  // Save the source pad\n  await db.set(`pad:${newPadId}`, newPad);\n\n  console.log(`Created: Source Pad: pad:${newPadId}`);\n  await newPad.saveToDatabase();\n\n  await db.shutdown();\n  console.info('finished');\n  process.exit(0)\n})();\n"
  },
  {
    "path": "bin/release.ts",
    "content": "'use strict';\n\n// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an\n// unhandled rejection into an uncaught exception, which does cause Node.js to exit.\n\nimport process from 'node:process'\n\nprocess.on('unhandledRejection', (err) => { throw err; });\n\nimport fs from 'node:fs';\nimport childProcess from 'node:child_process';\nimport log4js from 'log4js';\nimport path from 'node:path';\nimport semver from 'semver';\nimport {exec} from 'node:child_process';\n\nlog4js.configure({appenders: {console: {type: 'console'}},\n  categories: {\n    default: {appenders: ['console'], level: 'info'},\n  }});\n\n/*\n\nUsage\n\nnode bin/release.js patch\n\n*/\nconst usage =\n    'node bin/release.js [patch/minor/major] -- example: \"node bin/release.js patch\"';\n\nconst release = process.argv[2];\n\nif (!release) {\n  console.log(usage);\n  throw new Error('No release type included');\n}\n\nif (release !== 'patch' && release !== 'minor' && release !== 'major') {\n    console.log(usage);\n    throw new Error('Invalid release type');\n}\n\n\nconst cwd = path.join(fs.realpathSync(__dirname), '../');\nprocess.chdir(cwd);\n\n// Run command capturing stdout. Trailing newlines are stripped (like the shell does).\nconst runc =\n    (cmd:string, opts = {}) => childProcess.execSync(cmd, {encoding: 'utf8', ...opts}).replace(/\\n+$/, '').trim();\n// Run command without capturing stdout.\nconst run = (cmd: string, opts = {}) => childProcess.execSync(cmd, {stdio: 'inherit', ...opts});\n\nconst readJson = (filename: string) => JSON.parse(fs.readFileSync(filename, {encoding: 'utf8', flag: 'r'}));\n\nconst assertWorkDirClean = (opts:{\n    cwd?: string;\n} = {}) => {\n  // Stash any changes in the working directory so that we can check for modifications.\n  runc('git stash')\n  opts.cwd = runc('git rev-parse --show-cdup', opts) || cwd;\n  const m = runc('git diff-files --name-status', opts);\n  console.log(\">\"+m.trim()+\"<\")\n  if (m.length !== 0) {\n    throw new Error(`modifications in working directory ${opts.cwd}:\\n${m}`);\n  }\n  const u = runc('git ls-files -o --exclude-standard', opts);\n  if (u.length !== 0) {\n    throw new Error(`untracked files in working directory ${opts.cwd}:\\n${u}`);\n  }\n  const s = runc('git diff-index --cached --name-status HEAD', opts);\n  if (s.length !==0) {\n    throw new Error(`uncommitted changes in working directory ${opts.cwd}:\\n${s}`);\n  }\n};\n\nconst assertBranchCheckedOut = (branch: string, opts:{\n  cwd?: string;\n} = {}) => {\n  const b = runc('git symbolic-ref HEAD', opts);\n  if (b !== `refs/heads/${branch}`) {\n    const d = opts.cwd ? path.resolve(cwd, opts.cwd) : cwd;\n    throw new Error(`${branch} must be checked out (cwd: ${d})`);\n  }\n};\n\nconst assertUpstreamOk = (branch: string, opts:{\n  cwd?: string;\n} = {}) => {\n  const upstream = runc(`git rev-parse --symbolic-full-name ${branch}@{u}`, opts);\n  if (!(new RegExp(`^refs/remotes/[^/]+/${branch}`)).test(upstream)) {\n    throw new Error(`${branch} should track origin/${branch}; see git branch --set-upstream-to`);\n  }\n  try {\n    run(`git merge-base --is-ancestor ${branch} ${branch}@{u}`);\n  } catch (err:any) {\n    if (err.status !== 1) throw err;\n    throw new Error(`${branch} is ahead of origin/${branch}; do you need to push?`);\n  }\n};\n\n// Check if asciidoctor is installed\nexec('asciidoctor -v', (err) => {\n  if (err) {\n    console.log('Please install asciidoctor');\n    console.log('https://asciidoctor.org/docs/install-toolchain/');\n    process.exit(1);\n  }\n});\n\nconst dirExists = (dir: string) => {\n  try {\n    return fs.statSync(dir).isDirectory();\n  } catch (err:any) {\n    if (err.code !== 'ENOENT') throw err;\n    return false;\n  }\n};\n\n// Sanity checks for Etherpad repo.\nassertWorkDirClean();\nassertBranchCheckedOut('develop');\nassertUpstreamOk('develop');\nassertUpstreamOk('master');\n\n// Sanity checks for documentation repo.\nif (!dirExists('../ether.github.com')) {\n  throw new Error('please clone documentation repo: ' +\n                  '(cd .. && git clone git@github.com:ether/ether.github.com.git)');\n}\nassertWorkDirClean({cwd: '../ether.github.com/'});\nassertBranchCheckedOut('master', {cwd: '../ether.github.com/'});\nassertUpstreamOk('master', {cwd: '../ether.github.com/'});\n\nconst changelog = fs.readFileSync('CHANGELOG.md', {encoding: 'utf8', flag: 'r'});\nconst pkg = readJson('./src/package.json');\nconst currentVersion = pkg.version;\n\nconst newVersion = semver.inc(currentVersion, release);\nif (!newVersion) {\n  console.log(usage);\n  throw new Error('Unable to generate new version from input');\n}\n\nif (!changelog.startsWith(`# ${newVersion}\\n`)) {\n  throw new Error(`No changelog record for ${newVersion}, please create changelog record`);\n}\n\n// ////////////////////////////////////////////////////////////////////////////////////////////////\n// Done with sanity checks, now it's time to make changes.\n\ntry {\n  console.log('Updating develop branch...');\n  run('git pull --ff-only');\n\n  console.log(`Bumping ${release} version (to ${newVersion})...`);\n  pkg.version = newVersion;\n\n  run(`echo \"$(jq '. += {\"version\": \"'${newVersion}'\"}' src/package.json)\" > src/package.json`)\n  run(`echo \"$(jq '. += {\"version\": \"'${newVersion}'\"}' admin/package.json)\" > admin/package.json`)\n  run(`echo \"$(jq '. += {\"version\": \"'${newVersion}'\"}' bin/package.json)\" > bin/package.json`)\n  run(`echo \"$(jq '. += {\"version\": \"'${newVersion}'\"}' ./package.json)\" > ./package.json`)\n\n  // run npm version `release` where release is patch, minor or major\n  run('pnpm install');\n  // run npm install --package-lock-only <-- required???\n\n  run('git add -A');\n  run('git commit -m \"bump version\"');\n  console.log('Switching to master...');\n  run('git checkout master');\n  console.log('Updating master branch...');\n  run('git pull --ff-only');\n  console.log('Merging develop into master...');\n  run('git merge --no-ff --no-edit develop');\n  console.log(`Creating ${newVersion} tag...`);\n  run(`git tag -a '${newVersion}' -m '${newVersion}'`);\n  run(`git tag -a 'v${newVersion}' -m 'v${newVersion}'`);\n  console.log('Switching back to develop...');\n  run('git checkout develop');\n  console.log('Merging master into develop...');\n  run('git merge --no-ff --no-edit master');\n} catch (err:any) {\n  console.error(err.toString());\n  console.warn('Resetting repository...');\n  console.warn('Resetting master...');\n  run('git checkout -f master');\n  run('git reset --hard @{u}');\n  console.warn('Resetting develop...');\n  run('git checkout -f develop');\n  run('git reset --hard @{u}');\n  console.warn(`Deleting ${newVersion} tag...`);\n  run(`git rev-parse -q --verify refs/tags/'${newVersion}' >/dev/null || exit 0; ` +\n      `git tag -d '${newVersion}'`);\n  run(`git rev-parse -q --verify refs/tags/'v${newVersion}' >/dev/null || exit 0; ` +\n      `git tag -d 'v${newVersion}'`);\n  throw err;\n}\n\ntry {\n  console.log('Building documentation...');\n  run('pnpm run makeDocs');\n  console.log('Updating ether.github.com master branch...');\n  run('git pull --ff-only', {cwd: '../ether.github.com/'});\n  console.log('Committing documentation...');\n  run(`cp -R out/doc/ ../ether.github.com/public/doc/v'${newVersion}'`);\n  run(`pnpm version ${newVersion}`, {cwd: '../ether.github.com'});\n  run('git add .', {cwd: '../ether.github.com/'});\n  run(`git commit -m '${newVersion} docs'`, {cwd: '../ether.github.com/'});\n} catch (err:any) {\n  console.error(err.toString());\n  console.warn('Resetting repository...');\n  console.warn('Resetting master...');\n  run('git checkout -f master', {cwd: '../ether.github.com/'});\n  run('git reset --hard @{u}', {cwd: '../ether.github.com/'});\n  throw err;\n}\n\nconsole.log('Done.');\nconsole.log('Review the new commits and the new tag:');\nconsole.log('  git log --graph --date-order --boundary --oneline --decorate develop@{u}..develop');\nconsole.log(`  git show '${newVersion}'`);\nconsole.log('  (cd ../ether.github.com && git show)');\nconsole.log('If everything looks good then push:');\nconsole.log('Run ./bin/push-after-release.sh');\nconsole.log('Creating a Windows build is not necessary anymore and will be created by GitHub action');\nconsole.log('After the windows binary is created a new release with the set version is created automatically.' +\n    ' Just paste the release notes in there');\nconsole.log('The docs are updated automatically with the new version. While the windows build' +\n    ' is generated people can still download the older versions.');\nconsole.log('Finally go public with an announcement via our comms channels :)');\n"
  },
  {
    "path": "bin/repairPad.ts",
    "content": "'use strict';\n\nimport process from \"node:process\";\n\n/*\n * This is a repair tool. It extracts all datas of a pad, removes and inserts them again.\n */\n\n// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an\n// unhandled rejection into an uncaught exception, which does cause Node.js to exit.\nprocess.on('unhandledRejection', (err) => { throw err; });\n\nconsole.warn('WARNING: This script must not be used while etherpad is running!');\n\nif (process.argv.length !== 3) throw new Error('Use: node ./src/bin/repairPad.js $PADID');\n\n// get the padID\nconst padId = process.argv[2];\n\nlet valueCount = 0;\n\n(async () => {\n  // initialize database\n  require('ep_etherpad-lite/node/utils/Settings');\n  const db = require('ep_etherpad-lite/node/db/DB');\n  await db.init();\n\n  // get the pad\n  const padManager = require('ep_etherpad-lite/node/db/PadManager');\n  const pad = await padManager.getPad(padId);\n\n  // accumulate the required keys\n  const neededDBValues = [`pad:${padId}`];\n\n  // add all authors\n  neededDBValues.push(...pad.getAllAuthors().map((author: string) => `globalAuthor:${author}`));\n\n  // add all revisions\n  for (let rev = 0; rev <= pad.head; ++rev) {\n    neededDBValues.push(`pad:${padId}:revs:${rev}`);\n  }\n\n  // add all chat values\n  for (let chat = 0; chat <= pad.chatHead; ++chat) {\n    neededDBValues.push(`pad:${padId}:chat:${chat}`);\n  }\n  // now fetch and reinsert every key\n  console.log('Fetch and reinsert every key');\n  for (const key of neededDBValues) {\n    if (valueCount % 100 === 0) console.log(valueCount + \"/\" + neededDBValues.length);\n    const value = await db.get(key);\n    // if it isn't a globalAuthor value which we want to ignore..\n    // console.log(`Key: ${key}, value: ${JSON.stringify(value)}`);\n    await db.remove(key);\n    await db.set(key, value);\n    valueCount++;\n  }\n\n  console.info(`Finished: Replaced ${valueCount} values in the database`);\n  process.exit(0)\n})();\n"
  },
  {
    "path": "bin/run.sh",
    "content": "#!/bin/sh\n\n# Move to the Etherpad base directory.\nMY_DIR=$(cd \"${0%/*}\" && pwd -P) || exit 1\ncd \"${MY_DIR}/..\" || exit 1\n\n# Source constants and useful functions\n. bin/functions.sh\n\nignoreRoot=0\nfor ARG in \"$@\"; do\n  if [ \"$ARG\" = \"--root\" ]; then\n    ignoreRoot=1\n  fi\ndone\n\n# Stop the script if it's started as root\nif [ \"$(id -u)\" -eq 0 ] && [ \"$ignoreRoot\" -eq 0 ]; then\n  cat <<EOF >&2\nYou shouldn't start Etherpad as root!\nPlease type 'Etherpad rocks my socks' (or restart with the '--root'\nargument) if you still want to start it as root:\nEOF\n  printf \"> \" >&2\n  read -r rocks\n  [ \"$rocks\" = \"Etherpad rocks my socks\" ] || fatal \"Your input was incorrect\"\nfi\n\n# Prepare the environment\nbin/installDeps.sh \"$@\" || exit 1\n\n\n## Create the admin ui\nif [ -z \"$NODE_ENV\" ] || [ \"$NODE_ENV\" = \"development\" ]; then\n  ADMIN_UI_PATH=\"$(dirname \"$0\")/../admin\"\n  UI_PATH=\"$(dirname \"$0\")/../ui\"\n  log \"Creating the admin UI...\"\n  (cd \"$ADMIN_UI_PATH\" && pnpm run build)\n  (cd \"$UI_PATH\" && pnpm run build)\nelse\n  log \"Cannot create the admin UI in production mode\"\nfi\n\n# Move to the node folder and start\nlog \"Starting Etherpad...\"\n\n# cd src\nexec pnpm run prod \"$@\"\n"
  },
  {
    "path": "bin/safeRun.sh",
    "content": "#!/bin/sh\n\n# This script ensures that ep-lite is automatically restarting after\n# an error happens\n\n# Handling Errors\n#   0 silent\n#   1 email\nERROR_HANDLING=0\n# Your email address which should receive the error messages\nEMAIL_ADDRESS=\"no-reply@example.com\"\n# Sets the minimum amount of time between the sending of error emails.\n# This ensures you do not get spammed during an endless reboot loop\n# It's the time in seconds\nTIME_BETWEEN_EMAILS=600 # 10 minutes\n\n# DON'T EDIT AFTER THIS LINE\n\npecho() { printf %s\\\\n \"$*\"; }\nlog() { pecho \"$@\"; }\nerror() { log \"ERROR: $@\" >&2; }\nfatal() { error \"$@\"; exit 1; }\n\nLAST_EMAIL_SEND=0\n\n# Move to the Etherpad base directory.\nMY_DIR=$(cd \"${0%/*}\" && pwd -P) || exit 1\ncd \"${MY_DIR}/..\" || exit 1\n\n# Check if a logfile parameter is set\nLOG=\"$1\"\n[ -n \"${LOG}\" ] || fatal \"Set a logfile as the first parameter\"\nshift\n\nwhile true; do\n  # Try to touch the file if it doesn't exist\n  [ -f \"${LOG}\" ] || touch \"${LOG}\" || fatal \"Logfile '${LOG}' is not writeable\"\n\n  # Check if the file is writeable\n  [ -w \"${LOG}\" ] || fatal \"Logfile '${LOG}' is not writeable\"\n\n  # Start the application\n  bin/run.sh \"$@\" >>${LOG} 2>>${LOG}\n\n  TIME_FMT=$(date +%Y-%m-%dT%H:%M:%S%z)\n\n  # Send email\n  if [ \"$ERROR_HANDLING\" = 1 ]; then\n    TIME_NOW=$(date +%s)\n    TIME_SINCE_LAST_SEND=$(($TIME_NOW - $LAST_EMAIL_SEND))\n\n    if [ \"$TIME_SINCE_LAST_SEND\" -gt \"$TIME_BETWEEN_EMAILS\" ]; then\n      {\n        cat <<EOF\nServer was restarted at: ${TIME_FMT}\nThe last 50 lines of the log before the server exited:\n\nEOF\n        tail -n 50 \"${LOG}\"\n      } | mail -s \"Etherpad restarted\" \"$EMAIL_ADDRESS\"\n\n      LAST_EMAIL_SEND=$TIME_NOW\n    fi\n  fi\n\n  pecho \"RESTART! ${TIME_FMT}\" >>${LOG}\n\n  # Sleep 10 seconds before restart\n  sleep 10\ndone\n"
  },
  {
    "path": "bin/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    /* Visit https://aka.ms/tsconfig to read more about this file */\n\n    /* Projects */\n    // \"incremental\": true,                              /* Save .tsbuildinfo files to allow for incremental compilation of projects. */\n    // \"composite\": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */\n    // \"tsBuildInfoFile\": \"./.tsbuildinfo\",              /* Specify the path to .tsbuildinfo incremental compilation file. */\n    // \"disableSourceOfProjectReferenceRedirect\": true,  /* Disable preferring source files instead of declaration files when referencing composite projects. */\n    // \"disableSolutionSearching\": true,                 /* Opt a project out of multi-project reference checking when editing. */\n    // \"disableReferencedProjectLoad\": true,             /* Reduce the number of projects loaded automatically by TypeScript. */\n\n    /* Language and Environment */\n    \"target\": \"es2016\",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */\n    // \"lib\": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */\n    // \"jsx\": \"preserve\",                                /* Specify what JSX code is generated. */\n    // \"experimentalDecorators\": true,                   /* Enable experimental support for legacy experimental decorators. */\n    // \"emitDecoratorMetadata\": true,                    /* Emit design-type metadata for decorated declarations in source files. */\n    // \"jsxFactory\": \"\",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */\n    // \"jsxFragmentFactory\": \"\",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */\n    // \"jsxImportSource\": \"\",                            /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */\n    // \"reactNamespace\": \"\",                             /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */\n    // \"noLib\": true,                                    /* Disable including any library files, including the default lib.d.ts. */\n    // \"useDefineForClassFields\": true,                  /* Emit ECMAScript-standard-compliant class fields. */\n    // \"moduleDetection\": \"auto\",                        /* Control what method is used to detect module-format JS files. */\n\n    /* Modules */\n    \"module\": \"commonjs\",                                /* Specify what module code is generated. */\n    // \"rootDir\": \"./\",                                  /* Specify the root folder within your source files. */\n    // \"moduleResolution\": \"node10\",                     /* Specify how TypeScript looks up a file from a given module specifier. */\n    // \"baseUrl\": \"./\",                                  /* Specify the base directory to resolve non-relative module names. */\n    // \"paths\": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */\n    // \"rootDirs\": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */\n    // \"typeRoots\": [],                                  /* Specify multiple folders that act like './node_modules/@types'. */\n    // \"types\": [],                                      /* Specify type package names to be included without being referenced in a source file. */\n    // \"allowUmdGlobalAccess\": true,                     /* Allow accessing UMD globals from modules. */\n    // \"moduleSuffixes\": [],                             /* List of file name suffixes to search when resolving a module. */\n    // \"allowImportingTsExtensions\": true,               /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */\n    // \"resolvePackageJsonExports\": true,                /* Use the package.json 'exports' field when resolving package imports. */\n    // \"resolvePackageJsonImports\": true,                /* Use the package.json 'imports' field when resolving imports. */\n    // \"customConditions\": [],                           /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */\n    \"resolveJsonModule\": true,                        /* Enable importing .json files. */\n    // \"allowArbitraryExtensions\": true,                 /* Enable importing files with any extension, provided a declaration file is present. */\n    // \"noResolve\": true,                                /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */\n\n    /* JavaScript Support */\n    // \"allowJs\": true,                                  /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */\n    // \"checkJs\": true,                                  /* Enable error reporting in type-checked JavaScript files. */\n    // \"maxNodeModuleJsDepth\": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */\n\n    /* Emit */\n    // \"declaration\": true,                              /* Generate .d.ts files from TypeScript and JavaScript files in your project. */\n    // \"declarationMap\": true,                           /* Create sourcemaps for d.ts files. */\n    // \"emitDeclarationOnly\": true,                      /* Only output d.ts files and not JavaScript files. */\n    // \"sourceMap\": true,                                /* Create source map files for emitted JavaScript files. */\n    // \"inlineSourceMap\": true,                          /* Include sourcemap files inside the emitted JavaScript. */\n    // \"outFile\": \"./\",                                  /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */\n    // \"outDir\": \"./\",                                   /* Specify an output folder for all emitted files. */\n    // \"removeComments\": true,                           /* Disable emitting comments. */\n    // \"noEmit\": true,                                   /* Disable emitting files from a compilation. */\n    // \"importHelpers\": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */\n    // \"importsNotUsedAsValues\": \"remove\",               /* Specify emit/checking behavior for imports that are only used for types. */\n    // \"downlevelIteration\": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */\n    // \"sourceRoot\": \"\",                                 /* Specify the root path for debuggers to find the reference source code. */\n    // \"mapRoot\": \"\",                                    /* Specify the location where debugger should locate map files instead of generated locations. */\n    // \"inlineSources\": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */\n    // \"emitBOM\": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */\n    // \"newLine\": \"crlf\",                                /* Set the newline character for emitting files. */\n    // \"stripInternal\": true,                            /* Disable emitting declarations that have '@internal' in their JSDoc comments. */\n    // \"noEmitHelpers\": true,                            /* Disable generating custom helper functions like '__extends' in compiled output. */\n    // \"noEmitOnError\": true,                            /* Disable emitting files if any type checking errors are reported. */\n    // \"preserveConstEnums\": true,                       /* Disable erasing 'const enum' declarations in generated code. */\n    // \"declarationDir\": \"./\",                           /* Specify the output directory for generated declaration files. */\n    // \"preserveValueImports\": true,                     /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */\n\n    /* Interop Constraints */\n    // \"isolatedModules\": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */\n    // \"verbatimModuleSyntax\": true,                     /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */\n    // \"allowSyntheticDefaultImports\": true,             /* Allow 'import x from y' when a module doesn't have a default export. */\n    \"esModuleInterop\": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */\n    // \"preserveSymlinks\": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */\n    \"forceConsistentCasingInFileNames\": true,            /* Ensure that casing is correct in imports. */\n\n    /* Type Checking */\n    \"strict\": true,                                      /* Enable all strict type-checking options. */\n    // \"noImplicitAny\": true,                            /* Enable error reporting for expressions and declarations with an implied 'any' type. */\n    // \"strictNullChecks\": true,                         /* When type checking, take into account 'null' and 'undefined'. */\n    // \"strictFunctionTypes\": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */\n    // \"strictBindCallApply\": true,                      /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */\n    // \"strictPropertyInitialization\": true,             /* Check for class properties that are declared but not set in the constructor. */\n    // \"noImplicitThis\": true,                           /* Enable error reporting when 'this' is given the type 'any'. */\n    // \"useUnknownInCatchVariables\": true,               /* Default catch clause variables as 'unknown' instead of 'any'. */\n    // \"alwaysStrict\": true,                             /* Ensure 'use strict' is always emitted. */\n    // \"noUnusedLocals\": true,                           /* Enable error reporting when local variables aren't read. */\n    // \"noUnusedParameters\": true,                       /* Raise an error when a function parameter isn't read. */\n    // \"exactOptionalPropertyTypes\": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */\n    // \"noImplicitReturns\": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */\n    // \"noFallthroughCasesInSwitch\": true,               /* Enable error reporting for fallthrough cases in switch statements. */\n    // \"noUncheckedIndexedAccess\": true,                 /* Add 'undefined' to a type when accessed using an index. */\n    // \"noImplicitOverride\": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */\n    // \"noPropertyAccessFromIndexSignature\": true,       /* Enforces using indexed accessors for keys declared using an indexed type. */\n    // \"allowUnusedLabels\": true,                        /* Disable error reporting for unused labels. */\n    // \"allowUnreachableCode\": true,                     /* Disable error reporting for unreachable code. */\n\n    /* Completeness */\n    // \"skipDefaultLibCheck\": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */\n    \"skipLibCheck\": true                                 /* Skip type checking all .d.ts files. */\n  }\n}\n"
  },
  {
    "path": "bin/updatePlugins.sh",
    "content": "#!/bin/sh\nset -e\nmydir=$(cd \"${0%/*}\" && pwd -P) || exit 1\ncd \"${mydir}\"/..\nOUTDATED=$(npm outdated --depth=0 | awk '{print $1}' | grep '^ep_') || {\n  echo \"All plugins are up-to-date\"\n  exit 0\n}\nset -- ${OUTDATED}\necho \"Updating plugins: $*\"\nexec pnpm install \"$@\"\n"
  },
  {
    "path": "doc/.gitignore",
    "content": ".vitepress/cache\ndist\n"
  },
  {
    "path": "doc/.vitepress/config.mts",
    "content": "import { defineConfig } from 'vitepress'\nimport {version} from '../../package.json'\n// https://vitepress.dev/reference/site-config\nconst commitRef = process.env.COMMIT_REF?.slice(0, 8) || 'dev'\n\n\nexport default defineConfig({\n  title: \"Etherpad Documentation\",\n  description: \"Next Generation Collaborative Document Editing\",\n  base: '/',\n  themeConfig: {\n    search: {\n      provider: 'local'\n    },\n    // https://vitepress.dev/reference/default-theme-config\n    nav: [\n      { text: 'Home', link: '/' },\n      { text: 'Getting started', link: '/docker.md' }\n    ],\n    logo:'/favicon.ico',\n\n    sidebar: {\n        '/': [\n          {\n            link: '/',\n            text: 'About',\n            items: [\n                { text: 'Docker', link: '/docker.md' },\n                { text: 'Localization', link: '/localization.md' },\n                { text: 'Cookies', link: '/cookies.md' },\n                { text: 'Plugins', link: '/plugins.md' },\n                { text: 'Stats', link: '/stats.md' },\n                {text: 'Skins', link: '/skins.md' },\n                {text: 'Demo', link: '/demo.md' },\n              {text: 'CLI', link: '/cli.md'},\n                ]\n          },\n          {\n            text: 'API',\n            link: '/api/',\n            items: [\n              { text: 'Changeset', link: '/api/changeset_library.md' },\n              {text: 'Editbar', link: '/api/editbar.md' },\n              {text: 'EditorInfo', link: '/api/editorInfo.md' },\n              {text: 'Embed Parameters', link: '/api/embed_parameters.md' },\n              {text: 'Hooks Client Side', link: '/api/hooks_client-side.md' },\n              {text: 'Hooks Server Side', link: '/api/hooks_server-side.md' },\n              {text: 'Plugins', link: '/api/pluginfw.md' },\n              {text: 'Toolbar', link: '/api/toolbar.md' },\n              {text: 'HTTP API', link: '/api/http_api.md' },\n            ]\n          },\n          {\n          text: 'Old Docs',\n            items: [\n                { text: 'Easysync description', link: '/etherpad-lite/easysync/easysync-full-description.pdf' },\n                { text: 'Easysync notes', link: '/etherpad-lite/easysync/easysync-notes.pdf' }\n            ]\n          }\n        ],\n      '/stats': [\n        {\n          text: 'Stats',\n          items:[\n            { text: 'Stats', link: '/stats/' }\n          ]\n        }\n      ]\n    },\n    footer: {\n      message: `Published under Apache License`,\n      copyright: `(${commitRef}) v${version} by Etherpad Foundation`\n    },\n\n    socialLinks: [\n      { icon: 'github', link: 'https://github.com/ether/etherpad-lite' }\n    ]\n  }\n})\n"
  },
  {
    "path": "doc/.vitepress/theme/components/SvgImage.vue",
    "content": "<script setup lang=\"ts\">\ndefineProps<{ svg: string }>()\n</script>\n\n<template>\n  <figure class=\"svg-image-root\" v-html=\"svg\" />\n</template>\n\n<style>\n.svg-image-root {\n  background-color: #eee;\n  border-radius: 8px;\n  padding: 1ch;\n  margin: 1ch 0;\n}\nhtml.dark .svg-image-root {\n  background-color: #313641;\n}\n.svg-image-root svg text {\n  font-family: var(--vp-font-family-base);\n}\n</style>\n"
  },
  {
    "path": "doc/.vitepress/theme/index.ts",
    "content": "import { h } from 'vue'\nimport type { Theme } from 'vitepress'\nimport DefaultTheme from 'vitepress/theme'\nimport './styles/vars.css'\nimport SvgImage from './components/SvgImage.vue'\n\nexport default {\n    extends: DefaultTheme,\n    enhanceApp({ app }) {\n        app.component('SvgImage', SvgImage)\n    },\n} satisfies Theme\n"
  },
  {
    "path": "doc/.vitepress/theme/styles/vars.css",
    "content": "/**\n * Colors\n * -------------------------------------------------------------------------- */\n\n:root {\n    --vp-c-brand: #646cff;\n    --vp-c-brand-light: #747bff;\n    --vp-c-brand-lighter: #9499ff;\n    --vp-c-brand-lightest: #bcc0ff;\n    --vp-c-brand-dark: #535bf2;\n    --vp-c-brand-darker: #454ce1;\n    --vp-c-brand-dimm: rgba(100, 108, 255, 0.08);\n}\n\n/**\n * Component: Button\n * -------------------------------------------------------------------------- */\n\n:root {\n    --vp-button-brand-border: var(--vp-c-brand-light);\n    --vp-button-brand-text: var(--vp-c-white);\n    --vp-button-brand-bg: var(--vp-c-brand);\n    --vp-button-brand-hover-border: var(--vp-c-brand-light);\n    --vp-button-brand-hover-text: var(--vp-c-white);\n    --vp-button-brand-hover-bg: var(--vp-c-brand-light);\n    --vp-button-brand-active-border: var(--vp-c-brand-light);\n    --vp-button-brand-active-text: var(--vp-c-white);\n    --vp-button-brand-active-bg: var(--vp-button-brand-bg);\n}\n\n/**\n * Component: Home\n * -------------------------------------------------------------------------- */\n\n:root {\n    --vp-home-hero-name-color: transparent;\n    --vp-home-hero-name-background: -webkit-linear-gradient(\n            120deg,\n            #0f775b 30%,\n            #0f775b\n    );\n\n    --vp-home-hero-image-background-image: linear-gradient(\n            -45deg,\n            #0f775b 50%,\n            #0f775b 50%\n    );\n    --vp-home-hero-image-filter: blur(40px);\n}\n\n@media (min-width: 640px) {\n    :root {\n        --vp-home-hero-image-filter: blur(56px);\n    }\n}\n\n@media (min-width: 960px) {\n    :root {\n        --vp-home-hero-image-filter: blur(72px);\n    }\n}\n\n/**\n * Component: Custom Block\n * -------------------------------------------------------------------------- */\n\n:root {\n    --vp-custom-block-tip-border: var(--vp-c-brand);\n    --vp-custom-block-tip-text: var(--vp-c-brand-darker);\n    --vp-custom-block-tip-bg: var(--vp-c-brand-dimm);\n}\n\n.dark {\n    --vp-custom-block-tip-border: var(--vp-c-brand);\n    --vp-custom-block-tip-text: var(--vp-c-brand-lightest);\n    --vp-custom-block-tip-bg: var(--vp-c-brand-dimm);\n}\n"
  },
  {
    "path": "doc/api/api.adoc",
    "content": "include::./embed_parameters.adoc[]\n\ninclude::./http_api.adoc[]\n\ninclude::./hooks_overview.adoc[]\n\ninclude::./hooks_client-side.adoc[]\n\ninclude::./hooks_server-side.adoc[]\n\ninclude::./editorInfo.adoc[]\n\ninclude::./changeset_library.adoc[]\n\ninclude::./pluginfw.adoc[]\n\ninclude::./toolbar.adoc[]\n\ninclude::./editbar.adoc[]\n"
  },
  {
    "path": "doc/api/changeset_library.adoc",
    "content": "== Changeset Library\n\nThe https://github.com/ether/etherpad-lite/blob/develop/src/static/js/Changeset.ts[changeset\nlibrary]\nprovides tools to create, read, and apply changesets.\n\n=== Changeset\n\n[source,javascript]\n----\nconst Changeset = require('ep_etherpad-lite/static/js/Changeset');\n----\n\nA changeset describes the difference between two revisions of a document. When a\nuser edits a pad, the browser generates and sends a changeset to the server,\nwhich relays it to the other users and saves a copy (so that every past revision\nis accessible).\n\nA transmitted changeset looks like this:\n\n[source]\n----\n'Z:z>1|2=m=b*0|1+1$\\n'\n----\n\n=== Attribute Pool\n\n[source,javascript]\n----\nconst AttributePool = require('ep_etherpad-lite/static/js/AttributePool');\n----\n\nChangesets do not include any attribute key–value pairs. Instead, they use\nnumeric identifiers that reference attributes kept in an https://github.com/ether/etherpad-lite/blob/develop/src/static/js/AttributePool.ts[attribute pool].\nThis attribute interning reduces the transmission overhead of attributes that\nare used many times.\n\nThere is one attribute pool per pad, and it includes every current and\nhistorical attribute used in the pad.\n\n=== Further Reading\n\nDetailed information about the changesets & Easysync protocol:\n\n* https://github.com/ether/etherpad-lite/blob/develop/doc/easysync/easysync-notes.pdf[Easysync Protocol]\n* https://github.com/ether/etherpad-lite/blob/develop/doc/easysync/easysync-full-description.pdf[Etherpad and EasySync Technical Manual]\n"
  },
  {
    "path": "doc/api/changeset_library.md",
    "content": "# Changeset Library\n\nThe [changeset\nlibrary](https://github.com/ether/etherpad-lite/blob/develop/src/static/js/Changeset.ts)\nprovides tools to create, read, and apply changesets.\n\n## Changeset\n\n```javascript\nconst Changeset = require('ep_etherpad-lite/static/js/Changeset');\n```\n\nA changeset describes the difference between two revisions of a document. When a\nuser edits a pad, the browser generates and sends a changeset to the server,\nwhich relays it to the other users and saves a copy (so that every past revision\nis accessible).\n\nA transmitted changeset looks like this:\n\n```\n'Z:z>1|2=m=b*0|1+1$\\n'\n```\n\n## Attribute Pool\n\n```javascript\nconst AttributePool = require('ep_etherpad-lite/static/js/AttributePool');\n```\n\nChangesets do not include any attribute key–value pairs. Instead, they use\nnumeric identifiers that reference attributes kept in an [attribute\npool](https://github.com/ether/etherpad-lite/blob/develop/src/static/js/AttributePool.ts).\nThis attribute interning reduces the transmission overhead of attributes that\nare used many times.\n\nThere is one attribute pool per pad, and it includes every current and\nhistorical attribute used in the pad.\n\n## Further Reading\n\nDetailed information about the changesets & Easysync protocol:\n\n* [Easysync Protocol](https://github.com/ether/etherpad-lite/blob/develop/doc/easysync/easysync-notes.pdf)\n* [Etherpad and EasySync Technical Manual](https://github.com/ether/etherpad-lite/blob/develop/doc/easysync/easysync-full-description.pdf)\n"
  },
  {
    "path": "doc/api/editbar.adoc",
    "content": "== Editbar\nsrc/static/js/pad_editbar.js\n\n=== isEnabled()\n\n=== disable()\n\n=== toggleDropDown(dropdown)\nShows the dropdown `div.popup` whose `id` equals `dropdown`.\n\n=== registerCommand(cmd, callback)\nRegister a handler for a specific command. Commands are fired if the corresponding button is clicked or the corresponding select is changed.\n\n=== registerAceCommand(cmd, callback)\nCreates an ace callstack and calls the callback with an ace instance (and a toolbar item, if applicable): `callback(cmd, ace, item)`.\n\nExample:\n\n[source, javascript]\n----\ntoolbar.registerAceCommand(\"insertorderedlist\", function (cmd, ace) {\n  ace.ace_doInsertOrderedList();\n});\n----\n\n=== registerDropdownCommand(cmd, dropdown)\nTies a `div.popup` where `id` equals `dropdown` to a `command` fired by clicking a button.\n\n=== triggerCommand(cmd[, item])\nTriggers a command (optionally with some internal representation of the toolbar item that triggered it).\n"
  },
  {
    "path": "doc/api/editbar.md",
    "content": "# Editbar\n\nLocated in `src/static/js/pad_editbar.js`\n\n## isEnabled()\n\nIf the editorbar contains the class `enabledtoolbar`, it is enabled.\n\n\n## disable()\n\nDisables the editorbar. This is done by adding the class `disabledtoolbar` and removing the enabledtoolbar\n\n## toggleDropDown(dropdown)\n\nShows the dropdown `div.popup` whose `id` equals `dropdown`.\n\n## registerCommand(cmd, callback)\n\nRegister a handler for a specific command. Commands are fired if the corresponding button is clicked or the corresponding select is changed.\n\n## registerAceCommand(cmd, callback)\nCreates an ace callstack and calls the callback with an ace instance (and a toolbar item, if applicable): `callback(cmd, ace, item)`.\n\nExample:\n```\ntoolbar.registerAceCommand(\"insertorderedlist\", function (cmd, ace) {\n  ace.ace_doInsertOrderedList();\n});\n```\n\n## registerDropdownCommand(cmd, dropdown)\nTies a `div.popup` where `id` equals `dropdown` to a `command` fired by clicking a button.\n\n## triggerCommand(cmd[, item])\nTriggers a command (optionally with some internal representation of the toolbar item that triggered it).\n"
  },
  {
    "path": "doc/api/editorInfo.adoc",
    "content": "== editorInfo\n\n=== editorInfo.ace_replaceRange(start, end, text)\nThis function replaces a range (from `start` to `end`) with `text`.\n\n=== editorInfo.ace_getRep()\nReturns the `rep` object.\n\n=== editorInfo.ace_getAuthor()\n\n=== editorInfo.ace_inCallStack()\n\n=== editorInfo.ace_inCallStackIfNecessary(?)\n\n=== editorInfo.ace_focus(?)\n\n=== editorInfo.ace_importText(?)\n\n=== editorInfo.ace_importAText(?)\n\n=== editorInfo.ace_exportText(?)\n\n=== editorInfo.ace_editorChangedSize(?)\n\n=== editorInfo.ace_setOnKeyPress(?)\n\n=== editorInfo.ace_setOnKeyDown(?)\n\n=== editorInfo.ace_setNotifyDirty(?)\n\n=== editorInfo.ace_dispose(?)\n\n=== editorInfo.ace_setEditable(bool)\n\n=== editorInfo.ace_execCommand(?)\n\n=== editorInfo.ace_callWithAce(fn, callStack, normalize)\n\n=== editorInfo.ace_setProperty(key, value)\n\n=== editorInfo.ace_setBaseText(txt)\n\n=== editorInfo.ace_setBaseAttributedText(atxt, apoolJsonObj)\n\n=== editorInfo.ace_applyChangesToBase(c, optAuthor, apoolJsonObj)\n\n=== editorInfo.ace_prepareUserChangeset()\n\n=== editorInfo.ace_applyPreparedChangesetToBase()\n\n=== editorInfo.ace_setUserChangeNotificationCallback(f)\n\n=== editorInfo.ace_setAuthorInfo(author, info)\n\n=== editorInfo.ace_fastIncorp(?)\n\n=== editorInfo.ace_isCaret(?)\n\n=== editorInfo.ace_getLineAndCharForPoint(?)\n\n=== editorInfo.ace_performDocumentApplyAttributesToCharRange(?)\n\n=== editorInfo.ace_setAttributeOnSelection(attribute, enabled)\n\nSets an attribute on current range.\nExample: `call.editorInfo.ace_setAttributeOnSelection(\"turkey::balls\", true); // turkey is the attribute here, balls is the value\nNotes: to remove the attribute pass enabled as false\n\n=== editorInfo.ace_toggleAttributeOnSelection(?)\n\n=== editorInfo.ace_getAttributeOnSelection(attribute, prevChar)\nReturns a boolean if an attribute exists on a selected range.\nprevChar value should be true if you want to get the previous Character attribute instead of the current selection for example\nif the caret is at position 0,1 (after first character) it's probable you want the attributes on the character at 0,0\nThe attribute should be the string name of the attribute applied to the selection IE subscript\nExample usage: Apply the activeButton Class to a button if an attribute is on a highlighted/selected caret position or range.\nExample `var isItThere = documentAttributeManager.getAttributeOnSelection(\"turkey::balls\", true);`\n\nSee the ep_subscript plugin for an example of this function in action.\nNotes: Does not work on first or last character of a line.  Suffers from a race condition if called with aceEditEvent.\n\n=== editorInfo.ace_performSelectionChange(?)\n\n=== editorInfo.ace_doIndentOutdent(?)\n\n=== editorInfo.ace_doUndoRedo(?)\n\n=== editorInfo.ace_doInsertUnorderedList(?)\n\n=== editorInfo.ace_doInsertOrderedList(?)\n\n=== editorInfo.ace_performDocumentApplyAttributesToRange()\n\n=== editorInfo.ace_getAuthorInfos()\nReturns an info object about the author. Object key = author_id and info includes author's bg color value.\nUse to define your own authorship.\n\n=== editorInfo.ace_performDocumentReplaceRange(start, end, newText)\nThis function replaces a range (from [x1,y1] to [x2,y2]) with `newText`.\n\n=== editorInfo.ace_performDocumentReplaceCharRange(startChar, endChar, newText)\nThis function replaces a range (from y1 to y2) with `newText`.\n\n=== editorInfo.ace_renumberList(lineNum)\nIf you delete a line, calling this method will fix the line numbering.\n\n=== editorInfo.ace_doReturnKey()\nForces a return key at the current caret position.\n\n=== editorInfo.ace_isBlockElement(element)\nReturns true if your passed element is registered as a block element\n\n=== editorInfo.ace_getLineListType(lineNum)\nReturns the line's html list type.\n\n=== editorInfo.ace_caretLine()\nReturns X position of the caret.\n\n=== editorInfo.ace_caretColumn()\nReturns Y position of the caret.\n\n=== editorInfo.ace_caretDocChar()\nReturns the Y offset starting from [x=0,y=0]\n\n=== editorInfo.ace_isWordChar(?)\n"
  },
  {
    "path": "doc/api/editorInfo.md",
    "content": "# EditorInfo\n\nLocation: `src/static/js/ace2_inner.js`\n\n## editorInfo.ace_replaceRange(start, end, text)\nThis function replaces a range (from `start` to `end`) with `text`.\n\n## editorInfo.ace_getRep()\n\nReturns the `rep` object. The rep object consists of the following properties:\n\n- `lines`: Implemented as a skip list\n- `selStart`: The start of the selection\n- `selEnd`: The end of the selection\n- `selFocusAtStart`: Whether the selection is focused at the start\n- `alltext`: The entire text of the document\n- `alines`: The entire text of the document, split into lines\n- `apool`: The pool of attributes\n\n## editorInfo.ace_getAuthor()\n\nReturns the authors of the pad. If the pad has no authors, it returns an empty object.\n\n\n## editorInfo.ace_inCallStack()\n\nReturns true if the editor is in the call stack.\n\n## editorInfo.ace_inCallStackIfNecessary(?)\n\nExecutes the function if the editor is in the call stack.\n\n## editorInfo.ace_focus(?)\n\nFocuses the editor.\n\n## editorInfo.ace_importText(?)\n\nImports text into the editor.\n\n## editorInfo.ace_importAText(?)\n\nImports text and attributes into the editor.\n\n## editorInfo.ace_exportText(?)\n\nExports the text from the editor.\n\n## editorInfo.ace_editorChangedSize(?)\n\nChanges the size of the editor.\n\n## editorInfo.ace_setOnKeyPress(?)\n\nSets the key press event.\n\n## editorInfo.ace_setOnKeyDown(?)\n\nSets the key down event.\n\n## editorInfo.ace_setNotifyDirty(?)\n\nSets the dirty notification.\n\n## editorInfo.ace_dispose(?)\n\nDisposes the editor.\n\n## editorInfo.ace_setEditable(bool)\n\nSets the editor to be editable or not.\n\n## editorInfo.ace_execCommand(?)\n\nExecutes a command.\n\n## editorInfo.ace_callWithAce(fn, callStack, normalize)\n\nCalls a function with the ace instance.\n\n## editorInfo.ace_setProperty(key, value)\n\nSets a property.\n\n## editorInfo.ace_setBaseText(txt)\n\nSets the base text.\n\n## editorInfo.ace_setBaseAttributedText(atxt, apoolJsonObj)\n\nSets the base attributed text.\n\n## editorInfo.ace_applyChangesToBase(c, optAuthor, apoolJsonObj)\n\nApplies changes to the base.\n\n## editorInfo.ace_prepareUserChangeset()\n\nPrepares the user changeset.\n\n## editorInfo.ace_applyPreparedChangesetToBase()\n\nApplies the prepared changeset to the base.\n\n## editorInfo.ace_setUserChangeNotificationCallback(f)\n\nSets the user change notification callback.\n\n## editorInfo.ace_setAuthorInfo(author, info)\n\nSets the author info.\n\n## editorInfo.ace_fastIncorp(?)\n\nIncorporates changes quickly.\n\n## editorInfo.ace_isCaret(?)\n\nReturns true if the caret is at the specified position.\n\n## editorInfo.ace_getLineAndCharForPoint(?)\n\nReturns the line and character for a point.\n\n## editorInfo.ace_performDocumentApplyAttributesToCharRange(?)\n\nApplies attributes to a character range.\n\n## editorInfo.ace_setAttributeOnSelection(attribute, enabled)\n\nSets an attribute on current range.\nExample: `call.editorInfo.ace_setAttributeOnSelection(\"turkey::balls\", true); // turkey is the attribute here, balls is the value\nNotes: to remove the attribute pass enabled as false\n\n## editorInfo.ace_toggleAttributeOnSelection(?)\n\nToggles an attribute on the current range.\n\n## editorInfo.ace_getAttributeOnSelection(attribute, prevChar)\nReturns a boolean if an attribute exists on a selected range.\nprevChar value should be true if you want to get the previous Character attribute instead of the current selection for example\nif the caret is at position 0,1 (after first character) it's probable you want the attributes on the character at 0,0\nThe attribute should be the string name of the attribute applied to the selection IE subscript\nExample usage: Apply the activeButton Class to a button if an attribute is on a highlighted/selected caret position or range.\nExample `var isItThere = documentAttributeManager.getAttributeOnSelection(\"turkey::balls\", true);`\n\nSee the ep_subscript plugin for an example of this function in action.\nNotes: Does not work on first or last character of a line.  Suffers from a race condition if called with aceEditEvent.\n\n## editorInfo.ace_performSelectionChange(?)\n\nPerforms a selection change.\n\n## editorInfo.ace_doIndentOutdent(?)\n\nIndents or outdents the selection.\n\n## editorInfo.ace_doUndoRedo(?)\n\nUndoes or redoes the last action.\n\n## editorInfo.ace_doInsertUnorderedList(?)\n\nInserts an unordered list.\n\n## editorInfo.ace_doInsertOrderedList(?)\n\nInserts an ordered list.\n\n## editorInfo.ace_performDocumentApplyAttributesToRange()\n\nApplies attributes to a range.\n\n## editorInfo.ace_getAuthorInfos()\nReturns an info object about the author. Object key = author_id and info includes author's bg color value.\nUse to define your own authorship.\n\n## editorInfo.ace_performDocumentReplaceRange(start, end, newText)\nThis function replaces a range (from [x1,y1] to [x2,y2]) with `newText`.\n\n## editorInfo.ace_performDocumentReplaceCharRange(startChar, endChar, newText)\nThis function replaces a range (from y1 to y2) with `newText`.\n\n## editorInfo.ace_renumberList(lineNum)\nIf you delete a line, calling this method will fix the line numbering.\n\n## editorInfo.ace_doReturnKey()\nForces a return key at the current caret position.\n\n## editorInfo.ace_isBlockElement(element)\nReturns true if your passed element is registered as a block element.\n\n## editorInfo.ace_getLineListType(lineNum)\nReturns the line's html list type.\n\n## editorInfo.ace_caretLine()\nReturns X position of the caret.\n\n## editorInfo.ace_caretColumn()\nReturns Y position of the caret.\n\n## editorInfo.ace_caretDocChar()\n\nReturns the Y offset starting from [x=0,y=0]\n\n## editorInfo.ace_isWordChar(?)\n\nReturns true if the character is a word character.\n"
  },
  {
    "path": "doc/api/embed_parameters.adoc",
    "content": "== Embed parameters\nYou can easily embed your etherpad-lite into any webpage by using iframes. You can configure the embedded pad using embed parameters.\n\nExample:\n\nCut and paste the following code into any webpage to embed a pad. The parameters below will hide the chat and the line numbers and will auto-focus on Line 4.\n\n[source, html]\n----\n<iframe src='http://pad.test.de/p/PAD_NAME#L4?showChat=false&showLineNumbers=false' width=600 height=400></iframe>\n----\n\n=== showLineNumbers\n * Boolean\n\nDefault: true\n\n=== showControls\n * Boolean\n\nDefault: true\n\n=== showChat\n * Boolean\n\nDefault: true\n\n=== useMonospaceFont\n * Boolean\n\nDefault: false\n\n=== userName\n * String\n\nDefault: \"unnamed\"\n\nExample: `userName=Etherpad%20User`\n\n=== userColor\n * String (css hex color value)\n\nDefault: randomly chosen by pad server\n\nExample: `userColor=%23ff9900`\n\n=== noColors\n * Boolean\n\nDefault: false\n\n=== alwaysShowChat\n * Boolean\n\nDefault: false\n\n=== lang\n * String\n\nDefault: en\n\nExample: `lang=ar` (translates the interface into Arabic)\n\n=== rtl\n * Boolean\n\nDefault: true\nDisplays pad text from right to left.\n\n=== #L\n * Int\n\nDefault: 0\nFocuses pad at specific line number and places caret at beginning of this line\nSpecial note: Is not a URL parameter but instead of a Hash value\n\n"
  },
  {
    "path": "doc/api/embed_parameters.md",
    "content": "# Embed parameters\nYou can easily embed your etherpad-lite into any webpage by using iframes. You can configure the embedded pad using embed parameters.\n\nExample:\n\nCut and paste the following code into any webpage to embed a pad. The parameters below will hide the chat and the line numbers and will auto-focus on Line 4.\n\n```\n<iframe src='http://pad.test.de/p/PAD_NAME#L4?showChat=false&showLineNumbers=false' width=600 height=400></iframe>\n```\n\n## showLineNumbers\n* Boolean\n\nDefault: true\n\n## showControls\n* Boolean\n\nDefault: true\n\n## showChat\n* Boolean\n\nDefault: true\n\n## useMonospaceFont\n* Boolean\n\nDefault: false\n\n## userName\n* String\n\nDefault: \"unnamed\"\n\nExample: `userName=Etherpad%20User`\n\n## userColor\n* String (css hex color value)\n\nDefault: randomly chosen by pad server\n\nExample: `userColor=%23ff9900`\n\n## noColors\n* Boolean\n\nDefault: false\n\n## alwaysShowChat\n* Boolean\n\nDefault: false\n\n## lang\n* String\n\nDefault: en\n\nExample: `lang=ar` (translates the interface into Arabic)\n\n## rtl\n* Boolean\n\nDefault: true\nDisplays pad text from right to left.\n\n## #L\n* Int\n\nDefault: 0\nFocuses pad at specific line number and places caret at beginning of this line\nSpecial note: Is not a URL parameter but instead of a Hash value\n\n"
  },
  {
    "path": "doc/api/hooks_client-side.adoc",
    "content": "== Client-side hooks\n\nMost of these hooks are called during or in order to set up the formatting\nprocess.\n\n=== documentReady\nCalled from: `src/templates/pad.html`\n\nThings in context:\n\nnothing\n\nThis hook proxies the functionality of jQuery's `$(document).ready` event.\n\n=== aceDomLinePreProcessLineAttributes\n\nCalled from: `src/static/js/domline.js`\n\nThings in context:\n\n1. domline - The current DOM line being processed\n2. cls - The class of the current block element (useful for styling)\n\nThis hook is called for elements in the DOM that have the \"lineMarkerAttribute\"\nset. You can add elements into this category with the aceRegisterBlockElements\nhook above. This hook is run BEFORE the numbered and ordered lists logic is\napplied.\n\nThe return value of this hook should have the following structure:\n\n`{ preHtml: String, postHtml: String, processedMarker: Boolean }`\n\nThe preHtml and postHtml values will be added to the HTML display of the\nelement, and if processedMarker is true, the engine won't try to process it any\nmore.\n\n=== aceDomLineProcessLineAttributes\n\nCalled from: `src/static/js/domline.js`\n\nThings in context:\n\n1. domline - The current DOM line being processed\n2. cls - The class of the current block element (useful for styling)\n\nThis hook is called for elements in the DOM that have the \"lineMarkerAttribute\"\nset. You can add elements into this category with the aceRegisterBlockElements\nhook above. This hook is run AFTER the ordered and numbered lists logic is\napplied.\n\nThe return value of this hook should have the following structure:\n\n`{ preHtml: String, postHtml: String, processedMarker: Boolean }`\n\nThe preHtml and postHtml values will be added to the HTML display of the\nelement, and if processedMarker is true, the engine won't try to process it any\nmore.\n\n=== aceCreateDomLine\n\nCalled from: `src/static/js/domline.js`\n\nThings in context:\n\n1. domline - the current DOM line being processed\n2. cls - The class of the current element (useful for styling)\n\nThis hook is called for any line being processed by the formatting engine,\nunless the aceDomLineProcessLineAttributes hook from above returned true, in\nwhich case this hook is skipped.\n\nThe return value of this hook should have the following structure:\n\n`{ extraOpenTags: String, extraCloseTags: String, cls: String }`\n\nextraOpenTags and extraCloseTags will be added before and after the element in\nquestion, and cls will be the new class of the element going forward.\n\n=== acePostWriteDomLineHTML\n\nCalled from: `src/static/js/domline.js`\n\nThings in context:\n\n1. node - the DOM node that just got written to the page\n\nThis hook is for right after a node has been fully formatted and written to the\npage.\n\n=== aceAttribsToClasses\n\nCalled from: `src/static/js/linestylefilter.js`\n\nThings in context:\n\n1. linestylefilter - the JavaScript object that's currently processing the ace\n   attributes\n2. key - the current attribute being processed\n3. value - the value of the attribute being processed\n\nThis hook is called during the attribute processing procedure, and should be\nused to translate key, value pairs into valid HTML classes that can be inserted\ninto the DOM.\n\nThe return value for this function should be a list of classes, which will then\nbe parsed into a valid class string.\n\n=== aceAttribClasses\n\nCalled from: `src/static/js/linestylefilter.js`\n\nThings in context:\n1. Attributes - Object of Attributes\n\nThis hook is called when attributes are investigated on a line. It is useful if\nyou want to add another attribute type or property type to a pad.\n\nExample:\n\n[source,javascript]\n----\nexports.aceAttribClasses = function(hook_name, attr, cb){\n  attr.sub = 'tag:sub';\n  cb(attr);\n}\n----\n\n=== aceGetFilterStack\n\nCalled from: `src/static/js/linestylefilter.js`\n\nThings in context:\n\n1. linestylefilter - the JavaScript object that's currently processing the ace\n   attributes\n2. browser - an object indicating which browser is accessing the page\n\nThis hook is called to apply custom regular expression filters to a set of\nstyles. The one example available is the ep_linkify plugin, which adds internal\nlinks. They use it to find the telltale `[[ ]]` syntax that signifies internal\nlinks, and finding that syntax, they add in the internalHref attribute to be\nlater used by the aceCreateDomLine hook (documented above).\n\n=== aceEditorCSS\n\nCalled from: `src/static/js/ace.js`\n\nThings in context: None\n\nThis hook is provided to allow custom CSS files to be loaded. The return value\nshould be an array of resource urls or paths relative to the plugins directory.\n\n=== aceInitInnerdocbodyHead\n\nCalled from: `src/static/js/ace.js`\n\nThings in context:\n\n1. iframeHTML - the HTML of the editor iframe up to this point, in array format\n\nThis hook is called during the creation of the editor HTML. The array should\nhave lines of HTML added to it, giving the plugin author a chance to add in\nmeta, script, link, and other tags that go into the `<head>` element of the\neditor HTML document.\n\n=== aceEditEvent\n\nCalled from: `src/static/js/ace2_inner.js`\n\nThings in context:\n\n1. callstack - a bunch of information about the current action\n2. editorInfo - information about the user who is making the change\n3. rep - information about where the change is being made\n4. documentAttributeManager - information about attributes in the document (this\n   is a mystery to me)\n\nThis hook is made available to edit the edit events that might occur when\nchanges are made. Currently you can change the editor information, some of the\nmeanings of the edit, and so on. You can also make internal changes (internal to\nyour plugin) that use the information provided by the edit event.\n\n=== aceRegisterNonScrollableEditEvents\n\nCalled from: `src/static/js/ace2_inner.js`\n\nThings in context: None\n\nWhen aceEditEvent (documented above) finishes processing the event, it scrolls\nthe viewport to make caret visible to the user, but if you don't want that\nbehavior to happen you can use this hook to register which edit events should\nnot scroll viewport. The return value of this hook should be a list of event\nnames.\n\nExample:\n\n[source, javascript]\n----\nexports.aceRegisterNonScrollableEditEvents = function(){\n  return [ 'repaginate', 'updatePageCount' ];\n}\n----\n\n=== aceRegisterBlockElements\n\nCalled from: `src/static/js/ace2_inner.js`\n\nThings in context: None\n\nThe return value of this hook will add elements into the \"lineMarkerAttribute\"\ncategory, making the aceDomLineProcessLineAttributes hook (documented below)\ncall for those elements.\n\n=== aceInitialized\n\nCalled from: `src/static/js/ace2_inner.js`\n\nThings in context:\n\n1. editorInfo - information about the user who will be making changes through\n   the interface, and a way to insert functions into the main ace object (see\n   ep_headings)\n2. rep - information about where the user's cursor is\n3. documentAttributeManager - some kind of magic\n\nThis hook is for inserting further information into the ace engine, for later\nuse in formatting hooks.\n\n=== postAceInit\n\nCalled from: `src/static/js/pad.js`\n\nThings in context:\n\n1. ace - the ace object that is applied to this editor.\n2. clientVars - Object containing client-side configuration such as author ID\n   and plugin settings. Your plugin can manipulate this object via the\n   `clientVars` server-side hook.\n3. pad - the pad object of the current pad.\n\n=== postToolbarInit\n\nCalled from: `src/static/js/pad_editbar.js`\n\nThings in context:\n\n1. ace - the ace object that is applied to this editor.\n2. toolbar - Editbar instance. See below for the Editbar documentation.\n\nCan be used to register custom actions to the toolbar.\n\nUsage examples:\n\n* https://github.com/tiblu/ep_authorship_toggle\n\n=== postTimesliderInit\n\nCalled from: `src/static/js/timeslider.js`\n\nThere doesn't appear to be any example available of this particular hook being\nused, but it gets fired after the timeslider is all set up.\n\n=== goToRevisionEvent\n\nCalled from: `src/static/js/broadcast.js`\n\nThings in context:\n\n1. rev - The newRevision\n\nThis hook gets fired both on timeslider load (as timeslider shows a new\nrevision) and when the new revision is showed to a user. There doesn't appear to\nbe any example available of this particular hook being used.\n\n=== userJoinOrUpdate\n\nCalled from: `src/static/js/pad_userlist.js`\n\nThings in context:\n\n1. info - the user information\n\nThis hook is called on the client side whenever a user joins or changes. This\ncan be used to create notifications or an alternate user list.\n\n=== chatNewMessage\n\nCalled from: `src/static/js/chat.js`\n\nThis hook runs on the client side whenever a chat message is received from the\nserver. It can be used to create different notifications for chat messages. Hook\nfunctions can modify the `author`, `authorName`, `duration`, `rendered`,\n`sticky`, `text`, and `timeStr` context properties to change how the message is\nprocessed. The `text` and `timeStr` properties may contain HTML and come\npre-sanitized; plugins should be careful to sanitize any added user input to\navoid introducing an XSS vulnerability.\n\nContext properties:\n\n* `authorName`: The display name of the user that wrote the message.\n* `author`: The author ID of the user that wrote the message.\n* `text`: Sanitized message HTML, with URLs wrapped like `<a\n  href=\"url\">url</a>`. (Note that `message.text` is not sanitized or processed\n  in any way.)\n* `message`: The raw message object as received from the server, except with\n  time correction and a default `authorId` property if missing. Plugins must not\n  modify this object. Warning: Unlike `text`, `message.text` is not\n  pre-sanitized or processed in any way.\n* `rendered` - Used to override the default message rendering. Initially set to\n  `null`. If the hook function sets this to a DOM element object or a jQuery\n  object, then that object will be used as the rendered message UI. Otherwise,\n  if this is set to `null`, then Etherpad will render a default UI for the\n  message using the other context properties.\n* `sticky` (boolean): Whether the gritter notification should fade out on its\n  own or just sit there until manually closed.\n* `timestamp`: When the chat message was sent (milliseconds since epoch),\n  corrected using the difference between the local clock and the server's clock.\n* `timeStr`: The message timestamp as a formatted string.\n* `duration`: How long (in milliseconds) to display the gritter notification (0\n  to disable).\n\n=== chatSendMessage\n\nCalled from: `src/static/js/chat.js`\n\nThis hook runs on the client side whenever the user sends a new chat message.\nPlugins can mutate the message object to change the message text or add metadata\nto control how the message will be rendered by the `chatNewMessage` hook.\n\nContext properties:\n\n* `message`: The message object that will be sent to the Etherpad server.\n\n=== collectContentPre\n\nCalled from: src/static/js/contentcollector.js\n\nThings in context:\n\n1. cc - the contentcollector object\n2. state - the current state of the change being made\n3. tname - the tag name of this node currently being processed\n4. styl - the style applied to the node (probably CSS) -- Note the typo\n5. cls - the HTML class string of the node\n\nThis hook is called before the content of a node is collected by the usual\nmethods. The cc object can be used to do a bunch of things that modify the\ncontent of the pad. See, for example, the heading1 plugin for etherpad original.\n\nE.g. if you need to apply an attribute to newly inserted characters, call\ncc.doAttrib(state, \"attributeName\") which results in an attribute\nattributeName=true.\n\nIf you want to specify also a value, call cc.doAttrib(state,\n\"attributeName::value\") which results in an attribute attributeName=value.\n\n\n=== collectContentImage\n\nCalled from: src/static/js/contentcollector.js\n\nThings in context:\n\n1. cc - the contentcollector object\n2. state - the current state of the change being made\n3. tname - the tag name of this node currently being processed\n4. style - the style applied to the node (probably CSS)\n5. cls - the HTML class string of the node\n6. node - the node being modified\n\nThis hook is called before the content of an image node is collected by the\nusual methods. The cc object can be used to do a bunch of things that modify the\ncontent of the pad.\n\nExample:\n\n[source, javascript]\n----\nexports.collectContentImage = function(name, context){\n  context.state.lineAttributes.img = context.node.outerHTML;\n}\n\n----\n\n=== collectContentPost\n\nCalled from: src/static/js/contentcollector.js\n\nThings in context:\n\n1. cc - the contentcollector object\n2. state - the current state of the change being made\n3. tname - the tag name of this node currently being processed\n4. style - the style applied to the node (probably CSS)\n5. cls - the HTML class string of the node\n\nThis hook is called after the content of a node is collected by the usual\nmethods. The cc object can be used to do a bunch of things that modify the\ncontent of the pad. See, for example, the heading1 plugin for etherpad original.\n\n=== handleClientMessage_`name`\n\nCalled from: `src/static/js/collab_client.js`\n\nThings in context:\n\n1. payload - the data that got sent with the message (use it for custom message\n   content)\n\nThis hook gets called every time the client receives a message of type `name`.\nThis can most notably be used with the new HTTP API call, \"sendClientsMessage\",\nwhich sends a custom message type to all clients connected to a pad. You can\nalso use this to handle existing types.\n\n`collab_client.js` has a pretty extensive list of message types, if you want to\ntake a look.\n\n=== aceStartLineAndCharForPoint-aceEndLineAndCharForPoint\n\nCalled from: src/static/js/ace2_inner.js\n\nThings in context:\n\n1. callstack - a bunch of information about the current action\n2. editorInfo - information about the user who is making the change\n3. rep - information about where the change is being made\n4. root - the span element of the current line\n5. point - the starting/ending element where the cursor highlights\n6. documentAttributeManager - information about attributes in the document\n\nThis hook is provided to allow a plugin to turn DOM node selection into\n[line,char] selection. The return value should be an array of [line,char]\n\n=== aceKeyEvent\n\nCalled from: src/static/js/ace2_inner.js\n\nThings in context:\n\n1. callstack - a bunch of information about the current action\n2. editorInfo - information about the user who is making the change\n3. rep - information about where the change is being made\n4. documentAttributeManager - information about attributes in the document\n5. evt - the fired event\n\nThis hook is provided to allow a plugin to handle key events.\nThe return value should be true if you have handled the event.\n\n=== collectContentLineText\n\nCalled from: src/static/js/contentcollector.js\n\nThings in context:\n\n1. cc - the contentcollector object\n2. state - the current state of the change being made\n3. tname - the tag name of this node currently being processed\n4. text - the text for that line\n\nThis hook allows you to validate/manipulate the text before it's sent to the\nserver side. To change the text, either:\n\n* Set the `text` context property to the desired value and return `undefined`.\n* (Deprecated) Return a string. If a hook function changes the `text` context\n  property, the return value is ignored. If no hook function changes `text` but\n  multiple hook functions return a string, the first one wins.\n\nExample:\n\n[source,javascript]\n----\nexports.collectContentLineText = (hookName, context) => {\n  context.text = tweakText(context.text);\n};\n----\n\n=== collectContentLineBreak\n\nCalled from: src/static/js/contentcollector.js\n\nThings in context:\n\n1. cc - the contentcollector object\n2. state - the current state of the change being made\n3. tname - the tag name of this node currently being processed\n\nThis hook is provided to allow whether the br tag should induce a new magic\ndomline or not. The return value should be either true(break the line) or false.\n\n=== disableAuthorColorsForThisLine\n\nCalled from: src/static/js/linestylefilter.js\n\nThings in context:\n\n1. linestylefilter - the JavaScript object that's currently processing the ace\n   attributes\n2. text - the line text\n3. class - line class\n\nThis hook is provided to allow whether a given line should be deliniated with\nmultiple authors. Multiple authors in one line cause the creation of magic span\nlines. This might not suit you and now you can disable it and handle your own\ndeliniation. The return value should be either true(disable) or false.\n\n=== aceSetAuthorStyle\n\nCalled from: src/static/js/ace2_inner.js\n\nThings in context:\n\n1. dynamicCSS - css manager for inner ace\n2. outerDynamicCSS - css manager for outer ace\n3. parentDynamicCSS - css manager for parent document\n4. info - author style info\n5. author - author info\n6. authorSelector - css selector for author span in inner ace\n\nThis hook is provided to allow author highlight style to be modified. Registered\nhooks should return 1 if the plugin handles highlighting. If no plugin returns\n1, the core will use the default background-based highlighting.\n\n=== aceSelectionChanged\n\nCalled from: src/static/js/ace2_inner.js\n\nThings in context:\n\n1. rep - information about where the user's cursor is\n2. documentAttributeManager - information about attributes in the document\n\nThis hook allows a plugin to react to a cursor or selection change,\nperhaps to update a UI element based on the style at the cursor location.\n"
  },
  {
    "path": "doc/api/hooks_client-side.md",
    "content": "# Client-side hooks\n\nMost of these hooks are called during or in order to set up the formatting\nprocess.\n\n## documentReady\nCalled from: `src/templates/pad.html`\n\nThings in context:\n\nnothing\n\nThis hook proxies the functionality of jQuery's `$(document).ready` event.\n\n## aceDomLinePreProcessLineAttributes\n\nCalled from: `src/static/js/domline.js`\n\nThings in context:\n\n1. domline - The current DOM line being processed\n2. cls - The class of the current block element (useful for styling)\n\nThis hook is called for elements in the DOM that have the \"lineMarkerAttribute\"\nset. You can add elements into this category with the aceRegisterBlockElements\nhook above. This hook is run BEFORE the numbered and ordered lists logic is\napplied.\n\nThe return value of this hook should have the following structure:\n\n`{ preHtml: String, postHtml: String, processedMarker: Boolean }`\n\nThe preHtml and postHtml values will be added to the HTML display of the\nelement, and if processedMarker is true, the engine won't try to process it any\nmore.\n\n## aceDomLineProcessLineAttributes\n\nCalled from: `src/static/js/domline.js`\n\nThings in context:\n\n1. domline - The current DOM line being processed\n2. cls - The class of the current block element (useful for styling)\n\nThis hook is called for elements in the DOM that have the \"lineMarkerAttribute\"\nset. You can add elements into this category with the aceRegisterBlockElements\nhook above. This hook is run AFTER the ordered and numbered lists logic is\napplied.\n\nThe return value of this hook should have the following structure:\n\n`{ preHtml: String, postHtml: String, processedMarker: Boolean }`\n\nThe preHtml and postHtml values will be added to the HTML display of the\nelement, and if processedMarker is true, the engine won't try to process it any\nmore.\n\n## aceCreateDomLine\n\nCalled from: `src/static/js/domline.js`\n\nThings in context:\n\n1. domline - the current DOM line being processed\n2. cls - The class of the current element (useful for styling)\n\nThis hook is called for any line being processed by the formatting engine,\nunless the aceDomLineProcessLineAttributes hook from above returned true, in\nwhich case this hook is skipped.\n\nThe return value of this hook should have the following structure:\n\n`{ extraOpenTags: String, extraCloseTags: String, cls: String }`\n\nextraOpenTags and extraCloseTags will be added before and after the element in\nquestion, and cls will be the new class of the element going forward.\n\n## acePostWriteDomLineHTML\n\nCalled from: `src/static/js/domline.js`\n\nThings in context:\n\n1. node - the DOM node that just got written to the page\n\nThis hook is for right after a node has been fully formatted and written to the\npage.\n\n## aceAttribsToClasses\n\nCalled from: `src/static/js/linestylefilter.js`\n\nThings in context:\n\n1. linestylefilter - the JavaScript object that's currently processing the ace\n   attributes\n2. key - the current attribute being processed\n3. value - the value of the attribute being processed\n\nThis hook is called during the attribute processing procedure, and should be\nused to translate key, value pairs into valid HTML classes that can be inserted\ninto the DOM.\n\nThe return value for this function should be a list of classes, which will then\nbe parsed into a valid class string.\n\n## aceAttribClasses\n\nCalled from: `src/static/js/linestylefilter.js`\n\nThings in context:\n1. Attributes - Object of Attributes\n\nThis hook is called when attributes are investigated on a line. It is useful if\nyou want to add another attribute type or property type to a pad.\n\nExample:\n```\nexports.aceAttribClasses = function(hook_name, attr, cb){\n  attr.sub = 'tag:sub';\n  cb(attr);\n}\n```\n\n## aceGetFilterStack\n\nCalled from: `src/static/js/linestylefilter.js`\n\nThings in context:\n\n1. linestylefilter - the JavaScript object that's currently processing the ace\n   attributes\n2. browser - an object indicating which browser is accessing the page\n\nThis hook is called to apply custom regular expression filters to a set of\nstyles. The one example available is the ep_linkify plugin, which adds internal\nlinks. They use it to find the telltale `[[ ]]` syntax that signifies internal\nlinks, and finding that syntax, they add in the internalHref attribute to be\nlater used by the aceCreateDomLine hook (documented above).\n\n## aceEditorCSS\n\nCalled from: `src/static/js/ace.js`\n\nThings in context: None\n\nThis hook is provided to allow custom CSS files to be loaded. The return value\nshould be an array of resource urls or paths relative to the plugins directory.\n\n## aceInitInnerdocbodyHead\n\nCalled from: `src/static/js/ace.js`\n\nThings in context:\n\n1. iframeHTML - the HTML of the editor iframe up to this point, in array format\n\nThis hook is called during the creation of the editor HTML. The array should\nhave lines of HTML added to it, giving the plugin author a chance to add in\nmeta, script, link, and other tags that go into the `<head>` element of the\neditor HTML document.\n\n## aceEditEvent\n\nCalled from: `src/static/js/ace2_inner.js`\n\nThings in context:\n\n1. callstack - a bunch of information about the current action\n2. editorInfo - information about the user who is making the change\n3. rep - information about where the change is being made\n4. documentAttributeManager - information about attributes in the document (this\n   is a mystery to me)\n\nThis hook is made available to edit the edit events that might occur when\nchanges are made. Currently you can change the editor information, some of the\nmeanings of the edit, and so on. You can also make internal changes (internal to\nyour plugin) that use the information provided by the edit event.\n\n## aceRegisterNonScrollableEditEvents\n\nCalled from: `src/static/js/ace2_inner.js`\n\nThings in context: None\n\nWhen aceEditEvent (documented above) finishes processing the event, it scrolls\nthe viewport to make caret visible to the user, but if you don't want that\nbehavior to happen you can use this hook to register which edit events should\nnot scroll viewport. The return value of this hook should be a list of event\nnames.\n\nExample:\n```\nexports.aceRegisterNonScrollableEditEvents = function(){\n  return [ 'repaginate', 'updatePageCount' ];\n}\n```\n\n## aceRegisterBlockElements\n\nCalled from: `src/static/js/ace2_inner.js`\n\nThings in context: None\n\nThe return value of this hook will add elements into the \"lineMarkerAttribute\"\ncategory, making the aceDomLineProcessLineAttributes hook (documented below)\ncall for those elements.\n\n## aceInitialized\n\nCalled from: `src/static/js/ace2_inner.js`\n\nThings in context:\n\n1. editorInfo - information about the user who will be making changes through\n   the interface, and a way to insert functions into the main ace object (see\n   ep_headings)\n2. rep - information about where the user's cursor is\n3. documentAttributeManager - some kind of magic\n\nThis hook is for inserting further information into the ace engine, for later\nuse in formatting hooks.\n\n## postAceInit\n\nCalled from: `src/static/js/pad.js`\n\nThings in context:\n\n1. ace - the ace object that is applied to this editor.\n2. clientVars - Object containing client-side configuration such as author ID\n   and plugin settings. Your plugin can manipulate this object via the\n   `clientVars` server-side hook.\n3. pad - the pad object of the current pad.\n\n## postToolbarInit\n\nCalled from: `src/static/js/pad_editbar.js`\n\nThings in context:\n\n1. ace - the ace object that is applied to this editor.\n2. toolbar - Editbar instance. See below for the Editbar documentation.\n\nCan be used to register custom actions to the toolbar.\n\nUsage examples:\n\n* [https://github.com/tiblu/ep_authorship_toggle]()\n\n## postTimesliderInit\n\nCalled from: `src/static/js/timeslider.js`\n\nThere doesn't appear to be any example available of this particular hook being\nused, but it gets fired after the timeslider is all set up.\n\n## goToRevisionEvent\n\nCalled from: `src/static/js/broadcast.js`\n\nThings in context:\n\n1. rev - The newRevision\n\nThis hook gets fired both on timeslider load (as timeslider shows a new\nrevision) and when the new revision is showed to a user. There doesn't appear to\nbe any example available of this particular hook being used.\n\n## userJoinOrUpdate\n\nCalled from: `src/static/js/pad_userlist.js`\n\nThings in context:\n\n1. info - the user information\n\nThis hook is called on the client side whenever a user joins or changes. This\ncan be used to create notifications or an alternate user list.\n\n## `chatNewMessage`\n\nCalled from: `src/static/js/chat.js`\n\nThis hook runs on the client side whenever a chat message is received from the\nserver. It can be used to create different notifications for chat messages. Hook\nfunctions can modify the `author`, `authorName`, `duration`, `rendered`,\n`sticky`, `text`, and `timeStr` context properties to change how the message is\nprocessed. The `text` and `timeStr` properties may contain HTML and come\npre-sanitized; plugins should be careful to sanitize any added user input to\navoid introducing an XSS vulnerability.\n\nContext properties:\n\n* `authorName`: The display name of the user that wrote the message.\n* `author`: The author ID of the user that wrote the message.\n* `text`: Sanitized message HTML, with URLs wrapped like `<a\n  href=\"url\">url</a>`. (Note that `message.text` is not sanitized or processed\n  in any way.)\n* `message`: The raw message object as received from the server, except with\n  time correction and a default `authorId` property if missing. Plugins must not\n  modify this object. Warning: Unlike `text`, `message.text` is not\n  pre-sanitized or processed in any way.\n* `rendered` - Used to override the default message rendering. Initially set to\n  `null`. If the hook function sets this to a DOM element object or a jQuery\n  object, then that object will be used as the rendered message UI. Otherwise,\n  if this is set to `null`, then Etherpad will render a default UI for the\n  message using the other context properties.\n* `sticky` (boolean): Whether the gritter notification should fade out on its\n  own or just sit there until manually closed.\n* `timestamp`: When the chat message was sent (milliseconds since epoch),\n  corrected using the difference between the local clock and the server's clock.\n* `timeStr`: The message timestamp as a formatted string.\n* `duration`: How long (in milliseconds) to display the gritter notification (0\n  to disable).\n\n## `chatSendMessage`\n\nCalled from: `src/static/js/chat.js`\n\nThis hook runs on the client side whenever the user sends a new chat message.\nPlugins can mutate the message object to change the message text or add metadata\nto control how the message will be rendered by the `chatNewMessage` hook.\n\nContext properties:\n\n* `message`: The message object that will be sent to the Etherpad server.\n\n## collectContentPre\n\nCalled from: `src/static/js/contentcollector.js`\n\nThings in context:\n\n1. cc - the contentcollector object\n2. state - the current state of the change being made\n3. tname - the tag name of this node currently being processed\n4. styl - the style applied to the node (probably CSS) -- Note the typo\n5. cls - the HTML class string of the node\n\nThis hook is called before the content of a node is collected by the usual\nmethods. The cc object can be used to do a bunch of things that modify the\ncontent of the pad. See, for example, the heading1 plugin for etherpad original.\n\nE.g. if you need to apply an attribute to newly inserted characters, call\ncc.doAttrib(state, \"attributeName\") which results in an attribute\nattributeName=true.\n\nIf you want to specify also a value, call cc.doAttrib(state,\n\"attributeName::value\") which results in an attribute attributeName=value.\n\n\n## collectContentImage\n\nCalled from: `src/static/js/contentcollector.js`\n\nThings in context:\n\n1. cc - the contentcollector object\n2. state - the current state of the change being made\n3. tname - the tag name of this node currently being processed\n4. style - the style applied to the node (probably CSS)\n5. cls - the HTML class string of the node\n6. node - the node being modified\n\nThis hook is called before the content of an image node is collected by the\nusual methods. The cc object can be used to do a bunch of things that modify the\ncontent of the pad.\n\nExample:\n\n```\nexports.collectContentImage = function(name, context){\n  context.state.lineAttributes.img = context.node.outerHTML;\n}\n\n```\n\n## collectContentPost\n\nCalled from: `src/static/js/contentcollector.js`\n\nThings in context:\n\n1. cc - the contentcollector object\n2. state - the current state of the change being made\n3. tname - the tag name of this node currently being processed\n4. style - the style applied to the node (probably CSS)\n5. cls - the HTML class string of the node\n\nThis hook is called after the content of a node is collected by the usual\nmethods. The cc object can be used to do a bunch of things that modify the\ncontent of the pad. See, for example, the heading1 plugin for etherpad original.\n\n## handleClientMessage_`name`\n\nCalled from: `src/static/js/collab_client.js`\n\nThings in context:\n\n1. payload - the data that got sent with the message (use it for custom message\n   content)\n\nThis hook gets called every time the client receives a message of type `name`.\nThis can most notably be used with the new HTTP API call, \"sendClientsMessage\",\nwhich sends a custom message type to all clients connected to a pad. You can\nalso use this to handle existing types.\n\n`collab_client.js` has a pretty extensive list of message types, if you want to\ntake a look.\n\n## aceStartLineAndCharForPoint-aceEndLineAndCharForPoint\n\nCalled from: src/static/js/ace2_inner.js\n\nThings in context:\n\n1. callstack - a bunch of information about the current action\n2. editorInfo - information about the user who is making the change\n3. rep - information about where the change is being made\n4. root - the span element of the current line\n5. point - the starting/ending element where the cursor highlights\n6. documentAttributeManager - information about attributes in the document\n\nThis hook is provided to allow a plugin to turn DOM node selection into\n[line,char] selection. The return value should be an array of [line,char]\n\n## aceKeyEvent\n\nCalled from: `src/static/js/ace2_inner.js`\n\nThings in context:\n\n1. callstack - a bunch of information about the current action\n2. editorInfo - information about the user who is making the change\n3. rep - information about where the change is being made\n4. documentAttributeManager - information about attributes in the document\n5. evt - the fired event\n\nThis hook is provided to allow a plugin to handle key events.\nThe return value should be true if you have handled the event.\n\n## collectContentLineText\n\nCalled from: `src/static/js/contentcollector.js`\n\nThings in context:\n\n1. cc - the contentcollector object\n2. state - the current state of the change being made\n3. tname - the tag name of this node currently being processed\n4. text - the text for that line\n\nThis hook allows you to validate/manipulate the text before it's sent to the\nserver side. To change the text, either:\n\n* Set the `text` context property to the desired value and return `undefined`.\n* (Deprecated) Return a string. If a hook function changes the `text` context\n  property, the return value is ignored. If no hook function changes `text` but\n  multiple hook functions return a string, the first one wins.\n\nExample:\n\n```\nexports.collectContentLineText = (hookName, context) => {\n  context.text = tweakText(context.text);\n};\n```\n\n## collectContentLineBreak\n\nCalled from: `src/static/js/contentcollector.js`\n\nThings in context:\n\n1. cc - the contentcollector object\n2. state - the current state of the change being made\n3. tname - the tag name of this node currently being processed\n\nThis hook is provided to allow whether the br tag should induce a new magic\ndomline or not. The return value should be either true(break the line) or false.\n\n## disableAuthorColorsForThisLine\n\nCalled from: `src/static/js/linestylefilter.js`\n\nThings in context:\n\n1. linestylefilter - the JavaScript object that's currently processing the ace\n   attributes\n2. text - the line text\n3. class - line class\n\nThis hook is provided to allow whether a given line should be deliniated with\nmultiple authors. Multiple authors in one line cause the creation of magic span\nlines. This might not suit you and now you can disable it and handle your own\ndeliniation. The return value should be either true(disable) or false.\n\n## aceSetAuthorStyle\n\nCalled from: `src/static/js/ace2_inner.js`\n\nThings in context:\n\n1. dynamicCSS - css manager for inner ace\n2. outerDynamicCSS - css manager for outer ace\n3. parentDynamicCSS - css manager for parent document\n4. info - author style info\n5. author - author info\n6. authorSelector - css selector for author span in inner ace\n\nThis hook is provided to allow author highlight style to be modified. Registered\nhooks should return 1 if the plugin handles highlighting. If no plugin returns\n1, the core will use the default background-based highlighting.\n\n## aceSelectionChanged\n\nCalled from: `src/static/js/ace2_inner.js`\n\nThings in context:\n\n1. rep - information about where the user's cursor is\n2. documentAttributeManager - information about attributes in the document\n\nThis hook allows a plugin to react to a cursor or selection change,\nperhaps to update a UI element based on the style at the cursor location.\n"
  },
  {
    "path": "doc/api/hooks_overview.adoc",
    "content": "== Hooks\n\nA hook function is registered with a hook via the plugin's `ep.json` file. See\nthe Plugins section for details. A hook may have many registered functions from\ndifferent plugins.\n\nSome hooks call their registered functions one at a time until one of them\nreturns a value. Others always call all of their registered functions and\ncombine the results (if applicable).\n\n=== Registered hook functions\n\nNote: The documentation in this section applies to every hook unless the\nhook-specific documentation says otherwise.\n\n==== Arguments\n\nHook functions are called with three arguments:\n\n1. `hookName` - The name of the hook being invoked.\n2. `context` - An object with some relevant information about the context of the\n   call. See the hook-specific documentation for details.\n3. `cb` - For asynchronous operations this callback can be called to signal\n   completion and optionally provide a return value. The callback takes a single\n   argument, the meaning of which depends on the hook (see the \"Return values\"\n   section for general information that applies to most hooks). This callback\n   always returns `undefined`.\n\n==== Expected behavior\n\nThe presence of a callback parameter suggests that every hook function can run\nasynchronously. While that is the eventual goal, there are some legacy hooks\nthat expect their hook functions to provide a value synchronously. For such\nhooks, the hook functions must do one of the following:\n\n* Call the callback with a non-Promise value (`undefined` is acceptable) and\n  return `undefined`, in that order.\n* Return a non-Promise value other than `undefined` (`null` is acceptable) and\n  never call the callback. Note that `async` functions *always* return a\n  Promise, so they must never be used for synchronous hooks.\n* Only have two parameters (`hookName` and `context`) and return any non-Promise\n  value (`undefined` is acceptable).\n\nFor hooks that permit asynchronous behavior, the hook functions must do one or\nmore of the following:\n\n* Return `undefined` and call the callback, in either order.\n* Return something other than `undefined` (`null` is acceptable) and never call\n  the callback. Note that `async` functions *always* return a Promise, so they\n  must never call the callback.\n* Only have two parameters (`hookName` and `context`).\n\nNote that the acceptable behaviors for asynchronous hook functions is a superset\nof the acceptable behaviors for synchronous hook functions.\n\nWARNING: The number of parameters is determined by examining\nhttps://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/length[Function.length],\nwhich does not count https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters[default parameters]\nor https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/rest_parameters[\"rest\" parameters].\nTo avoid problems, do not use default or rest parameters when defining hook\nfunctions.\n\n==== Return values\n\nA hook function can provide a value to Etherpad in one of the following ways:\n\n* Pass the desired value as the first argument to the callback.\n* Return the desired value directly. The value must not be `undefined` unless\n  the hook function only has two parameters. (Hook functions with three\n  parameters that want to provide `undefined` should instead use the callback.)\n* For hooks that permit asynchronous behavior, return a Promise that resolves to\n  the desired value.\n* For hooks that permit asynchronous behavior, pass a Promise that resolves to\n  the desired value as the first argument to the callback.\n\nExamples:\n\n[source,javascript]\n----\nexports.exampleOne = (hookName, context, callback) => {\n  return 'valueOne';\n};\n\nexports.exampleTwo = (hookName, context, callback) => {\n  callback('valueTwo');\n  return;\n};\n\n// ONLY FOR HOOKS THAT PERMIT ASYNCHRONOUS BEHAVIOR\nexports.exampleThree = (hookName, context, callback) => {\n  return new Promise('valueThree');\n};\n\n// ONLY FOR HOOKS THAT PERMIT ASYNCHRONOUS BEHAVIOR\nexports.exampleFour = (hookName, context, callback) => {\n  callback(new Promise('valueFour'));\n  return;\n};\n\n// ONLY FOR HOOKS THAT PERMIT ASYNCHRONOUS BEHAVIOR\nexports.exampleFive = async (hookName, context) => {\n  // Note that this function is async, so it actually returns a Promise that\n  // is resolved to 'valueFive'.\n  return 'valueFive';\n};\n----\n\nEtherpad collects the values provided by the hook functions into an array,\nfilters out all `undefined` values, then flattens the array one level.\nFlattening one level makes it possible for a hook function to behave as if it\nwere multiple separate hook functions.\n\nFor example: Suppose a hook has eight registered functions that return the\nfollowing values: `1`, `[2]`, `['3a', '3b']` `[[4]]`, `undefined`,\n`[undefined]`, `[]`, and `null`. The value returned to the caller of the hook is\n`[1, 2, '3a', '3b', [4], undefined, null]`.\n"
  },
  {
    "path": "doc/api/hooks_overview.md",
    "content": "# Hooks\n\nA hook function is registered with a hook via the plugin's `ep.json` file. See\nthe Plugins section for details. A hook may have many registered functions from\ndifferent plugins.\n\nSome hooks call their registered functions one at a time until one of them\nreturns a value. Others always call all of their registered functions and\ncombine the results (if applicable).\n\n## Registered hook functions\n\nNote: The documentation in this section applies to every hook unless the\nhook-specific documentation says otherwise.\n\n### Arguments\n\nHook functions are called with three arguments:\n\n1. `hookName` - The name of the hook being invoked.\n2. `context` - An object with some relevant information about the context of the\n   call. See the hook-specific documentation for details.\n3. `cb` - For asynchronous operations this callback can be called to signal\n   completion and optionally provide a return value. The callback takes a single\n   argument, the meaning of which depends on the hook (see the \"Return values\"\n   section for general information that applies to most hooks). This callback\n   always returns `undefined`.\n\n### Expected behavior\n\nThe presence of a callback parameter suggests that every hook function can run\nasynchronously. While that is the eventual goal, there are some legacy hooks\nthat expect their hook functions to provide a value synchronously. For such\nhooks, the hook functions must do one of the following:\n\n* Call the callback with a non-Promise value (`undefined` is acceptable) and\n  return `undefined`, in that order.\n* Return a non-Promise value other than `undefined` (`null` is acceptable) and\n  never call the callback. Note that `async` functions *always* return a\n  Promise, so they must never be used for synchronous hooks.\n* Only have two parameters (`hookName` and `context`) and return any non-Promise\n  value (`undefined` is acceptable).\n\nFor hooks that permit asynchronous behavior, the hook functions must do one or\nmore of the following:\n\n* Return `undefined` and call the callback, in either order.\n* Return something other than `undefined` (`null` is acceptable) and never call\n  the callback. Note that `async` functions *always* return a Promise, so they\n  must never call the callback.\n* Only have two parameters (`hookName` and `context`).\n\nNote that the acceptable behaviors for asynchronous hook functions is a superset\nof the acceptable behaviors for synchronous hook functions.\n\nWARNING: The number of parameters is determined by examining\n[Function.length](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/length),\nwhich does not count [default\nparameters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters)\nor [\"rest\"\nparameters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/rest_parameters).\nTo avoid problems, do not use default or rest parameters when defining hook\nfunctions.\n\n### Return values\n\nA hook function can provide a value to Etherpad in one of the following ways:\n\n* Pass the desired value as the first argument to the callback.\n* Return the desired value directly. The value must not be `undefined` unless\n  the hook function only has two parameters. (Hook functions with three\n  parameters that want to provide `undefined` should instead use the callback.)\n* For hooks that permit asynchronous behavior, return a Promise that resolves to\n  the desired value.\n* For hooks that permit asynchronous behavior, pass a Promise that resolves to\n  the desired value as the first argument to the callback.\n\nExamples:\n\n```javascript\nexports.exampleOne = (hookName, context, callback) => {\n  return 'valueOne';\n};\n\nexports.exampleTwo = (hookName, context, callback) => {\n  callback('valueTwo');\n  return;\n};\n\n// ONLY FOR HOOKS THAT PERMIT ASYNCHRONOUS BEHAVIOR\nexports.exampleThree = (hookName, context, callback) => {\n  return new Promise('valueThree');\n};\n\n// ONLY FOR HOOKS THAT PERMIT ASYNCHRONOUS BEHAVIOR\nexports.exampleFour = (hookName, context, callback) => {\n  callback(new Promise('valueFour'));\n  return;\n};\n\n// ONLY FOR HOOKS THAT PERMIT ASYNCHRONOUS BEHAVIOR\nexports.exampleFive = async (hookName, context) => {\n  // Note that this function is async, so it actually returns a Promise that\n  // is resolved to 'valueFive'.\n  return 'valueFive';\n};\n```\n\nEtherpad collects the values provided by the hook functions into an array,\nfilters out all `undefined` values, then flattens the array one level.\nFlattening one level makes it possible for a hook function to behave as if it\nwere multiple separate hook functions.\n\nFor example: Suppose a hook has eight registered functions that return the\nfollowing values: `1`, `[2]`, `['3a', '3b']` `[[4]]`, `undefined`,\n`[undefined]`, `[]`, and `null`. The value returned to the caller of the hook is\n`[1, 2, '3a', '3b', [4], undefined, null]`.\n"
  },
  {
    "path": "doc/api/hooks_server-side.adoc",
    "content": "== Server-side hooks\nThese hooks are called on server-side.\n\n=== loadSettings\nCalled from: `src/node/server.ts`\n\nThings in context:\n\n1. settings - the settings object\n\nUse this hook to receive the global settings in your plugin.\n\n=== shutdown\nCalled from: `src/node/server.ts`\n\nThings in context: None\n\nThis hook runs before shutdown. Use it to stop timers, close sockets and files,\nflush buffers, etc. The database is not available while this hook is running.\nThe shutdown function must not block for long because there is a short timeout\nbefore the process is forcibly terminated.\n\nThe shutdown function must return a Promise, which must resolve to `undefined`.\nReturning `callback(value)` will return a Promise that is resolved to `value`.\n\nExample:\n\n[source, javascript]\n----\n// using an async function\nexports.shutdown = async (hookName, context) => {\n  await flushBuffers();\n};\n----\n\n=== pluginUninstall\nCalled from: `src/static/js/pluginfw/installer.js`\n\nThings in context:\n\n1. plugin_name - self-explanatory\n\nIf this hook returns an error, the callback to the uninstall function gets an error as well. This mostly seems useful for handling additional features added in based on the installation of other plugins, which is pretty cool!\n\n=== pluginInstall\nCalled from: `src/static/js/pluginfw/installer.js`\n\nThings in context:\n\n1. plugin_name - self-explanatory\n\nIf this hook returns an error, the callback to the install function gets an error, too. This seems useful for adding in features when a particular plugin is installed.\n\n=== init_<plugin name>\n\nCalled from: `src/static/js/pluginfw/plugins.js`\n\nRun during startup after the named plugin is initialized.\n\nContext properties:\n\n  * `logger`: An object with the following `console`-like methods: `debug`,\n    `info`, `log`, `warn`, `error`.\n\n=== expressPreSession\n\nCalled from: `src/node/hooks/express.js`\n\nCalled during server startup just before the\nhttps://www.npmjs.com/package/express-session[`express-session`] middleware is\nadded to the Express Application object. Use this hook to add route handlers or\nmiddleware that executes before `express-session` state is created and\nauthentication is performed. This is useful for creating public endpoints that\ndon't spam the database with new `express-session` records or trigger\nauthentication.\n\n**WARNING:** All handlers registered during this hook run before the built-in\nauthentication checks, so any handled endpoints will be public unless the\nhandler itself authenticates the user.\n\nContext properties:\n\n* `app`: The Express https://expressjs.com/en/4x/api.html==app[Application]\n  object.\n\nExample:\n\n[source,javascript]\n----\nexports.expressPreSession = async (hookName, {app}) => {\n  app.get('/hello-world', (req, res) => res.send('hello world'));\n};\n----\n\n=== expressConfigure\n\nCalled from: `src/node/hooks/express.js`\n\nCalled during server startup just after the\nhttps://www.npmjs.com/package/express-session[`express-session`] middleware is\nadded to the Express Application object. Use this hook to add route handlers or\nmiddleware that executes after `express-session` state is created and\nauthentication is performed.\n\nContext properties:\n\n* `app`: The Express https://expressjs.com/en/4x/api.html==app[Application]\n  object.\n\n=== expressCreateServer\n\nCalled from: `src/node/hooks/express.js`\n\nIdentical to the `expressConfigure` hook (the two run in parallel with each\nother) except this hook's context includes the HTTP Server object.\n\nContext properties:\n\n* `app`: The Express https://expressjs.com/en/4x/api.html==app[Application]\n  object.\n* `server`: The https://nodejs.org/api/http.html==class-httpserver[http.Server]\n  or https://nodejs.org/api/https.html==class-httpsserver[https.Server] object.\n\n=== expressCloseServer\n\nCalled from: `src/node/hooks/express.js`\n\nThings in context: Nothing\n\nThis hook is called when the HTTP server is closing, which happens during\nshutdown (see the shutdown hook) and when the server restarts (e.g., when a\nplugin is installed via the `/admin/plugins` page). The HTTP server may or may\nnot already be closed when this hook executes.\n\nExample:\n\n[source, javascript]\n----\nexports.expressCloseServer = async () => {\n  await doSomeCleanup();\n};\n----\n\n=== eejsBlock_`<name>`\nCalled from: `src/node/eejs/index.js`\n\nThings in context:\n\n1. content - the content of the block\n\nThis hook gets called upon the rendering of an ejs template block. For any specific kind of block, you can change how that block gets rendered by modifying the content object passed in.\n\nAvailable blocks in `pad.html` are:\n\n * `htmlHead` - after `<html>` and immediately before the title tag\n * `styles` - the style `<link>`s\n * `body` - the contents of the body tag\n * `editbarMenuLeft` - the left tool bar (consider using the toolbar controller instead of manually adding html here)\n * `editbarMenuRight` - right tool bar\n * `afterEditbar` - allows you to add stuff immediately after the toolbar\n * `userlist` - the contents of the userlist dropdown\n * `loading` - the initial loading message\n * `mySettings` - the left column of the settings dropdown (\"My view\"); intended for adding checkboxes only\n * `mySettings.dropdowns` - add your dropdown settings here\n * `globalSettings` - the right column of the settings dropdown (\"Global view\")\n * `importColumn` - import form\n * `exportColumn` - export form\n * `modals` - Contains all connectivity messages\n * `embedPopup` - the embed dropdown\n * `scripts` - Add your script tags here, if you really have to (consider use client-side hooks instead)\n\n`timeslider.html` blocks:\n\n * `timesliderStyles`\n * `timesliderScripts`\n * `timesliderBody`\n * `timesliderTop`\n * `timesliderEditbarRight`\n * `modals`\n\n`index.html` blocks:\n\n * `indexCustomStyles` - contains the `index.css` `<link>` tag, allows you to add your own or to customize the one provided by the active skin\n * `indexWrapper` - contains the form for creating new pads\n * `indexCustomScripts` - contains the `index.js` `<script>` tag, allows you to add your own or to customize the one provided by the active skin\n\n=== padInitToolbar\nCalled from: src/node/hooks/express/specialpages.js\n\nThings in context:\n\n1. toolbar - the toolbar controller that will render the toolbar eventually\n\nHere you can add custom toolbar items that will be available in the toolbar config in `settings.json`. For more about the toolbar controller see the API section.\n\nUsage examples:\n\n* https://github.com/tiblu/ep_authorship_toggle\n\n=== onAccessCheck\nCalled from: src/node/db/SecurityManager.js\n\nThings in context:\n\n1. padID - the real ID (never the read-only ID) of the pad the user wants to\n   access\n2. token - the token of the author\n3. sessionCookie - the session the use has\n\nThis hook gets called when the access to the concrete pad is being checked.\nReturn `false` to deny access.\n\n=== getAuthorId\n\nCalled from `src/node/db/AuthorManager.js`\n\nCalled when looking up (or creating) the author ID for a user, except for author\nIDs obtained via the HTTP API. Registered hook functions are called until one\nreturns a non-`undefined` value. If a truthy value is returned by a hook\nfunction, it is used as the user's author ID. Otherwise, the value of the\n`dbKey` context property is used to look up the author ID. If there is no such\nauthor ID at that key, a new author ID is generated and associated with that\nkey.\n\nContext properties:\n\n* `dbKey`: Database key to use when looking up the user's author ID if no hook\n  function returns an author ID. This is initialized to the user-supplied token\n  value (see the `token` context property), but hook functions can modify this\n  to control how author IDs are allocated to users. If no author ID is\n  associated with this database key, a new author ID will be randomly generated\n  and associated with the key. For security reasons, if this is modified it\n  should be modified to not look like a valid token (see the `token` context\n  property) unless the plugin intentionally wants the user to be able to\n  impersonate another user.\n* `token`: The user-supplied token, or nullish for an anonymous user. Tokens are\n  secret values that must not be disclosed to others. If non-null, the token is\n  guaranteed to be a string with the form `t.<base64url>` where `<base64url>` is\n  any valid non-empty base64url string (RFC 4648 section 5 with padding).\n  Example: `t.twim3X2_KGiRj8cJ-3602g==`.\n* `user`: If the user has authenticated, this is an object from `settings.users`\n  (or similar from an authentication plugin). Etherpad core and all good\n  authentication plugins set the `username` property of this object to a string\n  that uniquely identifies the authenticated user. This object is nullish if the\n  user has not authenticated.\n\nExample:\n\n[source,javascript]\n----\nexports.getAuthorId = async (hookName, context) => {\n  const {username} = context.user || {};\n  // If the user has not authenticated, or has \"authenticated\" as the guest\n  // user, do the default behavior (try another plugin if any, falling through\n  // to using the token as the database key).\n  if (!username || username === 'guest') return;\n  // The user is authenticated and has a username. Give the user a stable author\n  // ID so that they appear to be the same author even after clearing cookies or\n  // accessing the pad from another device. Note that this string is guaranteed\n  // to never have the form of a valid token; without that guarantee an\n  // unauthenticated user might be able to impersonate an authenticated user.\n  context.dbKey = `username=${username}`;\n  // Return a falsy but non-undefined value to stop Etherpad from calling any\n  // more getAuthorId hook functions and look up the author ID using the\n  // username-derived database key.\n  return '';\n};\n----\n\n=== padCreate\n\nCalled from: `src/node/db/Pad.js`\n\nCalled when a new pad is created.\n\nContext properties:\n\n* `pad`: The Pad object.\n* `authorId`: The ID of the author who created the pad.\n* `author` (**deprecated**): Synonym of `authorId`.\n\n=== padDefaultContent\n\nCalled from `src/node/db/Pad.js`\n\nCalled to obtain a pad's initial content, unless the pad is being created with\nspecific content. The return value is ignored; to change the content, modify the\n`content` context property.\n\nThis hook is run asynchronously. All registered hook functions are run\nconcurrently (via `Promise.all()`), so be careful to avoid race conditions when\nreading and modifying the context properties.\n\nContext properties:\n\n* `pad`: The newly created Pad object.\n* `authorId`: The author ID of the user that is creating the pad.\n* `type`: String identifying the content type. Currently this is `'text'` and\n  must not be changed. Future versions of Etherpad may add support for HTML,\n  jsdom objects, or other formats, so plugins must assert that this matches a\n  supported content type before reading `content`.\n* `content`: The pad's initial content. Change this property to change the pad's\n  initial content. If the content type is changed, the `type` property must also\n  be updated to match. Plugins must check the value of the `type` property\n  before reading this value.\n\n=== padLoad\n\nCalled from: `src/node/db/PadManager.js`\n\nCalled when a pad is loaded, including after new pad creation.\n\nContext properties:\n\n* `pad`: The Pad object.\n\n[#_padupdate]\n=== padUpdate\n\nCalled from: `src/node/db/Pad.js`\n\nCalled when an existing pad is updated.\n\nContext properties:\n\n* `pad`: The Pad object.\n* `authorId`: The ID of the author who updated the pad.\n* `author` (**deprecated**): Synonym of `authorId`.\n* `revs`: The index of the new revision.\n* `changeset`: The changeset of this revision (see <<_padupdate>>).\n\n=== padCopy\n\nCalled from: `src/node/db/Pad.js`\n\nCalled when a pad is copied so that plugins can copy plugin-specific database\nrecords or perform some other plugin-specific initialization.\n\nOrder of events when a pad is copied:\n\n  1. Destination pad is deleted if it exists and overwrite is permitted. This\n     causes the `padRemove` hook to run.\n  2. Pad-specific database records are copied in the database, except for\n     records with plugin-specific database keys.\n  3. A new Pad object is created for the destination pad. This causes the\n     `padLoad` hook to run.\n  4. This hook runs.\n\nContext properties:\n\n  * `srcPad`: The source Pad object.\n  * `dstPad`: The destination Pad object.\n\nUsage examples:\n\n  * https://github.com/ether/ep_comments_page\n\n=== padRemove\n\nCalled from: `src/node/db/Pad.js`\n\nCalled when an existing pad is removed/deleted. Plugins should use this to clean\nup any plugin-specific pad records from the database.\n\nContext properties:\n\n  * `pad`: Pad object for the pad that is being deleted.\n\nUsage examples:\n\n  * https://github.com/ether/ep_comments_page\n\n=== padCheck\n\nCalled from: `src/node/db/Pad.js`\n\nCalled when a consistency check is run on a pad, after the core checks have\ncompleted successfully. An exception should be thrown if the pad is faulty in\nsome way.\n\nContext properties:\n\n  * `pad`: The Pad object that is being checked.\n\n=== socketio\nCalled from: src/node/hooks/express/socketio.js\n\nThings in context:\n\n1. app - the application object\n2. io - the socketio object\n3. server - the http server object\n\nI have no idea what this is useful for, someone else will have to add this description.\n\n=== preAuthorize\n\nCalled from: `src/node/hooks/express/webaccess.js`\n\nCalled for each HTTP request before any authentication checks are performed. The\nregistered `preAuthorize` hook functions are called one at a time until one\nexplicitly grants or denies the request by returning `true` or `false`,\nrespectively. If none of the hook functions return anything, the access decision\nis deferred to the normal authentication and authorization checks.\n\nExample uses:\n\n* Always grant access to static content.\n* Process an OAuth callback.\n* Drop requests from IP addresses that have failed N authentication checks\n  within the past X minutes.\n\nReturn values:\n\n* `undefined` (or `[]`) defers the access decision to the next registered\n  `preAuthorize` hook function, or to the normal authentication and\n  authorization checks if no more `preAuthorize` hook functions remain.\n* `true` (or `[true]`) immediately grants access to the requested resource,\n  unless the request is for an `/admin` page in which case it is treated the\n  same as returning `undefined`. (This prevents buggy plugins from accidentally\n  granting admin access to the general public.)\n* `false` (or `[false]`) immediately denies the request. The `preAuthnFailure`\n  hook will be called to handle the failure.\n\nContext properties:\n\n* `req`: The Express https://expressjs.com/en/4x/api.html==req[Request] object.\n* `res`: The Express https://expressjs.com/en/4x/api.html==res[Response]\n  object.\n* `next`: Callback to immediately hand off handling to the next Express\n  middleware/handler, or to the next matching route if `'route'` is passed as\n  the first argument. Do not call this unless you understand the consequences.\n\nExample:\n\n[source,javascript]\n----\nexports.preAuthorize = async (hookName, {req}) => {\n  if (await ipAddressIsFirewalled(req)) return false;\n  if (requestIsForStaticContent(req)) return true;\n  if (requestIsForOAuthCallback(req)) return true;\n  // Defer the decision to the next step by returning undefined.\n};\n----\n\n=== authorize\nCalled from: src/node/hooks/express/webaccess.js\n\nThings in context:\n\n1. req - the request object\n2. res - the response object\n3. next - ?\n4. resource - the path being accessed\n\nThis hook is called to handle authorization. It is especially useful for\ncontrolling access to specific paths.\n\nA plugin's authorize function is only called if all of the following are true:\n\n* The request is not for static content or an API endpoint. (Requests for static\n  content and API endpoints are always authorized, even if unauthenticated.)\n* The `requireAuthentication` and `requireAuthorization` settings are both true.\n* The user has already successfully authenticated.\n* The user is not an admin (admin users are always authorized).\n* The path being accessed is not an `/admin` path (`/admin` paths can only be\n  accessed by admin users, and admin users are always authorized).\n* An authorize function from a different plugin has not already caused\n  authorization to pass or fail.\n\nNote that the authorize hook cannot grant access to `/admin` pages. If admin\naccess is desired, the `is_admin` user setting must be set to true. This can be\nset in the settings file or by the authenticate hook.\n\nYou can pass the following values to the provided callback:\n\n* `[true]` or `['create']` will grant access to modify or create the pad if the\n  request is for a pad, otherwise access is simply granted. Access to a pad will\n  be downgraded to modify-only if `settings.editOnly` is true or the user's\n  `canCreate` setting is set to `false`, and downgraded to read-only if the\n  user's `readOnly` setting is `true`.\n* `['modify']` will grant access to modify but not create the pad if the request\n  is for a pad, otherwise access is simply granted. Access to a pad will be\n  downgraded to read-only if the user's `readOnly` setting is `true`.\n* `['readOnly']` will grant read-only access.\n* `[false]` will deny access.\n* `[]` or `undefined` will defer the authorization decision to the next\n  authorization plugin (if any, otherwise deny).\n\nExample:\n\n[source, javascript]\n----\nexports.authorize = (hookName, context, cb) => {\n  const user = context.req.session.user;\n  const path = context.req.path;  // or context.resource\n  if (isExplicitlyProhibited(user, path)) return cb([false]);\n  if (isExplicitlyAllowed(user, path)) return cb([true]);\n  return cb([]);  // Let the next authorization plugin decide\n};\n----\n\n=== authenticate\nCalled from: src/node/hooks/express/webaccess.js\n\nThings in context:\n\n1. req - the request object\n2. res - the response object\n3. users - the users object from settings.json (possibly modified by plugins)\n4. next - ?\n5. username - the username used (optional)\n6. password - the password used (optional)\n\nThis hook is called to handle authentication.\n\nPlugins that supply an authenticate function should probably also supply an\nauthnFailure function unless falling back to HTTP basic authentication is\nappropriate upon authentication failure.\n\nThis hook is only called if either the `requireAuthentication` setting is true\nor the request is for an `/admin` page.\n\nCalling the provided callback with `[true]` or `[false]` will cause\nauthentication to succeed or fail, respectively. Calling the callback with `[]`\nor `undefined` will defer the authentication decision to the next authentication\nplugin (if any, otherwise fall back to HTTP basic authentication).\n\nIf you wish to provide a mix of restricted and anonymous access (e.g., some pads\nare private, others are public), you can \"authenticate\" (as a guest account)\nusers that have not yet logged in, and rely on other hooks (e.g., authorize,\nonAccessCheck, handleMessageSecurity) to authorize specific privileged actions.\n\nIf authentication is successful, the authenticate function MUST set\n`context.req.session.user` to the user's settings object. The `username`\nproperty of this object should be set to the user's username. The settings\nobject should come from global settings (`context.users[username]`).\n\nExample:\n\n[source, javascript]\n----\nexports.authenticate = (hook_name, context, cb) => {\n  if (notApplicableToThisPlugin(context)) {\n    return cb([]);  // Let the next authentication plugin decide\n  }\n  const username = authenticate(context);\n  if (!username) {\n    console.warn(`ep_myplugin.authenticate: Failed authentication from IP ${context.req.ip}`);\n    return cb([false]);\n  }\n  console.info(`ep_myplugin.authenticate: Successful authentication from IP ${context.req.ip} for user ${username}`);\n  const users = context.users;\n  if (!(username in users)) users[username] = {};\n  users[username].username = username;\n  context.req.session.user = users[username];\n  return cb([true]);\n};\n----\n\n=== authFailure\nCalled from: src/node/hooks/express/webaccess.js\n\nThings in context:\n\n1. req - the request object\n2. res - the response object\n3. next - ?\n\n**DEPRECATED:** Use authnFailure or authzFailure instead.\n\nThis hook is called to handle an authentication or authorization failure.\n\nPlugins that supply an authenticate function should probably also supply an\nauthnFailure function unless falling back to HTTP basic authentication is\nappropriate upon authentication failure.\n\nA plugin's authFailure function is only called if all of the following are true:\n\n* There was an authentication or authorization failure.\n* The failure was not already handled by an authFailure function from another\n  plugin.\n* For authentication failures: The failure was not already handled by the\n  authnFailure hook.\n* For authorization failures: The failure was not already handled by the\n  authzFailure hook.\n\nCalling the provided callback with `[true]` tells Etherpad that the failure was\nhandled and no further error handling is required. Calling the callback with\n`[]` or `undefined` defers error handling to the next authFailure plugin (if\nany, otherwise fall back to HTTP basic authentication for an authentication\nfailure or a generic 403 page for an authorization failure).\n\nExample:\n\n[source, javascript]\n----\nexports.authFailure = (hookName, context, cb) => {\n  if (notApplicableToThisPlugin(context)) {\n    return cb([]);  // Let the next plugin handle the error\n  }\n  context.res.redirect(makeLoginURL(context.req));\n  return cb([true]);\n};\n----\n\n=== preAuthzFailure\nCalled from: src/node/hooks/express/webaccess.js\n\nThings in context:\n\n1. req - the request object\n2. res - the response object\n\nThis hook is called to handle a pre-authentication authorization failure.\n\nA plugin's preAuthzFailure function is only called if the pre-authentication\nauthorization failure was not already handled by a preAuthzFailure function from\nanother plugin.\n\nCalling the provided callback with `[true]` tells Etherpad that the failure was\nhandled and no further error handling is required. Calling the callback with\n`[]` or `undefined` defers error handling to a preAuthzFailure function from\nanother plugin (if any, otherwise fall back to a generic 403 error page).\n\nExample:\n\n[source, javascript]\n----\nexports.preAuthzFailure = (hookName, context, cb) => {\n  if (notApplicableToThisPlugin(context)) return cb([]);\n  context.res.status(403).send(renderFancy403Page(context.req));\n  return cb([true]);\n};\n----\n\n=== authnFailure\nCalled from: src/node/hooks/express/webaccess.js\n\nThings in context:\n\n1. req - the request object\n2. res - the response object\n\nThis hook is called to handle an authentication failure.\n\nPlugins that supply an authenticate function should probably also supply an\nauthnFailure function unless falling back to HTTP basic authentication is\nappropriate upon authentication failure.\n\nA plugin's authnFailure function is only called if the authentication failure\nwas not already handled by an authnFailure function from another plugin.\n\nCalling the provided callback with `[true]` tells Etherpad that the failure was\nhandled and no further error handling is required. Calling the callback with\n`[]` or `undefined` defers error handling to an authnFailure function from\nanother plugin (if any, otherwise fall back to the deprecated authFailure hook).\n\nExample:\n\n[source, javascript]\n----\nexports.authnFailure = (hookName, context, cb) => {\n  if (notApplicableToThisPlugin(context)) return cb([]);\n  context.res.redirect(makeLoginURL(context.req));\n  return cb([true]);\n};\n----\n\n=== authzFailure\nCalled from: src/node/hooks/express/webaccess.js\n\nThings in context:\n\n1. req - the request object\n2. res - the response object\n\nThis hook is called to handle a post-authentication authorization failure.\n\nA plugin's authzFailure function is only called if the authorization failure was\nnot already handled by an authzFailure function from another plugin.\n\nCalling the provided callback with `[true]` tells Etherpad that the failure was\nhandled and no further error handling is required. Calling the callback with\n`[]` or `undefined` defers error handling to an authzFailure function from\nanother plugin (if any, otherwise fall back to the deprecated authFailure hook).\n\nExample:\n\n[source, javascript]\n----\nexports.authzFailure = (hookName, context, cb) => {\n  if (notApplicableToThisPlugin(context)) return cb([]);\n  if (needsPremiumAccount(context.req) && !context.req.session.user.premium) {\n    context.res.status(200).send(makeUpgradeToPremiumAccountPage(context.req));\n    return cb([true]);\n  }\n  // Use the generic 403 forbidden response.\n  return cb([]);\n};\n----\n\n=== handleMessage\n\nCalled from: `src/node/handler/PadMessageHandler.js`\n\nThis hook allows plugins to drop or modify incoming socket.io messages from\nclients, before Etherpad processes them. If any hook function returns `null`\nthen the message will not be subject to further processing.\n\nContext properties:\n\n* `message`: The message being handled.\n* `sessionInfo`: Object describing the socket.io session with the following\n  properties:\n  * `authorId`: The user's author ID.\n  * `padId`: The real (not read-only) ID of the pad.\n  * `readOnly`: Whether the client has read-only access (true) or read/write\n    access (false).\n* `socket`: The socket.io Socket object.\n* `client`: (**Deprecated**; use `socket` instead.) Synonym of `socket`.\n\nExample:\n\n[source,javascript]\n----\nexports.handleMessage = async (hookName, {message, socket}) => {\n  if (message.type === 'USERINFO_UPDATE') {\n    // Force the display name to the name associated with the account.\n    const user = socket.client.request.session.user || {};\n    if (user.name) message.data.userInfo.name = user.name;\n  }\n};\n----\n\n=== handleMessageSecurity\n\nCalled from: `src/node/handler/PadMessageHandler.js`\n\nCalled for each incoming message from a client. Allows plugins to grant\ntemporary write access to a pad.\n\nSupported return values:\n\n* `undefined`: No change in access status.\n* `'permitOnce'`: Override the user's read-only access for the current\n  `COLLABROOM` message only. Has no effect if the current message is not a\n  `COLLABROOM` message, or if the user already has write access to the pad.\n* `true`: (**Deprecated**; return `'permitOnce'` instead.) Override the user's\n  read-only access for all `COLLABROOM` messages from the same socket.io\n  connection (including the current message, if applicable) until the client's\n  next `CLIENT_READY` message. Has no effect if the user already has write\n  access to the pad. Read-only access is reset **after** each `CLIENT_READY`\n  message, so returning `true` has no effect for `CLIENT_READY` messages.\n\nContext properties:\n\n* `message`: The message being handled.\n* `sessionInfo`: Object describing the socket.io connection with the following\n  properties:\n  * `authorId`: The user's author ID.\n  * `padId`: The real (not read-only) ID of the pad.\n  * `readOnly`: Whether the client has read-only access (true) or read/write\n    access (false).\n* `socket`: The socket.io Socket object.\n* `client`: (**Deprecated**; use `socket` instead.) Synonym of `socket`.\n\nExample:\n\n[source,javascript]\n----\nexports.handleMessageSecurity = async (hookName, context) => {\n  const {message, sessionInfo: {readOnly}} = context;\n  if (!readOnly || message.type !== 'COLLABROOM') return;\n  if (await messageIsBenign(message)) return 'permitOnce';\n};\n----\n\n=== clientVars\nCalled from: src/node/handler/PadMessageHandler.js\n\nThings in context:\n\n1. clientVars - the basic `clientVars` built by the core\n2. pad - the pad this session is about\n3. socket - the socket.io Socket object\n\nThis hook is called after a client connects but before the initial configuration\nis sent to the client. Plugins can use this hook to manipulate the\nconfiguration. (Example: Add a tracking ID for an external analytics tool that\nis used client-side.)\n\nYou can manipulate `clientVars` in two different ways:\n* Return an object. The object will be merged into `clientVars` via\n  `Object.assign()`, so any keys that already exist in `clientVars` will be\n  overwritten by the values in the returned object.\n* Modify `context.clientVars`. Beware: Other plugins might also be reading or\n  manipulating the same `context.clientVars` object. To avoid race conditions,\n  you are encouraged to return an object rather than modify\n  `context.clientVars`.\n\nIf needed, you can access the user's account information (if authenticated) via\n`context.socket.client.request.session.user`.\n\nExamples:\n\n[source, javascript]\n----\n// Using an async function\nexports.clientVars = async (hookName, context) => {\n  const user = context.socket.client.request.session.user || {};\n  return {'accountUsername': user.username || '<unknown>'}\n};\n\n// Using a regular function\nexports.clientVars = (hookName, context, callback) => {\n  const user = context.socket.client.request.session.user || {};\n  return callback({'accountUsername': user.username || '<unknown>'});\n};\n----\n\n=== getLineHTMLForExport\n\nCalled from: `src/node/utils/ExportHtml.js`\n\nThis hook will allow a plug-in developer to re-write each line when exporting to\nHTML.\n\nContext properties:\n\n* `apool`: Pool object.\n* `attribLine`: Line attributes.\n* `line`:\n* `lineContent`:\n* `text`: Line text.\n* `padId`: Writable (not read-only) pad identifier.\n\nExample:\n\n[source,javascript]\n----\nconst AttributeMap = require('ep_etherpad-lite/static/js/AttributeMap');\nconst Changeset = require('ep_etherpad-lite/static/js/Changeset');\n\nexports.getLineHTMLForExport = async (hookName, context) => {\n  if (!context.attribLine) return;\n  const [op] = Changeset.deserializeOps(context.attribLine);\n  if (op == null) return;\n  const heading = AttributeMap.fromString(op.attribs, context.apool).get('heading');\n  if (!heading) return;\n  context.lineContent = `<${heading}>${context.lineContent}</${heading}>`;\n};\n----\n\n=== exportHTMLAdditionalContent\nCalled from: src/node/utils/ExportHtml.js\n\nThings in context:\n\n1. padId\n\nThis hook will allow a plug-in developer to include additional HTML content in\nthe body of the exported HTML.\n\nExample:\n\n[source, javascript]\n----\nexports.exportHTMLAdditionalContent = async (hookName, {padId}) => {\n  return 'I am groot in ' + padId;\n};\n----\n\n=== stylesForExport\nCalled from: src/node/utils/ExportHtml.js\n\nThings in context:\n\n1. padId - The Pad Id\n\nThis hook will allow a plug-in developer to append Styles to the Exported HTML.\n\nExample:\n\n[source, javascript]\n----\nexports.stylesForExport = function(hook, padId, cb){\n  cb(\"body{font-size:13.37em !important}\");\n}\n----\n\n=== aceAttribClasses\nCalled from: src/static/js/linestylefilter.js\n\nThis hook is called when attributes are investigated on a line. It is useful if\nyou want to add another attribute type or property type to a pad.\n\nAn attributes object is passed to the aceAttribClasses hook functions instead of\nthe usual context object. A hook function can either modify this object directly\nor provide an object whose properties will be assigned to the attributes object.\n\nExample:\n\n[source, javascript]\n----\nexports.aceAttribClasses = (hookName, attrs, cb) => {\n  return cb([{\n    sub: 'tag:sub',\n  }]);\n};\n----\n\n=== exportFileName\nCalled from src/node/handler/ExportHandler.js\n\nThings in context:\n\n1. padId\n\nThis hook will allow a plug-in developer to modify the file name of an exported pad.  This is useful if you want to export a pad under another name and/or hide the padId under export.  Note that the doctype or file extension cannot be modified for security reasons.\n\nExample:\n\n[source, javascript]\n----\nexports.exportFileName = function(hook, padId, callback){\n  callback(\"newFileName\"+padId);\n}\n----\n\n=== exportHtmlAdditionalTags\nCalled from src/node/utils/ExportHtml.js\n\nThings in context:\n\n1. Pad object\n\nThis hook will allow a plug-in developer to include more properties and attributes to support during HTML Export. If tags are stored as `['color', 'red']` on the attribute pool, use `exportHtmlAdditionalTagsWithData` instead. An Array should be returned.\n\nExample:\n\n[source, javascript]\n----\n// Add the props to be supported in export\nexports.exportHtmlAdditionalTags = function(hook, pad, cb){\n  var padId = pad.id;\n  cb([\"massive\",\"jugs\"]);\n};\n----\n\n=== exportHtmlAdditionalTagsWithData\nCalled from src/node/utils/ExportHtml.js\n\nThings in context:\n\n1. Pad object\n\nIdentical to `exportHtmlAdditionalTags`, but for tags that are stored with a specific value (not simply `true`) on the attribute pool. For example `['color', 'red']`, instead of `['bold', true]`. This hook will allow a plug-in developer to include more properties and attributes to support during HTML Export. An Array of arrays should be returned. The exported HTML will contain tags like `<span data-color=\"red\">` for the content where attributes are `['color', 'red']`.\n\nExample:\n\n[source, javascript]\n----\n// Add the props to be supported in export\nexports.exportHtmlAdditionalTagsWithData = function(hook, pad, cb){\n  var padId = pad.id;\n  cb([[\"color\", \"red\"], [\"color\", \"blue\"]]);\n};\n----\n\n=== exportEtherpadAdditionalContent\n\nCalled from `src/node/utils/ExportEtherpad.js` and\n`src/node/utils/ImportEtherpad.js`.\n\nCalled when exporting to an `.etherpad` file or when importing from an\n`.etherpad` file. The hook function should return prefixes for pad-specific\nrecords that should be included in the export/import. On export, all\n`${prefix}:${padId}` and `${prefix}:${padId}:*` records are included in the\ngenerated `.etherpad` file. On import, all `${prefix}:${padId}` and\n`${prefix}:${padId}:*` records are loaded into the database.\n\nContext properties: None.\n\nExample:\n\n[source, javascript]\n----\n// Add support for exporting comments metadata\nexports.exportEtherpadAdditionalContent = () => ['comments'];\n----\n\n=== exportEtherpad\n\nCalled from `src/node/utils/ExportEtherpad.js`.\n\nCalled when exporting to an `.etherpad` file.\n\nContext properties:\n\n  * `pad`: The exported pad's Pad object.\n  * `data`: JSONable output object. This is pre-populated with records from core\n    Etherpad as well as pad-specific records with prefixes from the\n    `exportEtherpadAdditionalContent` hook. Registered hook functions can modify\n    this object (but not replace the object) to perform any desired\n    transformations to the exported data (such as the inclusion of\n    plugin-specific records). All registered hook functions are executed\n    concurrently, so care should be taken to avoid race conditions with other\n    plugins.\n  * `dstPadId`: The pad ID that should be used when writing pad-specific records\n    to `data` (instead of `pad.id`). This avoids leaking the writable pad ID\n    when a user exports a read-only pad. This might be a dummy value; plugins\n    should not assume that it is either the pad's real writable ID or its\n    read-only ID.\n\n=== importEtherpad\n\nCalled from `src/node/utils/ImportEtherpad.js`.\n\nCalled when importing from an `.etherpad` file.\n\nContext properties:\n\n  * `pad`: Temporary Pad object containing the pad's data read from the imported\n    `.etherpad` file. The `pad.db` object is a temporary in-memory database\n    whose records will be copied to the real database after they are validated\n    (see the `padCheck` hook). Registered hook functions MUST NOT use the real\n    database to access (read or write) pad-specific records; they MUST instead\n    use `pad.db`. All registered hook functions are executed concurrently, so\n    care should be taken to avoid race conditions with other plugins.\n  * `data`: Raw JSONable object from the `.etherpad` file. This data must not be\n    modified.\n  * `srcPadId`: The pad ID used for the pad-specific information in `data`.\n\n=== import\n\nCalled from: `src/node/handler/ImportHandler.js`\n\nCalled when a user submits a document for import, before the document is\nconverted to HTML. The hook function should return a truthy value if the hook\nfunction elected to convert the document to HTML.\n\nContext properties:\n\n* `destFile`: The destination HTML filename.\n* `fileEnding`: The lower-cased filename extension from `srcFile` **with leading\n  period** (examples: `'.docx'`, `'.html'`, `'.etherpad'`).\n* `padId`: The identifier of the destination pad.\n* `srcFile`: The document to convert.\n* `ImportError`: Subclass of Error that can be thrown to provide a specific\n  error message to the user. The constructor's first argument must be a string\n  matching one of the https://github.com/ether/etherpad-lite/blob/1.9.6/src/static/js/pad_impexp.js#L80-L86[known error identifiers].\n\nExample:\n\n[source,javascript]\n----\nexports.import = async (hookName, {fileEnding, ImportError}) => {\n  // Reject all *.etherpad imports with a permission denied message.\n  if (fileEnding === '.etherpad') throw new ImportError('permission');\n};\n----\n\n=== userJoin\n\nCalled from: `src/node/handler/PadMessageHandler.js`\n\nCalled after users have been notified that a new user has joined the pad.\n\nContext properties:\n\n* `authorId`: The user's author identifier.\n* `displayName`: The user's display name.\n* `padId`: The real (not read-only) identifier of the pad the user joined. This\n  MUST NOT be shared with any users that are connected with read-only access.\n* `readOnly`: Whether the user only has read-only access.\n* `readOnlyPadId`: The read-only identifier of the pad the user joined.\n* `socket`: The socket.io Socket object.\n\nExample:\n\n```javascript\nexports.userJoin = async (hookName, {authorId, displayName, padId}) => {\n  console.log(`${authorId} (${displayName}) joined pad ${padId});\n};\n```\n\n=== userLeave\n\nCalled from: `src/node/handler/PadMessageHandler.js`\n\nCalled when a user disconnects from a pad. This is useful if you want to perform\ncertain actions after a pad has been edited.\n\nContext properties:\n\n* `authorId`: The user's author ID.\n* `padId`: The pad's real (not read-only) identifier.\n* `readOnly`: If truthy, the user only has read-only access.\n* `readOnlyPadId`: The pad's read-only identifier.\n* `socket`: The socket.io Socket object.\n\nExample:\n\n[source,javascript]\n----\nexports.userLeave = async (hookName, {author, padId}) => {\n  console.log(`${author} left pad ${padId}`);\n};\n----\n\n=== chatNewMessage\n\nCalled from: `src/node/handler/PadMessageHandler.js`\n\nCalled when a user (or plugin) generates a new chat message, just before it is\nsaved to the pad and relayed to all connected users.\n\nContext properties:\n\n* `message`: The chat message object. Plugins can mutate this object to change\n  the message text or add custom metadata to control how the message will be\n  rendered by the `chatNewMessage` client-side hook. The message's `authorId`\n  property can be trusted (the server overwrites any client-provided author ID\n  value with the user's actual author ID before this hook runs).\n* `padId`: The pad's real (not read-only) identifier.\n* `pad`: The pad's Pad object.\n"
  },
  {
    "path": "doc/api/hooks_server-side.md",
    "content": "# Server-side hooks\n\nThese hooks are called on server-side.\n\n## loadSettings\nCalled from: `src/node/server.js`\n\nThings in context:\n\n1. settings - the settings object\n\nUse this hook to receive the global settings in your plugin.\n\n## shutdown\nCalled from: `src/node/server.js`\n\nThings in context: None\n\nThis hook runs before shutdown. Use it to stop timers, close sockets and files,\nflush buffers, etc. The database is not available while this hook is running.\nThe shutdown function must not block for long because there is a short timeout\nbefore the process is forcibly terminated.\n\nThe shutdown function must return a Promise, which must resolve to `undefined`.\nReturning `callback(value)` will return a Promise that is resolved to `value`.\n\nExample:\n\n```\n// using an async function\nexports.shutdown = async (hookName, context) => {\n  await flushBuffers();\n};\n```\n\n## pluginUninstall\nCalled from: `src/static/js/pluginfw/installer.js`\n\nThings in context:\n\n1. plugin_name - self-explanatory\n\nIf this hook returns an error, the callback to the uninstall function gets an error as well. This mostly seems useful for handling additional features added in based on the installation of other plugins, which is pretty cool!\n\n## pluginInstall\nCalled from: `src/static/js/pluginfw/installer.js`\n\nThings in context:\n\n1. plugin_name - self-explanatory\n\nIf this hook returns an error, the callback to the install function gets an error, too. This seems useful for adding in features when a particular plugin is installed.\n\n## `init_<plugin name>`\n\nCalled from: `src/static/js/pluginfw/plugins.js`\n\nRun during startup after the named plugin is initialized.\n\nContext properties:\n\n* `logger`: An object with the following `console`-like methods: `debug`,\n  `info`, `log`, `warn`, `error`.\n\n## `expressPreSession`\n\nCalled from: `src/node/hooks/express.js`\n\nCalled during server startup just before the\n[`express-session`](https://www.npmjs.com/package/express-session) middleware is\nadded to the Express Application object. Use this hook to add route handlers or\nmiddleware that executes before `express-session` state is created and\nauthentication is performed. This is useful for creating public endpoints that\ndon't spam the database with new `express-session` records or trigger\nauthentication.\n\n**WARNING:** All handlers registered during this hook run before the built-in\nauthentication checks, so any handled endpoints will be public unless the\nhandler itself authenticates the user.\n\nContext properties:\n\n* `app`: The Express [Application](https://expressjs.com/en/4x/api.html#app)\n  object.\n\nExample:\n\n```javascript\nexports.expressPreSession = async (hookName, {app}) => {\n  app.get('/hello-world', (req, res) => res.send('hello world'));\n};\n```\n\n## `expressConfigure`\n\nCalled from: `src/node/hooks/express.js`\n\nCalled during server startup just after the\n[`express-session`](https://www.npmjs.com/package/express-session) middleware is\nadded to the Express Application object. Use this hook to add route handlers or\nmiddleware that executes after `express-session` state is created and\nauthentication is performed.\n\nContext properties:\n\n* `app`: The Express [Application](https://expressjs.com/en/4x/api.html#app)\n  object.\n\n## `expressCreateServer`\n\nCalled from: `src/node/hooks/express.js`\n\nIdentical to the `expressConfigure` hook (the two run in parallel with each\nother) except this hook's context includes the HTTP Server object.\n\nContext properties:\n\n* `app`: The Express [Application](https://expressjs.com/en/4x/api.html#app)\n  object.\n* `server`: The [http.Server](https://nodejs.org/api/http.html#class-httpserver)\n  or [https.Server](https://nodejs.org/api/https.html#class-httpsserver) object.\n\n## expressCloseServer\n\nCalled from: `src/node/hooks/express.js`\n\nThings in context: Nothing\n\nThis hook is called when the HTTP server is closing, which happens during\nshutdown (see the shutdown hook) and when the server restarts (e.g., when a\nplugin is installed via the `/admin/plugins` page). The HTTP server may or may\nnot already be closed when this hook executes.\n\nExample:\n\n```\nexports.expressCloseServer = async () => {\n  await doSomeCleanup();\n};\n```\n\n## eejsBlock_`<name>`\nCalled from: `src/node/eejs/index.js`\n\nThings in context:\n\n1. content - the content of the block\n\nThis hook gets called upon the rendering of an ejs template block. For any specific kind of block, you can change how that block gets rendered by modifying the content object passed in.\n\nAvailable blocks in `pad.html` are:\n\n* `htmlHead` - after `<html>` and immediately before the title tag\n* `styles` - the style `<link>`s\n* `body` - the contents of the body tag\n* `editbarMenuLeft` - the left tool bar (consider using the toolbar controller instead of manually adding html here)\n* `editbarMenuRight` - right tool bar\n* `afterEditbar` - allows you to add stuff immediately after the toolbar\n* `userlist` - the contents of the userlist dropdown\n* `loading` - the initial loading message\n* `mySettings` - the left column of the settings dropdown (\"My view\"); intended for adding checkboxes only\n* `mySettings.dropdowns` - add your dropdown settings here\n* `globalSettings` - the right column of the settings dropdown (\"Global view\")\n* `importColumn` - import form\n* `exportColumn` - export form\n* `modals` - Contains all connectivity messages\n* `embedPopup` - the embed dropdown\n* `scripts` - Add your script tags here, if you really have to (consider use client-side hooks instead)\n\n`timeslider.html` blocks:\n\n* `timesliderStyles`\n* `timesliderScripts`\n* `timesliderBody`\n* `timesliderTop`\n* `timesliderEditbarRight`\n* `modals`\n\n`index.html` blocks:\n\n* `indexCustomStyles` - contains the `index.css` `<link>` tag, allows you to add your own or to customize the one provided by the active skin\n* `indexWrapper` - contains the form for creating new pads\n* `indexCustomScripts` - contains the `index.js` `<script>` tag, allows you to add your own or to customize the one provided by the active skin\n\n## padInitToolbar\nCalled from: `src/node/hooks/express/specialpages.js`\n\nThings in context:\n\n1. toolbar - the toolbar controller that will render the toolbar eventually\n\nHere you can add custom toolbar items that will be available in the toolbar config in `settings.json`. For more about the toolbar controller see the API section.\n\nUsage examples:\n\n* https://github.com/tiblu/ep_authorship_toggle\n\n## onAccessCheck\nCalled from: `src/node/db/SecurityManager.js`\n\nThings in context:\n\n1. padID - the real ID (never the read-only ID) of the pad the user wants to\n   access\n2. token - the token of the author\n3. sessionCookie - the session the use has\n\nThis hook gets called when the access to the concrete pad is being checked.\nReturn `false` to deny access.\n\n## `getAuthorId`\n\nCalled from `src/node/db/AuthorManager.js`\n\nCalled when looking up (or creating) the author ID for a user, except for author\nIDs obtained via the HTTP API. Registered hook functions are called until one\nreturns a non-`undefined` value. If a truthy value is returned by a hook\nfunction, it is used as the user's author ID. Otherwise, the value of the\n`dbKey` context property is used to look up the author ID. If there is no such\nauthor ID at that key, a new author ID is generated and associated with that\nkey.\n\nContext properties:\n\n* `dbKey`: Database key to use when looking up the user's author ID if no hook\n  function returns an author ID. This is initialized to the user-supplied token\n  value (see the `token` context property), but hook functions can modify this\n  to control how author IDs are allocated to users. If no author ID is\n  associated with this database key, a new author ID will be randomly generated\n  and associated with the key. For security reasons, if this is modified it\n  should be modified to not look like a valid token (see the `token` context\n  property) unless the plugin intentionally wants the user to be able to\n  impersonate another user.\n* `token`: The user-supplied token, or nullish for an anonymous user. Tokens are\n  secret values that must not be disclosed to others. If non-null, the token is\n  guaranteed to be a string with the form `t.<base64url>` where `<base64url>` is\n  any valid non-empty base64url string (RFC 4648 section 5 with padding).\n  Example: `t.twim3X2_KGiRj8cJ-3602g==`.\n* `user`: If the user has authenticated, this is an object from `settings.users`\n  (or similar from an authentication plugin). Etherpad core and all good\n  authentication plugins set the `username` property of this object to a string\n  that uniquely identifies the authenticated user. This object is nullish if the\n  user has not authenticated.\n\nExample:\n\n```javascript\nexports.getAuthorId = async (hookName, context) => {\n  const {username} = context.user || {};\n  // If the user has not authenticated, or has \"authenticated\" as the guest\n  // user, do the default behavior (try another plugin if any, falling through\n  // to using the token as the database key).\n  if (!username || username === 'guest') return;\n  // The user is authenticated and has a username. Give the user a stable author\n  // ID so that they appear to be the same author even after clearing cookies or\n  // accessing the pad from another device. Note that this string is guaranteed\n  // to never have the form of a valid token; without that guarantee an\n  // unauthenticated user might be able to impersonate an authenticated user.\n  context.dbKey = `username=${username}`;\n  // Return a falsy but non-undefined value to stop Etherpad from calling any\n  // more getAuthorId hook functions and look up the author ID using the\n  // username-derived database key.\n  return '';\n};\n```\n\n## `padCreate`\n\nCalled from: `src/node/db/Pad.js`\n\nCalled when a new pad is created.\n\nContext properties:\n\n* `pad`: The Pad object.\n* `authorId`: The ID of the author who created the pad.\n* `author` (**deprecated**): Synonym of `authorId`.\n\n## `padDefaultContent`\n\nCalled from `src/node/db/Pad.js`\n\nCalled to obtain a pad's initial content, unless the pad is being created with\nspecific content. The return value is ignored; to change the content, modify the\n`content` context property.\n\nThis hook is run asynchronously. All registered hook functions are run\nconcurrently (via `Promise.all()`), so be careful to avoid race conditions when\nreading and modifying the context properties.\n\nContext properties:\n\n* `pad`: The newly created Pad object.\n* `authorId`: The author ID of the user that is creating the pad.\n* `type`: String identifying the content type. Currently this is `'text'` and\n  must not be changed. Future versions of Etherpad may add support for HTML,\n  jsdom objects, or other formats, so plugins must assert that this matches a\n  supported content type before reading `content`.\n* `content`: The pad's initial content. Change this property to change the pad's\n  initial content. If the content type is changed, the `type` property must also\n  be updated to match. Plugins must check the value of the `type` property\n  before reading this value.\n\n## `padLoad`\n\nCalled from: `src/node/db/PadManager.js`\n\nCalled when a pad is loaded, including after new pad creation.\n\nContext properties:\n\n* `pad`: The Pad object.\n\n## `padUpdate`\n\nCalled from: `src/node/db/Pad.js`\n\nCalled when an existing pad is updated.\n\nContext properties:\n\n* `pad`: The Pad object.\n* `authorId`: The ID of the author who updated the pad.\n* `author` (**deprecated**): Synonym of `authorId`.\n* `revs`: The index of the new revision.\n* `changeset`: The changeset of this revision (see [Changeset\n  Library](#index_changeset_library)).\n\n## `padCopy`\n\nCalled from: `src/node/db/Pad.js`\n\nCalled when a pad is copied so that plugins can copy plugin-specific database\nrecords or perform some other plugin-specific initialization.\n\nOrder of events when a pad is copied:\n\n1. Destination pad is deleted if it exists and overwrite is permitted. This\n   causes the `padRemove` hook to run.\n2. Pad-specific database records are copied in the database, except for\n   records with plugin-specific database keys.\n3. A new Pad object is created for the destination pad. This causes the\n   `padLoad` hook to run.\n4. This hook runs.\n\nContext properties:\n\n* `srcPad`: The source Pad object.\n* `dstPad`: The destination Pad object.\n\nUsage examples:\n\n* https://github.com/ether/ep_comments_page\n\n## `padRemove`\n\nCalled from: `src/node/db/Pad.js`\n\nCalled when an existing pad is removed/deleted. Plugins should use this to clean\nup any plugin-specific pad records from the database.\n\nContext properties:\n\n* `pad`: Pad object for the pad that is being deleted.\n\nUsage examples:\n\n* https://github.com/ether/ep_comments_page\n\n## `padCheck`\n\nCalled from: `src/node/db/Pad.js`\n\nCalled when a consistency check is run on a pad, after the core checks have\ncompleted successfully. An exception should be thrown if the pad is faulty in\nsome way.\n\nContext properties:\n\n* `pad`: The Pad object that is being checked.\n\n## socketio\nCalled from: `src/node/hooks/express/socketio.js`\n\nThings in context:\n\n1. app - the application object\n2. io - the socketio object\n3. server - the http server object\n\nI have no idea what this is useful for, someone else will have to add this description.\n\n## `preAuthorize`\n\nCalled from: `src/node/hooks/express/webaccess.js`\n\nCalled for each HTTP request before any authentication checks are performed. The\nregistered `preAuthorize` hook functions are called one at a time until one\nexplicitly grants or denies the request by returning `true` or `false`,\nrespectively. If none of the hook functions return anything, the access decision\nis deferred to the normal authentication and authorization checks.\n\nExample uses:\n\n* Always grant access to static content.\n* Process an OAuth callback.\n* Drop requests from IP addresses that have failed N authentication checks\n  within the past X minutes.\n\nReturn values:\n\n* `undefined` (or `[]`) defers the access decision to the next registered\n  `preAuthorize` hook function, or to the normal authentication and\n  authorization checks if no more `preAuthorize` hook functions remain.\n* `true` (or `[true]`) immediately grants access to the requested resource,\n  unless the request is for an `/admin` page in which case it is treated the\n  same as returning `undefined`. (This prevents buggy plugins from accidentally\n  granting admin access to the general public.)\n* `false` (or `[false]`) immediately denies the request. The `preAuthnFailure`\n  hook will be called to handle the failure.\n\nContext properties:\n\n* `req`: The Express [Request](https://expressjs.com/en/4x/api.html#req) object.\n* `res`: The Express [Response](https://expressjs.com/en/4x/api.html#res)\n  object.\n* `next`: Callback to immediately hand off handling to the next Express\n  middleware/handler, or to the next matching route if `'route'` is passed as\n  the first argument. Do not call this unless you understand the consequences.\n\nExample:\n\n```javascript\nexports.preAuthorize = async (hookName, {req}) => {\n  if (await ipAddressIsFirewalled(req)) return false;\n  if (requestIsForStaticContent(req)) return true;\n  if (requestIsForOAuthCallback(req)) return true;\n  // Defer the decision to the next step by returning undefined.\n};\n```\n\n## authorize\nCalled from: `src/node/hooks/express/webaccess.js`\n\nThings in context:\n\n1. req - the request object\n2. res - the response object\n3. next - ?\n4. resource - the path being accessed\n\nThis hook is called to handle authorization. It is especially useful for\ncontrolling access to specific paths.\n\nA plugin's authorize function is only called if all of the following are true:\n\n* The request is not for static content or an API endpoint. (Requests for static\n  content and API endpoints are always authorized, even if unauthenticated.)\n* The `requireAuthentication` and `requireAuthorization` settings are both true.\n* The user has already successfully authenticated.\n* The user is not an admin (admin users are always authorized).\n* The path being accessed is not an `/admin` path (`/admin` paths can only be\n  accessed by admin users, and admin users are always authorized).\n* An authorize function from a different plugin has not already caused\n  authorization to pass or fail.\n\nNote that the authorize hook cannot grant access to `/admin` pages. If admin\naccess is desired, the `is_admin` user setting must be set to true. This can be\nset in the settings file or by the authenticate hook.\n\nYou can pass the following values to the provided callback:\n\n* `[true]` or `['create']` will grant access to modify or create the pad if the\n  request is for a pad, otherwise access is simply granted. Access to a pad will\n  be downgraded to modify-only if `settings.editOnly` is true or the user's\n  `canCreate` setting is set to `false`, and downgraded to read-only if the\n  user's `readOnly` setting is `true`.\n* `['modify']` will grant access to modify but not create the pad if the request\n  is for a pad, otherwise access is simply granted. Access to a pad will be\n  downgraded to read-only if the user's `readOnly` setting is `true`.\n* `['readOnly']` will grant read-only access.\n* `[false]` will deny access.\n* `[]` or `undefined` will defer the authorization decision to the next\n  authorization plugin (if any, otherwise deny).\n\nExample:\n\n```js\nexports.authorize = (hookName, context, cb) => {\n  const user = context.req.session.user;\n  const path = context.req.path;  // or context.resource\n  if (isExplicitlyProhibited(user, path)) return cb([false]);\n  if (isExplicitlyAllowed(user, path)) return cb([true]);\n  return cb([]);  // Let the next authorization plugin decide\n};\n```\n\n## authenticate\nCalled from: `src/node/hooks/express/webaccess.js`\n\nThings in context:\n\n1. req - the request object\n2. res - the response object\n3. users - the users object from settings.json (possibly modified by plugins)\n4. next - ?\n5. username - the username used (optional)\n6. password - the password used (optional)\n\nThis hook is called to handle authentication.\n\nPlugins that supply an authenticate function should probably also supply an\nauthnFailure function unless falling back to HTTP basic authentication is\nappropriate upon authentication failure.\n\nThis hook is only called if either the `requireAuthentication` setting is true\nor the request is for an `/admin` page.\n\nCalling the provided callback with `[true]` or `[false]` will cause\nauthentication to succeed or fail, respectively. Calling the callback with `[]`\nor `undefined` will defer the authentication decision to the next authentication\nplugin (if any, otherwise fall back to HTTP basic authentication).\n\nIf you wish to provide a mix of restricted and anonymous access (e.g., some pads\nare private, others are public), you can \"authenticate\" (as a guest account)\nusers that have not yet logged in, and rely on other hooks (e.g., authorize,\nonAccessCheck, handleMessageSecurity) to authorize specific privileged actions.\n\nIf authentication is successful, the authenticate function MUST set\n`context.req.session.user` to the user's settings object. The `username`\nproperty of this object should be set to the user's username. The settings\nobject should come from global settings (`context.users[username]`).\n\nExample:\n\n```js\nexports.authenticate = (hook_name, context, cb) => {\n  if (notApplicableToThisPlugin(context)) {\n    return cb([]);  // Let the next authentication plugin decide\n  }\n  const username = authenticate(context);\n  if (!username) {\n    console.warn(`ep_myplugin.authenticate: Failed authentication from IP ${context.req.ip}`);\n    return cb([false]);\n  }\n  console.info(`ep_myplugin.authenticate: Successful authentication from IP ${context.req.ip} for user ${username}`);\n  const users = context.users;\n  if (!(username in users)) users[username] = {};\n  users[username].username = username;\n  context.req.session.user = users[username];\n  return cb([true]);\n};\n```\n\n## authFailure\nCalled from: `src/node/hooks/express/webaccess.js`\n\nThings in context:\n\n1. req - the request object\n2. res - the response object\n3. next - ?\n\n**DEPRECATED:** Use authnFailure or authzFailure instead.\n\nThis hook is called to handle an authentication or authorization failure.\n\nPlugins that supply an authenticate function should probably also supply an\nauthnFailure function unless falling back to HTTP basic authentication is\nappropriate upon authentication failure.\n\nA plugin's authFailure function is only called if all of the following are true:\n\n* There was an authentication or authorization failure.\n* The failure was not already handled by an authFailure function from another\n  plugin.\n* For authentication failures: The failure was not already handled by the\n  authnFailure hook.\n* For authorization failures: The failure was not already handled by the\n  authzFailure hook.\n\nCalling the provided callback with `[true]` tells Etherpad that the failure was\nhandled and no further error handling is required. Calling the callback with\n`[]` or `undefined` defers error handling to the next authFailure plugin (if\nany, otherwise fall back to HTTP basic authentication for an authentication\nfailure or a generic 403 page for an authorization failure).\n\nExample:\n\n```js\nexports.authFailure = (hookName, context, cb) => {\n  if (notApplicableToThisPlugin(context)) {\n    return cb([]);  // Let the next plugin handle the error\n  }\n  context.res.redirect(makeLoginURL(context.req));\n  return cb([true]);\n};\n```\n\n## preAuthzFailure\nCalled from: `src/node/hooks/express/webaccess.js`\n\nThings in context:\n\n1. req - the request object\n2. res - the response object\n\nThis hook is called to handle a pre-authentication authorization failure.\n\nA plugin's preAuthzFailure function is only called if the pre-authentication\nauthorization failure was not already handled by a preAuthzFailure function from\nanother plugin.\n\nCalling the provided callback with `[true]` tells Etherpad that the failure was\nhandled and no further error handling is required. Calling the callback with\n`[]` or `undefined` defers error handling to a preAuthzFailure function from\nanother plugin (if any, otherwise fall back to a generic 403 error page).\n\nExample:\n\n```js\nexports.preAuthzFailure = (hookName, context, cb) => {\n  if (notApplicableToThisPlugin(context)) return cb([]);\n  context.res.status(403).send(renderFancy403Page(context.req));\n  return cb([true]);\n};\n```\n\n## authnFailure\nCalled from: `src/node/hooks/express/webaccess.js`\n\nThings in context:\n\n1. req - the request object\n2. res - the response object\n\nThis hook is called to handle an authentication failure.\n\nPlugins that supply an authenticate function should probably also supply an\nauthnFailure function unless falling back to HTTP basic authentication is\nappropriate upon authentication failure.\n\nA plugin's authnFailure function is only called if the authentication failure\nwas not already handled by an authnFailure function from another plugin.\n\nCalling the provided callback with `[true]` tells Etherpad that the failure was\nhandled and no further error handling is required. Calling the callback with\n`[]` or `undefined` defers error handling to an authnFailure function from\nanother plugin (if any, otherwise fall back to the deprecated authFailure hook).\n\nExample:\n\n```js\nexports.authnFailure = (hookName, context, cb) => {\n  if (notApplicableToThisPlugin(context)) return cb([]);\n  context.res.redirect(makeLoginURL(context.req));\n  return cb([true]);\n};\n```\n\n## authzFailure\nCalled from: `src/node/hooks/express/webaccess.js`\n\nThings in context:\n\n1. req - the request object\n2. res - the response object\n\nThis hook is called to handle a post-authentication authorization failure.\n\nA plugin's authzFailure function is only called if the authorization failure was\nnot already handled by an authzFailure function from another plugin.\n\nCalling the provided callback with `[true]` tells Etherpad that the failure was\nhandled and no further error handling is required. Calling the callback with\n`[]` or `undefined` defers error handling to an authzFailure function from\nanother plugin (if any, otherwise fall back to the deprecated authFailure hook).\n\nExample:\n\n```js\nexports.authzFailure = (hookName, context, cb) => {\n  if (notApplicableToThisPlugin(context)) return cb([]);\n  if (needsPremiumAccount(context.req) && !context.req.session.user.premium) {\n    context.res.status(200).send(makeUpgradeToPremiumAccountPage(context.req));\n    return cb([true]);\n  }\n  // Use the generic 403 forbidden response.\n  return cb([]);\n};\n```\n\n## `handleMessage`\n\nCalled from: `src/node/handler/PadMessageHandler.js`\n\nThis hook allows plugins to drop or modify incoming socket.io messages from\nclients, before Etherpad processes them. If any hook function returns `null`\nthen the message will not be subject to further processing.\n\nContext properties:\n\n* `message`: The message being handled.\n* `sessionInfo`: Object describing the socket.io session with the following\n  properties:\n    * `authorId`: The user's author ID.\n    * `padId`: The real (not read-only) ID of the pad.\n    * `readOnly`: Whether the client has read-only access (true) or read/write\n      access (false).\n* `socket`: The socket.io Socket object.\n* `client`: (**Deprecated**; use `socket` instead.) Synonym of `socket`.\n\nExample:\n\n```javascript\nexports.handleMessage = async (hookName, {message, socket}) => {\n  if (message.type === 'USERINFO_UPDATE') {\n    // Force the display name to the name associated with the account.\n    const user = socket.client.request.session.user || {};\n    if (user.name) message.data.userInfo.name = user.name;\n  }\n};\n```\n\n## `handleMessageSecurity`\n\nCalled from: `src/node/handler/PadMessageHandler.js`\n\nCalled for each incoming message from a client. Allows plugins to grant\ntemporary write access to a pad.\n\nSupported return values:\n\n* `undefined`: No change in access status.\n* `'permitOnce'`: Override the user's read-only access for the current\n  `COLLABROOM` message only. Has no effect if the current message is not a\n  `COLLABROOM` message, or if the user already has write access to the pad.\n* `true`: (**Deprecated**; return `'permitOnce'` instead.) Override the user's\n  read-only access for all `COLLABROOM` messages from the same socket.io\n  connection (including the current message, if applicable) until the client's\n  next `CLIENT_READY` message. Has no effect if the user already has write\n  access to the pad. Read-only access is reset **after** each `CLIENT_READY`\n  message, so returning `true` has no effect for `CLIENT_READY` messages.\n\nContext properties:\n\n* `message`: The message being handled.\n* `sessionInfo`: Object describing the socket.io connection with the following\n  properties:\n    * `authorId`: The user's author ID.\n    * `padId`: The real (not read-only) ID of the pad.\n    * `readOnly`: Whether the client has read-only access (true) or read/write\n      access (false).\n* `socket`: The socket.io Socket object.\n* `client`: (**Deprecated**; use `socket` instead.) Synonym of `socket`.\n\nExample:\n\n```javascript\nexports.handleMessageSecurity = async (hookName, context) => {\n  const {message, sessionInfo: {readOnly}} = context;\n  if (!readOnly || message.type !== 'COLLABROOM') return;\n  if (await messageIsBenign(message)) return 'permitOnce';\n};\n```\n\n## clientVars\nCalled from: `src/node/handler/PadMessageHandler.js`\n\nThings in context:\n\n1. clientVars - the basic `clientVars` built by the core\n2. pad - the pad this session is about\n3. socket - the socket.io Socket object\n\nThis hook is called after a client connects but before the initial configuration\nis sent to the client. Plugins can use this hook to manipulate the\nconfiguration. (Example: Add a tracking ID for an external analytics tool that\nis used client-side.)\n\nYou can manipulate `clientVars` in two different ways:\n* Return an object. The object will be merged into `clientVars` via\n  `Object.assign()`, so any keys that already exist in `clientVars` will be\n  overwritten by the values in the returned object.\n* Modify `context.clientVars`. Beware: Other plugins might also be reading or\n  manipulating the same `context.clientVars` object. To avoid race conditions,\n  you are encouraged to return an object rather than modify\n  `context.clientVars`.\n\nIf needed, you can access the user's account information (if authenticated) via\n`context.socket.client.request.session.user`.\n\nExamples:\n\n```js\n// Using an async function\nexports.clientVars = async (hookName, context) => {\n  const user = context.socket.client.request.session.user || {};\n  return {'accountUsername': user.username || '<unknown>'}\n};\n\n// Using a regular function\nexports.clientVars = (hookName, context, callback) => {\n  const user = context.socket.client.request.session.user || {};\n  return callback({'accountUsername': user.username || '<unknown>'});\n};\n```\n\n## `getLineHTMLForExport`\n\nCalled from: `src/node/utils/ExportHtml.js`\n\nThis hook will allow a plug-in developer to re-write each line when exporting to\nHTML.\n\nContext properties:\n\n* `apool`: Pool object.\n* `attribLine`: Line attributes.\n* `line`:\n* `lineContent`:\n* `text`: Line text.\n* `padId`: Writable (not read-only) pad identifier.\n\nExample:\n\n```javascript\nconst AttributeMap = require('ep_etherpad-lite/static/js/AttributeMap');\nconst Changeset = require('ep_etherpad-lite/static/js/Changeset');\n\nexports.getLineHTMLForExport = async (hookName, context) => {\n  if (!context.attribLine) return;\n  const [op] = Changeset.deserializeOps(context.attribLine);\n  if (op == null) return;\n  const heading = AttributeMap.fromString(op.attribs, context.apool).get('heading');\n  if (!heading) return;\n  context.lineContent = `<${heading}>${context.lineContent}</${heading}>`;\n};\n```\n\n## exportHTMLAdditionalContent\nCalled from: `src/node/utils/ExportHtml.js`\n\nThings in context:\n\n1. padId\n\nThis hook will allow a plug-in developer to include additional HTML content in\nthe body of the exported HTML.\n\nExample:\n\n```js\nexports.exportHTMLAdditionalContent = async (hookName, {padId}) => {\n  return 'I am groot in ' + padId;\n};\n```\n\n## stylesForExport\nCalled from: `src/node/utils/ExportHtml.js`\n\nThings in context:\n\n1. padId - The Pad Id\n\nThis hook will allow a plug-in developer to append Styles to the Exported HTML.\n\nExample:\n\n```js\nexports.stylesForExport = function(hook, padId, cb){\n  cb(\"body{font-size:13.37em !important}\");\n}\n```\n\n## aceAttribClasses\nCalled from: `src/static/js/linestylefilter.js`\n\nThis hook is called when attributes are investigated on a line. It is useful if\nyou want to add another attribute type or property type to a pad.\n\nAn attributes object is passed to the aceAttribClasses hook functions instead of\nthe usual context object. A hook function can either modify this object directly\nor provide an object whose properties will be assigned to the attributes object.\n\nExample:\n\n```js\nexports.aceAttribClasses = (hookName, attrs, cb) => {\n  return cb([{\n    sub: 'tag:sub',\n  }]);\n};\n```\n\n## exportFileName\nCalled from `src/node/handler/ExportHandler.js`\n\nThings in context:\n\n1. padId\n\nThis hook will allow a plug-in developer to modify the file name of an exported pad.  This is useful if you want to export a pad under another name and/or hide the padId under export.  Note that the doctype or file extension cannot be modified for security reasons.\n\nExample:\n\n```js\nexports.exportFileName = function(hook, padId, callback){\n  callback(\"newFileName\"+padId);\n}\n```\n\n## exportHtmlAdditionalTags\nCalled from `src/node/utils/ExportHtml.js`\n\nThings in context:\n\n1. Pad object\n\nThis hook will allow a plug-in developer to include more properties and attributes to support during HTML Export. If tags are stored as `['color', 'red']` on the attribute pool, use `exportHtmlAdditionalTagsWithData` instead. An Array should be returned.\n\nExample:\n```js\n// Add the props to be supported in export\nexports.exportHtmlAdditionalTags = function(hook, pad, cb){\n  var padId = pad.id;\n  cb([\"massive\",\"jugs\"]);\n};\n```\n\n## exportHtmlAdditionalTagsWithData\nCalled from `src/node/utils/ExportHtml.js`\n\nThings in context:\n\n1. Pad object\n\nIdentical to `exportHtmlAdditionalTags`, but for tags that are stored with a specific value (not simply `true`) on the attribute pool. For example `['color', 'red']`, instead of `['bold', true]`. This hook will allow a plug-in developer to include more properties and attributes to support during HTML Export. An Array of arrays should be returned. The exported HTML will contain tags like `<span data-color=\"red\">` for the content where attributes are `['color', 'red']`.\n\nExample:\n```js\n// Add the props to be supported in export\nexports.exportHtmlAdditionalTagsWithData = function(hook, pad, cb){\n  var padId = pad.id;\n  cb([[\"color\", \"red\"], [\"color\", \"blue\"]]);\n};\n```\n\n## `exportEtherpadAdditionalContent`\n\nCalled from `src/node/utils/ExportEtherpad.js` and\n`src/node/utils/ImportEtherpad.js`.\n\nCalled when exporting to an `.etherpad` file or when importing from an\n`.etherpad` file. The hook function should return prefixes for pad-specific\nrecords that should be included in the export/import. On export, all\n`${prefix}:${padId}` and `${prefix}:${padId}:*` records are included in the\ngenerated `.etherpad` file. On import, all `${prefix}:${padId}` and\n`${prefix}:${padId}:*` records are loaded into the database.\n\nContext properties: None.\n\nExample:\n\n```js\n// Add support for exporting comments metadata\nexports.exportEtherpadAdditionalContent = () => ['comments'];\n```\n\n## `exportEtherpad`\n\nCalled from `src/node/utils/ExportEtherpad.js`.\n\nCalled when exporting to an `.etherpad` file.\n\nContext properties:\n\n* `pad`: The exported pad's Pad object.\n* `data`: JSONable output object. This is pre-populated with records from core\n  Etherpad as well as pad-specific records with prefixes from the\n  `exportEtherpadAdditionalContent` hook. Registered hook functions can modify\n  this object (but not replace the object) to perform any desired\n  transformations to the exported data (such as the inclusion of\n  plugin-specific records). All registered hook functions are executed\n  concurrently, so care should be taken to avoid race conditions with other\n  plugins.\n* `dstPadId`: The pad ID that should be used when writing pad-specific records\n  to `data` (instead of `pad.id`). This avoids leaking the writable pad ID\n  when a user exports a read-only pad. This might be a dummy value; plugins\n  should not assume that it is either the pad's real writable ID or its\n  read-only ID.\n\n## `importEtherpad`\n\nCalled from `src/node/utils/ImportEtherpad.js`.\n\nCalled when importing from an `.etherpad` file.\n\nContext properties:\n\n* `pad`: Temporary Pad object containing the pad's data read from the imported\n  `.etherpad` file. The `pad.db` object is a temporary in-memory database\n  whose records will be copied to the real database after they are validated\n  (see the `padCheck` hook). Registered hook functions MUST NOT use the real\n  database to access (read or write) pad-specific records; they MUST instead\n  use `pad.db`. All registered hook functions are executed concurrently, so\n  care should be taken to avoid race conditions with other plugins.\n* `data`: Raw JSONable object from the `.etherpad` file. This data must not be\n  modified.\n* `srcPadId`: The pad ID used for the pad-specific information in `data`.\n\n## `import`\n\nCalled from: `src/node/handler/ImportHandler.js`\n\nCalled when a user submits a document for import, before the document is\nconverted to HTML. The hook function should return a truthy value if the hook\nfunction elected to convert the document to HTML.\n\nContext properties:\n\n* `destFile`: The destination HTML filename.\n* `fileEnding`: The lower-cased filename extension from `srcFile` **with leading\n  period** (examples: `'.docx'`, `'.html'`, `'.etherpad'`).\n* `padId`: The identifier of the destination pad.\n* `srcFile`: The document to convert.\n* `ImportError`: Subclass of Error that can be thrown to provide a specific\n  error message to the user. The constructor's first argument must be a string\n  matching one of the [known error\n  identifiers](https://github.com/ether/etherpad-lite/blob/1.8.16/src/static/js/pad_impexp.js#L80-L86).\n\nExample:\n\n```javascript\nexports.import = async (hookName, {fileEnding, ImportError}) => {\n  // Reject all *.etherpad imports with a permission denied message.\n  if (fileEnding === '.etherpad') throw new ImportError('permission');\n};\n```\n\n## `userJoin`\n\nCalled from: `src/node/handler/PadMessageHandler.js`\n\nCalled after users have been notified that a new user has joined the pad.\n\nContext properties:\n\n* `authorId`: The user's author identifier.\n* `displayName`: The user's display name.\n* `padId`: The real (not read-only) identifier of the pad the user joined. This\n  MUST NOT be shared with any users that are connected with read-only access.\n* `readOnly`: Whether the user only has read-only access.\n* `readOnlyPadId`: The read-only identifier of the pad the user joined.\n* `socket`: The socket.io Socket object.\n\nExample:\n\n```javascript\nexports.userJoin = async (hookName, {authorId, displayName, padId}) => {\n  console.log(`${authorId} (${displayName}) joined pad ${padId});\n};\n```\n\n## `userLeave`\n\nCalled from: `src/node/handler/PadMessageHandler.js`\n\nCalled when a user disconnects from a pad. This is useful if you want to perform\ncertain actions after a pad has been edited.\n\nContext properties:\n\n* `authorId`: The user's author ID.\n* `padId`: The pad's real (not read-only) identifier.\n* `readOnly`: If truthy, the user only has read-only access.\n* `readOnlyPadId`: The pad's read-only identifier.\n* `socket`: The socket.io Socket object.\n\nExample:\n\n```javascript\nexports.userLeave = async (hookName, {author, padId}) => {\n  console.log(`${author} left pad ${padId}`);\n};\n```\n\n## `chatNewMessage`\n\nCalled from: `src/node/handler/PadMessageHandler.js`\n\nCalled when a user (or plugin) generates a new chat message, just before it is\nsaved to the pad and relayed to all connected users.\n\nContext properties:\n\n* `message`: The chat message object. Plugins can mutate this object to change\n  the message text or add custom metadata to control how the message will be\n  rendered by the `chatNewMessage` client-side hook. The message's `authorId`\n  property can be trusted (the server overwrites any client-provided author ID\n  value with the user's actual author ID before this hook runs).\n* `padId`: The pad's real (not read-only) identifier.\n* `pad`: The pad's Pad object.\n"
  },
  {
    "path": "doc/api/http_api.adoc",
    "content": "== HTTP API\n\n=== What can I do with this API?\nThe API gives another web application control of the pads. The basic functions are\n\n* create/delete pads\n* grant/forbid access to pads\n* get/set pad content\n\nThe API is designed in a way, so you can reuse your existing user system with their permissions, and map it to Etherpad. Means: Your web application still has to do authentication, but you can tell Etherpad via the api, which visitors should get which permissions. This allows Etherpad to fit into any web application and extend it with real-time functionality. You can embed the pads via an iframe into your website.\n\nTake a look at https://github.com/ether/etherpad-lite/wiki/HTTP-API-client-libraries[HTTP API client libraries] to check if a library in your favorite programming language is available.\n\n==== OpenAPI\n\nOpenAPI (formerly swagger) definitions are exposed under `/api/openapi.json` (latest) and `/api/{version}/openapi.json`. You can use official tools like https://editor.swagger.io/[Swagger Editor] to view and explore them.\n\n=== Examples\n\n==== Example 1\n\nA portal (such as WordPress) wants to give a user access to a new pad. Let's assume the user have the internal id 7 and his name is michael.\n\nPortal maps the internal userid to an etherpad author.\n\n> Request: `http://pad.domain/api/1/createAuthorIfNotExistsFor?apikey=secret&name=Michael&authorMapper=7`\n>\n> Response: `{code: 0, message:\"ok\", data: {authorID: \"a.s8oes9dhwrvt0zif\"}}`\n\nPortal maps the internal userid to an etherpad group:\n\n> Request: `http://pad.domain/api/1/createGroupIfNotExistsFor?apikey=secret&groupMapper=7`\n>\n> Response: `{code: 0, message:\"ok\", data: {groupID: \"g.s8oes9dhwrvt0zif\"}}`\n\nPortal creates a pad in the userGroup\n\n> Request: `http://pad.domain/api/1/createGroupPad?apikey=secret&groupID=g.s8oes9dhwrvt0zif&padName=samplePad&text=This is the first sentence in the pad`\n>\n> Response: `{code: 0, message:\"ok\", data: null}`\n\nPortal starts the session for the user on the group:\n\n> Request: `http://pad.domain/api/1/createSession?apikey=secret&groupID=g.s8oes9dhwrvt0zif&authorID=a.s8oes9dhwrvt0zif&validUntil=1312201246`\n>\n> Response: `{\"data\":{\"sessionID\": \"s.s8oes9dhwrvt0zif\"}}`\n\nPortal places the cookie \"sessionID\" with the given value on the client and creates an iframe including the pad.\n\n==== Example 2\n\nA portal (such as WordPress) wants to transform the contents of a pad that multiple admins edited into a blog post.\n\nPortal retrieves the contents of the pad for entry into the db as a blog post:\n\n> Request: `http://pad.domain/api/1/getText?apikey=secret&padID=g.s8oes9dhwrvt0zif$123`\n>\n> Response: `{code: 0, message:\"ok\", data: {text:\"Welcome Text\"}}`\n\nPortal submits content into new blog post\n\n> Portal.AddNewBlog(content)\n>\n\n=== Usage\n\n==== API version\nThe latest version is `1.2.15`\n\nThe current version can be queried via /api.\n\n==== Request Format\n\nThe API is accessible via HTTP. Starting from **1.8**, API endpoints can be invoked indifferently via GET or POST.\n\nThe URL of the HTTP request is of the form: `/api/$APIVERSION/$FUNCTIONNAME`. $APIVERSION depends on the endpoint you want to use. Depending on the verb you use (GET or POST) **parameters** can be passed differently.\n\nWhen invoking via GET (mandatory until **1.7.5** included), parameters must be included in the query string (example: `/api/$APIVERSION/$FUNCTIONNAME?apikey=<APIKEY>&param1=value1`). Please note that starting with nodejs 8.14+ the total size of HTTP request headers has been capped to 8192 bytes. This limits the quantity of data that can be sent in an API request.\n\nStarting from Etherpad **1.8** it is also possible to invoke the HTTP API via POST. In this case, querystring parameters will still be accepted, but **any parameter with the same name sent via POST will take precedence**. If you need to send large chunks of text (for example, for `setText()`) it is advisable to invoke via POST.\n\nExample with cURL using GET (toy example, no encoding):\n\n[source,bash]\n----\ncurl \"http://pad.domain/api/1/setText?apikey=secret&padID=padname&text=this_text_will_NOT_be_encoded_by_curl_use_next_example\"\n----\n\nExample with cURL using GET (better example, encodes text):\n\n[source,bash]\n----\ncurl \"http://pad.domain/api/1/setText?apikey=secret&padID=padname\" --get --data-urlencode \"text=Text sent via GET with proper encoding. For big documents, please use POST\"\n----\n\nExample with cURL using POST:\n\n[source,bash]\n----\ncurl \"http://pad.domain/api/1/setText?apikey=secret&padID=padname\" --data-urlencode \"text=Text sent via POST with proper encoding. For big texts (>8 KB), use this method\"\n----\n\n==== Response Format\nResponses are valid JSON in the following format:\n\n[source,jsonlines]\n----\n{\n  \"code\": number,\n  \"message\": string,\n  \"data\": obj\n}\n----\n\n* **code** a return code\n  * **0** everything ok\n  * **1** wrong parameters\n  * **2** internal error\n  * **3** no such function\n  * **4** no or wrong API Key\n* **message** a status message. It's ok if everything is fine, else it contains an error message\n* **data** the payload\n\n==== Overview\n\nimage::https://i.imgur.com/d0nWp.png[API Overview]\n\n=== Data Types\n\n* **groupID**  a string, the unique id of a group. Format is g.16RANDOMCHARS, for example g.s8oes9dhwrvt0zif\n* **sessionID** a string, the unique id of a session. Format is s.16RANDOMCHARS, for example s.s8oes9dhwrvt0zif\n* **authorID** a string, the unique id of an author. Format is a.16RANDOMCHARS, for example a.s8oes9dhwrvt0zif\n* **readOnlyID** a string, the unique id of a readonly relation to a pad. Format is r.16RANDOMCHARS, for example r.s8oes9dhwrvt0zif\n* **padID** a string, format is GROUPID$PADNAME, for example the pad test of group g.s8oes9dhwrvt0zif has padID g.s8oes9dhwrvt0zif$test\n\n==== Authentication\n\nAuthentication works via a token that is sent with each request as a post parameter.  There is a single token per Etherpad deployment.  This token will be random string, generated by Etherpad at the first start. It will be saved in APIKEY.txt in the root folder of Etherpad. Only Etherpad and the requesting application knows this key. Token management will not be exposed through this API.\n\n==== Node Interoperability\n\nAll functions will also be available through a node module accessible from other node.js applications.\n\n=== API Methods\n\n==== Groups\nPads can belong to a group. The padID of grouppads is starting with a groupID like g.asdfasdfasdfasdf$test\n\n===== createGroup()\n * API >= 1\n\ncreates a new group\n\n_Example returns:_\n\n* `{code: 0, message:\"ok\", data: {groupID: g.s8oes9dhwrvt0zif}}`\n\n===== createGroupIfNotExistsFor(groupMapper)\n * API >= 1\n\nthis functions helps you to map your application group ids to Etherpad group ids\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {groupID: g.s8oes9dhwrvt0zif}}`\n\n===== deleteGroup(groupID)\n * API >= 1\n\ndeletes a group\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: null}`\n  * `{code: 1, message:\"groupID does not exist\", data: null}`\n\n===== listPads(groupID)\n * API >= 1\n\nreturns all pads of this group\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {padIDs : [\"g.s8oes9dhwrvt0zif$test\", \"g.s8oes9dhwrvt0zif$test2\"]}`\n  * `{code: 1, message:\"groupID does not exist\", data: null}`\n\n===== createGroupPad(groupID, padName, [text], [authorId])\n * API >= 1\n * `authorId` in API >= 1.3.0\n\ncreates a new pad in this group\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {padID: \"g.s8oes9dhwrvt0zif$test\"}`\n  * `{code: 1, message:\"padName does already exist\", data: null}`\n  * `{code: 1, message:\"groupID does not exist\", data: null}`\n\n===== listAllGroups()\n * API >= 1.1\n\nlists all existing groups\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {groupIDs: [\"g.mKjkmnAbSMtCt8eL\", \"g.3ADWx6sbGuAiUmCy\"]}}`\n  * `{code: 0, message:\"ok\", data: {groupIDs: []}}`\n\n==== Author\nThese authors are bound to the attributes the users choose (color and name).\n\n===== createAuthor([name])\n * API >= 1\n\ncreates a new author\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {authorID: \"a.s8oes9dhwrvt0zif\"}}`\n\n===== createAuthorIfNotExistsFor(authorMapper [, name])\n * API >= 1\n\nthis functions helps you to map your application author ids to Etherpad author ids\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {authorID: \"a.s8oes9dhwrvt0zif\"}}`\n\n===== listPadsOfAuthor(authorID)\n * API >= 1\n\nreturns an array of all pads this author contributed to\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {padIDs: [\"g.s8oes9dhwrvt0zif$test\", \"g.s8oejklhwrvt0zif$foo\"]}}`\n  * `{code: 1, message:\"authorID does not exist\", data: null}`\n\n===== getAuthorName(authorID)\n * API >= 1.1\n\nReturns the Author Name of the author\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {authorName: \"John McLear\"}}`\n\n-> can't be deleted cause this would involve scanning all the pads where this author was\n\n==== Session\nSessions can be created between a group and an author. This allows an author to access more than one group. The sessionID will be set as a cookie to the client and is valid until a certain date. The session cookie can also contain multiple comma-separated sessionIDs, allowing a user to edit pads in different groups at the same time. Only users with a valid session for this group, can access group pads. You can create a session after you authenticated the user at your web application, to give them access to the pads. You should save the sessionID of this session and delete it after the user logged out.\n\n===== createSession(groupID, authorID, validUntil)\n * API >= 1\n\ncreates a new session. validUntil is an unix timestamp in seconds\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {sessionID: \"s.s8oes9dhwrvt0zif\"}}`\n  * `{code: 1, message:\"groupID doesn't exist\", data: null}`\n  * `{code: 1, message:\"authorID doesn't exist\", data: null}`\n  * `{code: 1, message:\"validUntil is in the past\", data: null}`\n\n===== deleteSession(sessionID)\n * API >= 1\n\ndeletes a session\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: null}`\n  * `{code: 1, message:\"sessionID does not exist\", data: null}`\n\n===== getSessionInfo(sessionID)\n * API >= 1\n\nreturns information about a session\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {authorID: \"a.s8oes9dhwrvt0zif\", groupID: g.s8oes9dhwrvt0zif, validUntil: 1312201246}}`\n  * `{code: 1, message:\"sessionID does not exist\", data: null}`\n\n===== listSessionsOfGroup(groupID)\n * API >= 1\n\nreturns all sessions of a group\n\n_Example returns:_\n\n  * `{\"code\":0,\"message\":\"ok\",\"data\":{\"s.oxf2ras6lvhv2132\":{\"groupID\":\"g.s8oes9dhwrvt0zif\",\"authorID\":\"a.akf8finncvomlqva\",\"validUntil\":2312905480}}}`\n  * `{code: 1, message:\"groupID does not exist\", data: null}`\n\n===== listSessionsOfAuthor(authorID)\n * API >= 1\n\nreturns all sessions of an author\n\n_Example returns:_\n\n  * `{\"code\":0,\"message\":\"ok\",\"data\":{\"s.oxf2ras6lvhv2132\":{\"groupID\":\"g.s8oes9dhwrvt0zif\",\"authorID\":\"a.akf8finncvomlqva\",\"validUntil\":2312905480}}}`\n  * `{code: 1, message:\"authorID does not exist\", data: null}`\n\n==== Pad Content\n\nPad content can be updated and retrieved through the API\n\n===== getText(padID, [rev])\n * API >= 1\n\nreturns the text of a pad\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {text:\"Welcome Text\"}}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n\n===== setText(padID, text, [authorId])\n * API >= 1\n * `authorId` in API >= 1.3.0\n\nSets the text of a pad.\n\nIf your text is long (>8 KB), please invoke via POST and include `text` parameter in the body of the request, not in the URL (since Etherpad **1.8**).\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: null}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n  * `{code: 1, message:\"text too long\", data: null}`\n\n===== appendText(padID, text, [authorId])\n * API >= 1.2.13\n * `authorId` in API >= 1.3.0\n\nAppends text to a pad.\n\nIf your text is long (>8 KB), please invoke via POST and include `text` parameter in the body of the request, not in the URL (since Etherpad **1.8**).\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: null}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n  * `{code: 1, message:\"text too long\", data: null}`\n\n===== getHTML(padID, [rev])\n * API >= 1\n\nreturns the text of a pad formatted as HTML\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {html:\"Welcome Text<br>More Text\"}}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n\n===== setHTML(padID, html, [authorId])\n * API >= 1\n * `authorId` in API >= 1.3.0\n\nsets the text of a pad based on HTML, HTML must be well-formed. Malformed HTML will send a warning to the API log.\n\nIf `html` is long (>8 KB), please invoke via POST and include `html` parameter in the body of the request, not in the URL (since Etherpad **1.8**).\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: null}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n\n===== getAttributePool(padID)\n * API >= 1.2.8\n\nreturns the attribute pool of a pad\n\n_Example returns:_\n\n  * `{ \"code\":0,\n       \"message\":\"ok\",\n       \"data\": {\n         \"pool\":{\n           \"numToAttrib\":{\n             \"0\":[\"author\",\"a.X4m8bBWJBZJnWGSh\"],\n             \"1\":[\"author\",\"a.TotfBPzov54ihMdH\"],\n             \"2\":[\"author\",\"a.StiblqrzgeNTbK05\"],\n             \"3\":[\"bold\",\"true\"]\n           },\n           \"attribToNum\":{\n             \"author,a.X4m8bBWJBZJnWGSh\":0,\n             \"author,a.TotfBPzov54ihMdH\":1,\n             \"author,a.StiblqrzgeNTbK05\":2,\n             \"bold,true\":3\n           },\n           \"nextNum\":4\n         }\n       }\n     }`\n  * `{\"code\":1,\"message\":\"padID does not exist\",\"data\":null}`\n\n===== getRevisionChangeset(padID, [rev])\n * API >= 1.2.8\n\nget the changeset at a given revision, or last revision if 'rev' is not defined.\n\n_Example returns:_\n\n  * `{ \"code\" : 0,\n       \"message\" : \"ok\",\n       \"data\" : \"Z:1>6b|5+6b$Welcome to Etherpad!\\n\\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\\n\\nGet involved with Etherpad at https://etherpad.org\\n\"\n     }`\n  * `{\"code\":1,\"message\":\"padID does not exist\",\"data\":null}`\n  * `{\"code\":1,\"message\":\"rev is higher than the head revision of the pad\",\"data\":null}`\n\n===== createDiffHTML(padID, startRev, endRev)\n * API >= 1.2.7\n\nreturns an object of diffs from 2 points in a pad\n\n_Example returns:_\n\n  * `{\"code\":0,\"message\":\"ok\",\"data\":{\"html\":\"<style>\\n.authora_HKIv23mEbachFYfH {background-color: #a979d9}\\n.authora_n4gEeMLsv1GivNeh {background-color: #a9b5d9}\\n.removed {text-decoration: line-through; -ms-filter:'progid:DXImageTransform.Microsoft.Alpha(Opacity=80)'; filter: alpha(opacity=80); opacity: 0.8; }\\n</style>Welcome to Etherpad!<br><br>This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!<br><br>Get involved with Etherpad at <a href=\\\"http&#x3a;&#x2F;&#x2F;etherpad&#x2e;org\\\">http:&#x2F;&#x2F;etherpad.org</a><br><span class=\\\"authora_HKIv23mEbachFYfH\\\">aw</span><br><br>\",\"authors\":[\"a.HKIv23mEbachFYfH\",\"\"]}}`\n  * `{\"code\":4,\"message\":\"no or wrong API Key\",\"data\":null}`\n\n===== restoreRevision(padId, rev, [authorId])\n * API >= 1.2.11\n * `authorId` in API >= 1.3.0\n\nRestores revision from past as new changeset\n\n_Example returns:_\n\n  * {code:0, message:\"ok\", data:null}\n  * {code: 1, message:\"padID does not exist\", data: null}\n\n==== Chat\n\n===== getChatHistory(padID, [start, end])\n * API >= 1.2.7\n\nreturns\n\n* a part of the chat history, when `start` and `end` are given\n* the whole chat history, when no extra parameters are given\n\n\n_Example returns:_\n\n* `{\"code\":0,\"message\":\"ok\",\"data\":{\"messages\":[{\"text\":\"foo\",\"userId\":\"a.foo\",\"time\":1359199533759,\"userName\":\"test\"},{\"text\":\"bar\",\"userId\":\"a.foo\",\"time\":1359199534622,\"userName\":\"test\"}]}}`\n* `{code: 1, message:\"start is higher or equal to the current chatHead\", data: null}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n===== getChatHead(padID)\n * API >= 1.2.7\n\nreturns the chatHead (last number of the last chat-message) of the pad\n\n\n_Example returns:_\n\n* `{code: 0, message:\"ok\", data: {chatHead: 42}}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n===== appendChatMessage(padID, text, authorID [, time])\n * API >= 1.2.12\n\ncreates a chat message, saves it to the database and sends it to all connected clients of this pad\n\n\n_Example returns:_\n\n* `{code: 0, message:\"ok\", data: null}`\n* `{code: 1, message:\"text is no string\", data: null}`\n\n=== Pad\nGroup pads are normal pads, but with the name schema GROUPID$PADNAME. A security manager controls access of them and it's forbidden for normal pads to include a $ in the name.\n\n==== createPad(padID, [text], [authorId])\n * API >= 1\n * `authorId` in API >= 1.3.0\n\ncreates a new (non-group) pad.  Note that if you need to create a group Pad, you should call **createGroupPad**.\nYou get an error message if you use one of the following characters in the padID: \"/\", \"?\", \"&\" or \"#\".\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: null}`\n  * `{code: 1, message:\"padID does already exist\", data: null}`\n  * `{code: 1, message:\"malformed padID: Remove special characters\", data: null}`\n\n==== getRevisionsCount(padID)\n * API >= 1\n\nreturns the number of revisions of this pad\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {revisions: 56}}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n\n==== getSavedRevisionsCount(padID)\n * API >= 1.2.11\n\nreturns the number of saved revisions of this pad\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {savedRevisions: 42}}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n\n==== listSavedRevisions(padID)\n * API >= 1.2.11\n\nreturns the list of saved revisions of this pad\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {savedRevisions: [2, 42, 1337]}}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n\n==== saveRevision(padID [, rev])\n * API >= 1.2.11\n\nsaves a revision\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: null}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n\n==== padUsersCount(padID)\n * API >= 1\n\nreturns the number of user that are currently editing this pad\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {padUsersCount: 5}}`\n\n==== padUsers(padID)\n * API >= 1.1\n\nreturns the list of users that are currently editing this pad\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {padUsers: [{colorId:\"#c1a9d9\",\"name\":\"username1\",\"timestamp\":1345228793126,\"id\":\"a.n4gEeMLsvg12452n\"},{\"colorId\":\"#d9a9cd\",\"name\":\"Hmmm\",\"timestamp\":1345228796042,\"id\":\"a.n4gEeMLsvg12452n\"}]}}`\n  * `{code: 0, message:\"ok\", data: {padUsers: []}}`\n\n==== deletePad(padID)\n * API >= 1\n\ndeletes a pad\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: null}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n\n==== copyPad(sourceID, destinationID[, force=false])\n * API >= 1.2.8\n\ncopies a pad with full history and chat. If force is true and the destination pad exists, it will be overwritten.\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: null}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n\n==== copyPadWithoutHistory(sourceID, destinationID, [force=false], [authorId])\n* API >= 1.2.15\n * `authorId` in API >= 1.3.0\n\ncopies a pad without copying the history and chat. If force is true and the destination pad exists, it will be overwritten.\nNote that all the revisions will be lost! In most of the cases one should use `copyPad` API instead.\n\n_Example returns:_\n\n* `{code: 0, message:\"ok\", data: null}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n==== movePad(sourceID, destinationID[, force=false])\n * API >= 1.2.8\n\nmoves a pad. If force is true and the destination pad exists, it will be overwritten.\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: null}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n\n==== getReadOnlyID(padID)\n * API >= 1\n\nreturns the read only link of a pad\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {readOnlyID: \"r.s8oes9dhwrvt0zif\"}}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n\n==== getPadID(readOnlyID)\n * API >= 1.2.10\n\nreturns the id of a pad which is assigned to the readOnlyID\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {padID: \"p.s8oes9dhwrvt0zif\"}}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n\n==== setPublicStatus(padID, publicStatus)\n * API >= 1\n\nsets a boolean for the public status of a group pad\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: null}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n  * `{code: 1, message:\"You can only get/set the publicStatus of pads that belong to a group\", data: null}`\n\n==== getPublicStatus(padID)\n * API >= 1\n\nreturn true of false\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {publicStatus: true}}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n  * `{code: 1, message:\"You can only get/set the publicStatus of pads that belong to a group\", data: null}`\n\n==== listAuthorsOfPad(padID)\n * API >= 1\n\nreturns an array of authors who contributed to this pad\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {authorIDs : [\"a.s8oes9dhwrvt0zif\", \"a.akf8finncvomlqva\"]}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n\n==== getLastEdited(padID)\n * API >= 1\n\nreturns the timestamp of the last revision of the pad\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {lastEdited: 1340815946602}}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n\n==== sendClientsMessage(padID, msg)\n * API >= 1.1\n\nsends a custom message of type `msg` to the pad\n\n_Example returns:_\n\n  * `{code: 0, message:\"ok\", data: {}}`\n  * `{code: 1, message:\"padID does not exist\", data: null}`\n\n==== checkToken()\n * API >= 1.2\n\nreturns ok when the current api token is valid\n\n_Example returns:_\n\n  * `{\"code\":0,\"message\":\"ok\",\"data\":null}`\n  * `{\"code\":4,\"message\":\"no or wrong API Key\",\"data\":null}`\n\n=== Pads\n\n==== listAllPads()\n * API >= 1.2.1\n\nlists all pads on this epl instance\n\n_Example returns:_\n\n * `{code: 0, message:\"ok\", data: {padIDs: [\"testPad\", \"thePadsOfTheOthers\"]}}`\n\n==== Global\n\n===== getStats()\n *  API >= 1.2.14\n\nget stats of the etherpad instance\n\n_Example returns_:\n\n * `{\"code\":0,\"message\":\"ok\",\"data\":{\"totalPads\":3,\"totalSessions\": 2,\"totalActivePads\": 1}}`\n"
  },
  {
    "path": "doc/api/http_api.md",
    "content": "# HTTP API\n\n## What can I do with this API?\n\nThe API gives another web application control of the pads. The basic functions are\n\n* create/delete pads\n* grant/forbid access to pads\n* get/set pad content\n\nThe API is designed in a way, so you can reuse your existing user system with their permissions, and map it to Etherpad. Means: Your web application still has to do authentication, but you can tell Etherpad via the api, which visitors should get which permissions. This allows Etherpad to fit into any web application and extend it with real-time functionality. You can embed the pads via an iframe into your website.\n\nTake a look at [HTTP API client libraries](https://github.com/ether/etherpad-lite/wiki/HTTP-API-client-libraries) to check if a library in your favorite programming language is available.\n\n### OpenAPI\n\nOpenAPI (formerly swagger) definitions are exposed under `/api/openapi.json` (latest) and `/api/{version}/openapi.json`. You can use official tools like [Swagger Editor](https://editor.swagger.io/) to view and explore them.\n\n## Examples\n\n### Example 1\n\nA portal (such as WordPress) wants to give a user access to a new pad. Let's assume the user have the internal id 7 and his name is michael.\n\nPortal maps the internal userid to an etherpad author.\n\n\n#### Request\n\n```http\nGET /api/1/createAuthorIfNotExistsFor?name=Michael&authorMapper=7\n```\n\n\n### Response\n\n```json\n{\"code\": 0, \"message\":\"ok\", \"data\": {\"authorID\": \"a.s8oes9dhwrvt0zif\"}}\n```\n\n#### Request\n> Portal maps the internal userid to an etherpad group:\n\n```http\nGET http://pad.domain/api/1/createGroupIfNotExistsFor?groupMapper=7\n```\n\n### Response\n\n```json\n{\"code\": 0, \"message\":\"ok\", \"data\": {\"groupID\": \"g.s8oes9dhwrvt0zif\"}}\n```\n\n> Portal creates a pad in the userGroup\n\n#### Request\n\n```http\nGET http://pad.domain/api/1/createGroupPad?groupID=g.s8oes9dhwrvt0zif&padName=samplePad&text=This is the first sentence in the pad\n```\n\n#### Response\n\n```json\n{\"code\": 0, \"message\":\"ok\", \"data\": null}\n```\n\n> Portal starts the session for the user on the group:\n\n#### Request\n\n```http\nGET http://pad.domain/api/1/createSession?groupID=g.s8oes9dhwrvt0zif&authorID=a.s8oes9dhwrvt0zif&validUntil=1312201246\n```\n\n### Response\n    \n```json\n{\"code\": 0, \"message\":\"ok\", \"data\": {\"sessionID\": \"s.s8oes9dhwrvt0zif\"}}\n```\n\nPortal places the cookie \"sessionID\" with the given value on the client and creates an iframe including the pad.\n\n### Example 2\n\nA portal (such as WordPress) wants to transform the contents of a pad that multiple admins edited into a blog post.\n\nPortal retrieves the contents of the pad for entry into the db as a blog post:\n\n> Request: `http://pad.domain/api/1/getText?&padID=g.s8oes9dhwrvt0zif$123`\n>\n> Response: `{code: 0, message:\"ok\", data: {text:\"Welcome Text\"}}`\n\nPortal submits content into new blog post\n\n> Portal.AddNewBlog(content)\n\n## Usage\n\n### API version\nThe latest version is `1.2.15`\n\nThe current version can be queried via /api.\n\n### Request Format\n\nThe API is accessible via HTTP. Starting from **1.8**, API endpoints can be invoked indifferently via GET or POST.\n\nThe URL of the HTTP request is of the form: `/api/$APIVERSION/$FUNCTIONNAME`. $APIVERSION depends on the endpoint you want to use. Depending on the verb you use (GET or POST) **parameters** can be passed differently.\n\nWhen invoking via GET (mandatory until **1.7.5** included), parameters must be included in the query string (example: `/api/$APIVERSION/$FUNCTIONNAME?param1=value1`). Please note that starting with nodejs 8.14+ the total size of HTTP request headers has been capped to 8192 bytes. This limits the quantity of data that can be sent in an API request.\n\nStarting from Etherpad **1.8** it is also possible to invoke the HTTP API via POST. In this case, querystring parameters will still be accepted, but **any parameter with the same name sent via POST will take precedence**. If you need to send large chunks of text (for example, for `setText()`) it is advisable to invoke via POST.\n\nExample with cURL using GET (toy example, no encoding):\n```\ncurl \"http://pad.domain/api/1/setText?padID=padname&text=this_text_will_NOT_be_encoded_by_curl_use_next_example\"\n```\n\nExample with cURL using GET (better example, encodes text):\n```\ncurl \"http://pad.domain/api/1/setText?padID=padname\" --get --data-urlencode \"text=Text sent via GET with proper encoding. For big documents, please use POST\"\n```\n\nExample with cURL using POST:\n```\ncurl \"http://pad.domain/api/1/setText?padID=padname\" --data-urlencode \"text=Text sent via POST with proper encoding. For big texts (>8 KB), use this method\"\n```\n\n### Response Format\nResponses are valid JSON in the following format:\n\n```json\n{\n  \"code\": number,\n  \"message\": string,\n  \"data\": obj\n}\n```\n\n* **code** a return code\n  * **0** everything ok\n  * **1** wrong parameters\n  * **2** internal error\n  * **3** no such function\n  * **4** no or wrong API Key\n* **message** a status message. It's ok if everything is fine, else it contains an error message\n* **data** the payload\n\n### Overview\n\n![API Overview](https://i.imgur.com/d0nWp.png)\n\n## Data Types\n\n* **groupID**  a string, the unique id of a group. Format is g.16RANDOMCHARS, for example g.s8oes9dhwrvt0zif\n* **sessionID** a string, the unique id of a session. Format is s.16RANDOMCHARS, for example s.s8oes9dhwrvt0zif\n* **authorID** a string, the unique id of an author. Format is a.16RANDOMCHARS, for example a.s8oes9dhwrvt0zif\n* **readOnlyID** a string, the unique id of a readonly relation to a pad. Format is r.16RANDOMCHARS, for example r.s8oes9dhwrvt0zif\n* **padID** a string, format is GROUPID$PADNAME, for example the pad test of group g.s8oes9dhwrvt0zif has padID g.s8oes9dhwrvt0zif$test\n\n### Authentication\n\nAuthentication works via an OAuth token that is sent with each request as an Authorization header, i.e. `Authorization: Bearer YOUR_TOKEN`. You can add new clients that can sign in via the API by adding new entries to the sso section in the settings.json.\n\n\n#### Example for browser login clients\n\nThis example illustrates how to add a new client that can sign in via the API using the browser login method. This method is used for users trying to sign in to the API via the browser. You can log in with the users in the settings.json file. The redirect URI is the URL where the user is redirected after the login. This is normally your etherpad instance url.\n\n```json\n      {\n        \"client_id\": \"admin_client\",\n        \"client_secret\": \"admin\",\n        \"grant_types\": [\"authorization_code\"],\n        \"response_types\": [\"code\"],\n        \"redirect_uris\": [\"http://my-etherpad-instance.com\"],\n      }\n```\n\n\n#### Example for services\n\nThis example illustrates how to add a new client that can sign in via the API using the client credentials method. This method is used for services trying to sign in to the API where there is no browser.\nE.g. a service that creates a pad for a user or a service that inserts a text into a pad. Just make sure that the secret is complex enough as anybody who knows the secret can access the API.\n\n```json\n      {\n  \"client_id\": \"client_credentials\",\n  \"redirect_uris\": [],\n  \"response_types\": [],\n  \"grant_types\": [\"code\"],\n  \"client_secret\": \"client_credentials\",\n  \"extraParams\": [\n    {\n      \"name\": \"admin\",\n      \"value\": \"true\"\n    }\n  ]\n}\n```\n\nObtain a Bearer token:\n\n`curl --request POST --url 'https://your.server.tld/oidc/token' --header 'content-type: application/x-www-form-urlencoded' --data grant_type=client_credentials --data client_id=client_credentials --data client_secret=client_credentials`\n\n\n### Node Interoperability\n\nAll functions will also be available through a node module accessible from other node.js applications.\n\n## API Methods\n\n### Groups\nPads can belong to a group. The padID of grouppads is starting with a groupID like `g.asdfasdfasdfasdf$test`\n\n#### createGroup()\n* API >= 1\n\ncreates a new group\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {groupID: g.s8oes9dhwrvt0zif}}`\n\n#### createGroupIfNotExistsFor(groupMapper)\n* API >= 1\n\nthis functions helps you to map your application group ids to Etherpad group ids\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {groupID: g.s8oes9dhwrvt0zif}}`\n\n#### deleteGroup(groupID)\n* API >= 1\n\ndeletes a group\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: null}`\n* `{code: 1, message:\"groupID does not exist\", data: null}`\n\n#### listPads(groupID)\n* API >= 1\n\nreturns all pads of this group\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {padIDs : [\"g.s8oes9dhwrvt0zif$test\", \"g.s8oes9dhwrvt0zif$test2\"]}`\n* `{code: 1, message:\"groupID does not exist\", data: null}`\n\n#### createGroupPad(groupID, padName, [text], [authorId])\n* API >= 1\n* `authorId` in API >= 1.3.0\n\ncreates a new pad in this group\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {padID: \"g.s8oes9dhwrvt0zif$test\"}`\n* `{code: 1, message:\"padName does already exist\", data: null}`\n* `{code: 1, message:\"groupID does not exist\", data: null}`\n\n#### listAllGroups()\n* API >= 1.1\n\nlists all existing groups\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {groupIDs: [\"g.mKjkmnAbSMtCt8eL\", \"g.3ADWx6sbGuAiUmCy\"]}}`\n* `{code: 0, message:\"ok\", data: {groupIDs: []}}`\n\n### Author\nThese authors are bound to the attributes the users choose (color and name).\n\n#### createAuthor([name])\n* API >= 1\n\ncreates a new author\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {authorID: \"a.s8oes9dhwrvt0zif\"}}`\n\n#### createAuthorIfNotExistsFor(authorMapper [, name])\n* API >= 1\n\nthis functions helps you to map your application author ids to Etherpad author ids\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {authorID: \"a.s8oes9dhwrvt0zif\"}}`\n\n#### listPadsOfAuthor(authorID)\n* API >= 1\n\nreturns an array of all pads this author contributed to\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {padIDs: [\"g.s8oes9dhwrvt0zif$test\", \"g.s8oejklhwrvt0zif$foo\"]}}`\n* `{code: 1, message:\"authorID does not exist\", data: null}`\n\n#### getAuthorName(authorID)\n* API >= 1.1\n\nReturns the Author Name of the author\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {authorName: \"John McLear\"}}`\n\n-> can't be deleted cause this would involve scanning all the pads where this author was\n\n### Session\nSessions can be created between a group and an author. This allows an author to access more than one group. The sessionID will be set as a cookie to the client and is valid until a certain date. The session cookie can also contain multiple comma-separated sessionIDs, allowing a user to edit pads in different groups at the same time. Only users with a valid session for this group, can access group pads. You can create a session after you authenticated the user at your web application, to give them access to the pads. You should save the sessionID of this session and delete it after the user logged out.\n\n#### createSession(groupID, authorID, validUntil)\n* API >= 1\n\ncreates a new session. validUntil is an unix timestamp in seconds\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {sessionID: \"s.s8oes9dhwrvt0zif\"}}`\n* `{code: 1, message:\"groupID doesn't exist\", data: null}`\n* `{code: 1, message:\"authorID doesn't exist\", data: null}`\n* `{code: 1, message:\"validUntil is in the past\", data: null}`\n\n\n#### deleteSession(sessionID)\n* API >= 1\n\ndeletes a session\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: null}`\n* `{code: 1, message:\"sessionID does not exist\", data: null}`\n\n#### getSessionInfo(sessionID)\n* API >= 1\n\nreturns information about a session\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {authorID: \"a.s8oes9dhwrvt0zif\", groupID: g.s8oes9dhwrvt0zif, validUntil: 1312201246}}`\n* `{code: 1, message:\"sessionID does not exist\", data: null}`\n\n#### listSessionsOfGroup(groupID)\n* API >= 1\n\nreturns all sessions of a group\n\n*Example returns:*\n* `{\"code\":0,\"message\":\"ok\",\"data\":{\"s.oxf2ras6lvhv2132\":{\"groupID\":\"g.s8oes9dhwrvt0zif\",\"authorID\":\"a.akf8finncvomlqva\",\"validUntil\":2312905480}}}`\n* `{code: 1, message:\"groupID does not exist\", data: null}`\n\n#### listSessionsOfAuthor(authorID)\n* API >= 1\n\nreturns all sessions of an author\n\n*Example returns:*\n* `{\"code\":0,\"message\":\"ok\",\"data\":{\"s.oxf2ras6lvhv2132\":{\"groupID\":\"g.s8oes9dhwrvt0zif\",\"authorID\":\"a.akf8finncvomlqva\",\"validUntil\":2312905480}}}`\n* `{code: 1, message:\"authorID does not exist\", data: null}`\n\n### Pad Content\n\nPad content can be updated and retrieved through the API\n\n#### getText(padID, [rev])\n* API >= 1\n\nreturns the text of a pad\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {text:\"Welcome Text\"}}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### setText(padID, text, [authorId])\n* API >= 1\n* `authorId` in API >= 1.3.0\n\nSets the text of a pad.\n\nIf your text is long (>8 KB), please invoke via POST and include `text` parameter in the body of the request, not in the URL (since Etherpad **1.8**).\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: null}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n* `{code: 1, message:\"text too long\", data: null}`\n\n#### appendText(padID, text, [authorId])\n* API >= 1.2.13\n* `authorId` in API >= 1.3.0\n\n\nAppends text to a pad.\n\nIf your text is long (>8 KB), please invoke via POST and include `text` parameter in the body of the request, not in the URL (since Etherpad **1.8**).\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: null}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n* `{code: 1, message:\"text too long\", data: null}`\n\n#### getHTML(padID, [rev])\n* API >= 1\n\nreturns the text of a pad formatted as HTML\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {html:\"Welcome Text<br>More Text\"}}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### setHTML(padID, html, [authorId])\n* API >= 1\n* `authorId` in API >= 1.3.0\n\nsets the text of a pad based on HTML, HTML must be well-formed. Malformed HTML will send a warning to the API log.\n\nIf `html` is long (>8 KB), please invoke via POST and include `html` parameter in the body of the request, not in the URL (since Etherpad **1.8**).\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: null}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### getAttributePool(padID)\n* API >= 1.2.8\n\nreturns the attribute pool of a pad\n\n*Example returns:*\n* `{ \"code\":0,\n  \"message\":\"ok\",\n  \"data\": {\n  \"pool\":{\n  \"numToAttrib\":{\n  \"0\":[\"author\",\"a.X4m8bBWJBZJnWGSh\"],\n  \"1\":[\"author\",\"a.TotfBPzov54ihMdH\"],\n  \"2\":[\"author\",\"a.StiblqrzgeNTbK05\"],\n  \"3\":[\"bold\",\"true\"]\n  },\n  \"attribToNum\":{\n  \"author,a.X4m8bBWJBZJnWGSh\":0,\n  \"author,a.TotfBPzov54ihMdH\":1,\n  \"author,a.StiblqrzgeNTbK05\":2,\n  \"bold,true\":3\n  },\n  \"nextNum\":4\n  }\n  }\n  }`\n* `{\"code\":1,\"message\":\"padID does not exist\",\"data\":null}`\n\n#### getRevisionChangeset(padID, [rev])\n* API >= 1.2.8\n\nget the changeset at a given revision, or last revision if 'rev' is not defined.\n\n*Example returns:*\n* `{ \"code\" : 0,\n  \"message\" : \"ok\",\n  \"data\" : \"Z:1>6b|5+6b$Welcome to Etherpad!\\n\\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\\n\\nGet involved with Etherpad at https://etherpad.org\\n\"\n  }`\n* `{\"code\":1,\"message\":\"padID does not exist\",\"data\":null}`\n* `{\"code\":1,\"message\":\"rev is higher than the head revision of the pad\",\"data\":null}`\n\n#### createDiffHTML(padID, startRev, endRev)\n* API >= 1.2.7\n\nreturns an object of diffs from 2 points in a pad\n\n*Example returns:*\n* `{\"code\":0,\"message\":\"ok\",\"data\":{\"html\":\"<style>\\n.authora_HKIv23mEbachFYfH {background-color: #a979d9}\\n.authora_n4gEeMLsv1GivNeh {background-color: #a9b5d9}\\n.removed {text-decoration: line-through; -ms-filter:'progid:DXImageTransform.Microsoft.Alpha(Opacity=80)'; filter: alpha(opacity=80); opacity: 0.8; }\\n</style>Welcome to Etherpad!<br><br>This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!<br><br>Get involved with Etherpad at <a href=\\\"http&#x3a;&#x2F;&#x2F;etherpad&#x2e;org\\\">http:&#x2F;&#x2F;etherpad.org</a><br><span class=\\\"authora_HKIv23mEbachFYfH\\\">aw</span><br><br>\",\"authors\":[\"a.HKIv23mEbachFYfH\",\"\"]}}`\n* `{\"code\":4,\"message\":\"no or wrong API Key\",\"data\":null}`\n\n#### restoreRevision(padId, rev, [authorId])\n* API >= 1.2.11\n* `authorId` in API >= 1.3.0\n\n* Example returns:*\n* `{code:0, message:\"ok\", data:null}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n### Chat\n#### getChatHistory(padID, [start, end])\n* API >= 1.2.7\n\nreturns\n\n* a part of the chat history, when `start` and `end` are given\n* the whole chat history, when no extra parameters are given\n\n\n*Example returns:*\n\n* `{\"code\":0,\"message\":\"ok\",\"data\":{\"messages\":[{\"text\":\"foo\",\"userId\":\"a.foo\",\"time\":1359199533759,\"userName\":\"test\"},{\"text\":\"bar\",\"userId\":\"a.foo\",\"time\":1359199534622,\"userName\":\"test\"}]}}`\n* `{code: 1, message:\"start is higher or equal to the current chatHead\", data: null}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### getChatHead(padID)\n* API >= 1.2.7\n\nreturns the chatHead (last number of the last chat-message) of the pad\n\n\n*Example returns:*\n\n* `{code: 0, message:\"ok\", data: {chatHead: 42}}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### appendChatMessage(padID, text, authorID [, time])\n* API >= 1.2.12\n\ncreates a chat message, saves it to the database and sends it to all connected clients of this pad\n\n*Example returns:*\n\n* `{code: 0, message:\"ok\", data: null}`\n* `{code: 1, message:\"text is no string\", data: null}`\n\n### Pad\nGroup pads are normal pads, but with the name schema GROUPID$PADNAME. A security manager controls access of them and it's forbidden for normal pads to include a $ in the name.\n\n#### createPad(padID, [text], [authorId])\n* API >= 1\n* `authorId` in API >= 1.3.0\n\ncreates a new (non-group) pad.  Note that if you need to create a group Pad, you should call **createGroupPad**.\nYou get an error message if you use one of the following characters in the padID: \"/\", \"?\", \"&\" or \"#\".\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: null}`\n* `{code: 1, message:\"padID does already exist\", data: null}`\n* `{code: 1, message:\"malformed padID: Remove special characters\", data: null}`\n\n#### getRevisionsCount(padID)\n* API >= 1\n\nreturns the number of revisions of this pad\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {revisions: 56}}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### getSavedRevisionsCount(padID)\n* API >= 1.2.11\n\nreturns the number of saved revisions of this pad\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {savedRevisions: 42}}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### listSavedRevisions(padID)\n* API >= 1.2.11\n\nreturns the list of saved revisions of this pad\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {savedRevisions: [2, 42, 1337]}}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### saveRevision(padID [, rev])\n* API >= 1.2.11\n\nsaves a revision\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: null}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### padUsersCount(padID)\n* API >= 1\n\nreturns the number of user that are currently editing this pad\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {padUsersCount: 5}}`\n\n#### padUsers(padID)\n* API >= 1.1\n\nreturns the list of users that are currently editing this pad\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {padUsers: [{colorId:\"#c1a9d9\",\"name\":\"username1\",\"timestamp\":1345228793126,\"id\":\"a.n4gEeMLsvg12452n\"},{\"colorId\":\"#d9a9cd\",\"name\":\"Hmmm\",\"timestamp\":1345228796042,\"id\":\"a.n4gEeMLsvg12452n\"}]}}`\n* `{code: 0, message:\"ok\", data: {padUsers: []}}`\n\n#### deletePad(padID)\n* API >= 1\n\ndeletes a pad\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: null}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### copyPad(sourceID, destinationID[, force=false])\n* API >= 1.2.8\n\ncopies a pad with full history and chat. If force is true and the destination pad exists, it will be overwritten.\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: null}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### copyPadWithoutHistory(sourceID, destinationID, [force=false], [authorId])\n* API >= 1.2.15\n* `authorId` in API >= 1.3.0\n\ncopies a pad without copying the history and chat. If force is true and the destination pad exists, it will be overwritten.\nNote that all the revisions will be lost! In most of the cases one should use `copyPad` API instead.\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: null}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### movePad(sourceID, destinationID[, force=false])\n* API >= 1.2.8\n\nmoves a pad. If force is true and the destination pad exists, it will be overwritten.\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: null}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### getReadOnlyID(padID)\n* API >= 1\n\nreturns the read only link of a pad\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {readOnlyID: \"r.s8oes9dhwrvt0zif\"}}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### getPadID(readOnlyID)\n* API >= 1.2.10\n\nreturns the id of a pad which is assigned to the readOnlyID\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {padID: \"p.s8oes9dhwrvt0zif\"}}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### setPublicStatus(padID, publicStatus)\n* API >= 1\n\nsets a boolean for the public status of a group pad\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: null}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n* `{code: 1, message:\"You can only get/set the publicStatus of pads that belong to a group\", data: null}`\n\n#### getPublicStatus(padID)\n* API >= 1\n\nreturn true of false\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {publicStatus: true}}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n* `{code: 1, message:\"You can only get/set the publicStatus of pads that belong to a group\", data: null}`\n\n#### listAuthorsOfPad(padID)\n* API >= 1\n\nreturns an array of authors who contributed to this pad\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {authorIDs : [\"a.s8oes9dhwrvt0zif\", \"a.akf8finncvomlqva\"]}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### getLastEdited(padID)\n* API >= 1\n\nreturns the timestamp of the last revision of the pad\n\n*Example returns:*\n* `{code: 0, message:\"ok\", data: {lastEdited: 1340815946602}}`\n* `{code: 1, message:\"padID does not exist\", data: null}`\n\n#### sendClientsMessage(padID, msg)\n* API >= 1.1\n\nsends a custom message of type `msg` to the pad\n\n*Example returns:*\n```json\n{\"code\": 0, \"message\":\"ok\", \"data\": {}}\n```\n\n```json\n{\"code\": 1, \"message\":\"padID does not exist\", \"data\": null}\n```\n\n#### checkToken()\n* API >= 1.2\n\nreturns ok when the current api token is valid\n\n*Example returns:*\n```json\n{\"code\":0,\"message\":\"ok\",\"data\":null}\n```\n```json\n{\"code\":4,\"message\":\"no or wrong API Key\",\"data\":null}\n```\n\n### Pads\n\n#### listAllPads()\n* API >= 1.2.1\n\nlists all pads on this epl instance\n\n*Example returns:*\n```json\n{\"code\": 0, \"message\":\"ok\", \"data\": {\"padIDs\": [\"testPad\", \"thePadsOfTheOthers\"]}}\n```\n\n### Global\n\n#### getStats()\n*  API >= 1.2.14\n\nget stats of the etherpad instance\n\n*Example returns*\n```json\n{\"code\":0,\"message\":\"ok\",\"data\":{\"totalPads\":3,\"totalSessions\": 2,\"totalActivePads\": 1}}\n```\n\n"
  },
  {
    "path": "doc/api/index.md",
    "content": "# API documentation\n\nThis part of the documentation is about the API of Etherpad. It is intended for developers who want to write applications that interact with Etherpad instances or plugins.\n"
  },
  {
    "path": "doc/api/pluginfw.adoc",
    "content": "== Plugin Framework\n\n`require(\"ep_etherpad-lite/static/js/plugingfw/plugins\")`\n\n=== plugins.update\n\n`require(\"ep_etherpad-lite/static/js/plugingfw/plugins\").update()` will use npm\nto list all installed modules and read their ep.json files, registering the\ncontained hooks. A hook registration is a pair of a hook name and a function\nreference (filename for require() plus function name)\n\n=== hooks.callAll\n\n`require(\"ep_etherpad-lite/static/js/plugingfw/hooks\").callAll(\"hook_name\",\n{argname:value})` will call all hook functions registered for `hook_name` with\n`{argname:value}`.\n\n=== hooks.aCallAll\n\n?\n\n=== ...\n"
  },
  {
    "path": "doc/api/pluginfw.md",
    "content": "# Plugin Framework\n\n`require(\"ep_etherpad-lite/static/js/plugingfw/plugins\")`\n\n## plugins.update\n\n`require(\"ep_etherpad-lite/static/js/plugingfw/plugins\").update()` will use npm\nto list all installed modules and read their ep.json files, registering the\ncontained hooks. A hook registration is a pair of a hook name and a function\nreference (filename for require() plus function name)\n\n## hooks.callAll\n\n`require(\"ep_etherpad-lite/static/js/plugingfw/hooks\").callAll(\"hook_name\",\n{argname:value})` will call all hook functions registered for `hook_name` with\n`{argname:value}`.\n\n## hooks.aCallAll\n\n?\n\n## ...\n"
  },
  {
    "path": "doc/api/toolbar.adoc",
    "content": "== Toolbar controller\nsrc/node/utils/toolbar.js\n\n=== button(opts)\n * {Object} `opts`\n   * `command` - this command fill be fired on the editbar on click\n   * `localizationId` - will be set as `data-l10-id`\n   * `class` - here you can add additional classes to the button\n\nReturns: {Button}\n\nExample:\n\n[source, javascript]\n----\nvar orderedlist = toolbar.button({\n  command: \"insertorderedlist\",\n  localizationId: \"pad.toolbar.ol.title\",\n  class: \"buttonicon buttonicon-insertorderedlist\"\n})\n----\n\nYou can also create buttons with text:\n\n[source, javascript]\n----\nvar myButton = toolbar.button({\n  command: \"myButton\",\n  localizationId: \"myPlugin.toolbar.myButton\",\n  class: \"buttontext\"\n})\n----\n\n=== selectButton(opts)\n * {Object} `opts`\n   * `id` - id of the menu item\n   * `selectId` - id of the select element\n   * `command` - this command fill be fired on the editbar on change\n\nReturns: {SelectButton}\n\n=== SelectButton.addOption(value, text, attributes)\n * {String} value - The value of this option\n * {String} text - the label text used for this option\n * {Object} attributes - any additional html attributes go here (e.g. `data-l10n-id`)\n\n=== registerButton(name, item)\n  * {String} name - used to reference the item in the toolbar config in settings.json\n  * {Button|SelectButton} item - the button to add\n"
  },
  {
    "path": "doc/api/toolbar.md",
    "content": "# Toolbar controller\nsrc/node/utils/toolbar.js\n\n## button(opts)\n* {Object} `opts`\n    * `command` - this command fill be fired on the editbar on click\n    * `localizationId` - will be set as `data-l10-id`\n    * `class` - here you can add additional classes to the button\n\nReturns: {Button}\n\nExample:\n```\nvar orderedlist = toolbar.button({\n  command: \"insertorderedlist\",\n  localizationId: \"pad.toolbar.ol.title\",\n  class: \"buttonicon buttonicon-insertorderedlist\"\n})\n```\n\nYou can also create buttons with text:\n\n```\nvar myButton = toolbar.button({\n  command: \"myButton\",\n  localizationId: \"myPlugin.toolbar.myButton\",\n  class: \"buttontext\"\n})\n```\n\n## selectButton(opts)\n* {Object} `opts`\n    * `id` - id of the menu item\n    * `selectId` - id of the select element\n    * `command` - this command fill be fired on the editbar on change\n\nReturns: {SelectButton}\n\n## SelectButton.addOption(value, text, attributes)\n* {String} value - The value of this option\n* {String} text - the label text used for this option\n* {Object} attributes - any additional html attributes go here (e.g. `data-l10n-id`)\n\n## registerButton(name, item)\n* {String} name - used to reference the item in the toolbar config in settings.json\n* {Button|SelectButton} item - the button to add\n"
  },
  {
    "path": "doc/assets/style.css",
    "content": "body {\n  border-top: solid #44b492 5pt;\n  line-height: 150%;\n  font-family: \"Quicksand\", sans-serif;\n  color: #313b4a;\n  max-width: 1440px;\n  margin: 0 auto;\n  padding: 20px;\n}\n\na {\n  color: #555;\n}\n\nh1,\nh2 {\n  color: #44b492;\n  line-height: 100%;\n}\n\nh2 {\n  font-size: 48px;\n}\n\nh3 {\n  font-size: 1.8rem;\n}\n\nh4 {\n  font-size: 1.5rem;\n}\n\nh5 {\n  font-size: 1.2rem;\n}\n\na:hover {\n  color: #44b492;\n}\n\npre {\n  background-color: #e0e0e0;\n  padding: 20px;\n}\n\ncode {\n  background-color: #e0e0e0;\n}\n\nimg {\n  max-width: 100%;\n}\n\ntable,\nth,\ntd {\n  text-align: left;\n  border: 1px solid gray;\n  border-collapse: collapse;\n}\n\nth {\n  padding: 0.5em;\n  background: #eee;\n}\n\ntd {\n  padding: 0.5em;\n}\n"
  },
  {
    "path": "doc/cli.md",
    "content": "# CLI\n\nYou can find different tools for migrating things, checking your Etherpad health in the bin directory.\nOne of these is the migrateDB command. It takes two settings.json files and copies data from one source to another one.\nIn this example we migrate from the old dirty db to the new rustydb engine. So we copy these files to the root of the etherpad-directory.\n\n````json\n{\n  \"dbType\": \"dirty\",\n  \"dbSettings\": {\n    \"filename\": \"./var/rusty.db\"\n  }\n}\n````\n\n\n\n````json\n{\n  \"dbType\": \"rustydb\",\n  \"dbSettings\": {\n    \"filename\": \"./var/rusty2.db\"\n  }\n}\n````\n\n\nAfter that we need to move the data from dirty to rustydb.\nTherefore, we call `pnpm run --filter bin migrateDB --file1 test1.json --file2 test2.json` with these two files in our root directories. After some time the data should be copied over to the new database.\n"
  },
  {
    "path": "doc/cookies.adoc",
    "content": "== Cookies\nCookies used by Etherpad.\n\n[cols=\"1,1,1,1,1,1,1,1\"]\n|===\n\n| Name\n| Sample value\n| Domain\n| Path\n| Expires/max-age\n| Http-only\n| Secure\n|  Usage description\n|express_sid\n| s%3A7yCNjRmTW8ylGQ53I2IhOwYF9...\n| example.org\n|/\n| Session\n| true\n| true\n| Session ID of the https://expressjs.com[Express web framework]. When Etherpad is behind a reverse proxy, and an administrator wants to use session stickiness, he may use this cookie. If you are behind a reverse proxy, please remember to set `trustProxy: true` in `settings.json`. Set in https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/node/hooks/express/webaccess.js#L131[webaccess.js#L131].\n\n\n|language\n| en\n| example.org\n| /\n| Session\n| false\n| true\n| The language of the UI (e.g.: `en-GB`, `it`). Set in https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad_editor.js#L111[pad_editor.js#L111].\n\n\n|prefs / prefsHttp\n| %7B%22epThemesExtTheme%22...\n| example.org\n| /p\n| year 3000\n| false\n| true\n| Client-side preferences (e.g.: font family, chat always visible, show authorship colors, ...). Set in https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad_cookie.js#L49[pad_cookie.js#L49]. `prefs` is used if Etherpad is accessed over HTTPS, `prefsHttp` if accessed over HTTP. For more info see https://github.com/ether/etherpad-lite/issues/3179.\n\n\n\n|token\n| t.tFzkihhhBf4xKEpCK3PU\n| example.org\n| /\n| 60 days\n| false\n| true\n| A random token representing the author, of the form `t.randomstring_of_lenght_20`. The random string is generated by the client, at https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad.js#L55-L66[pad.js#L55-L66]. This cookie is always set by the client at https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad.js#L153-L158[pad.js#L153-L158] without any solicitation from the server. It is used for all the pads accessed via the web UI (not used for the HTTP API). On the server side, its value is accessed at https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/node/db/SecurityManager.js#L33[SecurityManager.js#L33].\n|===\n\nFor more info, visit the related discussion at https://github.com/ether/etherpad-lite/issues/3563.\n\nEtherpad HTTP API clients may make use (if they choose so) to send another cookie:\n\n\n[cols=\"1,1,1\"]\n|===\n\n| Name\n| Sample value\n| Domain\n| Usage description\n\n\n| sessionID\n| s.1c70968b333b25476a2c7bdd0e0bed17\n| example.org\n| Sessions can be created between a group and an author. This allows an author to access more than one group. The sessionID will be set as a cookie to the client and is valid until a certain date. The session cookie can also contain multiple comma-separated sessionIDs, allowing a user to edit pads in different groups at the same time. More info - https://github.com/ether/etherpad-lite/blob/develop/doc/api/http_api.md#session\n|===\n"
  },
  {
    "path": "doc/cookies.md",
    "content": "# Cookies\n\nCookies used by Etherpad.\n\n| Name              | Sample value                     | Domain      | Path | Expires/max-age | Http-only | Secure | Usage description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |\n|-------------------|----------------------------------|-------------|------|-----------------|-----------|--------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| express_sid       | s%3A7yCNjRmTW8ylGQ53I2IhOwYF9... | example.org | /    | Session         | true      | true   | Session ID of the [Express web framework](https://expressjs.com). When Etherpad is behind a reverse proxy, and an administrator wants to use session stickiness, he may use this cookie. If you are behind a reverse proxy, please remember to set `trustProxy: true` in `settings.json`. Set in [webaccess.js#L131](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/node/hooks/express/webaccess.js#L131).                                                                                                                                                                                                                                                                                                                                       |\n| language          | en                               | example.org | /    | Session         | false     | true   | The language of the UI (e.g.: `en-GB`, `it`). Set in [pad_editor.js#L111](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad_editor.js#L111).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |\n| prefs / prefsHttp | %7B%22epThemesExtTheme%22...     | example.org | /p   | year 3000       | false     | true   | Client-side preferences (e.g.: font family, chat always visible, show authorship colors, ...). Set in [pad_cookie.js#L49](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad_cookie.js#L49). `prefs` is used if Etherpad is accessed over HTTPS, `prefsHttp` if accessed over HTTP. For more info see https://github.com/ether/etherpad-lite/issues/3179.                                                                                                                                                                                                                                                                                                                                                                              |\n| token             | t.tFzkihhhBf4xKEpCK3PU           | example.org | /    | 60 days         | false     | true   | A random token representing the author, of the form `t.randomstring_of_lenght_20`. The random string is generated by the client, at ([pad.js#L55-L66](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad.js#L55-L66)). This cookie is always set by the client (at [pad.js#L153-L158](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad.js#L153-L158)) without any solicitation from the server. It is used for all the pads accessed via the web UI (not used for the HTTP API). On the server side, its value is accessed at [SecurityManager.js#L33](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/node/db/SecurityManager.js#L33). |\n\nFor more info, visit the related discussion at https://github.com/ether/etherpad-lite/issues/3563.\n\nEtherpad HTTP API clients may make use (if they choose so) to send another cookie:\n\n| Name      | Sample value                       | Domain      | Usage description                                                                                                                                                                                                                                                                                                                                                                                                                          |\n|-----------|------------------------------------|-------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| sessionID | s.1c70968b333b25476a2c7bdd0e0bed17 | example.org | Sessions can be created between a group and an author. This allows an author to access more than one group. The sessionID will be set as a cookie to the client and is valid until a certain date. The session cookie can also contain multiple comma-separated sessionIDs, allowing a user to edit pads in different groups at the same time. More info - https://github.com/ether/etherpad-lite/blob/develop/doc/api/http_api.md#session |\n"
  },
  {
    "path": "doc/database.adoc",
    "content": "== Database structure\n\n=== Keys and their values\n\n==== groups\nA list of all existing groups (a JSON object with groupIDs as keys and `1` as values).\n\n==== pad:$PADID\nContains all information about pads\n\n* **atext** - the latest attributed text\n* **pool** - the attribute pool\n* **head** - the number of the latest revision\n* **chatHead** - the number of the latest chat entry\n* **public** - flag that disables security for this pad\n* **passwordHash** - string that contains a salted sha512 sum of this pad's password\n\n==== pad:$PADID:revs:$REVNUM\nSaves a revision $REVNUM of pad $PADID\n\n* **meta**\n  * **author** - the autorID of this revision\n  * **timestamp** - the timestamp of when this revision was created\n* **changeset** - the changeset of this revision\n\n==== pad:$PADID:chat:$CHATNUM\nSaves a chat entry with num $CHATNUM of pad $PADID\n\n* **text** - the text of this chat entry\n* **userId** - the authorID of this chat entry\n* **time** - the timestamp of this chat entry\n\n==== pad2readonly:$PADID\nTranslates a padID to a readonlyID\n\n==== readonly2pad:$READONLYID\nTranslates a readonlyID to a padID\n\n==== token2author:$TOKENID\nTranslates a token to an authorID\n\n==== globalAuthor:$AUTHORID\nInformation about an author\n\n* **name** - the name of this author as shown in the pad\n* **colorID** - the colorID of this author as shown in the pad\n\n==== mapper2group:$MAPPER\nMaps an external application identifier to an internal group\n\n==== mapper2author:$MAPPER\nMaps an external application identifier to an internal author\n\n==== group:$GROUPID\na group of pads\n\n* **pads** - object with pad names in it, values are 1\n==== session:$SESSIONID\na session between an author and a group\n\n* **groupID** - the groupID the session belongs too\n* **authorID** - the authorID the session belongs too\n* **validUntil** - the timestamp until this session is valid\n\n==== author2sessions:$AUTHORID\nsaves the sessions of an author\n\n* **sessionsIDs** - object with sessionIDs in it, values are 1\n\n==== group2sessions:$GROUPID\n\n* **sessionsIDs** - object with sessionIDs in it, values are 1\n"
  },
  {
    "path": "doc/demo.md",
    "content": "# Demo of Etherpad\n\nThis is a demo of Etherpad. It shows how to use Etherpad and what it can do.\n\n## The toolbar\n\n![The toolbar](/etherpad_basic.png)\n\n## The pad\n\n![The pad](/etherpad_demo.gif)\n\n## Etherpad with a virtual webcam\n\n![Etherpad full features](/etherpad_full_features.png)\n\n## Etherpad skin variants\n\n![Etherpad skin variants](/etherpad_skin_variants.gif)\n"
  },
  {
    "path": "doc/docker.adoc",
    "content": "== Docker\n\nThe official Docker image is available on https://hub.docker.com/r/etherpad/etherpad.\n\n=== Downloading from Docker Hub\nIf you are ok downloading a https://hub.docker.com/r/etherpad/etherpad[prebuilt image from Docker Hub], these are the commands:\n\n[source, bash]\n----\n# gets the latest published version\ndocker pull etherpad/etherpad\n\n# gets a specific version\ndocker pull etherpad/etherpad:1.8.0\n----\n\n=== Build a personalized container\n\nIf you want to use a personalized settings file, **you will have to rebuild your image**.\nAll of the following instructions are as a member of the `docker` group.\nBy default, the Etherpad Docker image is built and run in `production` mode: no development dependencies are installed, and asset bundling speeds up page load time.\n\n=== Building and running with docker compose\nA docker compose file is provided in the project. Please first copy `.env.default` to `.env` and adjust the variables to your preference.\n\n```\ndocker compose up -d # will build and start the docker container on port 9001 with development settings.\n```\n\nStarting dev server:\n\n```\ndocker compose exec app bash -c \"./bin/run.sh\"\n```\n\nFor production, please create your own docker compose file and change the `target` property in the build section to `production`. In addition, change the NODE_ENV in environment to production. For instance:\n\n```\ndocker compose -f docker-compose-production.yml up -d\n```\n\nFor plugins, please add them in the build section under ETHERPAD_PLUGINS, for instance:\n\n```\n     args:\n        ETHERPAD_PLUGINS: >-\n          ep_image_upload\n          ep_embedded_hyperlinks2\n          ep_headings2\n          ep_align\n\t\t\t\t\t...\n```\n\n==== Rebuilding with custom settings\nEdit `<BASEDIR>/settings.json.docker` at your will. When rebuilding the image, this file will be copied inside your image and renamed to `settings.json`.\n\n**Each configuration parameter can also be set via an environment variable**, using the syntax `\"${ENV_VAR}\"` or `\"${ENV_VAR:default_value}\"`. For details, refer to `settings.json.template`.\n\n==== Rebuilding including some plugins\nIf you want to install some plugins in your container, it is sufficient to list them in the ETHERPAD_PLUGINS build variable.\nThe variable value has to be a space separated, double quoted list of plugin names (see examples).\n\nSome plugins will need personalized settings. Just refer to the previous section, and include them in your custom `settings.json.docker`.\n\n==== Rebuilding including export functionality for DOC/PDF/ODT\n\nIf you want to be able to export your pads to DOC/PDF/ODT files, you can install\neither Abiword or Libreoffice via setting a build variable.\n\n===== Via Abiword\n\nFor installing Abiword, set the `INSTALL_ABIWORD` build variable to any value.\n\nAlso, you will need to configure the path to the abiword executable\nvia setting the `abiword` property in `<BASEDIR>/settings.json.docker` to\n`/usr/bin/abiword` or via setting the environment variable  `ABIWORD` to\n`/usr/bin/abiword`.\n\n===== Via Libreoffice\n\nFor installing Libreoffice instead, set the `INSTALL_SOFFICE` build variable\nto any value.\n\nAlso, you will need to configure the path to the libreoffice executable\nvia setting the `soffice` property in `<BASEDIR>/settings.json.docker` to\n`/usr/bin/soffice` or via setting the environment variable  `SOFFICE` to\n`/usr/bin/soffice`.\n\n==== Examples\n\nBuild a Docker image from the currently checked-out code:\n\n[source,bash]\n----\ndocker build --tag <YOUR_USERNAME>/etherpad .\n----\n\nInclude two plugins in the container:\n\n[source,bash]\n----\ndocker build --build-arg ETHERPAD_PLUGINS=\"ep_comments_page ep_author_neat\" --tag <YOUR_USERNAME>/etherpad .\n----\n\n=== Running your instance:\n\nTo run your instance:\n\n[source,bash]\n----\ndocker run --detach --publish <DESIRED_PORT>:9001 <YOUR_USERNAME>/etherpad\n----\n\nAnd point your browser to `http://<YOUR_IP>:<DESIRED_PORT>`\n\n=== Options available by default\n\nThe `settings.json.docker` available by default allows to control almost every setting via environment variables.\n\n==== General\n\n[cols=\"1,1,1\"]\n|===\n| Variable\n| Description\n| Default\n| `TITLE`\n| The name of the instance\n| `Etherpad`\n\n| `FAVICON`\n| favicon default name, or a fully specified URL to your own favicon\n| `favicon.ico`\n| `DEFAULT_PAD_TEXT`\n| The default text of a pad\n| `Welcome to Etherpad! This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents! Get involved with Etherpad at https://etherpad.org`\n\n| `IP`\n| IP which etherpad should bind at. Change to `::` for IPv6\n| `0.0.0.0`\n| `PORT`\n| port which etherpad should bind at\n| `9001`\n| `ADMIN_PASSWORD`\n| the password for the `admin` user (leave unspecified if you do not want to create it)\n|\n| `USER_PASSWORD`\n| the password for the first user `user` (leave unspecified if you do not want to create it)\n|\n|===\n\n==== Database\n\n[cols=\"1,1,1\"]\n|===\n| Variable\n| Description\n| Default\n\n| `DB_TYPE`     | a database supported by https://www.npmjs.com/package/ueberdb2 | not set, thus will fall back to `DirtyDB` (please choose one instead)\n| `DB_HOST`     | the host of the database\n|\n\n| `DB_PORT`\n| the port of the database\n|\n\n| `DB_NAME`\n| the database name\n|\n\n| `DB_USER`\n| a database user with sufficient permissions to create tables\n|\n\n| `DB_PASS`\n| the password for the database username\n|\n\n| `DB_CHARSET`\n| the character set for the tables (only required for MySQL)\n|\n\n| `DB_FILENAME`\n| in case `DB_TYPE` is `DirtyDB` or `sqlite`, the database file.\n| `var/dirty.db`, `var/etherpad.sq3`\n|===\n\nIf your database needs additional settings, you will have to use a personalized `settings.json.docker` and rebuild the container (or otherwise put the updated `settings.json` inside your image).\n\n\n==== Pad Options\n\n[cols=\"1,1,1\"]\n|===\n\n| Variable\n| Description\n| Default\n\n\n| `PAD_OPTIONS_NO_COLORS`\n|\n| `false`\n\n\n| `PAD_OPTIONS_SHOW_CONTROLS`\n|\n| `true`\n\n| `PAD_OPTIONS_SHOW_CHAT`\n|\n| `true`\n\n| `PAD_OPTIONS_SHOW_LINE_NUMBERS`\n|\n| `true`\n\n| `PAD_OPTIONS_USE_MONOSPACE_FONT`\n|\n| `false`\n\n| `PAD_OPTIONS_USER_NAME`\n|\n| `null`\n\n| `PAD_OPTIONS_USER_COLOR`\n|\n| `null`\n\n| `PAD_OPTIONS_RTL`\n|\n| `false`\n\n| `PAD_OPTIONS_ALWAYS_SHOW_CHAT`\n|\n| `false`\n\n| `PAD_OPTIONS_CHAT_AND_USERS`\n|\n|  `false`\n\n| `PAD_OPTIONS_LANG`\n|\n| `null`\n|===\n\n==== Shortcuts\n\n[cols=\"1,1,1\"]\n|===\n| Variable\n| Description\n| Default\n\n\n| `PAD_SHORTCUTS_ENABLED_ALT_F9`\n| focus on the File Menu and/or editbar\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_ALT_C`\n| focus on the Chat window\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_CMD_S`\n| save a revision\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_CMD_Z`\n| undo/redo\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_CMD_Y`\n| redo\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_CMD_I`\n| italic\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_CMD_B`\n| bold\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_CMD_U`\n| underline\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_CMD_H`\n| backspace\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_CMD_5`\n| strike through\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_CMD_SHIFT_1`\n| ordered list\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_CMD_SHIFT_2`\n| shows a gritter popup showing a line author\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_CMD_SHIFT_L`\n| unordered list\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_CMD_SHIFT_N`\n| ordered list\n|`true`\n\n| `PAD_SHORTCUTS_ENABLED_CMD_SHIFT_C`\n| clear authorship\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_DELETE`\n|\n| `true`\n\n\n| `PAD_SHORTCUTS_ENABLED_RETURN`\n|\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_ESC`\n| in mozilla versions 14-19 avoid reconnecting pad\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_TAB`\n| indent\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_CTRL_HOME`\n| scroll to top of pad\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_PAGE_UP`\n|\n| `true`\n\n| `PAD_SHORTCUTS_ENABLED_PAGE_DOWN`\n|\n| `true`\n|===\n\n==== Skins\n\nYou can use the UI skin variants builder at `/p/test#skinvariantsbuilder`\n\nFor the colibris skin only, you can choose how to render the three main containers:\n  * toolbar (top menu with icons)\n  * editor (containing the text of the pad)\n  * background (area outside of editor, mostly visible when using page style)\n\nFor each of the 3 containers you can choose 4 color combinations:\n   * super-light\n   * light\n   * dark\n   * super-dark\n\nFor the editor container, you can also make it full width by adding `full-width-editor` variant (by default editor is rendered as a page, with a max-width of 900px).\n\n[cols=\"1,1,1\"]\n|===\n| Variable\n| Description\n| Default\n\n| `SKIN_NAME`\n| either `no-skin`, `colibris` or an existing directory under `src/static/skins`\n| `colibris`\n\n| `SKIN_VARIANTS`\n| multiple skin variants separated by spaces\n| `super-light-toolbar super-light-editor light-background`\n|===\n\n==== Logging\n\n[cols=\"1,1,1\"]\n|===\n| Variable\n| Description\n| Default\n\n\n| `LOGLEVEL`\n| valid values are `DEBUG`, `INFO`, `WARN` and `ERROR` | `INFO`\n\n| `DISABLE_IP_LOGGING`\n| Privacy: disable IP logging\n| `false`\n|===\n\n==== Advanced\n\n[cols=\"1,1,1\"]\n|===\n| Variable\n| Description\n| Default\n\n|`COOKIE_KEY_ROTATION_INTERVAL`\n|How often (ms) to rotate in a new secret for signing cookies\n|`86400000` (1 day)\n\n| `COOKIE_SAME_SITE`\n| Value of the SameSite cookie property.\n| `\"Lax\"`\n\n| `COOKIE_SESSION_LIFETIME`\n| How long (ms) a user can be away before they must log in again.\n| `864000000` (10 days)\n\n| `COOKIE_SESSION_REFRESH_INTERVAL`\n| How often (ms) to write the latest cookie expiration time.\n| `86400000` (1 day)\n\n| `SHOW_SETTINGS_IN_ADMIN_PAGE`\n| hide/show the settings.json in admin page\n| `true`\n\n| `TRUST_PROXY`\n| set to `true` if you are using a reverse proxy in front of Etherpad (for example: Traefik for SSL termination via Let's Encrypt). This will affect security and correctness of the logs if not done\n| `false`\n\n| `IMPORT_MAX_FILE_SIZE`\n| maximum allowed file size when importing a pad, in bytes.\n| `52428800` (50 MB)\n\n| `IMPORT_EXPORT_MAX_REQ_PER_IP`\n| maximum number of import/export calls per IP.\n| `10`\n\n| `IMPORT_EXPORT_RATE_LIMIT_WINDOW`\n| the call rate for import/export requests will be estimated in this time window (in milliseconds)\n| `90000`\n\n| `COMMIT_RATE_LIMIT_DURATION`\n| duration of the rate limit window for commits by individual users/IPs (in seconds)                               | `1`\n\n| `COMMIT_RATE_LIMIT_POINTS`\n| maximum number of changes per IP to allow during the rate limit window\n| `10`\n\n| `SUPPRESS_ERRORS_IN_PAD_TEXT`\n| Should we suppress errors from being visible in the default Pad Text?\n| `false\n\n| `REQUIRE_SESSION`\n| If this option is enabled, a user must have a session to access pads. This effectively allows only group pads to be accessed.\n| `false`\n\n| `EDIT_ONLY`\n| Users may edit pads but not create new ones. Pad creation is only via the API. This applies both to group pads and regular pads.\n| `false`\n\n| `MINIFY`\n| If true, all css & js will be minified before sending to the client. This will improve the loading performance massively, but makes it difficult to debug the javascript/css\n| `true`\n\n| `MAX_AGE`\n| How long may clients use served javascript code (in seconds)? Not setting this may cause problems during deployment. Set to 0 to disable caching.\n| `21600` (6 hours)\n\n| `ABIWORD`\n| Absolute path to the Abiword executable. Abiword is needed to get advanced import/export features of pads. Setting it to null disables Abiword and will only allow plain text and HTML import/exports.\n| `null`\n\n| `SOFFICE`\n| This is the absolute path to the soffice executable. LibreOffice can be used in lieu of Abiword to export pads. Setting it to null disables LibreOffice exporting.\n| `null`\n\n| `ALLOW_UNKNOWN_FILE_ENDS`\n| Allow import of file types other than the supported ones: txt, doc, docx, rtf, odt, html & htm\n| `true`\n\n| `REQUIRE_AUTHENTICATION`\n| This setting is used if you require authentication of all users. Note: \"/admin\" always requires authentication.\n| `false`\n\n| `REQUIRE_AUTHORIZATION`\n| Require authorization by a module, or a user with is_admin set, see below.\n| `false`\n\n| `AUTOMATIC_RECONNECTION_TIMEOUT`\n| Time (in seconds) to automatically reconnect pad when a \"Force reconnect\" message is shown to user. Set to 0 to disable automatic reconnection.\n| `0`\n\n| `FOCUS_LINE_PERCENTAGE_ABOVE`\n| Percentage of viewport height to be additionally scrolled. e.g. 0.5, to place caret line in the middle of viewport, when user edits a line above of the viewport. Set to 0 to disable extra scrolling\n| `0`\n\n| `FOCUS_LINE_PERCENTAGE_BELOW`\n| Percentage of viewport height to be additionally scrolled. e.g. 0.5, to place caret line in the middle of viewport, when user edits a line below of the viewport. Set to 0 to disable extra scrolling\n| `0`\n\n| `FOCUS_LINE_PERCENTAGE_ARROW_UP`\n| Percentage of viewport height to be additionally scrolled when user presses arrow up in the line of the top of the viewport. Set to 0 to let the scroll to be handled as default by Etherpad\n| `0`\n\n| `FOCUS_LINE_DURATION`\n| Time (in milliseconds) used to animate the scroll transition. Set to 0 to disable animation\n| `0`\n\n| `FOCUS_LINE_CARET_SCROLL`\n| Flag to control if it should scroll when user places the caret in the last line of the viewport\n| `false`\n\n| `SOCKETIO_MAX_HTTP_BUFFER_SIZE`\n| The maximum size (in bytes) of a single message accepted via Socket.IO. If a client sends a larger message, its connection gets closed to prevent DoS (memory exhaustion) attacks.\n| `50000`\n\n| `LOAD_TEST`\n| Allow Load Testing tools to hit the Etherpad Instance. WARNING: this will disable security on the instance.\n| `false`\n\n| `DUMP_ON_UNCLEAN_EXIT`\n| Enable dumping objects preventing a clean exit of Node.js. WARNING: this has a significant performance impact.\n| `false`\n\n| `EXPOSE_VERSION`\n| Expose Etherpad version in the web interface and in the Server http header. Do not enable on production machines.\n| `false`\n|===\n\n==== Examples\n\nUse a Postgres database, no admin user enabled:\n\n[source,bash]\n----\ndocker run -d \\\n\t--name etherpad         \\\n\t-p 9001:9001            \\\n\t-e 'DB_TYPE=postgres'   \\\n\t-e 'DB_HOST=db.local'   \\\n\t-e 'DB_PORT=4321'       \\\n\t-e 'DB_NAME=etherpad'   \\\n\t-e 'DB_USER=dbusername' \\\n\t-e 'DB_PASS=mypassword' \\\n\tetherpad/etherpad\n----\n\nRun enabling the administrative user `admin`:\n\n[source,bash]\n----\ndocker run -d \\\n\t--name etherpad \\\n\t-p 9001:9001 \\\n\t-e 'ADMIN_PASSWORD=supersecret' \\\n\tetherpad/etherpad\n----\n\nRun a test instance running DirtyDB on a persistent volume:\n\n[source, bash]\n----\ndocker run -d \\\n\t-v etherpad_data:/opt/etherpad-lite/var \\\n\t-p 9001:9001 \\\n\tetherpad/etherpad\n----\n"
  },
  {
    "path": "doc/docker.md",
    "content": "# Docker\n\nThe official Docker image is available on https://hub.docker.com/r/etherpad/etherpad.\n\n## Downloading from Docker Hub\nIf you are ok downloading a [prebuilt image from Docker Hub](https://hub.docker.com/r/etherpad/etherpad), these are the commands:\n```bash\n# gets the latest published version\ndocker pull etherpad/etherpad\n\n# gets a specific version\ndocker pull etherpad/etherpad:1.8.0\n```\n\n## Build a personalized container\n\nIf you want to use a personalized settings file, **you will have to rebuild your image**.\nAll of the following instructions are as a member of the `docker` group.\nBy default, the Etherpad Docker image is built and run in `production` mode: no development dependencies are installed, and asset bundling speeds up page load time.\n\n### Rebuilding with custom settings\nEdit `<BASEDIR>/settings.json.docker` at your will. When rebuilding the image, this file will be copied inside your image and renamed to `settings.json`.\n\n**Each configuration parameter can also be set via an environment variable**, using the syntax `\"${ENV_VAR}\"` or `\"${ENV_VAR:default_value}\"`. For details, refer to `settings.json.template`.\n\n### Rebuilding including some plugins\nIf you want to install some plugins in your container, it is sufficient to list them in the ETHERPAD_PLUGINS build variable.\nThe variable value has to be a space separated, double quoted list of plugin names (see examples).\n\nSome plugins will need personalized settings. Just refer to the previous section, and include them in your custom `settings.json.docker`.\n\n### Rebuilding including export functionality for DOC/PDF/ODT\n\nIf you want to be able to export your pads to DOC/PDF/ODT files, you can install\neither Abiword or Libreoffice via setting a build variable.\n\n#### Via Abiword\n\nFor installing Abiword, set the `INSTALL_ABIWORD` build variable to any value.\n\nAlso, you will need to configure the path to the abiword executable\nvia setting the `abiword` property in `<BASEDIR>/settings.json.docker` to\n`/usr/bin/abiword` or via setting the environment variable  `ABIWORD` to\n`/usr/bin/abiword`.\n\n#### Via Libreoffice\n\nFor installing Libreoffice instead, set the `INSTALL_SOFFICE` build variable\nto any value.\n\nAlso, you will need to configure the path to the libreoffice executable\nvia setting the `soffice` property in `<BASEDIR>/settings.json.docker` to\n`/usr/bin/soffice` or via setting the environment variable  `SOFFICE` to\n`/usr/bin/soffice`.\n\n### Examples\n\nBuild a Docker image from the currently checked-out code:\n```bash\ndocker build --tag <YOUR_USERNAME>/etherpad .\n```\n\nInclude two plugins in the container:\n```bash\ndocker build --build-arg ETHERPAD_PLUGINS=\"ep_comments_page ep_author_neat\" --tag <YOUR_USERNAME>/etherpad .\n```\n\n## Running your instance:\n\nTo run your instance:\n```bash\ndocker run --detach --publish <DESIRED_PORT>:9001 <YOUR_USERNAME>/etherpad\n```\n\nAnd point your browser to `http://<YOUR_IP>:<DESIRED_PORT>`\n\n## Options available by default\n\nThe `settings.json.docker` available by default allows to control almost every setting via environment variables.\n\n### General\n\n| Variable           | Description                                                                                | Default                                                                                                                                                                                                                             |\n| ------------------ | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `TITLE`            | The name of the instance                                                                   | `Etherpad`                                                                                                                                                                                                                          |\n| `FAVICON`          | favicon default name, or a fully specified URL to your own favicon                         | `favicon.ico`                                                                                                                                                                                                                       |\n| `DEFAULT_PAD_TEXT` | The default text of a pad                                                                  | `Welcome to Etherpad! This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents! Get involved with Etherpad at https://etherpad.org` |\n| `IP`               | IP which etherpad should bind at. Change to `::` for IPv6                                  | `0.0.0.0`                                                                                                                                                                                                                           |\n| `PORT`             | port which etherpad should bind at                                                         | `9001`                                                                                                                                                                                                                              |\n| `ADMIN_PASSWORD`   | the password for the `admin` user (leave unspecified if you do not want to create it)      |                                                                                                                                                                                                                                     |\n| `USER_PASSWORD`    | the password for the first user `user` (leave unspecified if you do not want to create it) |                                                                                                                                                                                                                                     |\n\n\n### Database\n\n| Variable      | Description                                                    | Default                                                               |\n| ------------- | -------------------------------------------------------------- | --------------------------------------------------------------------- |\n| `DB_TYPE`     | a database supported by https://www.npmjs.com/package/ueberdb2 | not set, thus will fall back to `DirtyDB` (please choose one instead) |\n| `DB_HOST`     | the host of the database                                       |                                                                       |\n| `DB_PORT`     | the port of the database                                       |                                                                       |\n| `DB_NAME`     | the database name                                              |                                                                       |\n| `DB_USER`     | a database user with sufficient permissions to create tables   |                                                                       |\n| `DB_PASS`     | the password for the database username                         |                                                                       |\n| `DB_CHARSET`  | the character set for the tables (only required for MySQL)     |                                                                       |\n| `DB_FILENAME` | in case `DB_TYPE` is `DirtyDB` or `sqlite`, the database file. | `var/dirty.db`, `var/etherpad.sq3`                                    |\n\nIf your database needs additional settings, you will have to use a personalized `settings.json.docker` and rebuild the container (or otherwise put the updated `settings.json` inside your image).\n\n\n### Pad Options\n\n| Variable                         | Description | Default |\n| -------------------------------- | ----------- | ------- |\n| `PAD_OPTIONS_NO_COLORS`          |             | `false` |\n| `PAD_OPTIONS_SHOW_CONTROLS`      |             | `true`  |\n| `PAD_OPTIONS_SHOW_CHAT`          |             | `true`  |\n| `PAD_OPTIONS_SHOW_LINE_NUMBERS`  |             | `true`  |\n| `PAD_OPTIONS_USE_MONOSPACE_FONT` |             | `false` |\n| `PAD_OPTIONS_USER_NAME`          |             | `null`  |\n| `PAD_OPTIONS_USER_COLOR`         |             | `null`  |\n| `PAD_OPTIONS_RTL`                |             | `false` |\n| `PAD_OPTIONS_ALWAYS_SHOW_CHAT`   |             | `false` |\n| `PAD_OPTIONS_CHAT_AND_USERS`     |             | `false` |\n| `PAD_OPTIONS_LANG`               |             | `null`  |\n\n\n### Shortcuts\n\n| Variable                            | Description                                      | Default |\n| ----------------------------------- | ------------------------------------------------ | ------- |\n| `PAD_SHORTCUTS_ENABLED_ALT_F9`      | focus on the File Menu and/or editbar            | `true`  |\n| `PAD_SHORTCUTS_ENABLED_ALT_C`       | focus on the Chat window                         | `true`  |\n| `PAD_SHORTCUTS_ENABLED_CMD_S`       | save a revision                                  | `true`  |\n| `PAD_SHORTCUTS_ENABLED_CMD_Z`       | undo/redo                                        | `true`  |\n| `PAD_SHORTCUTS_ENABLED_CMD_Y`       | redo                                             | `true`  |\n| `PAD_SHORTCUTS_ENABLED_CMD_I`       | italic                                           | `true`  |\n| `PAD_SHORTCUTS_ENABLED_CMD_B`       | bold                                             | `true`  |\n| `PAD_SHORTCUTS_ENABLED_CMD_U`       | underline                                        | `true`  |\n| `PAD_SHORTCUTS_ENABLED_CMD_H`       | backspace                                        | `true`  |\n| `PAD_SHORTCUTS_ENABLED_CMD_5`       | strike through                                   | `true`  |\n| `PAD_SHORTCUTS_ENABLED_CMD_SHIFT_1` | ordered list                                     | `true`  |\n| `PAD_SHORTCUTS_ENABLED_CMD_SHIFT_2` | shows a gritter popup showing a line author      | `true`  |\n| `PAD_SHORTCUTS_ENABLED_CMD_SHIFT_L` | unordered list                                   | `true`  |\n| `PAD_SHORTCUTS_ENABLED_CMD_SHIFT_N` | ordered list                                     | `true`  |\n| `PAD_SHORTCUTS_ENABLED_CMD_SHIFT_C` | clear authorship                                 | `true`  |\n| `PAD_SHORTCUTS_ENABLED_DELETE`      |                                                  | `true`  |\n| `PAD_SHORTCUTS_ENABLED_RETURN`      |                                                  | `true`  |\n| `PAD_SHORTCUTS_ENABLED_ESC`         | in mozilla versions 14-19 avoid reconnecting pad | `true`  |\n| `PAD_SHORTCUTS_ENABLED_TAB`         | indent                                           | `true`  |\n| `PAD_SHORTCUTS_ENABLED_CTRL_HOME`   | scroll to top of pad                             | `true`  |\n| `PAD_SHORTCUTS_ENABLED_PAGE_UP`     |                                                  | `true`  |\n| `PAD_SHORTCUTS_ENABLED_PAGE_DOWN`   |                                                  | `true`  |\n\n\n### Skins\n\nYou can use the UI skin variants builder at `/p/test#skinvariantsbuilder`\n\nFor the colibris skin only, you can choose how to render the three main containers:\n* toolbar (top menu with icons)\n* editor (containing the text of the pad)\n* background (area outside of editor, mostly visible when using page style)\n\nFor each of the 3 containers you can choose 4 color combinations:\n* super-light\n* light\n* dark\n* super-dark\n\nFor the editor container, you can also make it full width by adding `full-width-editor` variant (by default editor is rendered as a page, with a max-width of 900px).\n\n| Variable        | Description                                                                    | Default                                                   |\n| --------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------- |\n| `SKIN_NAME`     | either `no-skin`, `colibris` or an existing directory under `src/static/skins` | `colibris`                                                |\n| `SKIN_VARIANTS` | multiple skin variants separated by spaces                                     | `super-light-toolbar super-light-editor light-background` |\n\n\n### Logging\n\n| Variable             | Description                                          | Default |\n| -------------------- | ---------------------------------------------------- | ------- |\n| `LOGLEVEL`           | valid values are `DEBUG`, `INFO`, `WARN` and `ERROR` | `INFO`  |\n| `DISABLE_IP_LOGGING` | Privacy: disable IP logging                          | `false` |\n\n\n### Advanced\n\n| Variable                          | Description                                                                                                                                                                                            | Default               |\n|-----------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------|\n| `COOKIE_SAME_SITE`                | Value of the SameSite cookie property.                                                                                                                                                                 | `\"Lax\"`               |\n| `COOKIE_SESSION_LIFETIME`         | How long (ms) a user can be away before they must log in again.                                                                                                                                        | `864000000` (10 days) |\n| `COOKIE_SESSION_REFRESH_INTERVAL` | How often (ms) to write the latest cookie expiration time.                                                                                                                                             | `86400000` (1 day)    |\n| `SHOW_SETTINGS_IN_ADMIN_PAGE`     | hide/show the settings.json in admin page                                                                                                                                                              | `true`                |\n| `TRUST_PROXY`                     | set to `true` if you are using a reverse proxy in front of Etherpad (for example: Traefik for SSL termination via Let's Encrypt). This will affect security and correctness of the logs if not done    | `false`               |\n| `IMPORT_MAX_FILE_SIZE`            | maximum allowed file size when importing a pad, in bytes.                                                                                                                                              | `52428800` (50 MB)    |\n| `IMPORT_EXPORT_MAX_REQ_PER_IP`    | maximum number of import/export calls per IP.                                                                                                                                                          | `10`                  |\n| `IMPORT_EXPORT_RATE_LIMIT_WINDOW` | the call rate for import/export requests will be estimated in this time window (in milliseconds)                                                                                                       | `90000`               |\n| `COMMIT_RATE_LIMIT_DURATION`      | duration of the rate limit window for commits by individual users/IPs (in seconds)                                                                                                                     | `1`                   |\n| `COMMIT_RATE_LIMIT_POINTS`        | maximum number of changes per IP to allow during the rate limit window                                                                                                                                 | `10`                  |\n| `SUPPRESS_ERRORS_IN_PAD_TEXT`     | Should we suppress errors from being visible in the default Pad Text?                                                                                                                                  | `false`               |\n| `REQUIRE_SESSION`                 | If this option is enabled, a user must have a session to access pads. This effectively allows only group pads to be accessed.                                                                          | `false`               |\n| `EDIT_ONLY`                       | Users may edit pads but not create new ones. Pad creation is only via the API. This applies both to group pads and regular pads.                                                                       | `false`               |\n| `MINIFY`                          | If true, all css & js will be minified before sending to the client. This will improve the loading performance massively, but makes it difficult to debug the javascript/css                           | `true`                |\n| `MAX_AGE`                         | How long may clients use served javascript code (in seconds)? Not setting this may cause problems during deployment. Set to 0 to disable caching.                                                      | `21600` (6 hours)     |\n| `ABIWORD`                         | Absolute path to the Abiword executable. Abiword is needed to get advanced import/export features of pads. Setting it to null disables Abiword and will only allow plain text and HTML import/exports. | `null`                |\n| `SOFFICE`                         | This is the absolute path to the soffice executable. LibreOffice can be used in lieu of Abiword to export pads. Setting it to null disables LibreOffice exporting.                                     | `null`                |\n| `ALLOW_UNKNOWN_FILE_ENDS`         | Allow import of file types other than the supported ones: txt, doc, docx, rtf, odt, html & htm                                                                                                         | `true`                |\n| `REQUIRE_AUTHENTICATION`          | This setting is used if you require authentication of all users. Note: \"/admin\" always requires authentication.                                                                                        | `false`               |\n| `REQUIRE_AUTHORIZATION`           | Require authorization by a module, or a user with is_admin set, see below.                                                                                                                             | `false`               |\n| `AUTOMATIC_RECONNECTION_TIMEOUT`  | Time (in seconds) to automatically reconnect pad when a \"Force reconnect\" message is shown to user. Set to 0 to disable automatic reconnection.                                                        | `0`                   |\n| `FOCUS_LINE_PERCENTAGE_ABOVE`     | Percentage of viewport height to be additionally scrolled. e.g. 0.5, to place caret line in the middle of viewport, when user edits a line above of the viewport. Set to 0 to disable extra scrolling  | `0`                   |\n| `FOCUS_LINE_PERCENTAGE_BELOW`     | Percentage of viewport height to be additionally scrolled. e.g. 0.5, to place caret line in the middle of viewport, when user edits a line below of the viewport. Set to 0 to disable extra scrolling  | `0`                   |\n| `FOCUS_LINE_PERCENTAGE_ARROW_UP`  | Percentage of viewport height to be additionally scrolled when user presses arrow up in the line of the top of the viewport. Set to 0 to let the scroll to be handled as default by Etherpad           | `0`                   |\n| `FOCUS_LINE_DURATION`             | Time (in milliseconds) used to animate the scroll transition. Set to 0 to disable animation                                                                                                            | `0`                   |\n| `FOCUS_LINE_CARET_SCROLL`         | Flag to control if it should scroll when user places the caret in the last line of the viewport                                                                                                        | `false`               |\n| `SOCKETIO_MAX_HTTP_BUFFER_SIZE`   | The maximum size (in bytes) of a single message accepted via Socket.IO. If a client sends a larger message, its connection gets closed to prevent DoS (memory exhaustion) attacks.                     | `50000`               |\n| `LOAD_TEST`                       | Allow Load Testing tools to hit the Etherpad Instance. WARNING: this will disable security on the instance.                                                                                            | `false`               |\n| `DUMP_ON_UNCLEAN_EXIT`            | Enable dumping objects preventing a clean exit of Node.js. WARNING: this has a significant performance impact.                                                                                         | `false`               |\n| `EXPOSE_VERSION`                  | Expose Etherpad version in the web interface and in the Server http header. Do not enable on production machines.                                                                                      | `false`               |\n\n### Add plugin configurations\n\nIt is possible to add arbitrary configurations for plugins by setting the `EP__PLUGIN__<PLUGIN_NAME>__<CONFIG_NAME>` environment variable. It is important to separate paths with a double underscore `__`.\n\nFor example, to configure the `ep_comments` plugin to use the `comments` database, you can set the following environment variables:\n\nThe original config looks like this:\n```json\n\"ep_comments_page\": {\n  \"highlightSelectedText\": true\n},\n```\nWe have two paths ep_comments_page and highlightSelectedText, so we need to set the following environment variable:\n\n\n```yaml\nEP__ep_comments_page__highlightSelectedText=true\n```\n\n### Examples\n\nUse a Postgres database, no admin user enabled:\n\n```shell\ndocker run -d \\\n\t--name etherpad         \\\n\t-p 9001:9001            \\\n\t-e 'DB_TYPE=postgres'   \\\n\t-e 'DB_HOST=db.local'   \\\n\t-e 'DB_PORT=4321'       \\\n\t-e 'DB_NAME=etherpad'   \\\n\t-e 'DB_USER=dbusername' \\\n\t-e 'DB_PASS=mypassword' \\\n\tetherpad/etherpad\n```\n\nRun enabling the administrative user `admin`:\n\n```shell\ndocker run -d \\\n\t--name etherpad \\\n\t-p 9001:9001 \\\n\t-e 'ADMIN_PASSWORD=supersecret' \\\n\tetherpad/etherpad\n```\n\nRun a test instance running DirtyDB on a persistent volume:\n\n```shell\ndocker run -d \\\n\t-v etherpad_data:/opt/etherpad-lite/var \\\n\t-p 9001:9001 \\\n\tetherpad/etherpad\n```\n\n\n\n## Ready to use Docker Compose\n\n```yaml\nservices:\n  app:\n    user: \"0:0\"\n    image: etherpad/etherpad:latest\n    tty: true\n    stdin_open: true\n    volumes:\n      - plugins:/opt/etherpad-lite/src/plugin_packages\n      - etherpad-var:/opt/etherpad-lite/var\n    depends_on:\n      - postgres\n    environment:\n      NODE_ENV: production\n      ADMIN_PASSWORD: ${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:-admin}\n      DB_CHARSET: ${DOCKER_COMPOSE_APP_DB_CHARSET:-utf8mb4}\n      DB_HOST: postgres\n      DB_NAME: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad}\n      DB_PASS: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin}\n      DB_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432}\n      DB_TYPE: \"postgres\"\n      DB_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}\n      # For now, the env var DEFAULT_PAD_TEXT cannot be unset or empty; it seems to be mandatory in the latest version of etherpad\n      DEFAULT_PAD_TEXT: ${DOCKER_COMPOSE_APP_DEFAULT_PAD_TEXT:- }\n      DISABLE_IP_LOGGING: ${DOCKER_COMPOSE_APP_DISABLE_IP_LOGGING:-false}\n      SOFFICE: ${DOCKER_COMPOSE_APP_SOFFICE:-null}\n      TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-true}\n    restart: always\n    ports:\n      - \"${DOCKER_COMPOSE_APP_PORT_PUBLISHED:-9001}:${DOCKER_COMPOSE_APP_PORT_TARGET:-9001}\"\n\n  postgres:\n    image: postgres:15-alpine\n    environment:\n      POSTGRES_DB: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad}\n      POSTGRES_PASSWORD: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin}\n      POSTGRES_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432}\n      POSTGRES_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}\n      PGDATA: /var/lib/postgresql/data/pgdata\n    restart: always\n    # Exposing the port is not needed unless you want to access this database instance from the host.\n    # Be careful when other postgres docker container are running on the same port\n    # ports:\n    #   - \"5432:5432\"\n    volumes:\n      - postgres_data:/var/lib/postgresql/data/pgdata\n\nvolumes:\n  postgres_data:\n  plugins:\n  etherpad-var:\n```\n"
  },
  {
    "path": "doc/documentation.adoc",
    "content": "== About this Documentation\n\nThe goal of this documentation is to comprehensively explain Etherpad,\nboth from a reference as well as a conceptual point of view.\n\nWhere appropriate, property types, method arguments, and the arguments\nprovided to event handlers are detailed in a list underneath the topic\nheading.\n\nEvery `.html` file is generated based on the corresponding\n`.md` file in the `doc/api/` folder in the source tree. The\ndocumentation is generated using the `src/bin/doc/generate.js` program.\nThe HTML template is located at `doc/template.html`.\n"
  },
  {
    "path": "doc/documentation.md",
    "content": "# About this Documentation\n\n<!-- type=misc -->\n\nThe goal of this documentation is to comprehensively explain Etherpad,\nboth from a reference and a conceptual point of view.\n\nWhere appropriate, property types, method arguments, and the arguments\nprovided to event handlers are detailed in a list underneath the topic\nheading.\n\nEvery `.html` file is generated based on the corresponding\n`.md` file in the `doc/api/` folder in the source tree. The\ndocumentation is generated using the `src/bin/doc/generate.js` program.\nThe HTML template is located at `doc/template.html`.\n"
  },
  {
    "path": "doc/index.adoc",
    "content": ":version: {VERSION}\n\n= Etherpad v{version} Manual &amp; Documentation\n:stylesheet: assets/style.css\n:toc:\n:toclevels: 4\n:source-highlighter: highlight.js\n\ninclude::./documentation.adoc[]\n\ninclude::./stats.adoc[]\n\ninclude::./localization.adoc[]\n\ninclude::./docker.adoc[]\n\ninclude::./skins.adoc[]\n\ninclude::./api/api.adoc[]\n\ninclude::./plugins.adoc[]\n\ninclude::./cookies.adoc[]\n\ninclude::./database.adoc[]\n"
  },
  {
    "path": "doc/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\nhero:\n  name: \"Etherpad\"\n  text: \"Next generation collaborative document editing\"\n  tagline: Make your documents come alive\n  image:\n    src: /favicon.ico\n    alt: Etherpad hero image\n  actions:\n    - theme: brand\n      text: Install\n      link: /docker\n    - theme: alt\n      text: API documentation\n      link: /api/\nfeatures:\n  - icon: ⚡️\n    title: Real-time editing\n    details: Collaborate with others in real-time. See changes as they happen in an instant\n  - icon: 🛠️\n    title: Extensible plugin framework\n    details: Add new features to Etherpad with plugins. Create your own or use existing ones\n  - icon: 💬\n    title: Real-time chat\n    details: Communicate with others while editing. Discuss changes and share ideas\n  - icon: 📝\n    title: Rich text editing\n    details: Format text, add images, and more. Create beautiful documents with ease\n  - icon: 🌐\n    title: Multi-language support\n    details: Use Etherpad in your preferred language. Localize the interface and documents\n  - icon: 📦\n    title: Easy to install\n    details: Get started quickly with Docker. Install Etherpad with a single command\n---\n\n"
  },
  {
    "path": "doc/localization.adoc",
    "content": "== Localization\nEtherpad provides a multi-language user interface, that's apart from your users' content, so users from different countries can collaborate on a single document, while still having the user interface displayed in their mother tongue.\n\n\n=== Translating\nWe rely on https://translatewiki.net to handle the translation process for us, so if you'd like to help...\n\n1. Sign up at https://translatewiki.net\n2. Visit our https://translatewiki.net/wiki/Translating:Etherpad_lite[TWN project page]\n3. Click on `Translate Etherpad lite interface`\n4. Choose a target language, you'd like to translate our interface to, and hit `Fetch`\n5. Start translating!\n\nTranslations will be send back to us regularly and will eventually appear in the next release.\n\n=== Implementation\n\n==== Server-side\n`/src/locales` contains files for all supported languages which contain the translated strings. Translation files are simple `*.json` files and look like this:\n\n[source,json]\n----\n{ \n  \"pad.modals.connected\": \"Connecté.\",\n  \"pad.modals.uderdup\": \"Ouvrir dans une nouvelle fenêtre.\",\n  \"pad.toolbar.unindent.title\": \"Dèsindenter\",\n  \"pad.toolbar.undo.title\": \"Annuler (Ctrl-Z)\",\n  \"timeslider.pageTitle\": \"{{appTitle}} Curseur temporel\",\n  ...\n}\n----\n\nEach translation consists of a key (the id of the string that is to be translated) and the translated string. Terms in curly braces must not be touched but left as they are, since they represent a dynamically changing part of the string like a variable. Imagine a message welcoming a user: `Welcome, {{userName}}!` would be translated as `Ahoy, {{userName}}!` in pirate.\n\n==== Client-side\nWe use a `language` cookie to save your language settings if you change them. If you don't, we autodetect your locale using information from your browser. Then, the preferred language is fed into a library called https://github.com/marcelklehr/html10n.js[html10n.js], which loads the appropriate translations and applies them to our templates. Its features include translation params, pluralization, include rules and a nice javascript API.\n\n\n\n=== Localizing plugins\n\n==== 1. Mark the strings to translate\n\nIn the template files of your plugin, change all hardcoded messages/strings...\n\nfrom:\n\n[source,html]\n----\n<option value=\"0\">Heading 1</option>\n----\nto:\n\n[source,html]\n----\n<option data-l10n-id=\"ep_heading.h1\" value=\"0\"></option>\n----\n\nIn the javascript files of your plugin, change all hardcoded messages/strings...\n\nfrom:\n\n[source,js]\n----\nalert ('Chat');\n----\nto:\n\n[source,js]\n----\nalert(window._('pad.chat'));\n----\n==== 2. Create translate files in the locales directory of your plugin\n\n* The name of the file must be the language code of the language it contains translations for (see https://joker-x.github.io/languages4translatewiki/test/[supported lang codes]; e.g. en ? English, es ? Spanish...)\n* The extension of the file must be `.json`\n* The default language is English, so your plugin should always provide `en.json`\n* In order to avoid naming conflicts, your message keys should start with the name of your plugin followed by a dot (see below)\n\n*ep_your-plugin/locales/en.json*\n\n[source, json]\n----\n{ \n  \"ep_your-plugin.h1\": \"Heading 1\"\n}\n----\n\n*ep_your-plugin/locales/es.json*\n\n[source, json]\n----\n{ \n  \"ep_your-plugin.h1\": \"Título 1\"\n}\n----\n\nEvery time the http server is started, it will auto-detect your messages and merge them automatically with the core messages.\n\n==== Overwrite core messages\n\nYou can overwrite Etherpad's core messages in your plugin's locale files.\nFor example, if you want to replace `Chat` with `Notes`, simply add...\n\n*ep_your-plugin/locales/en.json*\n\n[source,json]\n----\n{ \n  \"ep_your-plugin.h1\": \"Heading 1\",\n  \"pad.chat\": \"Notes\"\n}\n----\n\n=== Customization for Administrators\n\nAs an Etherpad administrator, it is possible to overwrite core messages as well as messages in plugins. These include error messages, labels, and user instructions. Whereas the localization in the source code is in separate files separated by locale, an administrator's custom localizations are in `settings.json` under the `customLocaleStrings` key, with each locale separated by a sub-key underneath.\n\nFor example, let's say you want to change the text on the \"New Pad\" button on Etherpad's home page. If you look in `locales/en.json` (or `locales/en-gb.json`) you'll see the key for this text is `\"index.newPad\"`. You could add the following to `settings.json`:\n\n[source,json]\n----\n  \"customLocaleStrings\": {\n    \"fr\": {\n      \"index.newPad\": \"Créer un document\"\n    },\n    \"en-gb\": {\n      \"index.newPad\": \"Create a document\"\n    },\n    \"en\": {\n      \"index.newPad\": \"Create a document\"\n    }\n  }\n----\n"
  },
  {
    "path": "doc/localization.md",
    "content": "# Localization\nEtherpad provides a multi-language user interface, that's apart from your users' content, so users from different countries can collaborate on a single document, while still having the user interface displayed in their mother tongue.\n\n\n## Translating\nWe rely on https://translatewiki.net to handle the translation process for us, so if you'd like to help...\n\n1. Sign up at https://translatewiki.net\n2. Visit our [TWN project page](https://translatewiki.net/wiki/Translating:Etherpad_lite)\n3. Click on `Translate Etherpad lite interface`\n4. Choose a target language, you'd like to translate our interface to, and hit `Fetch`\n5. Start translating!\n\nTranslations will be send back to us regularly and will eventually appear in the next release.\n\n## Implementation\n\n### Server-side\n`/src/locales` contains files for all supported languages which contain the translated strings. Translation files are simple `*.json` files and look like this:\n\n```json\n{ \"pad.modals.connected\": \"Connecté.\"\n, \"pad.modals.uderdup\": \"Ouvrir dans une nouvelle fenêtre.\"\n, \"pad.toolbar.unindent.title\": \"Dèsindenter\"\n, \"pad.toolbar.undo.title\": \"Annuler (Ctrl-Z)\"\n, \"timeslider.pageTitle\": \"{{appTitle}} Curseur temporel\"\n, ...\n}\n```\n\nEach translation consists of a key (the id of the string that is to be translated) and the translated string. Terms in curly braces must not be touched but left as they are, since they represent a dynamically changing part of the string like a variable. Imagine a message welcoming a user: `Welcome, {{userName}}!` would be translated as `Ahoy, {{userName}}!` in pirate.\n\n### Client-side\nWe use a `language` cookie to save your language settings if you change them. If you don't, we autodetect your locale using information from your browser. Then, the preferred language is fed into a library called [html10n.js](https://github.com/marcelklehr/html10n.js), which loads the appropriate translations and applies them to our templates. Its features include translation params, pluralization, include rules and a nice javascript API.\n\n\n\n## Localizing plugins\n\n### 1. Mark the strings to translate\n\nIn the template files of your plugin, change all hardcoded messages/strings...\n\nfrom:\n```html\n<option value=\"0\">Heading 1</option>\n```\nto:\n```html\n<option data-l10n-id=\"ep_heading.h1\" value=\"0\"></option>\n```\n\nIn the javascript files of your plugin, change all hardcoded messages/strings...\n\nfrom:\n```js\nalert ('Chat');\n```\nto:\n```js\nalert(window._('pad.chat'));\n```\n### 2. Create translate files in the locales directory of your plugin\n\n* The name of the file must be the language code of the language it contains translations for (see [supported lang codes](https://joker-x.github.com/languages4translatewiki/test/); e.g. en ? English, es ? Spanish...)\n* The extension of the file must be `.json`\n* The default language is English, so your plugin should always provide `en.json`\n* In order to avoid naming conflicts, your message keys should start with the name of your plugin followed by a dot (see below)\n\n*ep_your-plugin/locales/en.json*\n```\n{ \"ep_your-plugin.h1\": \"Heading 1\"\n}\n```\n\n*ep_your-plugin/locales/es.json*\n```\n{ \"ep_your-plugin.h1\": \"Título 1\"\n}\n```\n\nEvery time the http server is started, it will auto-detect your messages and merge them automatically with the core messages.\n\n### Overwrite core messages\n\nYou can overwrite Etherpad's core messages in your plugin's locale files.\nFor example, if you want to replace `Chat` with `Notes`, simply add...\n\n*ep_your-plugin/locales/en.json*\n```\n{ \"ep_your-plugin.h1\": \"Heading 1\"\n, \"pad.chat\": \"Notes\"\n}\n```\n\n## Customization for Administrators\n\nAs an Etherpad administrator, it is possible to overwrite core messages as well as messages in plugins. These include error messages, labels, and user instructions. Whereas the localization in the source code is in separate files separated by locale, an administrator's custom localizations are in `settings.json` under the `customLocaleStrings` key, with each locale separated by a sub-key underneath.\n\nFor example, let's say you want to change the text on the \"New Pad\" button on Etherpad's home page. If you look in `locales/en.json` (or `locales/en-gb.json`) you'll see the key for this text is `\"index.newPad\"`. You could add the following to `settings.json`:\n\n```\n  \"customLocaleStrings\": {\n    \"fr\": {\n      \"index.newPad\": \"Créer un document\"\n    },\n    \"en-gb\": {\n      \"index.newPad\": \"Create a document\"\n    },\n    \"en\": {\n      \"index.newPad\": \"Create a document\"\n    }\n  }\n```\n"
  },
  {
    "path": "doc/package.json",
    "content": "{\n  \"devDependencies\": {\n    \"vitepress\": \"^2.0.0-alpha.16\"\n  },\n  \"scripts\": {\n    \"docs:dev\": \"vitepress dev\",\n    \"docs:build\": \"vitepress build\",\n    \"docs:preview\": \"vitepress preview\"\n  },\n  \"peerDependencies\": {\n    \"search-insights\": \"^2.17.3\"\n  },\n  \"overrides\": {\n    \"vite\": \"npm:rolldown-vite@7.2.10\"\n  }\n}\n"
  },
  {
    "path": "doc/plugins.adoc",
    "content": "== Plugins\n\nEtherpad allows you to extend its functionality with plugins. A plugin registers\nhooks (functions) for certain events (thus certain features) in Etherpad to\nexecute its own functionality based on these events.\n\nPublicly available plugins can be found in the npm registry (see\n<https://npmjs.org>). Etherpad's naming convention for plugins is to prefix your\nplugins with `ep_`. So, e.g. it's `ep_flubberworms`. Thus you can install\nplugins from npm, using `npm install --no-save --legacy-peer-deps\nep_flubberworm` in Etherpad's root directory.\n\nYou can also browse to `http://yourEtherpadInstan.ce/admin/plugins`, which will\nlist all installed plugins and those available on npm. It even provides\nfunctionality to search through all available plugins.\n\n=== Folder structure\n\nIdeally a plugin has the following folder structure:\n\n[source]\n----\nep_<plugin>/\n ├ .github/\n │  └ workflows/\n │     └ npmpublish.yml  ◄─ GitHub workflow to auto-publish on push\n ├ static/\n │  ├ css/               ◄─ static .css files\n │  ├ images/            ◄─ static image files\n │  ├ js/\n │  │  └ index.js        ◄─ static client-side code\n │  └ tests/\n │     ├ backend/\n │     │  └ specs/       ◄─ backend (server) tests\n │     └ frontend/\n │        └ specs/       ◄─ frontend (client) tests\n ├ templates/            ◄─ EJS templates (.html, .js, .css, etc.)\n ├ locales/\n │  ├ en.json            ◄─ English (US) strings\n │  └ qqq.json           ◄─ optional hints for translators\n ├ .travis.yml           ◄─ Travis CI config\n ├ LICENSE\n ├ README.md\n ├ ep.json               ◄─ Etherpad plugin definition\n ├ index.js              ◄─ server-side code\n ├ package.json\n └ package-lock.json\n----\n\nIf your plugin includes client-side hooks, put them in `static/js/`. If you're\nadding in CSS or image files, you should put those files in `static/css/ `and\n`static/image/`, respectively, and templates go into `templates/`. Translations\ngo into `locales/`. Tests go in `static/tests/backend/specs/` and\n`static/tests/frontend/specs/`.\n\nA Standard directory structure like this makes it easier to navigate through\nyour code. That said, do note, that this is not actually *required* to make your\nplugin run. If you want to make use of our i18n system, you need to put your\ntranslations into `locales/`, though, in order to have them integrated. (See\n\"Localization\" for more info on how to localize your plugin.)\n\n=== Plugin definition\n\nYour plugin definition goes into `ep.json`. In this file you register your hook\nfunctions, indicate the parts of your plugin and the order of execution. (A\ndocumentation of all available events to hook into can be found in chapter\n<<Server-side hooks>>.)\n\n[source,json]\n----\n{\n  \"parts\": [\n    {\n      \"name\": \"nameThisPartHoweverYouWant\",\n      \"hooks\": {\n        \"authenticate\": \"ep_<plugin>/<file>:functionName1\",\n        \"expressCreateServer\": \"ep_<plugin>/<file>:functionName2\"\n      },\n      \"client_hooks\": {\n        \"acePopulateDOMLine\": \"ep_<plugin>/<file>:functionName3\"\n      }\n    }\n  ]\n}\n----\n\nA hook function registration maps a hook name to a hook function specification.\nThe hook function specification looks like `ep_example/file.js:functionName`. It\nconsists of two parts separated by a colon: a module name to `require()` and the\nname of a function exported by the named module. See\nhttps://nodejs.org/docs/latest/api/modules.html#modules_module_exports[`module.exports`]\nfor how to export a function.\n\nFor the module name you can omit the `.js` suffix, and if the file is `index.js`\nyou can use just the directory name. You can also omit the module name entirely,\nin which case it defaults to the plugin name (e.g., `ep_example`).\n\nYou can also omit the function name. If you do, Etherpad will look for an\nexported function whose name matches the name of the hook (e.g.,\n`authenticate`).\n\nIf either the module name or the function name is omitted (or both), the colon\nmay also be omitted unless the provided module name contains a colon. (So if the\nmodule name is `C:\\foo.js` then the hook function specification with the\nfunction name omitted would be `\"C:\\\\foo.js:\"`.)\n\nExamples: Suppose the plugin name is `ep_example`. All of the following are\nequivalent, and will cause the `authorize` hook to call the `exports.authorize`\nfunction in `index.js` from the `ep_example` plugin:\n\n* `\"authorize\": \"ep_example/index.js:authorize\"`\n* `\"authorize\": \"ep_example/index.js:\"`\n* `\"authorize\": \"ep_example/index.js\"`\n* `\"authorize\": \"ep_example/index:authorize\"`\n* `\"authorize\": \"ep_example/index:\"`\n* `\"authorize\": \"ep_example/index\"`\n* `\"authorize\": \"ep_example:authorize\"`\n* `\"authorize\": \"ep_example:\"`\n* `\"authorize\": \"ep_example\"`\n* `\"authorize\": \":authorize\"`\n* `\"authorize\": \":\"`\n* `\"authorize\": \"\"`\n\n==== Client hooks and server hooks\n\nThere are server hooks, which will be executed on the server (e.g.\n`expressCreateServer`), and there are client hooks, which are executed on the\nclient (e.g. `acePopulateDomLine`). Be sure to not make assumptions about the\nenvironment your code is running in, e.g. don't try to access `process`, if you\nknow your code will be run on the client, and likewise, don't try to access\n`window` on the server...\n\n==== Styling\n\nWhen you install a client-side plugin (e.g. one that implements at least one\nclient-side hook), the plugin name is added to the `class` attribute of the div\n`#editorcontainerbox` in the main window. This gives you the opportunity of\ntuning the appearance of the main UI in your plugin.\n\nFor example, this is the markup with no plugins installed:\n\n[source,html]\n----\n<div id=\"editorcontainerbox\" class=\"\">\n----\n\nand this is the contents after installing `someplugin`:\n\n[source,html]\n----\n<div id=\"editorcontainerbox\" class=\"ep_someplugin\">\n----\n\nThis feature was introduced in Etherpad **1.8**.\n\n==== Parts\n\nAs your plugins become more and more complex, you will find yourself in the need\nto manage dependencies between plugins. E.g. you want the hooks of a certain\nplugin to be executed before (or after) yours. You can also manage these\ndependencies in your plugin definition file `ep.json`:\n\n[source,json]\n----\n{\n  \"parts\": [\n    {\n      \"name\": \"onepart\",\n      \"pre\": [],\n      \"post\": [\"ep_onemoreplugin/partone\"],\n      \"hooks\": {\n        \"storeBar\": \"ep_monospace/plugin:storeBar\",\n        \"getFoo\": \"ep_monospace/plugin:getFoo\"\n      }\n    },\n    {\n      \"name\": \"otherpart\",\n      \"pre\": [\"ep_my_example/somepart\", \"ep_otherplugin/main\"],\n      \"post\": [],\n      \"hooks\": {\n        \"someEvent\": \"ep_my_example/otherpart:someEvent\",\n        \"another\": \"ep_my_example/otherpart:another\"\n      }\n    }\n  ]\n}\n----\n\nUsually a plugin will add only one functionality at a time, so it will probably\nonly use one `part` definition to register its hooks. However, sometimes you\nhave to put different (unrelated) functionalities into one plugin. For this you\nwill want use parts, so other plugins can depend on them.\n\n===== pre/post\n\nThe `\"pre\"` and `\"post\"` definitions, affect the order in which parts of a\nplugin are executed. This ensures that plugins and their hooks are executed in\nthe correct order.\n\n`\"pre\"` lists parts that must be executed *before* the defining part. `\"post\"`\nlists parts that must be executed *after* the defining part.\n\nYou can, on a basic level, think of this as double-ended dependency listing. If\nyou have a dependency on another plugin, you can make sure it loads before yours\nby putting it in `\"pre\"`. If you are setting up things that might need to be\nused by a plugin later, you can ensure proper order by putting it in `\"post\"`.\n\nNote that it would be far more sane to use `\"pre\"` in almost any case, but if\nyou want to change config variables for another plugin, or maybe modify its\nenvironment, `\"post\"` could definitely be useful.\n\nAlso, note that dependencies should *also* be listed in your package.json, so\nthey can be `npm install`'d automagically when your plugin gets installed.\n\n=== Package definition\n\nYour plugin must also contain a https://docs.npmjs.com/files/package.json[package definition\nfile], called package.json, in the\nproject root - this file contains various metadata relevant to your plugin, such\nas the name and version number, author, project hompage, contributors, a short\ndescription, etc. If you publish your plugin on npm, these metadata are used for\npackage search etc., but it's necessary for Etherpad plugins, even if you don't\npublish your plugin.\n\n[source,json]\n----\n{\n  \"name\": \"ep_PLUGINNAME\",\n  \"version\": \"0.0.1\",\n  \"description\": \"DESCRIPTION\",\n  \"author\": \"USERNAME (REAL NAME) <MAIL@EXAMPLE.COM>\",\n  \"contributors\": [],\n  \"dependencies\": {\"MODULE\": \"0.3.20\"},\n  \"engines\": {\"node\": \">=12.17.0\"}\n}\n----\n\n=== Templates\n\nIf your plugin adds or modifies the front end HTML (e.g. adding buttons or\nchanging their functions), you should put the necessary HTML code for such\noperations in `templates/`, in files of type \".ejs\", since Etherpad uses EJS for\nHTML templating. See the following link for more information about EJS:\n<https://github.com/visionmedia/ejs>.\n\n=== Writing and running front-end tests for your plugin\n\nEtherpad allows you to easily create front-end tests for plugins.\n\n1. Create a new folder: `%your_plugin%/static/tests/frontend/specs`\n2. Put your spec file in there. (Example spec files are visible in\n   `%etherpad_root_folder%/frontend/tests/specs`.)\n3. Visit http://yourserver.com/frontend/tests and your front-end tests will run.\n"
  },
  {
    "path": "doc/plugins.md",
    "content": "# Plugins\n\nEtherpad allows you to extend its functionality with plugins. A plugin registers\nhooks (functions) for certain events (thus certain features) in Etherpad to\nexecute its own functionality based on these events.\n\nPublicly available plugins can be found in the npm registry (see\n<https://npmjs.org>). Etherpad's naming convention for plugins is to prefix your\nplugins with `ep_`. So, e.g. it's `ep_flubberworms`. Thus you can install\nplugins from npm, using `pnpm run plugins install ep_flubberworms` in Etherpad's root directory.\n\nAlso see [wiki article](https://github.com/ether/etherpad-lite/wiki/Available-Plugins) for more info.\n\nYou can also browse to `http://yourEtherpadInstan.ce/admin/plugins`, which will\nlist all installed plugins and those available on npm. It even provides\nfunctionality to search through all available plugins.\n\n## Folder structure\n\nIdeally a plugin has the following folder structure:\n\n```\nep_<plugin>/\n ├ .github/\n │  └ workflows/\n │     └ npmpublish.yml  ◄─ GitHub workflow to auto-publish on push\n ├ static/\n │  ├ css/               ◄─ static .css files\n │  ├ images/            ◄─ static image files\n │  ├ js/\n │  │  └ index.js        ◄─ static client-side code\n │  └ tests/\n │     ├ backend/\n │     │  └ specs/       ◄─ backend (server) tests\n │     └ frontend/\n │        └ specs/       ◄─ frontend (client) tests\n ├ templates/            ◄─ EJS templates (.html, .js, .css, etc.)\n ├ locales/\n │  ├ en.json            ◄─ English (US) strings\n │  └ qqq.json           ◄─ optional hints for translators\n ├ .travis.yml           ◄─ Travis CI config\n ├ LICENSE\n ├ README.md\n ├ ep.json               ◄─ Etherpad plugin definition\n ├ index.js              ◄─ server-side code\n ├ package.json\n └ package-lock.json\n```\n\nIf your plugin includes client-side hooks, put them in `static/js/`. If you're\nadding in CSS or image files, you should put those files in `static/css/ `and\n`static/image/`, respectively, and templates go into `templates/`. Translations\ngo into `locales/`. Tests go in `static/tests/backend/specs/` and\n`static/tests/frontend/specs/`.\n\nA Standard directory structure like this makes it easier to navigate through\nyour code. That said, do note, that this is not actually *required* to make your\nplugin run. If you want to make use of our i18n system, you need to put your\ntranslations into `locales/`, though, in order to have them integrated. (See\n\"Localization\" for more info on how to localize your plugin.)\n\n## Plugin definition\n\nYour plugin definition goes into `ep.json`. In this file you register your hook\nfunctions, indicate the parts of your plugin and the order of execution. (A\ndocumentation of all available events to hook into can be found in chapter\n[hooks](#all_hooks).)\n\n```json\n{\n  \"parts\": [\n    {\n      \"name\": \"nameThisPartHoweverYouWant\",\n      \"hooks\": {\n        \"authenticate\": \"ep_<plugin>/<file>:functionName1\",\n        \"expressCreateServer\": \"ep_<plugin>/<file>:functionName2\"\n      },\n      \"client_hooks\": {\n        \"acePopulateDOMLine\": \"ep_<plugin>/<file>:functionName3\"\n      }\n    }\n  ]\n}\n```\n\nA hook function registration maps a hook name to a hook function specification.\nThe hook function specification looks like `ep_example/file.js:functionName`. It\nconsists of two parts separated by a colon: a module name to `require()` and the\nname of a function exported by the named module. See\n[`module.exports`](https://nodejs.org/docs/latest/api/modules.html#modules_module_exports)\nfor how to export a function.\n\nFor the module name you can omit the `.js` suffix, and if the file is `index.js`\nyou can use just the directory name. You can also omit the module name entirely,\nin which case it defaults to the plugin name (e.g., `ep_example`).\n\nYou can also omit the function name. If you do, Etherpad will look for an\nexported function whose name matches the name of the hook (e.g.,\n`authenticate`).\n\nIf either the module name or the function name is omitted (or both), the colon\nmay also be omitted unless the provided module name contains a colon. (So if the\nmodule name is `C:\\foo.js` then the hook function specification with the\nfunction name omitted would be `\"C:\\\\foo.js:\"`.)\n\nExamples: Suppose the plugin name is `ep_example`. All of the following are\nequivalent, and will cause the `authorize` hook to call the `exports.authorize`\nfunction in `index.js` from the `ep_example` plugin:\n\n* `\"authorize\": \"ep_example/index.js:authorize\"`\n* `\"authorize\": \"ep_example/index.js:\"`\n* `\"authorize\": \"ep_example/index.js\"`\n* `\"authorize\": \"ep_example/index:authorize\"`\n* `\"authorize\": \"ep_example/index:\"`\n* `\"authorize\": \"ep_example/index\"`\n* `\"authorize\": \"ep_example:authorize\"`\n* `\"authorize\": \"ep_example:\"`\n* `\"authorize\": \"ep_example\"`\n* `\"authorize\": \":authorize\"`\n* `\"authorize\": \":\"`\n* `\"authorize\": \"\"`\n\n### Client hooks and server hooks\n\nThere are server hooks, which will be executed on the server (e.g.\n`expressCreateServer`), and there are client hooks, which are executed on the\nclient (e.g. `acePopulateDomLine`). Be sure to not make assumptions about the\nenvironment your code is running in, e.g. don't try to access `process`, if you\nknow your code will be run on the client, and likewise, don't try to access\n`window` on the server...\n\n### Styling\n\nWhen you install a client-side plugin (e.g. one that implements at least one\nclient-side hook), the plugin name is added to the `class` attribute of the div\n`#editorcontainerbox` in the main window. This gives you the opportunity of\ntuning the appearance of the main UI in your plugin.\n\nFor example, this is the markup with no plugins installed:\n\n```html\n<div id=\"editorcontainerbox\" class=\"\">\n```\n\nand this is the contents after installing `someplugin`:\n\n```html\n<div id=\"editorcontainerbox\" class=\"ep_someplugin\">\n```\n\nThis feature was introduced in Etherpad **1.8**.\n\n### Parts\n\nAs your plugins become more and more complex, you will find yourself in the need\nto manage dependencies between plugins. E.g. you want the hooks of a certain\nplugin to be executed before (or after) yours. You can also manage these\ndependencies in your plugin definition file `ep.json`:\n\n```json\n{\n  \"parts\": [\n    {\n      \"name\": \"onepart\",\n      \"pre\": [],\n      \"post\": [\"ep_onemoreplugin/partone\"]\n      \"hooks\": {\n        \"storeBar\": \"ep_monospace/plugin:storeBar\",\n        \"getFoo\": \"ep_monospace/plugin:getFoo\",\n      }\n    },\n    {\n      \"name\": \"otherpart\",\n      \"pre\": [\"ep_my_example/somepart\", \"ep_otherplugin/main\"],\n      \"post\": [],\n      \"hooks\": {\n        \"someEvent\": \"ep_my_example/otherpart:someEvent\",\n        \"another\": \"ep_my_example/otherpart:another\"\n      }\n    }\n  ]\n}\n```\n\nUsually a plugin will add only one functionality at a time, so it will probably\nonly use one `part` definition to register its hooks. However, sometimes you\nhave to put different (unrelated) functionalities into one plugin. For this you\nwill want use parts, so other plugins can depend on them.\n\n#### pre/post\n\nThe `\"pre\"` and `\"post\"` definitions, affect the order in which parts of a\nplugin are executed. This ensures that plugins and their hooks are executed in\nthe correct order.\n\n`\"pre\"` lists parts that must be executed *before* the defining part. `\"post\"`\nlists parts that must be executed *after* the defining part.\n\nYou can, on a basic level, think of this as double-ended dependency listing. If\nyou have a dependency on another plugin, you can make sure it loads before yours\nby putting it in `\"pre\"`. If you are setting up things that might need to be\nused by a plugin later, you can ensure proper order by putting it in `\"post\"`.\n\nNote that it would be far more sane to use `\"pre\"` in almost any case, but if\nyou want to change config variables for another plugin, or maybe modify its\nenvironment, `\"post\"` could definitely be useful.\n\nAlso, note that dependencies should *also* be listed in your package.json, so\nthey can be `npm install`'d automagically when your plugin gets installed.\n\n## Package definition\n\nYour plugin must also contain a [package definition\nfile](https://docs.npmjs.com/files/package.json), called package.json, in the\nproject root - this file contains various metadata relevant to your plugin, such\nas the name and version number, author, project hompage, contributors, a short\ndescription, etc. If you publish your plugin on npm, these metadata are used for\npackage search etc., but it's necessary for Etherpad plugins, even if you don't\npublish your plugin.\n\n```json\n{\n  \"name\": \"ep_PLUGINNAME\",\n  \"version\": \"0.0.1\",\n  \"description\": \"DESCRIPTION\",\n  \"author\": \"USERNAME (REAL NAME) <MAIL@EXAMPLE.COM>\",\n  \"contributors\": [],\n  \"dependencies\": {\"MODULE\": \"0.3.20\"},\n  \"engines\": {\"node\": \">=12.17.0\"}\n}\n```\n\n## Templates\n\nIf your plugin adds or modifies the front end HTML (e.g. adding buttons or\nchanging their functions), you should put the necessary HTML code for such\noperations in `templates/`, in files of type \".ejs\", since Etherpad uses EJS for\nHTML templating. See the following link for more information about EJS:\n<https://github.com/visionmedia/ejs>.\n\n## Writing and running front-end tests for your plugin\n\nEtherpad allows you to easily create front-end tests for plugins.\n\n1. Create a new folder: `%your_plugin%/static/tests/frontend/specs`\n2. Put your spec file in there. (Example spec files are visible in\n   `%etherpad_root_folder%/frontend/tests/specs`.)\n3. Visit http://yourserver.com/frontend/tests and your front-end tests will run.\n"
  },
  {
    "path": "doc/public/easysync/README.md",
    "content": "# About this folder\nWe put all documentations we found about the original Etherpad together in this folder. Most of this is still valid for the current (node.js based) Etherpad."
  },
  {
    "path": "doc/public/easysync/easysync-full-description.tex",
    "content": "\\documentclass{article} \n\\usepackage{hyperref}\n\n\\begin{document}\n\n\\title{Etherpad and EasySync Technical Manual}\n\\author{AppJet, Inc., with modifications by the Etherpad Foundation}\n\\date{\\today}\n\n\\maketitle\n\n%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%\n\\tableofcontents\n%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%\n\n\\section{Documents}\n\\begin{itemize}\n\\item A document is a list of characters, or a string.\n\\item A document can also be represented as a list of \\emph{changesets}.\n\\end{itemize}\n\n\\section{Changesets}\n\n\\begin{itemize}\n\\item A changeset represents a change to a document.\n\\item A changeset can be applied to a document to produce a new document.\n\\item When a document is represented as a list of changesets, it is assumed that the first changeset applies to the empty document, [].\n\\end{itemize}\n\n\n\\section{Changeset representation} \\label{representation}\n\n$$(\\ell \\rightarrow \\ell')[c_1,c_2,c_3,...]$$\n\nwhere\n\n\\begin{itemize}\n\\item[] $\\ell$ is the length of the document before the change,\n\\item[] $\\ell'$ is the length of the document after the change,\n\\item[] $[c_1,c_2,c_3,...]$ is an array of $\\ell'$ characters that described the document after the change.\n\\end{itemize}\n\nNote that $\\forall c_i : 0 \\leq i \\leq \\ell'$ is either an integer or a character.\n\n\\begin{itemize}\n\\item Integers represent retained characters in the original document.\n\\item Characters represent insertions.\n\\end{itemize}\n\n\\section{Constraints on Changesets}\n\n\\begin{itemize}\n\\item Changesets are canonical and therefor comparable.  When represented in computer memory, we always use the same representation for the same changeset.  If the memory representation of two changesets differ, they must be different changesets.\n\\item Changesets are compact.  Thus, if there are two ways to represent a changeset in computer memory, then we always use the representation that takes up the fewest bytes.\n\\end{itemize}\n\nLater we will discuss optimizations to changeset\nrepresentation (using ``strips'' and other such\ntechniques).  The two constraints must apply to any\nrepresentation of changesets.\n\n\\section{Notation}\n\n\\begin{itemize}\n\\item We use the algebraic multiplication notation to represent changeset application.\n\\item While changesets are defined as operations on documents, documents themselves are represented as a list of changesets, initially applying to the empty document.\n\\end{itemize}\n\n\\paragraph{Example}\n$A=(0\\rightarrow 5)[``hello\"]$\n$B=(5\\rightarrow 11)[0-4, ``\\ world\"]$\n\nWe can write the document ``hello world'' as $A\\cdot B$ or\njust $AB$.  Note that the ``initial document'' can be made\ninto the changeset $(0\\rightarrow\nN)[``<\\mathit{the\\ document\\ text}>\"]$.\n\nWhen $A$ and $B$ are changesets, we can also refer to $(AB)$ as ``the composition'' of $A$ and $B$.  Changesets are closed under composition.\n\n\\section{Composition of Changesets}\n\nFor any two changesets $A$, $B$ such that\n\n\\begin{itemize}\n\\item[] $A=(n_1\\rightarrow n_2)[\\cdots]$\n\\item[] $B=(n_2\\rightarrow n_3)[\\cdots]$\n\\end{itemize}\nit is clear that there is a third changeset $C=(n_1\\rightarrow n_3)[\\cdots]$ such that applying $C$ to a document $X$ yields the same resulting document as does applying $A$ and then $B$.  In this case, we write $AB=C$.\n\nGiven the representation from Section \\ref{representation}, it is straightforward to compute the composition of two changesets.\n\n\\section{Changeset Merging}\n\nNow we come to realtime document editing.  Suppose two different users make two different changes to the same document at the same time.  It is impossible to compose these changes.    For example, if we have the document $X$ of length $n$, we may have $A=(n\\rightarrow n_a)[\\ldots n_a \\mathrm{characters}]$,  $B=(n\\rightarrow n_b)[\\ldots n_b \\mathrm{characters}]$ where $n\\neq n_a\\neq n_b$.\n\nIt is impossible to compute $(XA)B$ because $B$ can only be applied to a document of length $n$, and $(XA)$ has length $n_a$.  Similarly, $A$ cannot be applied to $(XB)$ because $(XB)$ has length $n_b$.\n\nThis is where \\emph{merging} comes in.  Merging takes two changesets that apply to the same initial document (and that cannot be composed), and computes a single new changeset that preserves the intent of both changes.  The merge of $A$ and $B$ is written as $m(A,B)$.  For the Etherpad system to work, we require that $m(A,B)=m(B,A)$.\n\nAside from what we have said so far about merging, there are many different implementations that will lead to a workable system.  We have created one implementation for text that has the following constraints.\n\n\\section{Follows} \\label{follows}\n\nWhen users $A$ and $B$ have the same document $X$ on their screen, and they proceed to make respective changesets $A$ and $B$, it is no use to compute $m(A,B)$, because $m(A,B)$ applies to document $X$, but the users are already looking at document $XA$ and $XB$.  What we really want is to compute $B'$ and $A'$ such that\n$$XAB' = XBA' = Xm(A,B)$$\n\n``Following'' computes these $B'$ and $A'$ changesets.  The definition of the ``follow'' function $f$ is such that $Af(A,B)=Bf(B,A)=m(A,B)=m(B,A)$.  When we compute $f(A,B)$\n\\begin{itemize}\n\\item Insertions in $A$ become retained characters in $f(A,B)$\n\\item Insertions in $B$ become insertions in $f(A,B)$\n\\item Retain whatever characters are retained in \\emph{both} $A$ and $B$\n\\end{itemize}\n\n\\paragraph{Example}\n\nSuppose we have the initial document $X=(0\\rightarrow 8)[``\\mathit{baseball}\"]$ and user $A$ changes it to ``basil'' with changeset $A$, and user $B$ changes it to ``below'' with changeset $B$.\n\nWe have\n$X=(0\\rightarrow 8)[``\\mathit{baseball}\"]$ \\\\\n$A=(8\\rightarrow 5)[0-1, ``\\mathit{si}\", 7]$ \\\\\n$B=(8\\rightarrow 5)[0, ``\\mathit{e}\", 6, ``\\mathit{ow}\"]$ \\\\\n\nFirst we compute the merge $m(A,B)=m(B,A)$ according to the constraints\n\n$$m(A,B)=(8\\rightarrow 6)[0, \"e\", \"si\", \"ow\"] = (8\\rightarrow 6)[0, ``\\mathit{esiow}\"]$$\n\nThen we need to compute the follows $B'=f(A,B)$ and $A'=f(B,A)$.\n\n$$B'=f(A,B)=(5\\rightarrow 6)[0,``\\mathit{e}\",2,3,``\\mathit{ow}\"]$$\n\nNote that the numbers $0$, $2$, and $3$ are indices into $A=(8\\rightarrow 5)[0,1,``\\mathit{si}\",7]$\n\n\\begin{tabular}{ccccc}\n0 & 1 & 2 & 3 & 4 \\\\\n0 & 1 & s & i & 7\n\\end{tabular}\n\n$A'=f(B,A)=(5\\rightarrow 6)[0,1,\"si\",3,4]$\n\nWe can now double check that $AB'=BA'=m(A,B)=(8\\rightarrow 6)[0,``\\mathit{esiow}\"]$.\n\nNow that we have made the mathematical meaning of the\npreceding pages complete, we can build a client/server\nsystem to support realtime editing by multiple users.\n\n\\section{System Overview}\n\nThere is a server that holds the current state of a\ndocument.  Clients (users) can connect to the server from\ntheir web browsers.  The clients and server maintain state\nand can send messages to one another in real-time, but\nbecause we are in a web browser scenario, clients cannot\nsend each other messages directly, and must go through the\nserver always.  (This may distinguish from prior art?)\n\nThe other critical design feature of the system is that\n\\emph{A client must always be able to edit their local\n  copy of the document, so the user is never blocked from\n  typing because of waiting to send or receive data.}\n\n\\section{Client State}\n\nAt any moment in time, a client maintains its state in the\nform of 3 changesets.  The client document looks like\n$A\\cdot X \\cdot Y$, where\n\n$A$ is the latest server version, the composition of all\nchangesets committed to the server, from this client or\nfrom others, that the server has informed this client\nabout.  Initially $A=(0\\rightarrow N)[<\\mathit{initial\\ document\\ text}>]$.\n\n$X$ is the composition of all changesets this client has\nsubmitted to the server but has not heard back about yet.\nInitially $X=(N\\rightarrow N)[0,1,2,\\ldots, N-1]$, in\nother words, the identity, henceforth denoted $I_N$.\n\n$Y$ is the composition of all changesets this client has\nmade but has not yet submitted to the server yet.\nInitially $Y=(N\\rightarrow N)[0,1,2,\\ldots, N-1]$.\n\n\\section{Client Operations}\n\nA client can do 5 things.\n\n\\begin{enumerate}\n\\item Incorporate new typing into local state\n\\item Submit a changeset to the server\n\\item Hear back acknowledgement of a submitted changeset\n\\item Hear from the server about other clients' changesets\n\\item Connect to the server and request the initial document\n\\end{enumerate}\n\nAs these 5 events happen, the client updates its\nrepresentation $A\\cdot X \\cdot Y$ according to the\nrelations that follow.  Changes ``move left'' as time goes\nby: into $Y$ when the user types, into $X$ when change\nsets are submitted to the server, and into $A$ when the\nserver acknowledges changesets.\n\n\\subsection{New local typing}\n\nWhen a user makes an edit $E$ to the document, the client\ncomputes the composition $(Y\\cdot E)$ and updates its local\nstate, i.e. $Y \\leftarrow Y\\cdot E$.  I.e., if $Y$ is the\nvariable holding local unsubmitted changes, it will be\nassigned the new value $(Y\\cdot E)$.\n\n\\subsection{Submitting changesets to server}\n\nWhen a client submit its local changes to the server, it\ntransmits a copy of $Y$ and then assigns $Y$ to $X$, and\nassigns the identity to $Y$.  I.e.,\n\n\\begin{enumerate}\n\\item Send $Y$ to server,\n\\item $X \\leftarrow Y$\n\\item $Y \\leftarrow I_N$\n  (the identity).\n\\end{enumerate}\n\nThis happens every 500ms as long as it receives an\nacknowledgement.  Must receive ACK before submitting\nagain.  Note that $X$ is always equal to the identity\nbefore the second step occurs, so no information is lost.\n\n\\subsection{Hear ACK from server}\n\nWhen the client hears ACK from server,\n\n$A \\leftarrow A\\cdot X$ \\\\\n$X \\leftarrow I_N$\n\n\\subsection{Hear about another client's changeset}\n\nWhen a client hears about another client's changeset $B$,\nit computes a new $A$, $X$, and $Y$, which we will call\n$A'$, $X'$, and $Y'$ respectively.  It also computes a\nchangeset $D$ which is applied to the current text view on\nthe client, $V$.  Because $AXY$ must always equal the\ncurrent view, $AXY=V$ before the client hears about $B$,\nand $A'X'Y'=VD$ after the computation is performed.\n\nThe steps are:\n\n\\begin{enumerate}\n\\item Compute $A' = AB$\n\\item Compute $X' = f(B,X)$\n\\item Compute $Y' = f(f(X,B), Y)$\n\\item Compute $D=f(Y,f(X,B))$\n\\item Assign $A \\leftarrow A'$, $X \\leftarrow X'$, $Y \\leftarrow Y'$.\n\\item Apply $D$ to the current view of the document\n  displayed on the user's screen.\n\\end{enumerate}\n\nIn steps 2,3, and 4, $f$ is the follow operation described\nin Section \\ref{follows}.\n\n\\paragraph{Proof that $\\mathbf{AXY=V \\Rightarrow A'X'Y'=VD}$.}\nSubstituting $A'X'Y'=(AB)(f(B,X))(f(f(X,B),Y))$, we\nrecall that merges are commutative.  So for any two\nchangesets $P$ and $Q$, \n$$m(P,Q)=m(Q,P)=Qf(Q,P)=Pf(P,Q)$$\n\nApplying this to the relation above, we see\n\\begin{eqnarray*}\nA'X'Y'&=& AB f(B,X) f(f(X,B),Y) \\\\\n      &=&AX f(X,B) f(f(X,B),Y) \\\\\n      &=&A X Y f(Y, f(X,B)) \\\\\n      &=&A X Y D \\\\\n      &=&V D \n\\end{eqnarray*}\nAs claimed.\n\n\\subsection{Connect to server}\n\nWhen a client connects to the server for the first time,\nit first generates a random unique ID and sends this to\nthe server.  The client remembers this ID and sends it\nwith each changeset to the server.\n\nThe client receives the latest version of the document\nfrom the server, called HEADTEXT.  The client then sets\n\n\\begin{itemize}\n\\item[] $A \\leftarrow \\mathrm{HEADTEXT}$\n\\item[] $X \\leftarrow I_N$\n\\item[] $Y \\leftarrow I_N$\n\\end{itemize}\n\nAnd finally, the client displays HEADTEXT on the screen.\n\n\\section{Server Overview}\n\nLike the client(s), the server has state and performs\noperations.  Operations are only performed in response to\nmessages from clients.\n\n\\section{Server State}\n\nThe server maintains a document as an ordered list of\n\\emph{revision records}.  A revision record is a data\nstructure that contains a changeset and authorship\ninformation.\n\n\\begin{verbatim}\nRevisionRecord = {\n  ChangeSet,\n  Source (unique ID),\n  Revision Number (consecutive order, starting at 0)\n}\n\\end{verbatim}\n\nFor efficiency, the server may also store a variable\ncalled HEADTEXT, which is the composition of all\nchangesets in the list of revision records.  This is an\noptimization, because clearly this can be computed from\nthe set of revision records.\n\n\\section{Server Operations Overview}\n\nThe server does two things in addition to maintaining\nstate representing the set of connected clients and\nremembering what revision number each client is up to date\nwith:\n\n\\begin{enumerate}\n\\item Respond to a client's connection requesting the initial document.\n\\item Respond to a client's submission of a new changeset.\n\\end{enumerate}\n\n\\subsection{Respond to client connect}\nWhen a server receives a connection request from a client,\nit receives the client's unique ID and stores that in the\nserver's set of connected clients.  It then sends the\nclient the contents of HEADTEXT, and the corresponding\nrevision number.  Finally the server notes that this\nclient is up to date with that revision number.\n\n\\subsection{Respond to client changeset}\n\nWhen the server receives information from a client about\nthe client's changeset $C$, it does five things:\n\n\\begin{enumerate}\n\\item Notes that this change applies to revision number\n  $r_c$ (the client's latest revision).\n\\item Creates a new changeset $C'$ that is relative to the\n  server's most recent revision number, which we call\n  $r_H$ ($H$ for HEAD).  $C'$ can be computed using\n  follows (Section \\ref{follows}).  Remember that the server has a series of\n  changesets, \n$$S_0\\rightarrow S_1\\rightarrow \\ldots S_{r_c}\\rightarrow S_{r_c+1} \\rightarrow \\ldots \\rightarrow S_{r_H} $$\n$C$ is relative to $S_{r_c}$, but we need to compute $C'$ relative to $S_{r_H}$.\nWe can compute a new $C$ relative to $S_{r_c+1}$ by computing $f(S_{r_c+1},C)$.  Similarly we can repeat for\n$S_{r_c+2}$ and so forth until we have $C'$ represented relative to $S_{r_H}$.\n\\item Send $C'$ to all other clients\n\\item Send ACK back to original client\n\\item Add $C'$ to the server's list of revision records by creating a new revision record out of this and the client's ID.\n\n\\appendix\n\n\\section*{Additional topics}\n\\begin{enumerate}\n\\item Optimizations (strips, more caching, etc.)\n\\item Pseudocode for composition, merge, and follow\n\\item How authorship information is used to color-code the document based on who typed what\n\\item How persistent connections are maintained between client and server\n\\end{enumerate}\n\\end{enumerate}\n\n\n\\end{document}\n"
  },
  {
    "path": "doc/public/easysync/easysync-notes.tex",
    "content": "\\documentclass[12pt]{article}\n\n\\usepackage[T1]{fontenc}\n\\usepackage[USenglish]{babel} \n\n\n\\begin{document}\n\n\\title{Easysync Protocol}\n\\author{AppJet, Inc., with modifications by the Etherpad Foundation}\n\\date{\\today}\n\n\\maketitle\n\n\\section{Attributes}\n\nAn ``attribute'' is a (key,value) pair such as\n\\verb|(author,abc123)| or \\verb|(bold,true)|.\nSometimes an attribute is treated as an instruction to add\nthat attribute, in which case an empty value means to\nremove it.  So \\verb|(bold,)| removes the ``bold''\nattribute.  Attributes are interned and given numeric IDs,\nso the number ``\\verb|6|'' could represent\n``\\verb|(bold,true)|'', for example.  This mapping is\nstored in an attribute pool which may be shared by\nmultiple changesets.\n\nEntries in the pool must be unique, so that attributes can\nbe compared by their IDs.  Attribute names cannot contain\ncommas.\n\nA changeset looks something like the following:\n\n\\begin{verbatim}\nZ:5g>1|5=2p=v*4*5+1$x\n\\end{verbatim}\n\nWith the corresponding pool containing these entries (among others):\n\n\\begin{itemize}\n\\item[] \\verb|4| $\\rightarrow$ \\verb|(author,1059348573)|\n\\item[] \\verb|5| $\\rightarrow$ \\verb|(bold,true)|\n\\end{itemize}\n\nThis changeset, together with the attribute pool,\nrepresents inserting a bold letter ``x'' into the middle\nof a line.\n\nThe string consists of:\n\n\\begin{itemize}\n\\item a letter \\verb|Z| (the ``magic character'' and\n  format version identifier)\n\\item a series punctuation marks (operation codes or\n  ``opcodes'' for short), together with alphanumerics\n  (numeric values in base 36).\n\\item a dollar sign (\\verb|$|)\n\\item a string of characters used for insertion operations\n  (the ``char bank'')\n\\end{itemize}\n\nIn the example above, if we separate out the operations\nand convert the numbers to base 10, then we get:\n\\begin{verbatim}\nZ :196 >1 |5=97 =31 *4 *5 +1 $x\n\\end{verbatim}\nHere are descriptions of the operations, where capital\nletters are variables:\n\n\\begin{description}\n\\item{{\\bf :N}} \\quad \\\\ \nSource text has length $N$ (must be first op)\n\\item{{\\bf >N}} \\quad \\\\\nFinal text is $N$ (positive) characters longer than source\ntext (must be second op)\n\\item{{\\bf <N }} \\quad \\\\\nFinal text is $N$ (positive) characters shorter than\nsource text (must be second op)\n\\item{{\\bf >0 }} \\quad \\\\\nFinal text is same length as source text\n\\item{{\\bf +N }} \\quad \\\\\nInsert $N$ characters from the bank, none of them newlines\n\\item{{\\bf -N}} \\quad \\\\\nSkip over (delete) $N$ characters from the source text,\nnone of them newlines\n\\item{{\\bf =N}} \\quad \\\\\nKeep $N$ characters from the source text, none of them newlines\n\\item{{\\bf |L+N}} \\quad \\\\\nInsert $N$ characters from the source text, containing $L$\nnewlines.  The last character inserted MUST be a newline,\nbut not the (new) document's final newline.\n\\item{{\\bf |L-N}}  \\quad \\\\\nDelete $N$ characters from the source text, containing $L$\nnewlines. The last character inserted MUST be a newline,\nbut not the (old) document's final newline.\n\\item{{\\bf |L=N}}  \\quad \\\\\nKeep $N$ characters from the source text, containing L\nnewlines.  The last character kept MUST be a newline, and\nthe final newline of the document is allowed.\n\\item{{\\bf *I}} \\quad \\\\\nApply attribute $I$ from the pool to the following\n\\verb|+|, \\verb|=|, \\verb_|+_, or \\verb_|=_ command. In\nother words, any number of \\verb|*| ops can come before a\n\\verb_+_, \\verb_=_, or \\verb_|_ but not between a \\verb_|_\nand the corresponding \\verb_+_ or \\verb_=_. If \\verb_+_,\ntext is inserted having this attribute.  If \\verb_=_, text\nis kept but with the attribute applied as an attribute\naddition or removal. Consecutive attributes must be sorted\nlexically by (key,value) with key and value taken as\nstrings.  It's illegal to have duplicate keys for\n(key,value) pairs that apply to the same text.  It's\nillegal to have an empty value for a key in the case of an\ninsertion (\\verb_+_), the pair should just be omitted.\n\\end{description}\n\nCharacters from the source text that aren't accounted for\nare assumed to be kept with the same attributes.\n\n\\paragraph{Additional Constraints}\n\n\\begin{itemize}\n\\item Consecutive \\verb_+_, \\verb_-_, and \\verb_=_ ops of\n  the same type that could be combined are not allowed.\n  Whether combination is possible depends on the\n  attributes of the ops and whether each is multiline or\n not.  For example, two multiline deletions can never be\n consecutive, nor can any insertion come after a\n non-multiline insertion with the same attributes.\n\\item ``No-op'' ops are not allowed, such as deleting 0\n  characters.  However, attribute applications that don't\n  have any effect are allowed.\n\\item Characters at the end of the source text cannot be\n  explicitly kept with no changes; if the change doesn't\n  affect the last $N$ characters, those ``keep'' ops must\n  be left off.\n\\item In any consecutive sequence of insertions (\\verb_+_)\n  and deletions (\\verb_-_) with no keeps (\\verb_=_), the\n  deletions must come before the insertions.\n\\item The document text before and after will always end\n  with a newline.  This policy avoids a lot of\n  special-casing of the end of the document.  If a final\n  newline is always added when importing text and removed\n  when exporting text, then the changeset representation\n  can be used to process text files that may or may not\n  have a final newline.\n\\end{itemize}\n\n\\paragraph{Attribution string}\n\nAn \\emph{attribution string} is a series of inserts with\nno deletions or keeps.  For example, ``\\verb_*3+8|1+5_''\ndescribes the attributes of a string of length 13, where\nthe first 8 chars have attribute 3 and the next 5 chars\nhave no attributes, with the last of these 5 chars being a\nnewline.  Constraints apply similar to those affecting\nchangesets, but the restriction about the final newline of\nthe new document being added doesn't apply.\n\nAttributes in an attribution string cannot be empty, like\n``\\verb|(bold,)|'', they should instead be absent.\n\n\n\\section{Further Considerations}\n\n\\begin{itemize}\n\\item composing changesets/attributions with different\n  pools.\n\\item generalizing ``applyToAttribution'' to make\n  ``mutateAttributionLines'' and ``compose''\n\\end{itemize}\n\n\\section{Using Unicode?}\n\n\\begin{itemize}\n\\item no unicode (for efficient escaping, sightliness)\n\\item efficient operations for ACE and collab (attributed text, etc.)\n\\item good for time-slider\n\\item good for API\n\\item line-ending aware\nX more coherent (deleting or styling text merging with insertion)\n\\item server-side syntax highlighting?\n\\item unify author map with attribute pool\n\\item unify attributed text with changeset rep\n\\item not: reversible\n\\item force final newline of document to be preserved\n\\end{itemize}\n\n\\paragraph{Unicode bad!}\n\n\\begin{itemize}\n\\item ugly (hard to read)\n\\item more complex to parse\n\\item harder to store and transmit correctly\n\\item doesn't save all that much space anyway\n\\item blows up in size when string-escaped\n\\item embarrassing for API\n\\end{itemize}\n\n\n\\end{document}\n"
  },
  {
    "path": "doc/public/easysync/easysync-notes.txt",
    "content": "\n\nCopied from the old Etherpad. Found in /infrastructure/ace/\n\nGoals:\n\n- no unicode (for efficient escaping, sightliness)\n- efficient operations for ACE and collab (attributed text, etc.)\n- good for time-slider\n- good for API\n- line-ending aware\nX more coherent (deleting or styling text merging with insertion)\n- server-side syntax highlighting?\n- unify author map with attribute pool\n- unify attributed text with changeset rep\n- not: reversible\n- force final newline of document to be preserved\n\n- Unicode bad:\n  - ugly (hard to read)\n  - more complex to parse\n  - harder to store and transmit correctly\n  - doesn't save all that much space anyway\n  - blows up in size when string-escaped\n  - embarrassing for API\n\n\n# Attributes:\n\nAn \"attribute\" is a (key,value) pair such as (author,abc123456) or\n(bold,true).  Sometimes an attribute is treated as an instruction to\nadd that attribute, in which case an empty value means to remove it.\nSo (bold,) removes the \"bold\" attribute.  Attributes are interned and\ngiven numeric IDs, so the number \"6\" could represent \"(bold,true)\",\nfor example.  This mapping is stored in an attribute \"pool\" which may\nbe shared by multiple changesets.\n\nEntries in the pool must be unique, so that attributes can be compared\nby their IDs.  Attribute names cannot contain commas.\n\nA changeset looks something like the following:\n\nZ:5g>1|5=2p=v*4*5+1$x\n\nWith the corresponding pool containing these entries:\n\n...\n4 -> (author,1059348573)\n5 -> (bold,true)\n...\n\nThis changeset, together with the pool, represents inserting\na bold letter \"x\" into the middle of a line.  The string consists of:\n\n- a letter Z (the \"magic character\" and format version identifier)\n- a series of opcodes (punctuation) and numeric values in base 36 (the\n  alphanumerics)\n- a dollar sign ($)\n- a string of characters used by insertion operations (the \"char bank\")\n\nIf we separate out the operations and convert the numbers to base 10, we get:\n\nZ :196 >1 |5=97 =31 *4 *5 +1 $\"x\"\n\nHere are descriptions of the operations, where capital letters are variables:\n\n\":N\" : Source text has length N (must be first op)\n\">N\" : Final text is N (positive) characters longer than source text (must be second op)\n\"<N\" : Final text is N (positive) characters shorter than source text (must be second op)\n\">0\" : Final text is same length as source text\n\"+N\" : Insert N characters from the bank, none of them newlines\n\"-N\" : Skip over (delete) N characters from the source text, none of them newlines\n\"=N\" : Keep N characters from the source text, none of them newlines\n\"|L+N\" : Insert N characters from the source text, containing L newlines.  The last\n         character inserted MUST be a newline, but not the (new) document's final newline.  \n\"|L-N\" : Delete N characters from the source text, containing L newlines. The last\n         character inserted MUST be a newline, but not the (old) document's final newline.\n\"|L=N\" : Keep N characters from the source text, containing L newlines.  The last character\n         kept MUST be a newline, and the final newline of the document is allowed.\n\"*I\"   : Apply attribute I from the pool to the following +, =, |+, or |= command.\n         In other words, any number of * ops can come before a +, =, or | but not\n         between a | and the corresponding + or =.\n         If +, text is inserted having this attribute.  If =, text is kept but with\n         the attribute applied as an attribute addition or removal.\n         Consecutive attributes must be sorted lexically by (key,value) with key\n         and value taken as strings.  It's illegal to have duplicate keys\n         for (key,value) pairs that apply to the same text.  It's illegal to\n         have an empty value for a key in the case of an insertion (+), the\n         pair should just be omitted.\n\nCharacters from the source text that aren't accounted for are assumed to be kept\nwith the same attributes.\n\nAdditional Constraints:\n\n- Consecutive +, -, and = ops of the same type that could be combined are not allowed.\n  Whether combination is possible depends on the attributes of the ops and whether\n  each is multiline or not.  For example, two multiline deletions can never be\n  consecutive, nor can any insertion come after a non-multiline insertion with the\n  same attributes.\n- \"No-op\" ops are not allowed, such as deleting 0 characters.  However, attribute\n  applications that don't have any effect are allowed.\n- Characters at the end of the source text cannot be explicitly kept with no changes;\n  if the change doesn't affect the last N characters, those \"keep\" ops must be left off.\n- In any consecutive sequence of insertions (+) and deletions (-) with no keeps (=),\n  the deletions must come before the insertions.\n- The document text before and after will always end with a newline.  This policy avoids\n  a lot of special-casing of the end of the document.  If a final newline is\n  always added when importing text and removed when exporting text, then the\n  changeset representation can be used to process text files that may or may not\n  have a final newline.\n\nAttribution string:\n\nAn \"attribution string\" is a series of inserts with no deletions or keeps.\nFor example, \"*3+8|1+5\" describes the attributes of a string of length 13,\nwhere the first 8 chars have attribute 3 and the next 5 chars have no\nattributes, with the last of these 5 chars being a newline.  Constraints\napply similar to those affecting changesets, but the restriction about\nthe final newline of the new document being added doesn't apply.\n\nAttributes in an attribution string cannot be empty, like \"(bold,)\", they should\ninstead be absent.\n\n\n\n\n\n-------\nConsiderations:\n\n- composing changesets/attributions with different pools\n- generalizing \"applyToAttribution\" to make \"mutateAttributionLines\" and \"compose\"\n"
  },
  {
    "path": "doc/skins.adoc",
    "content": "== Skins\nYou can customize Etherpad appearance using skins.\nA skin is a directory located under `static/skins/<skin_name>`, with the following contents:\n\n* `index.js`: javascript that will be run in `/`\n* `index.css`: stylesheet affecting `/`\n* `pad.js`: javascript that will be run in `/p/:padid`\n* `pad.css`: stylesheet affecting `/p/:padid`\n* `timeslider.js`: javascript that will be run in `/p/:padid/timeslider`\n* `timeslider.css`: stylesheet affecting `/p/:padid/timeslider`\n* `favicon.ico`: overrides the default favicon\n* `robots.txt`: overrides the default `robots.txt`\n\nYou can choose a skin changing the parameter `skinName` in `settings.json`.\n\nSince Etherpad **1.7.5**, two skins are included:\n\n* `no-skin`: an empty skin, leaving the default Etherpad appearance unchanged, that you can use as a guidance to develop your own.\n* `colibris`: a new, experimental skin, that will become the default in Etherpad 2.0.\n"
  },
  {
    "path": "doc/skins.md",
    "content": "# Skins\nYou can customize Etherpad appearance using skins.\nA skin is a directory located under `static/skins/<skin_name>`, with the following contents:\n\n* `index.js`: javascript that will be run in `/`\n* `index.css`: stylesheet affecting `/`\n* `pad.js`: javascript that will be run in `/p/:padid`\n* `pad.css`: stylesheet affecting `/p/:padid`\n* `timeslider.js`: javascript that will be run in `/p/:padid/timeslider`\n* `timeslider.css`: stylesheet affecting `/p/:padid/timeslider`\n* `favicon.ico`: overrides the default favicon\n* `robots.txt`: overrides the default `robots.txt`\n\nYou can choose a skin changing the parameter `skinName` in `settings.json`.\n\nSince Etherpad **1.7.5**, two skins are included:\n\n* `no-skin`: an empty skin, leaving the default Etherpad appearance unchanged, that you can use as guidance to develop your own.\n* `colibris`: a new, experimental skin, that will become the default in Etherpad 2.0.\n"
  },
  {
    "path": "doc/stats.adoc",
    "content": "== Statistics\n\nEtherpad keeps track of the goings-on inside the edit machinery. If you'd like to have a look at this, just point your browser to `/stats`.\n\nWe currently measure:\n\n - totalUsers (counter)\n - connects (meter)\n - disconnects (meter)\n - pendingEdits (counter)\n - edits (timer)\n - failedChangesets (meter)\n - httpRequests (timer)\n - http500 (meter)\n - memoryUsage (gauge)\n\nUnder the hood, we are happy to rely on https://github.com/felixge/node-measured[measured] for all our metrics needs.\n\nTo modify or simply access our stats in your plugin, simply `require('ep_etherpad-lite/stats')` which is a https://yaorg.github.io/node-measured/packages/measured-core/Collection.html[`measured.Collection`].\n\n\n=== Prometheus scraper\n\nBesides the non standard `/stats` endpoint, Etherpad also exposes a `/stats/prometheus` endpoint which is compatible with Prometheus scraping. It includes a lot more metrics than the standard `/stats` endpoint. It contains the following metrics:\n\n- ueberdb stats\n- gc\n- memory\n- event loop lag\n- v8\n- and more\n"
  },
  {
    "path": "doc/stats.md",
    "content": "# Statistics\nEtherpad keeps track of the goings-on inside the edit machinery. If you'd like to have a look at this, just point your browser to `/stats`.\n\nWe currently measure:\n\n- totalUsers (counter)\n- connects (meter)\n- disconnects (meter)\n- pendingEdits (counter)\n- edits (timer)\n- failedChangesets (meter)\n- httpRequests (timer)\n- http500 (meter)\n- memoryUsage (gauge)\n\nUnder the hood, we are happy to rely on [measured](https://github.com/felixge/node-measured) for all our metrics needs.\n\nTo modify or simply access our stats in your plugin, simply `require('ep_etherpad-lite/stats')` which is a [`measured.Collection`](https://yaorg.github.io/node-measured/packages/measured-core/Collection.html).\n"
  },
  {
    "path": "docker-compose.dev.yml",
    "content": "version: \"3.8\"\n\n# Add this file to extend the docker-compose setup, e.g.:\n# docker-compose build --no-cache\n# docker-compose up -d --build --force-recreate\n\nservices:\n  app:\n    build:\n      context: .\n      args:\n        # Attention: installed plugins in the node_modules folder get overwritten during volume mount in dev\n        ETHERPAD_PLUGINS:\n      # change from development to production if needed\n      target: development\n    tty: true\n    stdin_open: true\n    volumes:\n      # no volume mapping of node_modules as otherwise the build-time installed plugins will be overwritten with the mount\n      # the same applies to package.json and pnpm-lock.yaml in root dir as these would also get overwritten and build time installed plugins will be removed\n      - ./src:/opt/etherpad-lite/src\n      - ./bin:/opt/etherpad-lite/bin\n    depends_on:\n      - postgres\n    environment:\n      # change from development to production if needed\n      NODE_ENV: development\n      ADMIN_PASSWORD: ${DOCKER_COMPOSE_APP_DEV_ADMIN_PASSWORD}\n      DB_CHARSET: ${DOCKER_COMPOSE_APP_DEV_ENV_DB_CHARSET:-utf8mb4}\n      DB_HOST: postgres\n      DB_NAME: ${DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_DATABASE:?}\n      DB_PASS: ${DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_PASSWORD:?}\n      DB_PORT: ${DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_PORT:-5432}\n      DB_TYPE: \"postgres\"\n      DB_USER: ${DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_USER:?}\n      # For now, the env var DEFAULT_PAD_TEXT cannot be unset or empty; it seems to be mandatory in the latest version of etherpad\n      DEFAULT_PAD_TEXT: ${DOCKER_COMPOSE_APP_DEV_ENV_DEFAULT_PAD_TEXT:- }\n      DISABLE_IP_LOGGING: ${DOCKER_COMPOSE_APP_DEV_ENV_DISABLE_IP_LOGGING:-true}\n      SOFFICE: ${DOCKER_COMPOSE_APP_DEV_ENV_SOFFICE:-null}\n      TRUST_PROXY: ${DOCKER_COMPOSE_APP_DEV_ENV_TRUST_PROXY:-true}\n    restart: always\n    ports:\n      - \"${DOCKER_COMPOSE_APP_DEV_PORT_PUBLISHED:-9001}:${DOCKER_COMPOSE_APP_DEV_PORT_TARGET:-9001}\"\n\n  postgres:\n    image: postgres:15-alpine\n    # Pass config parameters to the mysql server.\n    # Find more information below when you need to generate the ssl-relevant file your self\n    environment:\n      POSTGRES_DB: ${DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_DATABASE:?}\n      POSTGRES_PASSWORD: ${DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_PASSWORD:?}\n      POSTGRES_PORT: ${DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_PORT:-5432}\n      POSTGRES_USER: ${DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_USER:?}\n      PGDATA: /var/lib/postgresql/data/pgdata\n    restart: always\n    # Exposing the port is not needed unless you want to access this database instance from the host.\n    # Be careful when other postgres docker container are running on the same port\n    # ports:\n    #   - \"5432:5432\"\n    volumes:\n      - postgres_data:/var/lib/postgresql/data\n\nvolumes:\n  postgres_data:\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  app:\n    user: \"5001:0\"\n    image: etherpad/etherpad:latest\n    tty: true\n    stdin_open: true\n    volumes:\n      - plugins:/opt/etherpad-lite/src/plugin_packages\n      - etherpad-var:/opt/etherpad-lite/var\n    depends_on:\n      - postgres\n    environment:\n      NODE_ENV: production\n      ADMIN_PASSWORD: ${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:-admin}\n      DB_CHARSET: ${DOCKER_COMPOSE_APP_DB_CHARSET:-utf8mb4}\n      DB_HOST: postgres\n      DB_NAME: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad}\n      DB_PASS: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin}\n      DB_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432}\n      DB_TYPE: \"postgres\"\n      DB_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}\n      # For now, the env var DEFAULT_PAD_TEXT cannot be unset or empty; it seems to be mandatory in the latest version of etherpad\n      DEFAULT_PAD_TEXT: ${DOCKER_COMPOSE_APP_DEFAULT_PAD_TEXT:- }\n      DISABLE_IP_LOGGING: ${DOCKER_COMPOSE_APP_DISABLE_IP_LOGGING:-false}\n      SOFFICE: ${DOCKER_COMPOSE_APP_SOFFICE:-null}\n      TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-true}\n    restart: always\n    ports:\n      - \"${DOCKER_COMPOSE_APP_PORT_PUBLISHED:-9001}:${DOCKER_COMPOSE_APP_PORT_TARGET:-9001}\"\n\n  postgres:\n    image: postgres:15-alpine\n    environment:\n      POSTGRES_DB: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad}\n      POSTGRES_PASSWORD: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin}\n      POSTGRES_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432}\n      POSTGRES_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}\n      PGDATA: /var/lib/postgresql/data/pgdata\n    restart: always\n    # Exposing the port is not needed unless you want to access this database instance from the host.\n    # Be careful when other postgres docker container are running on the same port\n    # ports:\n    #   - \"5432:5432\"\n    volumes:\n      - postgres_data:/var/lib/postgresql/data\n\nvolumes:\n  postgres_data:\n  plugins:\n  etherpad-var:\n"
  },
  {
    "path": "local_plugins/.gitignore",
    "content": "# ignore everything\n*\n!.gitignore\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"etherpad\",\n  \"description\": \"A free and open source realtime collaborative editor\",\n  \"homepage\": \"https://etherpad.org\",\n  \"type\": \"module\",\n  \"keywords\": [\n    \"etherpad\",\n    \"realtime\",\n    \"collaborative\",\n    \"editor\"\n  ],\n  \"bin\": {\n    \"etherpad-healthcheck\": \"bin/etherpad-healthcheck\"\n  },\n  \"scripts\": {\n    \"lint\": \"pnpm --filter ep_etherpad-lite run lint\",\n    \"test\": \"pnpm --filter ep_etherpad-lite run test\",\n    \"test-utils\": \"pnpm --filter ep_etherpad-lite run test-utils\",\n    \"test-container\": \"pnpm --filter ep_etherpad-lite run test-container\",\n    \"dev\": \"pnpm --filter ep_etherpad-lite run dev\",\n    \"prod\": \"pnpm --filter ep_etherpad-lite run prod\",\n    \"ts-check\": \"pnpm --filter ep_etherpad-lite run ts-check\",\n    \"ts-check:watch\": \"pnpm --filter ep_etherpad-lite run ts-check:watch\",\n    \"test-ui\": \"pnpm --filter ep_etherpad-lite run test-ui\",\n    \"test-ui:ui\": \"pnpm --filter ep_etherpad-lite run test-ui:ui\",\n    \"test-admin\": \"pnpm --filter ep_etherpad-lite run test-admin\",\n    \"test-admin:ui\": \"pnpm --filter ep_etherpad-lite run test-admin:ui\",\n    \"plugins\": \"pnpm --filter bin run plugins\",\n    \"install-plugins\": \"pnpm --filter bin run plugins i\",\n    \"remove-plugins\": \"pnpm --filter bin run remove-plugins\",\n    \"list-plugins\": \"pnpm --filter bin run list-plugins\",\n    \"build:etherpad\": \"pnpm --filter admin run build-copy && pnpm --filter ui run build-copy\",\n    \"build:ui\": \"pnpm --filter ui run build-copy && pnpm --filter admin run build-copy\",\n    \"makeDocs\": \"pnpm --filter bin run makeDocs\"\n  },\n  \"dependencies\": {\n    \"ep_etherpad-lite\": \"link:src\"\n  },\n  \"devDependencies\": {\n    \"admin\": \"link:admin\",\n    \"docs\": \"link:doc\",\n    \"ui\": \"link:ui\"\n  },\n  \"engines\": {\n    \"node\": \">=20.0.0\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/ether/etherpad-lite.git\"\n  },\n  \"engineStrict\": true,\n  \"version\": \"2.6.1\",\n  \"license\": \"Apache-2.0\"\n}\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - src\n  - admin\n  - bin\n  - doc\n  - ui\nonlyBuiltDependencies:\n  - '@scarf/scarf'\n  - '@swc/core'\n  - esbuild\n"
  },
  {
    "path": "settings.json.docker",
    "content": "/**\n * THIS IS THE SETTINGS FILE THAT IS COPIED INSIDE THE DOCKER CONTAINER.\n *\n * By default, some runtime customizations are supported (see the\n * documentation).\n *\n * If you need more control, edit this file and rebuild the container.\n */\n\n/*\n * This file must be valid JSON. But comments are allowed\n *\n * Please edit settings.json, not settings.json.template\n *\n * Please note that starting from Etherpad 1.6.0 you can store DB credentials in\n * a separate file (credentials.json).\n *\n *\n * ENVIRONMENT VARIABLE SUBSTITUTION\n * =================================\n *\n * All the configuration values can be read from environment variables using the\n * syntax \"${ENV_VAR}\" or \"${ENV_VAR:default_value}\".\n *\n * This is useful, for example, when running in a Docker container.\n *\n * DETAILED RULES:\n *   - If the environment variable is set to the string \"true\" or \"false\", the\n *     value becomes Boolean true or false.\n *   - If the environment variable is set to the string \"null\", the value\n *     becomes null.\n *   - If the environment variable is set to the string \"undefined\", the setting\n *     is removed entirely, except when used as the member of an array in which\n *     case it becomes null.\n *   - If the environment variable is set to a string representation of a finite\n *     number, the string is converted to that number.\n *   - If the environment variable is set to any other string, including the\n *     empty string, the value is that string.\n *   - If the environment variable is unset and a default value is provided, the\n *     value is as if the environment variable was set to the provided default:\n *       - \"${UNSET_VAR:}\" becomes the empty string.\n *       - \"${UNSET_VAR:foo}\" becomes the string \"foo\".\n *       - \"${UNSET_VAR:true}\" and \"${UNSET_VAR:false}\" become true and false.\n *       - \"${UNSET_VAR:null}\" becomes null.\n *       - \"${UNSET_VAR:undefined}\" causes the setting to be removed (or be set\n *         to null, if used as a member of an array).\n *   - If the environment variable is unset and no default value is provided,\n *     the value becomes null. THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF\n *     ETHERPAD; if you want the default value to be null, you should explicitly\n *     specify \"null\" as the default value.\n *\n * EXAMPLE:\n *    \"port\":     \"${PORT:9001}\"\n *    \"minify\":   \"${MINIFY}\"\n *    \"skinName\": \"${SKIN_NAME:colibris}\"\n *\n * Would read the configuration values for those items from the environment\n * variables PORT, MINIFY and SKIN_NAME.\n *\n * If PORT and SKIN_NAME variables were not defined, the default values 9001 and\n * \"colibris\" would be used.\n * The configuration value \"minify\", on the other hand, does not have a\n * designated default value. Thus, if the environment variable MINIFY were\n * undefined, \"minify\" would be null.\n *\n * REMARKS:\n * 1) please note that variable substitution always needs to be quoted.\n *\n *    \"port\":     9001,            <-- Literal values. When not using\n *    \"minify\":   false                substitution, only strings must be\n *    \"skinName\": \"colibris\"           quoted. Booleans and numbers must not.\n *\n *    \"port\":     \"${PORT:9001}\"   <-- CORRECT: if you want to use a variable\n *    \"minify\":   \"${MINIFY:true}\"     substitution, put quotes around its name,\n *    \"skinName\": \"${SKIN_NAME}\"       even if the required value is a number or\n *                                     a boolean.\n *                                     Etherpad will take care of rewriting it\n *                                     to the proper type if necessary.\n *\n *    \"port\":     ${PORT:9001}     <-- ERROR: this is not valid json. Quotes\n *    \"minify\":   ${MINIFY}            around variable names are missing.\n *    \"skinName\": ${SKIN_NAME}\n *\n * 2) Beware of undefined variables and default values: nulls and empty strings\n *    are different!\n *\n *    This is particularly important for user's passwords (see the relevant\n *    section):\n *\n *    \"password\": \"${PASSW}\"  // if PASSW is not defined would result in password === null\n *    \"password\": \"${PASSW:}\" // if PASSW is not defined would result in password === ''\n *\n *    If you want to use an empty value (null) as default value for a variable,\n *    simply do not set it, without putting any colons: \"${ABIWORD}\".\n *\n * 3) if you want to use newlines in the default value of a string parameter,\n *    use \"\\n\" as usual.\n *\n *    \"defaultPadText\" : \"${DEFAULT_PAD_TEXT:Line 1\\nLine 2}\"\n */\n{\n  /*\n   * Name your instance!\n   */\n  \"title\": \"${TITLE:Etherpad}\",\n\n  /*\n  * Whether to show recent pads on the homepage or not.\n  */\n  \"showRecentPads\": \"${SHOW_RECENT_PADS:true}\",\n\n  /*\n   * Pathname of the favicon you want to use. If null, the skin's favicon is\n   * used if one is provided by the skin, otherwise the default Etherpad favicon\n   * is used. If this is a relative path it is interpreted as relative to the\n   * Etherpad root directory.\n   */\n  \"favicon\": \"${FAVICON:null}\",\n\n  /*\n   * Skin name.\n   *\n   * Its value has to be an existing directory under src/static/skins.\n   * You can write your own, or use one of the included ones:\n   *\n   * - \"no-skin\":  an empty skin (default). This yields the unmodified,\n   *               traditional Etherpad theme.\n   * - \"colibris\": the new experimental skin (since Etherpad 1.8), candidate to\n   *               become the default in Etherpad 2.0\n   */\n  \"skinName\": \"${SKIN_NAME:colibris}\",\n\n  /*\n   * Skin Variants\n   *\n   * Use the UI skin variants builder at /p/test#skinvariantsbuilder\n   *\n   * For the colibris skin only, you can choose how to render the three main\n   * containers:\n   * - toolbar (top menu with icons)\n   * - editor (containing the text of the pad)\n   * - background (area outside of editor, mostly visible when using page style)\n   *\n   * For each of the 3 containers you can choose 4 color combinations:\n   * super-light, light, dark, super-dark.\n   *\n   * For example, to make the toolbar dark, you will include \"dark-toolbar\" into\n   * skinVariants.\n   *\n   * You can provide multiple skin variants separated by spaces. Default\n   * skinVariant is \"super-light-toolbar super-light-editor light-background\".\n   *\n   * For the editor container, you can also make it full width by adding\n   * \"full-width-editor\" variant (by default editor is rendered as a page, with\n   * a max-width of 900px).\n   */\n  \"skinVariants\": \"${SKIN_VARIANTS:super-light-toolbar super-light-editor light-background}\",\n\n  /*\n   * IP and port which Etherpad should bind at.\n   *\n   * Binding to a Unix socket is also supported: just use an empty string for\n   * the ip, and put the full path to the socket in the port parameter.\n   *\n   * EXAMPLE USING UNIX SOCKET:\n   *    \"ip\": \"\",                             // <-- has to be an empty string\n   *    \"port\" : \"/somepath/etherpad.socket\", // <-- path to a Unix socket\n   */\n  \"ip\": \"${IP:0.0.0.0}\",\n  \"port\": \"${PORT:9001}\",\n\n  /*\n   * Option to hide/show the settings.json in admin page.\n   *\n   * Default option is set to true\n   */\n  \"showSettingsInAdminPage\": \"${SHOW_SETTINGS_IN_ADMIN_PAGE:true}\",\n\n  /*\n   * Enable/disable the metrics endpoint.\n   *\n   * This is used by the monitoring plugins to collect metrics about Etherpad.\n   * If you do not use any monitoring plugins, you can disable this.\n   */\n  \"enableMetrics\": \"${ENABLE_METRICS:true}\",\n\n  /*\n   * Settings for cleanup of pads\n   */\n  \"cleanup\": {\n    \"enabled\": false,\n    \"keepRevisions\": 5\n  },\n\n  /*\n    The authentication method used by the server.\n    The default value is sso\n    If you want to use the old authentication system, change this to apikey\n   */\n  \"authenticationMethod\": \"${AUTHENTICATION_METHOD:sso}\",\n\n  /**\n  * Allow setting dark mode for the enduser. This is so if the user has preferred dark mode in their browser, Etherpad will respect that.\n  * Of course this overrides all the skin variants and the skinName set by the administrator.\n  **/\n  \"enableDarkMode\": \"${ENABLE_DARK_MODE:true}\",\n\n  /*\n   * Node native SSL support\n   *\n   * This is disabled by default.\n   * Make sure to have the minimum and correct file access permissions set so\n   * that the Etherpad server can access them\n   */\n\n  /*\n  \"ssl\" : {\n            \"key\"  : \"/path-to-your/epl-server.key\",\n            \"cert\" : \"/path-to-your/epl-server.crt\",\n            \"ca\": [\"/path-to-your/epl-intermediate-cert1.crt\", \"/path-to-your/epl-intermediate-cert2.crt\"]\n          },\n  */\n\n\n  /*\n   * Enables the use of a different server. We have a different one that syncs changes from the original server.\n   * It is hosted on GitHub and should not be blocked by many firewalls.\n   *  https://etherpad.org/ep_infos\n   */\n\n  \"updateServer\": \"https://etherpad.org/ep_infos\",\n\n  /*\n   * The type of the database.\n   *\n   * You can choose between many DB drivers, for example: dirty, postgres,\n   * sqlite, mysql.\n   *\n   * You shouldn't use \"dirty\" for for anything else than testing or\n   * development.\n   *\n   *\n   * Database specific settings are dependent on dbType, and go in dbSettings.\n   * Remember that since Etherpad 1.6.0 you can also store this information in\n   * credentials.json.\n   *\n   * For a complete list of the supported drivers, please refer to:\n   * https://www.npmjs.com/package/ueberdb2\n   */\n\n  \"dbType\": \"${DB_TYPE:dirty}\",\n  \"dbSettings\": {\n    \"host\":       \"${DB_HOST:undefined}\",\n    \"port\":       \"${DB_PORT:undefined}\",\n    \"database\":   \"${DB_NAME:undefined}\",\n    \"user\":       \"${DB_USER:undefined}\",\n    \"password\":   \"${DB_PASS:undefined}\",\n    \"charset\":    \"${DB_CHARSET:undefined}\",\n    \"filename\":   \"${DB_FILENAME:var/dirty.db}\",\n    \"collection\": \"${DB_COLLECTION:undefined}\",\n    \"url\":        \"${DB_URL:undefined}\"\n  },\n\n  /*\n   * The default text of a pad\n   */\n  \"defaultPadText\" : \"${DEFAULT_PAD_TEXT:Welcome to Etherpad!\\n\\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\\n\\nGet involved with Etherpad at https:\\/\\/etherpad.org\\n}\",\n\n  /*\n   * Default Pad behavior.\n   *\n   * Change them if you want to override.\n   */\n  \"padOptions\": {\n    \"noColors\":         \"${PAD_OPTIONS_NO_COLORS:false}\",\n    \"showControls\":     \"${PAD_OPTIONS_SHOW_CONTROLS:true}\",\n    \"showChat\":         \"${PAD_OPTIONS_SHOW_CHAT:true}\",\n    \"showLineNumbers\":  \"${PAD_OPTIONS_SHOW_LINE_NUMBERS:true}\",\n    \"useMonospaceFont\": \"${PAD_OPTIONS_USE_MONOSPACE_FONT:false}\",\n    \"userName\":         \"${PAD_OPTIONS_USER_NAME:null}\",\n    \"userColor\":        \"${PAD_OPTIONS_USER_COLOR:null}\",\n    \"rtl\":              \"${PAD_OPTIONS_RTL:false}\",\n    \"alwaysShowChat\":   \"${PAD_OPTIONS_ALWAYS_SHOW_CHAT:false}\",\n    \"chatAndUsers\":     \"${PAD_OPTIONS_CHAT_AND_USERS:false}\",\n    \"lang\":             \"${PAD_OPTIONS_LANG:null}\"\n  },\n\n  /*\n   * Pad Shortcut Keys\n   */\n  \"padShortcutEnabled\" : {\n    \"altF9\":     \"${PAD_SHORTCUTS_ENABLED_ALT_F9:true}\",      /* focus on the File Menu and/or editbar */\n    \"altC\":      \"${PAD_SHORTCUTS_ENABLED_ALT_C:true}\",       /* focus on the Chat window */\n    \"cmdShift2\": \"${PAD_SHORTCUTS_ENABLED_CMD_SHIFT_2:true}\", /* shows a gritter popup showing a line author */\n    \"delete\":    \"${PAD_SHORTCUTS_ENABLED_DELETE:true}\",\n    \"return\":    \"${PAD_SHORTCUTS_ENABLED_RETURN:true}\",\n    \"esc\":       \"${PAD_SHORTCUTS_ENABLED_ESC:true}\",         /* in mozilla versions 14-19 avoid reconnecting pad */\n    \"cmdS\":      \"${PAD_SHORTCUTS_ENABLED_CMD_S:true}\",       /* save a revision */\n    \"tab\":       \"${PAD_SHORTCUTS_ENABLED_TAB:true}\",         /* indent */\n    \"cmdZ\":      \"${PAD_SHORTCUTS_ENABLED_CMD_Z:true}\",       /* undo/redo */\n    \"cmdY\":      \"${PAD_SHORTCUTS_ENABLED_CMD_Y:true}\",       /* redo */\n    \"cmdI\":      \"${PAD_SHORTCUTS_ENABLED_CMD_I:true}\",       /* italic */\n    \"cmdB\":      \"${PAD_SHORTCUTS_ENABLED_CMD_B:true}\",       /* bold */\n    \"cmdU\":      \"${PAD_SHORTCUTS_ENABLED_CMD_U:true}\",       /* underline */\n    \"cmd5\":      \"${PAD_SHORTCUTS_ENABLED_CMD_5:true}\",       /* strike through */\n    \"cmdShiftL\": \"${PAD_SHORTCUTS_ENABLED_CMD_SHIFT_L:true}\", /* unordered list */\n    \"cmdShiftN\": \"${PAD_SHORTCUTS_ENABLED_CMD_SHIFT_N:true}\", /* ordered list */\n    \"cmdShift1\": \"${PAD_SHORTCUTS_ENABLED_CMD_SHIFT_1:true}\", /* ordered list */\n    \"cmdShiftC\": \"${PAD_SHORTCUTS_ENABLED_CMD_SHIFT_C:true}\", /* clear authorship */\n    \"cmdH\":      \"${PAD_SHORTCUTS_ENABLED_CMD_H:true}\",       /* backspace */\n    \"ctrlHome\":  \"${PAD_SHORTCUTS_ENABLED_CTRL_HOME:true}\",   /* scroll to top of pad */\n    \"pageUp\":    \"${PAD_SHORTCUTS_ENABLED_PAGE_UP:true}\",\n    \"pageDown\":  \"${PAD_SHORTCUTS_ENABLED_PAGE_DOWN:true}\"\n  },\n\n  /*\n   * Should we suppress errors from being visible in the default Pad Text?\n   */\n  \"suppressErrorsInPadText\": \"${SUPPRESS_ERRORS_IN_PAD_TEXT:false}\",\n\n  /*\n   * If this option is enabled, a user must have a session to access pads.\n   * This effectively allows only group pads to be accessed.\n   */\n  \"requireSession\": \"${REQUIRE_SESSION:false}\",\n\n  /*\n   * Users may edit pads but not create new ones.\n   *\n   * Pad creation is only via the API.\n   * This applies both to group pads and regular pads.\n   */\n  \"editOnly\": \"${EDIT_ONLY:false}\",\n\n  /*\n   * If true, all css & js will be minified before sending to the client.\n   *\n   * This will improve the loading performance massively, but makes it difficult\n   * to debug the javascript/css\n   */\n  \"minify\": \"${MINIFY:true}\",\n\n  /*\n   * How long may clients use served javascript code (in seconds)?\n   *\n   * Not setting this may cause problems during deployment.\n   * Set to 0 to disable caching.\n   */\n  \"maxAge\": \"${MAX_AGE:21600}\", // 60 * 60 * 6 = 6 hours\n\n  /*\n   * Absolute path to the Abiword executable.\n   *\n   * Abiword is needed to get advanced import/export features of pads. Setting\n   * it to null disables Abiword and will only allow plain text and HTML\n   * import/exports.\n   */\n  \"abiword\": \"${ABIWORD:null}\",\n\n  /*\n   * This is the absolute path to the soffice executable.\n   *\n   * LibreOffice can be used in lieu of Abiword to export pads.\n   * Setting it to null disables LibreOffice exporting.\n   */\n  \"soffice\": \"${SOFFICE:null}\",\n\n  /*\n   * Allow import of file types other than the supported ones:\n   * txt, doc, docx, rtf, odt, html & htm\n   */\n  \"allowUnknownFileEnds\": \"${ALLOW_UNKNOWN_FILE_ENDS:true}\",\n\n  /*\n   * This setting is used if you require authentication of all users.\n   *\n   * Note: \"/admin\" always requires authentication.\n   */\n  \"requireAuthentication\": \"${REQUIRE_AUTHENTICATION:false}\",\n\n  /*\n   * Require authorization by a module, or a user with is_admin set, see below.\n   */\n  \"requireAuthorization\": \"${REQUIRE_AUTHORIZATION:false}\",\n\n  /*\n   * When you use NGINX or another proxy/load-balancer set this to true.\n   *\n   * This is especially necessary when the reverse proxy performs SSL\n   * termination, otherwise the cookies will not have the \"secure\" flag.\n   *\n   * The other effect will be that the logs will contain the real client's IP,\n   * instead of the reverse proxy's IP.\n   */\n  \"trustProxy\": \"${TRUST_PROXY:false}\",\n\n  /*\n   * Settings controlling the session cookie issued by Etherpad.\n   */\n  \"cookie\": {\n    /*\n     * How often (in milliseconds) the key used to sign the express_sid cookie\n     * should be rotated. Long rotation intervals reduce signature verification\n     * overhead (because there are fewer historical keys to check) and database\n     * load (fewer historical keys to store, and less frequent queries to\n     * get/update the keys). Short rotation intervals are slightly more secure.\n     *\n     * Multiple Etherpad processes sharing the same database (table) is\n     * supported as long as the clock sync error is significantly less than this\n     * value.\n     *\n     * Key rotation can be disabled (not recommended) by setting this to 0 or\n     * null, or by disabling session expiration (see sessionLifetime).\n     */\n    // 86400000 = 1d * 24h/d * 60m/h * 60s/m * 1000ms/s\n    \"keyRotationInterval\": \"${COOKIE_KEY_ROTATION_INTERVAL:86400000}\",\n\n    /*\n     * Value of the SameSite cookie property. \"Lax\" is recommended unless\n     * Etherpad will be embedded in an iframe from another site, in which case\n     * this must be set to \"None\". Note: \"None\" will not work (the browser will\n     * not send the cookie to Etherpad) unless https is used to access Etherpad\n     * (either directly or via a reverse proxy with \"trustProxy\" set to true).\n     *\n     * \"Strict\" is not recommended because it has few security benefits but\n     * significant usability drawbacks vs. \"Lax\". See\n     * https://stackoverflow.com/q/41841880 for discussion.\n     */\n    \"sameSite\": \"${COOKIE_SAME_SITE:Lax}\",\n\n    /*\n     * How long (in milliseconds) after navigating away from Etherpad before the\n     * user is required to log in again. (The express_sid cookie is set to\n     * expire at time now + sessionLifetime when first created, and its\n     * expiration time is periodically refreshed to a new now + sessionLifetime\n     * value.) If requireAuthentication is false then this value does not really\n     * matter.\n     *\n     * The \"best\" value depends on your users' usage patterns and the amount of\n     * convenience you desire. A long lifetime is more convenient (users won't\n     * have to log back in as often) but has some drawbacks:\n     *   - It increases the amount of state kept in the database.\n     *   - It might weaken security somewhat: The cookie expiration is refreshed\n     *     indefinitely without consulting authentication or authorization\n     *     hooks, so once a user has accessed a pad, the user can continue to\n     *     use the pad until the user leaves for longer than sessionLifetime.\n     *   - More historical keys (sessionLifetime / keyRotationInterval) must be\n     *     checked when verifying signatures.\n     *\n     * Session lifetime can be set to infinity (not recommended) by setting this\n     * to null or 0. Note that if the session does not expire, most browsers\n     * will delete the cookie when the browser exits, but a session record is\n     * kept in the database forever.\n     */\n    // 864000000 = 10d * 24h/d * 60m/h * 60s/m * 1000ms/s\n    \"sessionLifetime\": \"${COOKIE_SESSION_LIFETIME:864000000}\",\n\n    /*\n     * How long (in milliseconds) before the expiration time of an active user's\n     * session is refreshed (to now + sessionLifetime). This setting affects the\n     * following:\n     *   - How often a new session expiration time will be written to the\n     *     database.\n     *   - How often each user's browser will ping the Etherpad server to\n     *     refresh the expiration time of the session cookie.\n     *\n     * High values reduce the load on the database and the load from browsers,\n     * but can shorten the effective session lifetime if Etherpad is restarted\n     * or the user navigates away.\n     *\n     * Automatic session refreshes can be disabled (not recommended) by setting\n     * this to null.\n     */\n    // 86400000 = 1d * 24h/d * 60m/h * 60s/m * 1000ms/s\n    \"sessionRefreshInterval\": \"${COOKIE_SESSION_REFRESH_INTERVAL:86400000}\"\n  },\n\n  /*\n   * Privacy: disable IP logging\n   */\n  \"disableIPlogging\": \"${DISABLE_IP_LOGGING:false}\",\n\n  /*\n   * Time (in seconds) to automatically reconnect pad when a \"Force reconnect\"\n   * message is shown to user.\n   *\n   * Set to 0 to disable automatic reconnection.\n   */\n  \"automaticReconnectionTimeout\": \"${AUTOMATIC_RECONNECTION_TIMEOUT:0}\",\n\n  /*\n   * By default, when caret is moved out of viewport, it scrolls the minimum\n   * height needed to make this line visible.\n   */\n  \"scrollWhenFocusLineIsOutOfViewport\": {\n\n    /*\n     * Percentage of viewport height to be additionally scrolled.\n     *\n     * E.g.: use \"percentage.editionAboveViewport\": 0.5, to place caret line in\n     *       the middle of viewport, when user edits a line above of the\n     *       viewport\n     *\n     * Set to 0 to disable extra scrolling\n     */\n    \"percentage\": {\n      \"editionAboveViewport\": \"${FOCUS_LINE_PERCENTAGE_ABOVE:0}\",\n      \"editionBelowViewport\": \"${FOCUS_LINE_PERCENTAGE_BELOW:0}\"\n    },\n\n    /*\n     * Time (in milliseconds) used to animate the scroll transition.\n     * Set to 0 to disable animation\n     */\n    \"duration\": \"${FOCUS_LINE_DURATION:0}\",\n\n    /*\n     * Flag to control if it should scroll when user places the caret in the\n     * last line of the viewport\n     */\n    \"scrollWhenCaretIsInTheLastLineOfViewport\": \"${FOCUS_LINE_CARET_SCROLL:false}\",\n\n    /*\n     * Percentage of viewport height to be additionally scrolled when user\n     * presses arrow up in the line of the top of the viewport.\n     *\n     * Set to 0 to let the scroll to be handled as default by Etherpad\n     */\n    \"percentageToScrollWhenUserPressesArrowUp\": \"${FOCUS_LINE_PERCENTAGE_ARROW_UP:0}\"\n  },\n\n  /*\n   * User accounts. These accounts are used by:\n   *   - default HTTP basic authentication if no plugin handles authentication\n   *   - some but not all authentication plugins\n   *   - some but not all authorization plugins\n   *\n   * User properties:\n   *   - password: The user's password. Some authentication plugins will ignore\n   *     this.\n   *   - is_admin: true gives access to /admin. Defaults to false. If you do not\n   *     uncomment this, /admin will not be available!\n   *   - readOnly: If true, this user will not be able to create new pads or\n   *     modify existing pads. Defaults to false.\n   *   - canCreate: If this is true and readOnly is false, this user can create\n   *     new pads. Defaults to true.\n   *\n   * Authentication and authorization plugins may define additional properties.\n   *\n   * WARNING: passwords should not be stored in plaintext in this file.\n   *          If you want to mitigate this, please install ep_hash_auth and\n   *          follow the section \"secure your installation\" in README.md\n   */\n\n  \"users\": {\n    \"admin\": {\n      // 1) \"password\" can be replaced with \"hash\" if you install ep_hash_auth\n      // 2) please note that if password is null, the user will not be created\n      \"password\": \"${ADMIN_PASSWORD:null}\",\n      \"is_admin\": true\n    },\n    \"user\": {\n      // 1) \"password\" can be replaced with \"hash\" if you install ep_hash_auth\n      // 2) please note that if password is null, the user will not be created\n      \"password\": \"${USER_PASSWORD:null}\",\n      \"is_admin\": false\n    }\n  },\n\n  /*\n   * Restrict socket.io transport methods\n   */\n  \"socketTransportProtocols\" : [\"websocket\", \"polling\"],\n\n  \"socketIo\": {\n    /*\n     * Maximum permitted client message size (in bytes). All messages from\n     * clients that are larger than this will be rejected. Large values make it\n     * possible to paste large amounts of text, and plugins may require a larger\n     * value to work properly, but increasing the value increases susceptibility\n     * to denial of service attacks (malicious clients can exhaust memory).\n     */\n    \"maxHttpBufferSize\": \"${SOCKETIO_MAX_HTTP_BUFFER_SIZE:50000}\"\n  },\n\n  /*\n   * Allow Load Testing tools to hit the Etherpad Instance.\n   *\n   * WARNING: this will disable security on the instance.\n   */\n  \"loadTest\": \"${LOAD_TEST:false}\",\n\n  /**\n  * Disable dump of objects preventing a clean exit\n  */\n  \"dumpOnUncleanExit\": \"${DUMP_ON_UNCLEAN_EXIT:false}\",\n\n  /*\n   * Disable indentation on new line when previous line ends with some special\n   * chars (':', '[', '(', '{')\n   */\n\n  /*\n  \"indentationOnNewLine\": false,\n  */\n\n  /*\n   * From Etherpad 1.8.3 onwards, import and export of pads is always rate\n   * limited.\n   *\n   * The default is to allow at most 10 requests per IP in a 90 seconds window.\n   * After that the import/export request is rejected.\n   *\n   * See https://github.com/nfriedly/express-rate-limit for more options\n   */\n  \"importExportRateLimiting\": {\n    // duration of the rate limit window (milliseconds)\n    \"windowMs\": \"${IMPORT_EXPORT_RATE_LIMIT_WINDOW:90000}\",\n\n    // maximum number of requests per IP to allow during the rate limit window\n    \"max\": \"${IMPORT_EXPORT_MAX_REQ_PER_IP:10}\"\n  },\n\n  /*\n   * From Etherpad 1.8.3 onwards, the maximum allowed size for a single imported\n   * file is always bounded.\n   *\n   * File size is specified in bytes. Default is 50 MB.\n   */\n  \"importMaxFileSize\": \"${IMPORT_MAX_FILE_SIZE:52428800}\", // 50 * 1024 * 1024\n\n  /*\n   * From Etherpad 1.8.5 onwards, when Etherpad is in production mode commits from individual users are rate limited\n   *\n   * The default is to allow at most 10 changes per IP in a 1 second window.\n   * After that the change is rejected.\n   *\n   * See https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#websocket-single-connection-prevent-flooding for more options\n   */\n  \"commitRateLimiting\": {\n    // duration of the rate limit window (seconds)\n    \"duration\": \"${COMMIT_RATE_LIMIT_DURATION:1}\",\n\n    // maximum number of changes per IP to allow during the rate limit window\n    \"points\": \"${COMMIT_RATE_LIMIT_POINTS:10}\"\n  },\n\n  /*\n   * Toolbar buttons configuration.\n   *\n   * Uncomment to customize.\n   */\n\n  /*\n  \"toolbar\": {\n    \"left\": [\n      [\"bold\", \"italic\", \"underline\", \"strikethrough\"],\n      [\"orderedlist\", \"unorderedlist\", \"indent\", \"outdent\"],\n      [\"undo\", \"redo\"],\n      [\"clearauthorship\"]\n    ],\n    \"right\": [\n      [\"importexport\", \"timeslider\", \"savedrevision\"],\n      [\"settings\", \"embed\", \"home\"],\n      [\"showusers\"]\n    ],\n    \"timeslider\": [\n      [\"timeslider_export\", \"timeslider_returnToPad\"]\n    ]\n  },\n  */\n\n  /*\n   * Expose Etherpad version in the web interface and in the Server http header.\n   *\n   * Do not enable on production machines.\n   */\n  \"exposeVersion\": \"${EXPOSE_VERSION:false}\",\n\n  /*\n   * The log level we are using.\n   *\n   * Valid values: DEBUG, INFO, WARN, ERROR\n   */\n  \"loglevel\": \"${LOGLEVEL:INFO}\",\n\n  /* Override any strings found in locale directories */\n  \"customLocaleStrings\": {},\n\n  /* Disable Admin UI tests */\n  \"enableAdminUITests\": false,\n\n  /*\n   * Enable/Disable case-insensitive pad names.\n   */\n  \"lowerCasePadIds\": \"${LOWER_CASE_PAD_IDS:false}\",\n  \"sso\": {\n    \"issuer\": \"${SSO_ISSUER:http://localhost:9001}\",\n      \"clients\": [\n        {\n          \"client_id\": \"${ADMIN_CLIENT:admin_client}\",\n          \"client_secret\": \"${ADMIN_SECRET:admin}\",\n          \"grant_types\": [\"authorization_code\"],\n          \"response_types\": [\"code\"],\n          \"redirect_uris\": [\"${ADMIN_REDIRECT:http://localhost:9001/admin/}\"]\n        },\n        {\n          \"client_id\": \"${USER_CLIENT:user_client}\",\n          \"client_secret\": \"${USER_SECRET:user}\",\n          \"grant_types\": [\"authorization_code\"],\n          \"response_types\": [\"code\"],\n          \"redirect_uris\": [\"${USER_REDIRECT:http://localhost:9001/}\"]\n        }\n      ]\n    },\n\n  /* Set the time to live for the tokens\n     This is the time of seconds a user is logged into Etherpad\n  \"ttl\": {\n      \"AccessToken\": 3600,\n      \"AuthorizationCode\": 600,\n      \"ClientCredentials\": 3600,\n      \"IdToken\": 3600,\n      \"RefreshToken\": 86400\n  }\n  */\n}\n"
  },
  {
    "path": "settings.json.template",
    "content": "/*\n * This file must be valid JSON. But comments are allowed\n *\n * Please edit settings.json, not settings.json.template\n *\n * Please note that starting from Etherpad 1.6.0 you can store DB credentials in\n * a separate file (credentials.json).\n *\n *\n * ENVIRONMENT VARIABLE SUBSTITUTION\n * =================================\n *\n * All the configuration values can be read from environment variables using the\n * syntax \"${ENV_VAR}\" or \"${ENV_VAR:default_value}\".\n *\n * This is useful, for example, when running in a Docker container.\n *\n * DETAILED RULES:\n *   - If the environment variable is set to the string \"true\" or \"false\", the\n *     value becomes Boolean true or false.\n *   - If the environment variable is set to the string \"null\", the value\n *     becomes null.\n *   - If the environment variable is set to the string \"undefined\", the setting\n *     is removed entirely, except when used as the member of an array in which\n *     case it becomes null.\n *   - If the environment variable is set to a string representation of a finite\n *     number, the string is converted to that number.\n *   - If the environment variable is set to any other string, including the\n *     empty string, the value is that string.\n *   - If the environment variable is unset and a default value is provided, the\n *     value is as if the environment variable was set to the provided default:\n *       - \"${UNSET_VAR:}\" becomes the empty string.\n *       - \"${UNSET_VAR:foo}\" becomes the string \"foo\".\n *       - \"${UNSET_VAR:true}\" and \"${UNSET_VAR:false}\" become true and false.\n *       - \"${UNSET_VAR:null}\" becomes null.\n *       - \"${UNSET_VAR:undefined}\" causes the setting to be removed (or be set\n *         to null, if used as a member of an array).\n *   - If the environment variable is unset and no default value is provided,\n *     the value becomes null. THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF\n *     ETHERPAD; if you want the default value to be null, you should explicitly\n *     specify \"null\" as the default value.\n *\n * EXAMPLE:\n *    \"port\":     \"${PORT:9001}\"\n *    \"minify\":   \"${MINIFY}\"\n *    \"skinName\": \"${SKIN_NAME:colibris}\"\n *\n * Would read the configuration values for those items from the environment\n * variables PORT, MINIFY and SKIN_NAME.\n *\n * If PORT and SKIN_NAME variables were not defined, the default values 9001 and\n * \"colibris\" would be used.\n * The configuration value \"minify\", on the other hand, does not have a\n * designated default value. Thus, if the environment variable MINIFY were\n * undefined, \"minify\" would be null.\n *\n * REMARKS:\n * 1) please note that variable substitution always needs to be quoted.\n *\n *    \"port\":     9001,            <-- Literal values. When not using\n *    \"minify\":   false                substitution, only strings must be\n *    \"skinName\": \"colibris\"           quoted. Booleans and numbers must not.\n *\n *    \"port\":     \"${PORT:9001}\"   <-- CORRECT: if you want to use a variable\n *    \"minify\":   \"${MINIFY:true}\"     substitution, put quotes around its name,\n *    \"skinName\": \"${SKIN_NAME}\"       even if the required value is a number or\n *                                     a boolean.\n *                                     Etherpad will take care of rewriting it\n *                                     to the proper type if necessary.\n *\n *    \"port\":     ${PORT:9001}     <-- ERROR: this is not valid json. Quotes\n *    \"minify\":   ${MINIFY}            around variable names are missing.\n *    \"skinName\": ${SKIN_NAME}\n *\n * 2) Beware of undefined variables and default values: nulls and empty strings\n *    are different!\n *\n *    This is particularly important for user's passwords (see the relevant\n *    section):\n *\n *    \"password\": \"${PASSW}\"  // if PASSW is not defined would result in password === null\n *    \"password\": \"${PASSW:}\" // if PASSW is not defined would result in password === ''\n *\n *    If you want to use an empty value (null) as default value for a variable,\n *    simply do not set it, without putting any colons: \"${ABIWORD}\".\n *\n * 3) if you want to use newlines in the default value of a string parameter,\n *    use \"\\n\" as usual.\n *\n *    \"defaultPadText\" : \"${DEFAULT_PAD_TEXT:Line 1\\nLine 2}\"\n */\n{\n  /*\n   * Name your instance!\n   */\n  \"title\": \"Etherpad\",\n\n    /*\n    * Whether to show recent pads on the homepage or not.\n    */\n    \"showRecentPads\": true,\n\n  /*\n   * Pathname of the favicon you want to use. If null, the skin's favicon is\n   * used if one is provided by the skin, otherwise the default Etherpad favicon\n   * is used. If this is a relative path it is interpreted as relative to the\n   * Etherpad root directory.\n   */\n  \"favicon\": null,\n\n  /*\n   * Skin name.\n   *\n   * Its value has to be an existing directory under src/static/skins.\n   * You can write your own, or use one of the included ones:\n   *\n   * - \"no-skin\":  an empty skin (default). This yields the unmodified,\n   *               traditional Etherpad theme.\n   * - \"colibris\": the new experimental skin (since Etherpad 1.8), candidate to\n   *               become the default in Etherpad 2.0\n   */\n  \"skinName\": \"colibris\",\n\n  /*\n   * Skin Variants\n   *\n   * Use the UI skin variants builder at /p/test#skinvariantsbuilder\n   *\n   * For the colibris skin only, you can choose how to render the three main\n   * containers:\n   * - toolbar (top menu with icons)\n   * - editor (containing the text of the pad)\n   * - background (area outside of editor, mostly visible when using page style)\n   *\n   * For each of the 3 containers you can choose 4 color combinations:\n   * super-light, light, dark, super-dark.\n   *\n   * For example, to make the toolbar dark, you will include \"dark-toolbar\" into\n   * skinVariants.\n   *\n   * You can provide multiple skin variants separated by spaces. Default\n   * skinVariant is \"super-light-toolbar super-light-editor light-background\".\n   *\n   * For the editor container, you can also make it full width by adding\n   * \"full-width-editor\" variant (by default editor is rendered as a page, with\n   * a max-width of 900px).\n   */\n  \"skinVariants\": \"super-light-toolbar super-light-editor light-background\",\n\n  /*\n   * IP and port which Etherpad should bind at.\n   *\n   * Binding to a Unix socket is also supported: just use an empty string for\n   * the ip, and put the full path to the socket in the port parameter.\n   *\n   * EXAMPLE USING UNIX SOCKET:\n   *    \"ip\": \"\",                             // <-- has to be an empty string\n   *    \"port\" : \"/somepath/etherpad.socket\", // <-- path to a Unix socket\n   */\n  \"ip\": \"0.0.0.0\",\n  \"port\": 9001,\n\n  /*\n   * Option to hide/show the settings.json in admin page.\n   *\n   * Default option is set to true\n   */\n  \"showSettingsInAdminPage\": true,\n\n  /*\n   * Enable/disable the metrics endpoint.\n   *\n   * This is used by the monitoring plugins to collect metrics about Etherpad.\n   * If you do not use any monitoring plugins, you can disable this.\n   */\n   \"enableMetrics\": \"${ENABLE_METRICS:true}\",\n\n  /*\n   * Settings for cleanup of pads\n   */\n  \"cleanup\": {\n    \"enabled\": false,\n    \"keepRevisions\": 5\n  },\n\n  /*\n   * Node native SSL support\n   *\n   * This is disabled by default.\n   * Make sure to have the minimum and correct file access permissions set so\n   * that the Etherpad server can access them\n   */\n\n  /*\n  \"ssl\" : {\n            \"key\"  : \"/path-to-your/epl-server.key\",\n            \"cert\" : \"/path-to-your/epl-server.crt\",\n            \"ca\": [\"/path-to-your/epl-intermediate-cert1.crt\", \"/path-to-your/epl-intermediate-cert2.crt\"]\n          },\n  */\n\n  /*\n   * The type of the database.\n   *\n   * You can choose between many DB drivers, for example: dirty, postgres,\n   * sqlite, mysql.\n   *\n   * You shouldn't use \"dirty\" for for anything else than testing or\n   * development.\n   *\n   *\n   * Database specific settings are dependent on dbType, and go in dbSettings.\n   * Remember that since Etherpad 1.6.0 you can also store this information in\n   * credentials.json.\n   *\n   * For a complete list of the supported drivers, please refer to:\n   * https://www.npmjs.com/package/ueberdb2\n   */\n\n  \"dbType\": \"dirty\",\n  \"dbSettings\": {\n    \"filename\": \"var/dirty.db\"\n  },\n\n  /*\n   * An Example of MySQL Configuration (commented out).\n   *\n   * See: https://github.com/ether/etherpad-lite/wiki/How-to-use-Etherpad-Lite-with-MySQL\n   */\n\n  /*\n  \"dbType\" : \"mysql\",\n  \"dbSettings\" : {\n    \"user\":     \"etherpaduser\",\n    \"host\":     \"localhost\",\n    \"port\":     3306,\n    \"password\": \"PASSWORD\",\n    \"database\": \"etherpad_lite_db\",\n    \"charset\":  \"utf8mb4\"\n  },\n  */\n\n  /*\n   * The default text of a pad\n   */\n  \"defaultPadText\" : \"Welcome to Etherpad!\\n\\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\\n\\nGet involved with Etherpad at https:\\/\\/etherpad.org\\n\",\n\n  /*\n   * Default Pad behavior.\n   *\n   * Change them if you want to override.\n   */\n  \"padOptions\": {\n    \"noColors\":         false,\n    \"showControls\":     true,\n    \"showChat\":         true,\n    \"showLineNumbers\":  true,\n    \"useMonospaceFont\": false,\n    \"userName\":         null,\n    \"userColor\":        null,\n    \"rtl\":              false,\n    \"alwaysShowChat\":   false,\n    \"chatAndUsers\":     false,\n    \"lang\":             null\n  },\n\n  /*\n   * Pad Shortcut Keys\n   */\n  \"padShortcutEnabled\" : {\n    \"altF9\":     true, /* focus on the File Menu and/or editbar */\n    \"altC\":      true, /* focus on the Chat window */\n    \"cmdShift2\": true, /* shows a gritter popup showing a line author */\n    \"delete\":    true,\n    \"return\":    true,\n    \"esc\":       true, /* in mozilla versions 14-19 avoid reconnecting pad */\n    \"cmdS\":      true, /* save a revision */\n    \"tab\":       true, /* indent */\n    \"cmdZ\":      true, /* undo/redo */\n    \"cmdY\":      true, /* redo */\n    \"cmdI\":      true, /* italic */\n    \"cmdB\":      true, /* bold */\n    \"cmdU\":      true, /* underline */\n    \"cmd5\":      true, /* strike through */\n    \"cmdShiftL\": true, /* unordered list */\n    \"cmdShiftN\": true, /* ordered list */\n    \"cmdShift1\": true, /* ordered list */\n    \"cmdShiftC\": true, /* clear authorship */\n    \"cmdH\":      true, /* backspace */\n    \"ctrlHome\":  true, /* scroll to top of pad */\n    \"pageUp\":    true,\n    \"pageDown\":  true\n  },\n\n  /*\n   * Enables the use of a different server. We have a different one that syncs changes from the original server.\n   * It is hosted on GitHub and should not be blocked by many firewalls.\n   *  https://etherpad.org/ep_infos\n   */\n\n  \"updateServer\": \"https://etherpad.org/ep_infos\",\n\n  /*\n   * Should we suppress errors from being visible in the default Pad Text?\n   */\n  \"suppressErrorsInPadText\": false,\n\n  /*\n   * If this option is enabled, a user must have a session to access pads.\n   * This effectively allows only group pads to be accessed.\n   */\n  \"requireSession\": false,\n\n  /*\n   * Users may edit pads but not create new ones.\n   *\n   * Pad creation is only via the API.\n   * This applies both to group pads and regular pads.\n   */\n  \"editOnly\": false,\n\n  /*\n   * If true, all css & js will be minified before sending to the client.\n   *\n   * This will improve the loading performance massively, but makes it difficult\n   * to debug the javascript/css\n   */\n  \"minify\": true,\n\n  /*\n   * How long may clients use served javascript code (in seconds)?\n   *\n   * Not setting this may cause problems during deployment.\n   * Set to 0 to disable caching.\n   */\n  \"maxAge\": 21600, // 60 * 60 * 6 = 6 hours\n\n  /*\n   * Absolute path to the Abiword executable.\n   *\n   * Abiword is needed to get advanced import/export features of pads. Setting\n   * it to null disables Abiword and will only allow plain text and HTML\n   * import/exports.\n   */\n  \"abiword\": null,\n\n  /*\n   * This is the absolute path to the soffice executable.\n   *\n   * LibreOffice can be used in lieu of Abiword to export pads.\n   * Setting it to null disables LibreOffice exporting.\n   */\n  \"soffice\": null,\n\n  /*\n   * Allow import of file types other than the supported ones:\n   * txt, doc, docx, rtf, odt, html & htm\n   */\n  \"allowUnknownFileEnds\": true,\n\n  /*\n   * This setting is used if you require authentication of all users.\n   *\n   * Note: \"/admin\" always requires authentication.\n   */\n  \"requireAuthentication\": false,\n\n  /*\n   * Require authorization by a module, or a user with is_admin set, see below.\n   */\n  \"requireAuthorization\": false,\n\n  /*\n   * When you use NGINX or another proxy/load-balancer set this to true.\n   *\n   * This is especially necessary when the reverse proxy performs SSL\n   * termination, otherwise the cookies will not have the \"secure\" flag.\n   *\n   * The other effect will be that the logs will contain the real client's IP,\n   * instead of the reverse proxy's IP.\n   */\n  \"trustProxy\": false,\n\n  /*\n   * Settings controlling the session cookie issued by Etherpad.\n   */\n  \"cookie\": {\n    /*\n     * How often (in milliseconds) the key used to sign the express_sid cookie\n     * should be rotated. Long rotation intervals reduce signature verification\n     * overhead (because there are fewer historical keys to check) and database\n     * load (fewer historical keys to store, and less frequent queries to\n     * get/update the keys). Short rotation intervals are slightly more secure.\n     *\n     * Multiple Etherpad processes sharing the same database (table) is\n     * supported as long as the clock sync error is significantly less than this\n     * value.\n     *\n     * Key rotation can be disabled (not recommended) by setting this to 0 or\n     * null, or by disabling session expiration (see sessionLifetime).\n     */\n    \"keyRotationInterval\": 86400000, // = 1d * 24h/d * 60m/h * 60s/m * 1000ms/s\n\n    /*\n     * Value of the SameSite cookie property. \"Lax\" is recommended unless\n     * Etherpad will be embedded in an iframe from another site, in which case\n     * this must be set to \"None\". Note: \"None\" will not work (the browser will\n     * not send the cookie to Etherpad) unless https is used to access Etherpad\n     * (either directly or via a reverse proxy with \"trustProxy\" set to true).\n     *\n     * \"Strict\" is not recommended because it has few security benefits but\n     * significant usability drawbacks vs. \"Lax\". See\n     * https://stackoverflow.com/q/41841880 for discussion.\n     */\n    \"sameSite\": \"Lax\",\n\n    /*\n     * How long (in milliseconds) after navigating away from Etherpad before the\n     * user is required to log in again. (The express_sid cookie is set to\n     * expire at time now + sessionLifetime when first created, and its\n     * expiration time is periodically refreshed to a new now + sessionLifetime\n     * value.) If requireAuthentication is false then this value does not really\n     * matter.\n     *\n     * The \"best\" value depends on your users' usage patterns and the amount of\n     * convenience you desire. A long lifetime is more convenient (users won't\n     * have to log back in as often) but has some drawbacks:\n     *   - It increases the amount of state kept in the database.\n     *   - It might weaken security somewhat: The cookie expiration is refreshed\n     *     indefinitely without consulting authentication or authorization\n     *     hooks, so once a user has accessed a pad, the user can continue to\n     *     use the pad until the user leaves for longer than sessionLifetime.\n     *   - More historical keys (sessionLifetime / keyRotationInterval) must be\n     *     checked when verifying signatures.\n     *\n     * Session lifetime can be set to infinity (not recommended) by setting this\n     * to null or 0. Note that if the session does not expire, most browsers\n     * will delete the cookie when the browser exits, but a session record is\n     * kept in the database forever.\n     */\n    \"sessionLifetime\": 864000000, // = 10d * 24h/d * 60m/h * 60s/m * 1000ms/s\n\n    /*\n     * How long (in milliseconds) before the expiration time of an active user's\n     * session is refreshed (to now + sessionLifetime). This setting affects the\n     * following:\n     *   - How often a new session expiration time will be written to the\n     *     database.\n     *   - How often each user's browser will ping the Etherpad server to\n     *     refresh the expiration time of the session cookie.\n     *\n     * High values reduce the load on the database and the load from browsers,\n     * but can shorten the effective session lifetime if Etherpad is restarted\n     * or the user navigates away.\n     *\n     * Automatic session refreshes can be disabled (not recommended) by setting\n     * this to null.\n     */\n    \"sessionRefreshInterval\": 86400000 // = 1d * 24h/d * 60m/h * 60s/m * 1000ms/s\n  },\n\n  /*\n   * Privacy: disable IP logging\n   */\n  \"disableIPlogging\": false,\n\n  /*\n   * Time (in seconds) to automatically reconnect pad when a \"Force reconnect\"\n   * message is shown to user.\n   *\n   * Set to 0 to disable automatic reconnection.\n   */\n  \"automaticReconnectionTimeout\": 0,\n\n  /*\n   * By default, when caret is moved out of viewport, it scrolls the minimum\n   * height needed to make this line visible.\n   */\n  \"scrollWhenFocusLineIsOutOfViewport\": {\n\n    /*\n     * Percentage of viewport height to be additionally scrolled.\n     *\n     * E.g.: use \"percentage.editionAboveViewport\": 0.5, to place caret line in\n     *       the middle of viewport, when user edits a line above of the\n     *       viewport\n     *\n     * Set to 0 to disable extra scrolling\n     */\n    \"percentage\": {\n      \"editionAboveViewport\": 0,\n      \"editionBelowViewport\": 0\n    },\n\n    /*\n     * Time (in milliseconds) used to animate the scroll transition.\n     * Set to 0 to disable animation\n     */\n    \"duration\": 0,\n\n    /*\n     * Flag to control if it should scroll when user places the caret in the\n     * last line of the viewport\n     */\n    \"scrollWhenCaretIsInTheLastLineOfViewport\": false,\n\n    /*\n     * Percentage of viewport height to be additionally scrolled when user\n     * presses arrow up in the line of the top of the viewport.\n     *\n     * Set to 0 to let the scroll to be handled as default by Etherpad\n     */\n    \"percentageToScrollWhenUserPressesArrowUp\": 0\n  },\n\n  /*\n   * User accounts. These accounts are used by:\n   *   - default HTTP basic authentication if no plugin handles authentication\n   *   - some but not all authentication plugins\n   *   - some but not all authorization plugins\n   *\n   * User properties:\n   *   - password: The user's password. Some authentication plugins will ignore\n   *     this.\n   *   - is_admin: true gives access to /admin. Defaults to false. If you do not\n   *     uncomment this, /admin will not be available!\n   *   - readOnly: If true, this user will not be able to create new pads or\n   *     modify existing pads. Defaults to false.\n   *   - canCreate: If this is true and readOnly is false, this user can create\n   *     new pads. Defaults to true.\n   *\n   * Authentication and authorization plugins may define additional properties.\n   *\n   * WARNING: passwords should not be stored in plaintext in this file.\n   *          If you want to mitigate this, please install ep_hash_auth and\n   *          follow the section \"secure your installation\" in README.md\n   */\n\n  /*\n  \"users\": {\n    \"admin\": {\n      // 1) \"password\" can be replaced with \"hash\" if you install ep_hash_auth\n      // 2) please note that if password is null, the user will not be created\n      \"password\": \"changeme1\",\n      \"is_admin\": true\n    },\n    \"user\": {\n      // 1) \"password\" can be replaced with \"hash\" if you install ep_hash_auth\n      // 2) please note that if password is null, the user will not be created\n      \"password\": \"changeme1\",\n      \"is_admin\": false\n    }\n  },\n  */\n\n  /*\n   * Restrict socket.io transport methods\n   */\n  \"socketTransportProtocols\" : [\"websocket\", \"polling\"],\n\n  \"socketIo\": {\n    /*\n     * Maximum permitted client message size (in bytes). All messages from\n     * clients that are larger than this will be rejected. Large values make it\n     * possible to paste large amounts of text, and plugins may require a larger\n     * value to work properly, but increasing the value increases susceptibility\n     * to denial of service attacks (malicious clients can exhaust memory).\n     */\n    \"maxHttpBufferSize\": 50000\n  },\n\n  /*\n   * Allow Load Testing tools to hit the Etherpad Instance.\n   *\n   * WARNING: this will disable security on the instance.\n   */\n  \"loadTest\": false,\n\n  /**\n  * Disable dump of objects preventing a clean exit\n  */\n  \"dumpOnUncleanExit\": false,\n\n  /*\n   * Disable indentation on new line when previous line ends with some special\n   * chars (':', '[', '(', '{')\n   */\n\n  /*\n  \"indentationOnNewLine\": false,\n  */\n\n  /*\n   * From Etherpad 1.8.3 onwards, import and export of pads is always rate\n   * limited.\n   *\n   * The default is to allow at most 10 requests per IP in a 90 seconds window.\n   * After that the import/export request is rejected.\n   *\n   * See https://github.com/nfriedly/express-rate-limit for more options\n   */\n  \"importExportRateLimiting\": {\n    // duration of the rate limit window (milliseconds)\n    \"windowMs\": 90000,\n\n    // maximum number of requests per IP to allow during the rate limit window\n    \"max\": 10\n  },\n\n  /*\n   * From Etherpad 1.8.3 onwards, the maximum allowed size for a single imported\n   * file is always bounded.\n   *\n   * File size is specified in bytes. Default is 50 MB.\n   */\n  \"importMaxFileSize\": 52428800, // 50 * 1024 * 1024\n\n  /*\n    The authentication method used by the server.\n    The default value is sso\n    If you want to use the old authentication system, change this to apikey\n   */\n  \"authenticationMethod\": \"${AUTHENTICATION_METHOD:sso}\",\n\n    /**\n    * Allow setting dark mode for the enduser. This is so if the user has preferred dark mode in their browser, Etherpad will respect that.\n    * Of course this overrides all the skin variants and the skinName set by the administrator.\n    **/\n    \"enableDarkMode\": \"${ENABLE_DARK_MODE:true}\",\n\n  /*\n   * From Etherpad 1.8.5 onwards, when Etherpad is in production mode commits from individual users are rate limited\n   *\n   * The default is to allow at most 10 changes per IP in a 1 second window.\n   * After that the change is rejected.\n   *\n   * See https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#websocket-single-connection-prevent-flooding for more options\n   */\n  \"commitRateLimiting\": {\n    // duration of the rate limit window (seconds)\n    \"duration\": 1,\n\n    // maximum number of changes per IP to allow during the rate limit window\n    \"points\": 10\n  },\n\n  /*\n   * Toolbar buttons configuration.\n   *\n   * Uncomment to customize.\n   */\n\n  /*\n  \"toolbar\": {\n    \"left\": [\n      [\"bold\", \"italic\", \"underline\", \"strikethrough\"],\n      [\"orderedlist\", \"unorderedlist\", \"indent\", \"outdent\"],\n      [\"undo\", \"redo\"],\n      [\"clearauthorship\"]\n    ],\n    \"right\": [\n      [\"importexport\", \"timeslider\", \"savedrevision\"],\n      [\"settings\", \"embed\", \"home\"],\n      [\"showusers\"]\n    ],\n    \"timeslider\": [\n      [\"timeslider_export\", \"timeslider_returnToPad\"]\n    ]\n  },\n  */\n\n  /*\n   * Expose Etherpad version in the web interface and in the Server http header.\n   *\n   * Do not enable on production machines.\n   */\n  \"exposeVersion\": false,\n\n  /*\n   * The log level we are using.\n   *\n   * Valid values: DEBUG, INFO, WARN, ERROR\n   */\n  \"loglevel\": \"INFO\",\n\n    /*\n   * The log layout type to use.\n   *\n   * Valid values: basic, colored\n   */\n   \"logLayoutType\": \"colored\",\n\n  /* Override any strings found in locale directories */\n  \"customLocaleStrings\": {},\n\n  /* Disable Admin UI tests */\n  \"enableAdminUITests\": false,\n\n  /*\n   * Enable/Disable case-insensitive pad names.\n   */\n  \"lowerCasePadIds\": false,\n\n  \"sso\": {\n    \"issuer\": \"${SSO_ISSUER:http://localhost:9001}\",\n      \"clients\": [\n        {\n          \"client_id\": \"${ADMIN_CLIENT:admin_client}\",\n          \"client_secret\": \"${ADMIN_SECRET:admin}\",\n          \"grant_types\": [\"authorization_code\"],\n          \"response_types\": [\"code\"],\n          \"redirect_uris\": [\"${ADMIN_REDIRECT:http://localhost:9001/admin/}\"]\n        },\n        {\n          \"client_id\": \"${USER_CLIENT:user_client}\",\n          \"client_secret\": \"${USER_SECRET:user}\",\n          \"grant_types\": [\"authorization_code\"],\n          \"response_types\": [\"code\"],\n          \"redirect_uris\": [\"${USER_REDIRECT:http://localhost:9001/}\"]\n        }\n      ]\n    }\n\n    /* Set the time to live for the tokens\n         This is the time of seconds a user is logged into Etherpad\n    \"ttl\":  {\n        \"AccessToken\": 3600,\n        \"AuthorizationCode\": 600,\n        \"ClientCredentials\": 3600,\n        \"IdToken\": 3600,\n        \"RefreshToken\": 86400\n    }\n    */\n}\n"
  },
  {
    "path": "src/.eslintrc.cjs",
    "content": "'use strict';\n\n// This is a workaround for https://github.com/eslint/eslint/issues/3458\nrequire('eslint-config-etherpad/patch/modern-module-resolution');\n\nmodule.exports = {\n  ignorePatterns: [\n    '/static/js/vendors/browser.js',\n    '/static/js/vendors/farbtastic.js',\n    '/static/js/vendors/gritter.js',\n    '/static/js/vendors/html10n.js',\n    '/static/js/vendors/jquery.js',\n    '/static/js/vendors/nice-select.js',\n    '/tests/frontend/lib/',\n  ],\n  overrides: [\n    {\n      files: [\n        '**/.eslintrc.*',\n      ],\n      extends: 'etherpad/node',\n    },\n    {\n      files: [\n        '**/*',\n      ],\n      excludedFiles: [\n        '**/.eslintrc.*',\n        'tests/frontend/**/*',\n      ],\n      extends: 'etherpad/node',\n    },\n    {\n      files: [\n        'static/**/*',\n        'tests/frontend/helper.js',\n        'tests/frontend/helper/**/*',\n      ],\n      excludedFiles: [\n        '**/.eslintrc.*',\n      ],\n      extends: 'etherpad/browser',\n      env: {\n        'shared-node-browser': true,\n      },\n      overrides: [\n        {\n          files: [\n            'tests/frontend/helper/**/*',\n          ],\n          globals: {\n            helper: 'readonly',\n          },\n        },\n      ],\n    },\n    {\n      files: [\n        'tests/**/*',\n      ],\n      excludedFiles: [\n        '**/.eslintrc.*',\n        'tests/frontend/cypress/**/*',\n        'tests/frontend/helper.js',\n        'tests/frontend/helper/**/*',\n        'tests/frontend/travis/**/*',\n        'tests/ratelimit/**/*',\n      ],\n      extends: 'etherpad/tests',\n      rules: {\n        'mocha/no-exports': 'off',\n        'mocha/no-top-level-hooks': 'off',\n      },\n    },\n    {\n      files: [\n        'tests/backend/**/*',\n      ],\n      excludedFiles: [\n        '**/.eslintrc.*',\n      ],\n      extends: 'etherpad/tests/backend',\n      overrides: [\n        {\n          files: [\n            'tests/backend/**/*',\n          ],\n          excludedFiles: [\n            'tests/backend/specs/**/*',\n          ],\n          rules: {\n            'mocha/no-exports': 'off',\n            'mocha/no-top-level-hooks': 'off',\n          },\n        },\n      ],\n    },\n    {\n      files: [\n        'tests/frontend/**/*',\n      ],\n      excludedFiles: [\n        '**/.eslintrc.*',\n        'tests/frontend/cypress/**/*',\n        'tests/frontend/helper.js',\n        'tests/frontend/helper/**/*',\n        'tests/frontend/travis/**/*',\n      ],\n      extends: 'etherpad/tests/frontend',\n      overrides: [\n        {\n          files: [\n            'tests/frontend/**/*',\n          ],\n          excludedFiles: [\n            'tests/frontend/specs/**/*',\n          ],\n          rules: {\n            'mocha/no-exports': 'off',\n            'mocha/no-top-level-hooks': 'off',\n          },\n        },\n      ],\n    },\n    {\n      files: [\n        'tests/frontend/cypress/**/*',\n      ],\n      extends: 'etherpad/tests/cypress',\n    },\n    {\n      files: [\n        'tests/frontend/travis/**/*',\n      ],\n      extends: 'etherpad/node',\n    },\n  ],\n  root: true,\n};\n"
  },
  {
    "path": "src/README.md",
    "content": "Ignore this file and see the file in the base installation folder\n"
  },
  {
    "path": "src/ep.json",
    "content": "{\n  \"parts\": [\n    {\n      \"name\": \"DB\",\n      \"hooks\": {\n        \"shutdown\": \"ep_etherpad-lite/node/db/DB\"\n      }\n    },\n    {\n      \"name\": \"Minify\",\n      \"hooks\": {\n        \"shutdown\": \"ep_etherpad-lite/node/utils/Minify\"\n      }\n    },\n    {\n      \"name\": \"express\",\n      \"hooks\": {\n        \"createServer\": \"ep_etherpad-lite/node/hooks/express\",\n        \"restartServer\": \"ep_etherpad-lite/node/hooks/express\",\n        \"shutdown\": \"ep_etherpad-lite/node/hooks/express\"\n      }\n    },\n    {\n      \"name\": \"static\",\n      \"hooks\": {\n        \"expressPreSession\": \"ep_etherpad-lite/node/hooks/express/static\"\n      }\n    },\n    {\n      \"name\": \"stats\",\n      \"hooks\": {\n        \"shutdown\": \"ep_etherpad-lite/node/stats\"\n      }\n    },\n    {\n      \"name\": \"i18n\",\n      \"hooks\": {\n        \"expressPreSession\": \"ep_etherpad-lite/node/hooks/i18n\"\n      }\n    },\n    {\n      \"name\": \"specialpages\",\n      \"hooks\": {\n        \"expressCreateServer\": \"ep_etherpad-lite/node/hooks/express/specialpages\",\n        \"expressPreSession\": \"ep_etherpad-lite/node/hooks/express/specialpages\",\n        \"socketio\": \"ep_etherpad-lite/node/hooks/express/specialpages\"\n      }\n    },\n    {\n      \"name\": \"oauth2\",\n        \"hooks\": {\n            \"expressCreateServer\": \"ep_etherpad-lite/node/security/OAuth2Provider\"\n        }\n    },\n    {\n      \"name\": \"padurlsanitize\",\n      \"hooks\": {\n        \"expressCreateServer\": \"ep_etherpad-lite/node/hooks/express/padurlsanitize\"\n      }\n    },\n    {\n      \"name\": \"transferToken\",\n      \"hooks\": {\n        \"expressCreateServer\": \"ep_etherpad-lite/node/hooks/express/tokenTransfer\"\n      }\n    },\n    {\n      \"name\": \"pwa\",\n      \"hooks\": {\n        \"expressCreateServer\": \"ep_etherpad-lite/node/hooks/express/pwa\"\n      }\n    },\n    {\n      \"name\": \"apicalls\",\n      \"hooks\": {\n        \"expressPreSession\": \"ep_etherpad-lite/node/hooks/express/apicalls\"\n      }\n    },\n    {\n      \"name\": \"importexport\",\n      \"hooks\": {\n        \"expressCreateServer\": \"ep_etherpad-lite/node/hooks/express/importexport\"\n      }\n    },\n    {\n      \"name\": \"errorhandling\",\n      \"hooks\": {\n        \"expressCreateServer\": \"ep_etherpad-lite/node/hooks/express/errorhandling\"\n      }\n    },\n    {\n      \"name\": \"restApi\",\n      \"hooks\": {\n        \"expressCreateServer\": \"ep_etherpad-lite/node/handler/RestAPI\"\n      }\n    },\n    {\n      \"name\": \"socketio\",\n      \"hooks\": {\n        \"expressCloseServer\": \"ep_etherpad-lite/node/hooks/express/socketio\",\n        \"expressCreateServer\": \"ep_etherpad-lite/node/hooks/express/socketio\",\n        \"socketio\": \"ep_etherpad-lite/node/handler/PadMessageHandler\"\n      }\n    },\n    {\n      \"name\": \"admin\",\n      \"hooks\": {\n        \"expressCreateServer\": \"ep_etherpad-lite/node/hooks/express/admin\"\n      }\n    },\n    {\n      \"name\": \"adminplugins\",\n      \"hooks\": {\n        \"socketio\": \"ep_etherpad-lite/node/hooks/express/adminplugins\"\n      }\n    },\n    {\n      \"name\": \"adminsettings\",\n      \"hooks\": {\n        \"socketio\": \"ep_etherpad-lite/node/hooks/express/adminsettings\"\n      }\n    },\n    {\n      \"name\": \"openapi\",\n      \"hooks\": {\n        \"expressPreSession\": \"ep_etherpad-lite/node/hooks/express/openapi\"\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "src/locales/af.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Fwolff\",\n\t\t\t\"Naudefj\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Nuwe pad\",\n\t\"index.createOpenPad\": \"of skep/open 'n pad met die naam:\",\n\t\"pad.toolbar.bold.title\": \"Vet (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Kursief (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Onderstreep (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Deurgehaal (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Geordende lys (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Ongeordende lys (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Indenteer (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Verklein indentering (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Ongedaan maak (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Herdoen (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Verwyder skrywers se kleure (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Voer in/uit van/na verskillende lêerformate\",\n\t\"pad.toolbar.settings.title\": \"Voorkeure\",\n\t\"pad.colorpicker.save\": \"Stoor\",\n\t\"pad.colorpicker.cancel\": \"Kanselleer\",\n\t\"pad.loading\": \"Laai...\",\n\t\"pad.settings.myView\": \"My oorsig\",\n\t\"pad.settings.fontType.normal\": \"Normaal\",\n\t\"pad.settings.language\": \"Taal:\",\n\t\"pad.importExport.import_export\": \"Voer in/uit\",\n\t\"pad.importExport.import\": \"Laai enige tekslêer of dokument op\",\n\t\"pad.importExport.importSuccessful\": \"Sukses!\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Skoon teks\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document-formaat)\",\n\t\"pad.modals.cancel\": \"Kanselleer\",\n\t\"pad.modals.userdup.advice\": \"Maak weer 'n verbinding as u die venster wil gebruik.\",\n\t\"pad.modals.unauth\": \"Nie toegestaan\",\n\t\"pad.modals.deleted\": \"Geskrap.\",\n\t\"pad.share\": \"Deel die pad\",\n\t\"pad.share.readonly\": \"Lees-alleen\",\n\t\"pad.share.link\": \"Skakel\",\n\t\"pad.share.emebdcode\": \"Inbed URL\",\n\t\"pad.chat\": \"Klets\",\n\t\"pad.chat.title\": \"Maak kletsblad vir die pad oop\",\n\t\"timeslider.toolbar.returnbutton\": \"Terug na pad\",\n\t\"timeslider.toolbar.authors\": \"Outeurs:\",\n\t\"timeslider.toolbar.authorsList\": \"Geen outeurs\",\n\t\"timeslider.exportCurrent\": \"Huidige weergawe eksporteer as:\",\n\t\"timeslider.version\": \"Weergawe {{version}}\",\n\t\"timeslider.saved\": \"Gestoor op {{day}} {{month}} {{year}}\",\n\t\"timeslider.dateformat\": \"{{year}}-{{month}}-{{day}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Januarie\",\n\t\"timeslider.month.february\": \"Februarie\",\n\t\"timeslider.month.march\": \"Maart\",\n\t\"timeslider.month.april\": \"April\",\n\t\"timeslider.month.may\": \"Mei\",\n\t\"timeslider.month.june\": \"Junie\",\n\t\"timeslider.month.july\": \"Julie\",\n\t\"timeslider.month.august\": \"Augustus\",\n\t\"timeslider.month.september\": \"September\",\n\t\"timeslider.month.october\": \"Oktober\",\n\t\"timeslider.month.november\": \"November\",\n\t\"timeslider.month.december\": \"Desember\",\n\t\"pad.userlist.entername\": \"Verskaf u naam\",\n\t\"pad.userlist.unnamed\": \"sonder naam\",\n\t\"pad.impexp.importbutton\": \"Voer nou in\",\n\t\"pad.impexp.importing\": \"Besig met invoer...\",\n\t\"pad.impexp.importfailed\": \"Invoer het gefaal\"\n}\n"
  },
  {
    "path": "src/locales/ar.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Alami\",\n\t\t\t\"Ali1\",\n\t\t\t\"ArticleEditor404\",\n\t\t\t\"Haytham morsy\",\n\t\t\t\"Meno25\",\n\t\t\t\"Mido\",\n\t\t\t\"Shbib Al-Subaie\",\n\t\t\t\"Tala Ali\",\n\t\t\t\"Test Create account\",\n\t\t\t\"Tux-tn\",\n\t\t\t\"ديفيد\",\n\t\t\t\"محمد أحمد عبد الفتاح\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"لوحة تحكم المسؤول - Etherpad\",\n\t\"admin_plugins\": \"مدير المكونات الإضافية\",\n\t\"admin_plugins.available\": \"المكونات الإضافية المتاحة\",\n\t\"admin_plugins.available_not-found\": \"لم يتم العثور على أي مكونات إضافية.\",\n\t\"admin_plugins.available_fetching\": \"جاري الجلب…\",\n\t\"admin_plugins.available_install.value\": \"تنصيب\",\n\t\"admin_plugins.available_search.placeholder\": \"ابحث عن المكونات الإضافية للتثبيت\",\n\t\"admin_plugins.description\": \"الوصف\",\n\t\"admin_plugins.installed\": \"الإضافات المثبتة\",\n\t\"admin_plugins.installed_fetching\": \"جارٍ إحضار المكونات الإضافية المثبتة ...\",\n\t\"admin_plugins.installed_nothing\": \"لم تقم بتثبيت أي مكونات إضافية حتى الآن.\",\n\t\"admin_plugins.installed_uninstall.value\": \"إلغاء التثبيت\",\n\t\"admin_plugins.last-update\": \"آخر تحديث\",\n\t\"admin_plugins.name\": \"الاسم\",\n\t\"admin_plugins.page-title\": \"مدير البرنامج المساعد - Etherpad\",\n\t\"admin_plugins.version\": \"الإصدار\",\n\t\"admin_plugins_info\": \"معلومات استكشاف الأخطاء وإصلاحها\",\n\t\"admin_plugins_info.hooks\": \"خطافات مثبتة\",\n\t\"admin_plugins_info.hooks_client\": \"خطاطيف من جانب العميل\",\n\t\"admin_plugins_info.hooks_server\": \"خطاطيف من جانب الخادم\",\n\t\"admin_plugins_info.parts\": \"الأجزاء المثبتة\",\n\t\"admin_plugins_info.plugins\": \"الإضافات المثبتة\",\n\t\"admin_plugins_info.page-title\": \"معلومات البرنامج المساعد - Etherpad\",\n\t\"admin_plugins_info.version\": \"إصدار Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"أحدث إصدار متاح\",\n\t\"admin_plugins_info.version_number\": \"رقم الإصدار\",\n\t\"admin_settings\": \"إعدادات\",\n\t\"admin_settings.current\": \"التكوين الحالي\",\n\t\"admin_settings.current_example-devel\": \"مثال على قالب إعدادات التطوير\",\n\t\"admin_settings.current_example-prod\": \"مثال على قالب إعدادات الإنتاج\",\n\t\"admin_settings.current_restart.value\": \"أعد تشغيل Etherpad\",\n\t\"admin_settings.current_save.value\": \"حفظ الإعدادات\",\n\t\"admin_settings.page-title\": \"الإعدادات - Etherpad\",\n\t\"index.newPad\": \"باد جديد\",\n\t\"index.settings\": \"إعدادات\",\n\t\"index.transferSessionTitle\": \"جلسة النقل\",\n\t\"index.receiveSessionTitle\": \"تلقي الجلسة\",\n\t\"index.receiveSessionDescription\": \"هنا يمكنك استقبال جلسة Etherpad من متصفح أو جهاز آخر. مع ذلك، يُرجى العلم أن هذا سيؤدي إلى حذف جلستك الحالية، إن وُجدت.\",\n\t\"index.transferSession\": \"1. جلسة النقل\",\n\t\"index.transferSessionNow\": \"نقل الجلسة الآن\",\n\t\"index.copyLink\": \"2. نسخ الرابط\",\n\t\"index.copyLinkDescription\": \"انقر على الزر أدناه لنسخ الرابط إلى الحافظة الخاصة بك.\",\n\t\"index.copyLinkButton\": \"نسخ الرابط إلى الحافظة\",\n\t\"index.transferToSystem\": \"3. نسخ الجلسة إلى النظام الجديد\",\n\t\"index.transferToSystemDescription\": \"افتح الرابط المنسوخ في المتصفح أو الجهاز المستهدف لنقل جلستك.\",\n\t\"index.transferSessionDescription\": \"انقل جلستك الحالية إلى المتصفح أو الجهاز بالنقر على الزر أدناه. سيؤدي هذا إلى نسخ رابط لصفحة ستنقل جلستك عند فتحها في المتصفح أو الجهاز المستهدف.\",\n\t\"index.createOpenPad\": \"افتح الوسادة حسب الاسم\",\n\t\"index.openPad\": \"افتح باد موجودة بالاسم:\",\n\t\"index.recentPads\": \"الوسادات الأخيرة\",\n\t\"index.recentPadsEmpty\": \"لم يتم العثور على أي وسادات حديثة.\",\n\t\"index.generateNewPad\": \"إنشاء اسم لوحة عشوائي\",\n\t\"index.labelPad\": \"اسم الوسادة (اختياري)\",\n\t\"index.placeholderPadEnter\": \"الرجاء إدخال اسم الوسادة...\",\n\t\"index.createAndShareDocuments\": \"إنشاء المستندات ومشاركتها في الوقت الفعلي\",\n\t\"index.createAndShareDocumentsDescription\": \"يتيح لك Etherpad تحرير المستندات بشكل تعاوني في الوقت الفعلي، تمامًا مثل محرر متعدد اللاعبين مباشر يعمل في متصفحك.\",\n\t\"pad.toolbar.bold.title\": \"سميك (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"مائل (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"تسطير (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"شطب (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"قائمة مرتبة (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"قائمة غير مرتبة (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"إزاحة (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"حذف الإزاحة (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"فك (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"تكرار (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"مسح ألوان التأليف (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"استيراد/تصدير من/إلى تنسيقات ملفات مختلفة\",\n\t\"pad.toolbar.timeslider.title\": \"متصفح التاريخ\",\n\t\"pad.toolbar.savedRevision.title\": \"حفظ المراجعة\",\n\t\"pad.toolbar.settings.title\": \"الإعدادات\",\n\t\"pad.toolbar.embed.title\": \"تبادل و تضمين هذا الباد\",\n\t\"pad.toolbar.home.title\": \"العودة إلى المنزل\",\n\t\"pad.toolbar.showusers.title\": \"عرض المستخدمين على هذا الباد\",\n\t\"pad.colorpicker.save\": \"حفظ\",\n\t\"pad.colorpicker.cancel\": \"إلغاء\",\n\t\"pad.loading\": \"جارٍ التحميل...\",\n\t\"pad.noCookie\": \"الكوكيز غير متاحة. الرجاء السماح بتحميل الكوكيز على متصفحك!\",\n\t\"pad.permissionDenied\": \"ليس لديك إذن لدخول هذا الباد\",\n\t\"pad.settings.padSettings\": \"إعدادات الباد\",\n\t\"pad.settings.myView\": \"رؤيتي\",\n\t\"pad.settings.stickychat\": \"الدردشة دائما على الشاشة\",\n\t\"pad.settings.chatandusers\": \"أظهر الدردشة والمستخدمين\",\n\t\"pad.settings.colorcheck\": \"ألوان التأليف\",\n\t\"pad.settings.linenocheck\": \"أرقام الأسطر\",\n\t\"pad.settings.rtlcheck\": \"قراءة المحتويات من اليمين إلى اليسار؟\",\n\t\"pad.settings.fontType\": \"نوع الخط:\",\n\t\"pad.settings.fontType.normal\": \"عادي\",\n\t\"pad.settings.language\": \"اللغة:\",\n\t\"pad.settings.deletePad\": \"حذف الوسادة\",\n\t\"pad.delete.confirm\": \"هل تريد حقا حذف هذه الوسادة؟\",\n\t\"pad.settings.about\": \"حول\",\n\t\"pad.settings.poweredBy\": \"مدعوم من\",\n\t\"pad.importExport.import_export\": \"استيراد/تصدير\",\n\t\"pad.importExport.import\": \"تحميل أي ملف نصي أو وثيقة\",\n\t\"pad.importExport.importSuccessful\": \"ناجح!\",\n\t\"pad.importExport.export\": \"تصدير الباد الحالي بصفة:\",\n\t\"pad.importExport.exportetherpad\": \"إيثرباد\",\n\t\"pad.importExport.exporthtml\": \"إتش تي إم إل\",\n\t\"pad.importExport.exportplain\": \"نص عادي\",\n\t\"pad.importExport.exportword\": \"مايكروسوفت وورد\",\n\t\"pad.importExport.exportpdf\": \"صيغة المستندات المحمولة\",\n\t\"pad.importExport.exportopen\": \"ODF (نسق المستند المفتوح)\",\n\t\"pad.importExport.abiword.innerHTML\": \"لا يمكنك الاستيراد إلا من نص عادي أو من تنسيقات HTML. للحصول على المزيد من ميزات الاستيراد المتقدمة، يرجى <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">تثبيت AbiWord أو LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"متصل.\",\n\t\"pad.modals.reconnecting\": \"إعادة الاتصال ببادك..\",\n\t\"pad.modals.forcereconnect\": \"فرض إعادة الاتصال\",\n\t\"pad.modals.reconnecttimer\": \"جاري محاولة إعادة الاتصال\",\n\t\"pad.modals.cancel\": \"إلغاء\",\n\t\"pad.modals.userdup\": \"مفتوح في نافذة أخرى\",\n\t\"pad.modals.userdup.explanation\": \"يبدو أن هذا الباد تم فتحه في أكثر من نافذة متصفح في هذا الحاسوب.\",\n\t\"pad.modals.userdup.advice\": \"إعادة الاتصال لاستعمال هذه النافذة بدلاً  من الأخرى.\",\n\t\"pad.modals.unauth\": \"غير مخول\",\n\t\"pad.modals.unauth.explanation\": \"لقد تغيرت الأذونات الخاصة بك أثناء عرض هذه الصفحة. أعد محاولة الاتصال.\",\n\t\"pad.modals.looping.explanation\": \"هناك مشاكل في الاتصال مع ملقم التزامن.\",\n\t\"pad.modals.looping.cause\": \"ربما كنت متصلاً من خلال وكيل أو جدار حماية غير متوافق.\",\n\t\"pad.modals.initsocketfail\": \"لا يمكن الوصول إلى الخادم.\",\n\t\"pad.modals.initsocketfail.explanation\": \"تعذر الاتصال بخادم المزامنة.\",\n\t\"pad.modals.initsocketfail.cause\": \"هذا على الأرجح بسبب مشكلة في المستعرض الخاص بك أو الاتصال بالإنترنت.\",\n\t\"pad.modals.slowcommit.explanation\": \"الخادم لا يستجيب.\",\n\t\"pad.modals.slowcommit.cause\": \"يمكن أن يكون هذا بسبب مشاكل في الاتصال بالشبكة.\",\n\t\"pad.modals.badChangeset.explanation\": \"لقد صُنفَت إحدى عمليات التحرير التي قمت بها كعملية غير مسموح بها من قبل ملقم التزامن.\",\n\t\"pad.modals.badChangeset.cause\": \"يمكن أن يكون هذا بسبب تكوين ملقم خاطئ أو بسبب سلوك آخر غير متوقع. يرجى الاتصال بمسؤول الخدمة إذا كنت تعتقد بأن هناك خطأ ما. حاول إعادة الاتصال لمتابعة التحرير.\",\n\t\"pad.modals.corruptPad.explanation\": \"الباد الذي تحاول الوصول إليه تالف.\",\n\t\"pad.modals.corruptPad.cause\": \"قد يكون هذا بسبب تكوين ملقم خاطئ أو بسبب سلوك آخر غير متوقع. يرجى الاتصال بمسؤول الخدمة.\",\n\t\"pad.modals.deleted\": \"محذوف.\",\n\t\"pad.modals.deleted.explanation\": \"تمت إزالة هذا الباد.\",\n\t\"pad.modals.rateLimited\": \"معدل محدود.\",\n\t\"pad.modals.rateLimited.explanation\": \"لقد أرسلت الكثير من الرسائل إلى هذه اللوحة مما أدى إلى قطع الاتصال بك.\",\n\t\"pad.modals.rejected.explanation\": \"رفض الخادم الرسالة التي أرسلها متصفحك.\",\n\t\"pad.modals.rejected.cause\": \"ربما تم تحديث الخادم أثناء عرض اللوحة ، أو ربما كان هناك خطأ في Etherpad. حاول إعادة تحميل الصفحة.\",\n\t\"pad.modals.disconnected\": \"لم تعد متصلا.\",\n\t\"pad.modals.disconnected.explanation\": \"تم فقدان الاتصال بالخادم\",\n\t\"pad.modals.disconnected.cause\": \"قد يكون الخادم غير متوفر. يرجى إعلام مسؤول الخدمة إذا كان هذا لا يزال يحدث.\",\n\t\"pad.share\": \"شارك هذه الباد\",\n\t\"pad.share.readonly\": \"للقراءة فقط\",\n\t\"pad.share.link\": \"وصلة\",\n\t\"pad.share.emebdcode\": \"URL للتضمين\",\n\t\"pad.chat\": \"دردشة\",\n\t\"pad.chat.title\": \"فتح الدردشة لهذا الباد.\",\n\t\"pad.chat.loadmessages\": \"تحميل المزيد من الرسائل\",\n\t\"pad.chat.stick.title\": \"ألصق الدردشة بالشاشة\",\n\t\"pad.chat.writeMessage.placeholder\": \"اكتب رسالتك هنا\",\n\t\"timeslider.followContents\": \"اتبع تحديثات محتوى الوسادة\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} متصفح التاريخ\",\n\t\"timeslider.toolbar.returnbutton\": \"العودة إلى الباد\",\n\t\"timeslider.toolbar.authors\": \"المؤلفون:\",\n\t\"timeslider.toolbar.authorsList\": \"بدون مؤلفين\",\n\t\"timeslider.toolbar.exportlink.title\": \"تصدير\",\n\t\"timeslider.exportCurrent\": \"تصدير النسخة الحالية ك:\",\n\t\"timeslider.version\": \"إصدار {{version}}\",\n\t\"timeslider.saved\": \"محفوظ {{month}} {{day}}, {{year}}\",\n\t\"timeslider.playPause\": \"تشغيل / إيقاف مؤقت لمحتويات الباد\",\n\t\"timeslider.backRevision\": \"عد إلى مراجعة في هذه الباد\",\n\t\"timeslider.forwardRevision\": \"انطلق إلى مراجعة في هذه الباد\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"يناير\",\n\t\"timeslider.month.february\": \"فبراير\",\n\t\"timeslider.month.march\": \"مارس\",\n\t\"timeslider.month.april\": \"أبريل\",\n\t\"timeslider.month.may\": \"مايو\",\n\t\"timeslider.month.june\": \"يونيو\",\n\t\"timeslider.month.july\": \"يوليو\",\n\t\"timeslider.month.august\": \"أغسطس\",\n\t\"timeslider.month.september\": \"سبتمبر\",\n\t\"timeslider.month.october\": \"أكتوبر\",\n\t\"timeslider.month.november\": \"نوفمبر\",\n\t\"timeslider.month.december\": \"ديسمبر\",\n\t\"timeslider.unnamedauthors\": \"بدون اسم {{num}} {[plural(num) واحد: كاتب، آخر: مؤلف]}\",\n\t\"pad.savedrevs.marked\": \"هذا التنقيح محدد الآن كمراجعة محفوظة\",\n\t\"pad.savedrevs.timeslider\": \"يمكنك عرض المراجعات المحفوظة بزيارة متصفح التاريخ\",\n\t\"pad.userlist.entername\": \"أدخل اسمك\",\n\t\"pad.userlist.unnamed\": \"غير مسمى\",\n\t\"pad.editbar.clearcolors\": \"مسح ألوان التأليف أو المستند بأكمله؟ هذا لا يمكن التراجع عنه\",\n\t\"pad.impexp.importbutton\": \"الاستيراد الآن\",\n\t\"pad.impexp.importing\": \"الاستيراد...\",\n\t\"pad.impexp.confirmimport\": \"استيراد ملف سيؤدي للكتابة فوق النص الحالي بالباد. هل أنت متأكد من أنك تريد المتابعة؟\",\n\t\"pad.impexp.convertFailed\": \"لم نتمكن من استيراد هذا الملف. يرجى استخدام تنسيق مستند مختلف، أو النسخ واللصق يدوياً\",\n\t\"pad.impexp.padHasData\": \"لا يمكننا استيراد هذا الملف لأن هذا الباد تم بالفعل تغييره; الرجاء استيراد باد جديد\",\n\t\"pad.impexp.uploadFailed\": \"فشل التحميل، الرجاء المحاولة مرة أخرى\",\n\t\"pad.impexp.importfailed\": \"فشل الاستيراد\",\n\t\"pad.impexp.copypaste\": \"الرجاء نسخ/لصق\",\n\t\"pad.impexp.exportdisabled\": \"تصدير التنسيق {{type}} معطل. يرجى الاتصال بمسؤول النظام الخاص بك للحصول على التفاصيل.\",\n\t\"pad.impexp.maxFileSize\": \"الملف كبير جدا. اتصل بإداري الموقع الخاص بك لزيادة حجم الملف المسموح به للاستيراد\"\n}\n"
  },
  {
    "path": "src/locales/ast.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Xuacu\",\n\t\t\t\"YoaR\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Nuevu bloc\",\n\t\"index.createOpenPad\": \"o crear/abrir un bloc col nome:\",\n\t\"index.openPad\": \"abre un bloc qu'esiste col nome:\",\n\t\"pad.toolbar.bold.title\": \"Negrina (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Cursiva (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Sorrayáu (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Tacháu (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Llista ordenada (Ctrl+Mayús+N)\",\n\t\"pad.toolbar.ul.title\": \"Llista desordenada (Ctrl+Mayús+L)\",\n\t\"pad.toolbar.indent.title\": \"Sangría (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Sangría inversa (Mayúsc+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Desfacer (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Refacer (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Llimpiar los colores d'autoría (Ctrl+Mayús+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importar/Esportar ente distintos formatos de ficheru\",\n\t\"pad.toolbar.timeslider.title\": \"Eslizador de tiempu\",\n\t\"pad.toolbar.savedRevision.title\": \"Guardar revisión\",\n\t\"pad.toolbar.settings.title\": \"Configuración\",\n\t\"pad.toolbar.embed.title\": \"Compartir ya incrustar esti bloc\",\n\t\"pad.toolbar.showusers.title\": \"Amosar los usuarios d'esti bloc\",\n\t\"pad.colorpicker.save\": \"Guardar\",\n\t\"pad.colorpicker.cancel\": \"Zarrar\",\n\t\"pad.loading\": \"Cargando...\",\n\t\"pad.noCookie\": \"Nun pudo alcontrase la cookie. ¡Por favor, permite les cookies nel navegador! La sesión y preferencies  nun se guarden ente visites. Esto pue debese a qu'Etherpad inclúyese nun iFrame en dalgunos restoladores. Asegúrate de qu'Etherpad tea nel mesmu subdominiu/dominiu que l'iFrame padre\",\n\t\"pad.permissionDenied\": \"Nun tienes permisu pa entrar a esti bloc\",\n\t\"pad.settings.padSettings\": \"Configuración del bloc\",\n\t\"pad.settings.myView\": \"la mio vista\",\n\t\"pad.settings.stickychat\": \"Alderique en pantalla siempres\",\n\t\"pad.settings.chatandusers\": \"Amosar la charra y los usuarios\",\n\t\"pad.settings.colorcheck\": \"Colores d'autoría\",\n\t\"pad.settings.linenocheck\": \"Númberos de llinia\",\n\t\"pad.settings.rtlcheck\": \"¿Lleer el conteníu de drecha a izquierda?\",\n\t\"pad.settings.fontType\": \"Tipografía:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Llingua:\",\n\t\"pad.settings.about\": \"Tocante a\",\n\t\"pad.settings.poweredBy\": \"Col encontu de\",\n\t\"pad.importExport.import_export\": \"Importar/Esportar\",\n\t\"pad.importExport.import\": \"Xubir cualquier ficheru o documentu de testu\",\n\t\"pad.importExport.importSuccessful\": \"¡Correuto!\",\n\t\"pad.importExport.export\": \"Esportar el bloc actual como:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Testu simple\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Sólo se pue importar dende los formatos de testu planu o HTML. Pa carauterístiques d'importación más avanzaes <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">instala Abiword o LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Coneutáu.\",\n\t\"pad.modals.reconnecting\": \"Reconeutando col to bloc...\",\n\t\"pad.modals.forcereconnect\": \"Forzar la reconexón\",\n\t\"pad.modals.reconnecttimer\": \"Tentando reconeutar en\",\n\t\"pad.modals.cancel\": \"Zarrar\",\n\t\"pad.modals.userdup\": \"Abiertu n'otra ventana\",\n\t\"pad.modals.userdup.explanation\": \"Esti bloc paez que ta abiertu en más d'una ventana del navegador d'esti ordenador.\",\n\t\"pad.modals.userdup.advice\": \"Reconeutar pa usar esta ventana.\",\n\t\"pad.modals.unauth\": \"Non autorizáu\",\n\t\"pad.modals.unauth.explanation\": \"Los tos permisos camudaron mientres vies esta páxina. Intenta volver a coneutar.\",\n\t\"pad.modals.looping.explanation\": \"Hai problemes de comunicación col sirvidor de sincronización.\",\n\t\"pad.modals.looping.cause\": \"Pues tar coneutáu per un torgafueos o un proxy incompatibles.\",\n\t\"pad.modals.initsocketfail\": \"Sirvidor incalcanzable.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Nun se pudo coneutar col sirvidor de sincronización.\",\n\t\"pad.modals.initsocketfail.cause\": \"Probablemente ye por aciu d'un problema col navegador o cola to conexón a internet.\",\n\t\"pad.modals.slowcommit.explanation\": \"El sirvidor nun respuende.\",\n\t\"pad.modals.slowcommit.cause\": \"Pue ser por problemes de coneutividá de la rede.\",\n\t\"pad.modals.badChangeset.explanation\": \"El sirvidor de sincronización clasificó como illegal una edición que fizo.\",\n\t\"pad.modals.badChangeset.cause\": \"Esto podría dase por una mala configuración del sirvidor o por algún otru comportamientu inesperáu. Comuníquese col alministrador del serviciu si cree qu'esto ye un error. Intente volver a coneutar pa siguir editando.\",\n\t\"pad.modals.corruptPad.explanation\": \"El bloc al qu'intenta entrar ta corrompíu.\",\n\t\"pad.modals.corruptPad.cause\": \"Esto pue ser por una mala configuración del sirvidor o por algún otru comportamientu inesperáu. Comuníquese col alministrador del serviciu.\",\n\t\"pad.modals.deleted\": \"Desaniciáu\",\n\t\"pad.modals.deleted.explanation\": \"Esti bloc se desanició.\",\n\t\"pad.modals.disconnected\": \"Te desconeutasti.\",\n\t\"pad.modals.disconnected.explanation\": \"Perdióse la conexón col sirvidor\",\n\t\"pad.modals.disconnected.cause\": \"El sirvidor podría nun tar disponible. Por favor, avise al alministrador del serviciu si sigue pasando esto.\",\n\t\"pad.share\": \"Compartir esti bloc\",\n\t\"pad.share.readonly\": \"Sólo llectura\",\n\t\"pad.share.link\": \"Enllaz\",\n\t\"pad.share.emebdcode\": \"Incrustar URL\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Abrir el chat d'esti bloc.\",\n\t\"pad.chat.loadmessages\": \"Cargar más mensaxes\",\n\t\"pad.chat.stick.title\": \"Pegar charra a la pantalla\",\n\t\"pad.chat.writeMessage.placeholder\": \"Escribi'l mensaxe equí\",\n\t\"timeslider.pageTitle\": \"Eslizador de tiempu de {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Tornar al bloc\",\n\t\"timeslider.toolbar.authors\": \"Autores:\",\n\t\"timeslider.toolbar.authorsList\": \"Nun hai autores\",\n\t\"timeslider.toolbar.exportlink.title\": \"Esportar\",\n\t\"timeslider.exportCurrent\": \"Esportar la versión actual como:\",\n\t\"timeslider.version\": \"Versión {{version}}\",\n\t\"timeslider.saved\": \"Guardáu el {{day}} de {{month}} de {{year}}\",\n\t\"timeslider.playPause\": \"Reproducir/posar el conteníu del bloc\",\n\t\"timeslider.backRevision\": \"Dir a la revisión anterior d'esti bloc\",\n\t\"timeslider.forwardRevision\": \"Dir a la revisión siguiente d'esti bloc\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"de xineru\",\n\t\"timeslider.month.february\": \"de febreru\",\n\t\"timeslider.month.march\": \"de marzu\",\n\t\"timeslider.month.april\": \"d'abril\",\n\t\"timeslider.month.may\": \"de mayu\",\n\t\"timeslider.month.june\": \"de xunu\",\n\t\"timeslider.month.july\": \"de xunetu\",\n\t\"timeslider.month.august\": \"d'agostu\",\n\t\"timeslider.month.september\": \"de setiembre\",\n\t\"timeslider.month.october\": \"d'ochobre\",\n\t\"timeslider.month.november\": \"de payares\",\n\t\"timeslider.month.december\": \"d'avientu\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: autor anónimu, other: autores anónimos]}\",\n\t\"pad.savedrevs.marked\": \"Esta revisión marcose como revisión guardada\",\n\t\"pad.savedrevs.timeslider\": \"Pues ver les revisiones guardaes visitando la llinia temporal\",\n\t\"pad.userlist.entername\": \"Escribi'l to nome\",\n\t\"pad.userlist.unnamed\": \"ensin nome\",\n\t\"pad.editbar.clearcolors\": \"¿Llimpiar los colores d'autoría nel documentu ensembre? Esto nun pue desfacese\",\n\t\"pad.impexp.importbutton\": \"Importar agora\",\n\t\"pad.impexp.importing\": \"Importando...\",\n\t\"pad.impexp.confirmimport\": \"La importación d'un ficheru sustituirá'l testu actual del bloc. ¿Seguro que quies siguir?\",\n\t\"pad.impexp.convertFailed\": \"Nun pudimos importar esti ficheru. Por favor,usa otru formatu de ficheru diferente o copia y pega manualmente.\",\n\t\"pad.impexp.padHasData\": \"Nun pudimos importar esti ficheru porque esti bloc yá tuvo cambios; impórtalu a un bloc nuevu\",\n\t\"pad.impexp.uploadFailed\": \"Falló la carga del ficheru, intentalo otra vuelta\",\n\t\"pad.impexp.importfailed\": \"Falló la importación\",\n\t\"pad.impexp.copypaste\": \"Por favor, copia y apega\",\n\t\"pad.impexp.exportdisabled\": \"La esportación en formatu {{type}} ta desactivada. Por favor, comunica col alministrador del sistema pa más detalles.\",\n\t\"pad.impexp.maxFileSize\": \"El ficheru ye demasiao grande. Comunícate col alministrador del sitiu p'aumentar el tamañu de ficheru permitíu na importación\"\n}\n"
  },
  {
    "path": "src/locales/awa.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"1AnuraagPandey\",\n\t\t\t\"बडा काजी\"\n\t\t]\n\t},\n\t\"index.newPad\": \"नयाँ प्याड\",\n\t\"pad.toolbar.bold.title\": \"मोट (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"तिरछा (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"निम्न रेखाङ्कन (Ctrl-U)\",\n\t\"pad.toolbar.indent.title\": \"इन्डेन्ट (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"आउटडेन्ट (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"रद्द (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"पुन:लागु (Ctrl-Y)\",\n\t\"pad.toolbar.timeslider.title\": \"टाइमस्लाइडर\",\n\t\"pad.toolbar.savedRevision.title\": \"पुनरावलोकन संग्रह किहा जाय\",\n\t\"pad.toolbar.settings.title\": \"सेटिङ्ग\",\n\t\"pad.colorpicker.save\": \"सहेजा जाय\",\n\t\"pad.colorpicker.cancel\": \"रद्द करा जाय\",\n\t\"pad.loading\": \"लोड होत है...\",\n\t\"pad.settings.padSettings\": \"प्याड सेटिङ्ग\",\n\t\"pad.settings.myView\": \"हमार दृष्य\",\n\t\"pad.settings.colorcheck\": \"लेखकीय रङ्ग\",\n\t\"pad.settings.linenocheck\": \"हरफ संख्या\",\n\t\"pad.settings.fontType\": \"फन्ट प्रकार:\",\n\t\"pad.settings.fontType.normal\": \"साधारण\",\n\t\"pad.settings.language\": \"भाषा\",\n\t\"pad.importExport.import_export\": \"आयात/निर्यात\",\n\t\"pad.importExport.importSuccessful\": \"सफल!\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"सामान्य पाठ\",\n\t\"pad.importExport.exportword\": \"माइक्रोसफ्ट वर्ड\",\n\t\"pad.importExport.exportpdf\": \"पिडिएफ\",\n\t\"pad.importExport.exportopen\": \"ओडिएफ(खुल्ला कागजात ढाँचा)\",\n\t\"pad.modals.unauth\": \"अनाधिकृत\",\n\t\"pad.modals.initsocketfail\": \"सर्भरमा पहुँच से बहरे है ।\",\n\t\"pad.share.readonly\": \"पढय वाला खाली\",\n\t\"pad.share.link\": \"कडी\",\n\t\"pad.share.emebdcode\": \"URL जोडा जाय\",\n\t\"pad.chat\": \"बातचीत\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} समय रेखा\",\n\t\"timeslider.toolbar.authors\": \"लेखक:\",\n\t\"timeslider.toolbar.exportlink.title\": \"निर्यात\",\n\t\"timeslider.version\": \"संस्करण {{version}}\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"जनवरी\",\n\t\"timeslider.month.february\": \"फेब्रुअरी\",\n\t\"timeslider.month.march\": \"मार्च\",\n\t\"timeslider.month.april\": \"अप्रैल\",\n\t\"timeslider.month.may\": \"मई\",\n\t\"timeslider.month.june\": \"जून\",\n\t\"timeslider.month.july\": \"जुलाई\",\n\t\"timeslider.month.august\": \"अगस्त\",\n\t\"timeslider.month.september\": \"सेप्टेम्बर\",\n\t\"timeslider.month.october\": \"अक्टूबर\",\n\t\"timeslider.month.november\": \"नोभेम्बर\",\n\t\"timeslider.month.december\": \"डिसेम्बर\",\n\t\"timeslider.unnamedauthors\": \"{{num}} unnamed {[plural(num) one: author, other: authors ]}\",\n\t\"pad.userlist.unnamed\": \"बेनामी\",\n\t\"pad.impexp.importing\": \"आयात होत है...\",\n\t\"pad.impexp.importfailed\": \"आयात असफल रहा\",\n\t\"pad.impexp.copypaste\": \"कृपया कपी पेस्ट कीन जाय\"\n}\n"
  },
  {
    "path": "src/locales/az.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"AZISS\",\n\t\t\t\"Archaeodontosaurus\",\n\t\t\t\"Khan27\",\n\t\t\t\"Mastizada\",\n\t\t\t\"MuratTheTurkish\",\n\t\t\t\"Mushviq Abdulla\",\n\t\t\t\"NMW03\",\n\t\t\t\"Nemoralis\",\n\t\t\t\"Neriman2003\",\n\t\t\t\"Vesely35\",\n\t\t\t\"Wertuose\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"İdarəetmə Paneli - Etherpad\",\n\t\"admin_plugins\": \"Plugin meneceri\",\n\t\"admin_plugins.available\": \"Mövcud plaginlər\",\n\t\"admin_plugins.available_not-found\": \"Heç bir plagin tapılmadı.\",\n\t\"admin_plugins.available_fetching\": \"Alınır...\",\n\t\"admin_plugins.available_install.value\": \"Yüklə\",\n\t\"index.newPad\": \"Yeni lövhə\",\n\t\"index.createOpenPad\": \"və ya lövhəni bu adla yarat/aç:\",\n\t\"pad.toolbar.bold.title\": \"Qalın (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Kursiv (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Altından xətt çəkmə (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Üstdən xətləmək (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Sıralanmış siyahı (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Sırasız siyahı (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Abzas (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Çıxıntı (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Geri qaytar (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Qaytar (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Müəlliflik Rənglərini Təmizlə (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Müxtəlif fayl formatların(a/dan) idxal/ixrac\",\n\t\"pad.toolbar.timeslider.title\": \"Vaxt cədvəli\",\n\t\"pad.toolbar.savedRevision.title\": \"Düzəlişləri Saxla\",\n\t\"pad.toolbar.settings.title\": \"Tənzimləmələr\",\n\t\"pad.toolbar.embed.title\": \"Bu lövhəni paylaş və qur\",\n\t\"pad.toolbar.showusers.title\": \"Lövhədəki istifadəçiləri göstər\",\n\t\"pad.colorpicker.save\": \"Saxla\",\n\t\"pad.colorpicker.cancel\": \"İmtina\",\n\t\"pad.loading\": \"Yüklənir...\",\n\t\"pad.noCookie\": \"Çərəz tapıla bilmədi. Lütfən səyyahınızda çərəzlərə icazə verinǃ! Səfəriniz və ayarlarınız ziyarətlər arasında qeyd olunmayacaq. Bunun səbəbi, Etherpad'ın bəzi brauzerlərdə iFrame-ə daxil edilməsidir. Zəhmət olmasa Etherpad'ın ana iFrame ilə eyni alt etki/domendə olduğundan əmin olun\",\n\t\"pad.permissionDenied\": \"Bu lövhəyə daxil olmaq üçün icazəniz yoxdur\",\n\t\"pad.settings.padSettings\": \"Lövhə nizamlamaları\",\n\t\"pad.settings.myView\": \"Mənim Görüntüm\",\n\t\"pad.settings.stickychat\": \"Söhbət həmişə ekranda\",\n\t\"pad.settings.chatandusers\": \"Gap və İstifadəçiləri Göstər\",\n\t\"pad.settings.colorcheck\": \"Müəlliflik rəngləri\",\n\t\"pad.settings.linenocheck\": \"Sətir nömrələri\",\n\t\"pad.settings.rtlcheck\": \"Mühtəviyyat sağdan sola doğru oxunsunmu?\",\n\t\"pad.settings.fontType\": \"Şriftin tipi:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Dil:\",\n\t\"pad.settings.about\": \"Haqqında\",\n\t\"pad.settings.poweredBy\": \"$1 ilə işləyir\",\n\t\"pad.importExport.import_export\": \"İdxal/İxrac\",\n\t\"pad.importExport.import\": \"Hər hansı bir mətn faylı və ya sənəd yüklə\",\n\t\"pad.importExport.importSuccessful\": \"Uğurlu!\",\n\t\"pad.importExport.export\": \"Hazırkı lövhəni bu şəkildə ixrac et:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Adi mətn\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (açıq sənəd formatı)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Siz yalnız adi mətndən və ya HTML-dən idxal edə bilərsiniz. İdxalın daha mürəkkəb funksiyaları üçün, zəhmət olmasa, <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">AbiWord və ya LibreOffice quraşdırın</a>.\",\n\t\"pad.modals.connected\": \"Bağlandı.\",\n\t\"pad.modals.reconnecting\": \"Sizin lövhə yenidən qoşulur…\",\n\t\"pad.modals.forcereconnect\": \"Məcbur təkrarən bağlan\",\n\t\"pad.modals.reconnecttimer\": \"Yenidən qoşulur\",\n\t\"pad.modals.cancel\": \"Ləğv et\",\n\t\"pad.modals.userdup\": \"Başqa pəncərədə artıq açıqdır\",\n\t\"pad.modals.userdup.explanation\": \"Bu lövhə, ola bilsin ki, bu kompüterdəki brauzerin bir neçə pəncərəsində açılmışdır.\",\n\t\"pad.modals.userdup.advice\": \"Bu pəncərəni istifadə etmək üçün yenidən qoşul.\",\n\t\"pad.modals.unauth\": \"İcazəli deyil\",\n\t\"pad.modals.unauth.explanation\": \"Bu səhifəyə baxdığınız vaxt sizin icazəniz  dəyişilib. Bərpa etmək üşün yenidən cəhd edin.\",\n\t\"pad.modals.looping.explanation\": \"Sinxronlaşdırma serveri ilə kommunikasiya xətası var.\",\n\t\"pad.modals.looping.cause\": \"Ola bilsin ki, siz uyğun olmayan fayrvol və ya proksi vasitəsi ilə qoşulmağa cəhd göstərirsiniz.\",\n\t\"pad.modals.initsocketfail\": \"Server əlçatmazdır.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Sinxronlaşdırma serverinə qoşulma mümkünsüzdür.\",\n\t\"pad.modals.initsocketfail.cause\": \"Ehtimal ki, bu problem sizin brauzerinizlə və ya internet-birləşmənizlə əlaqədərdir.\",\n\t\"pad.modals.slowcommit.explanation\": \"Server cavab vermir.\",\n\t\"pad.modals.slowcommit.cause\": \"Bu şəbəkə bağlantısında problemlər yarana bilər.\",\n\t\"pad.modals.badChangeset.explanation\": \"Etdiyiniz bir redaktə sinxronizasiya  serveri tərəfindən qeyri-leqal/qanundan kənar olaraq təsbit edildi.\",\n\t\"pad.modals.badChangeset.cause\": \"Bu, yanlış server tərtibatı ya da başqa bir gözlənilməyən davranışlar nəticəsində ola bilər. Bu sizə bir xəta imiş kimi görünürsə lütfən servis nəzarətçisi ilə əlaqə yaradın. Redaktəyə davam etmək üçün yenidən qoşulmanı yoxlayın.\",\n\t\"pad.modals.corruptPad.explanation\": \"Daxil olmağa çalışdığınız lövhə zədəlidir.\",\n\t\"pad.modals.corruptPad.cause\": \"Bu, yanlış server tərtibatı ya da başqa bir gözlənilməyən davranışlardan əmələ gələ bilər. Lütfən servis nəzarətçisi ilə əlaqə yaradın.\",\n\t\"pad.modals.deleted\": \"Silindi.\",\n\t\"pad.modals.deleted.explanation\": \"Bu lövhə silindi.\",\n\t\"pad.modals.disconnected\": \"Əlaqə kəsilib.\",\n\t\"pad.modals.disconnected.explanation\": \"Serverə qoşulma itirilib\",\n\t\"pad.modals.disconnected.cause\": \"Server ola bilsin, əlçatmazdır. Əgər belə davam edərsə xidmət administratorunu xəbərdar edin.\",\n\t\"pad.share\": \"Bu lövhəni paylaş\",\n\t\"pad.share.readonly\": \"Yalnız oxuyun\",\n\t\"pad.share.link\": \"Keçid\",\n\t\"pad.share.emebdcode\": \"URL-ni yayımla\",\n\t\"pad.chat\": \"Söhbət\",\n\t\"pad.chat.title\": \"Bu lövhə üçün çat açın.\",\n\t\"pad.chat.loadmessages\": \"Daha çox mesaj yüklə\",\n\t\"pad.chat.stick.title\": \"Yazışmanı ekrana kilidlə\",\n\t\"pad.chat.writeMessage.placeholder\": \"Mesajını bura yaz\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Vaxt cədvəli\",\n\t\"timeslider.toolbar.returnbutton\": \"Lövhəyə qayıt\",\n\t\"timeslider.toolbar.authors\": \"Müəlliflər:\",\n\t\"timeslider.toolbar.authorsList\": \"Müəllif yoxdur\",\n\t\"timeslider.toolbar.exportlink.title\": \"İxrac\",\n\t\"timeslider.exportCurrent\": \"Cari versiyanı ixrac etmək kimi:\",\n\t\"timeslider.version\": \"Versiya {{version}}\",\n\t\"timeslider.saved\": \"Saxlanıldı {{day}} {{month}}, {{year}}\",\n\t\"timeslider.playPause\": \"Geri oxutma / Lövhə Məzmunlarını Dayandır\",\n\t\"timeslider.backRevision\": \"Sənədin bundan əvvəlki bir versiyasına qayıtmaq\",\n\t\"timeslider.forwardRevision\": \"Sənədin bundan sonrakı bir versiyasına qayıtmaq\",\n\t\"timeslider.dateformat\": \"{{day}} {{month}}, {{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Yanvar\",\n\t\"timeslider.month.february\": \"Fevral\",\n\t\"timeslider.month.march\": \"Mart\",\n\t\"timeslider.month.april\": \"Aprel\",\n\t\"timeslider.month.may\": \"May\",\n\t\"timeslider.month.june\": \"İyun\",\n\t\"timeslider.month.july\": \"İyul\",\n\t\"timeslider.month.august\": \"Avqust\",\n\t\"timeslider.month.september\": \"Sentyabr\",\n\t\"timeslider.month.october\": \"Oktyabr\",\n\t\"timeslider.month.november\": \"Noyabr\",\n\t\"timeslider.month.december\": \"Dekabr\",\n\t\"timeslider.unnamedauthors\": \"{{num}} adsız {[plural(num) one: müəllif, other: müəllif]}\",\n\t\"pad.savedrevs.marked\": \"Bu versiya indi yaddaşa saxlanmış kimi nişanlandı\",\n\t\"pad.savedrevs.timeslider\": \"Siz görə bilərsiniz saxlanılan versiyası miqyasında vaxt\",\n\t\"pad.userlist.entername\": \"Adınızı daxil edin\",\n\t\"pad.userlist.unnamed\": \"adsız\",\n\t\"pad.editbar.clearcolors\": \"Bütün sənədlərdə müəllif rəngləri təmizlənsin? Bu geri qaytarıla bilməz\",\n\t\"pad.impexp.importbutton\": \"İndi idxal et\",\n\t\"pad.impexp.importing\": \"İdxal...\",\n\t\"pad.impexp.confirmimport\": \"Faylın idxalı lövhədəki cari mətni yeniləyəcək. Davam etmək istədiyinizə əminsinizmi?\",\n\t\"pad.impexp.convertFailed\": \"Biz bu fayl idxal etmək mümkün deyil idi. Xahiş olunur müxtəlif sənəddən istifadə edin və ya kopyalayıb yapışdırmaq yolundan istifadə edin\",\n\t\"pad.impexp.padHasData\": \"Biz bu faylı idxal edə bilmədik, çünki bu lövhədə düzəlişlər edilib, lütfən yeni lövhə idxal edin\",\n\t\"pad.impexp.uploadFailed\": \"Yükləmədə səhv, xahiş olunur yenə cəhd edin\",\n\t\"pad.impexp.importfailed\": \"İdxal zamanı səhv\",\n\t\"pad.impexp.copypaste\": \"Xahiş edirik kopyalayıb yapışdırın\",\n\t\"pad.impexp.exportdisabled\": \"{{ type}} formatında ixrac söndürülmüşdür. Ətraflı informasiya üçün sistem administratoruna müraciət ediniz.\"\n}\n"
  },
  {
    "path": "src/locales/azb.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Alp Er Tunqa\",\n\t\t\t\"Amir a57\",\n\t\t\t\"Ilğım\",\n\t\t\t\"Koroğlu\",\n\t\t\t\"Mousa\"\n\t\t]\n\t},\n\t\"index.newPad\": \"یئنی یادداشت دفترچه سی\",\n\t\"index.createOpenPad\": \"یا دا ایجاد /بیر پد آدلا برابر آچماق:\",\n\t\"pad.toolbar.bold.title\": \"بویوت\",\n\t\"pad.toolbar.italic.title\": \"مورب\",\n\t\"pad.toolbar.underline.title\": \"خطدین آلتی\",\n\t\"pad.toolbar.strikethrough.title\": \"خط یئمیش (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"جوتدنمیش فهرست (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"جوتدنمه‌میش لیست (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"ایچری باتما (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"ائشیگه چیخدیغی (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"باطل ائتمک\",\n\t\"pad.toolbar.redo.title\": \"یئنی دن\",\n\t\"pad.toolbar.clearAuthorship.title\": \"یازیچی بوْیالارینی سیلمک (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"آیری قالیب لردن /ایچری توکمه / ائشیگه توکمه\",\n\t\"pad.toolbar.timeslider.title\": \"زمان اسلایدی\",\n\t\"pad.toolbar.savedRevision.title\": \"نۆسخه‌نی ذخیره ائت\",\n\t\"pad.toolbar.settings.title\": \"تنظیملر\",\n\t\"pad.toolbar.embed.title\": \"بو یادداشت دفترچه سین یئرلشدیر و پایلاش\",\n\t\"pad.toolbar.showusers.title\": \"بو دفترچه یادداشت دا اولان کاربرلری گوستر\",\n\t\"pad.colorpicker.save\": \"ذخیره ائت\",\n\t\"pad.colorpicker.cancel\": \"وازگئچ\",\n\t\"pad.loading\": \"یوکلنیر...\",\n\t\"pad.noCookie\": \"کوکی تاپیلمادی. لوطفن براوزرینیزده کوکیلره ایجازه وئرین!\",\n\t\"pad.permissionDenied\": \"بو نوت دفترچه سینه ال تاپماق اوچون ایجازه نیز یوخدور.\",\n\t\"pad.settings.padSettings\": \"یادداشت دفترچه سینین تنظیملر\",\n\t\"pad.settings.myView\": \"منیم گورنتوم\",\n\t\"pad.settings.stickychat\": \"نمایش صفحه سینده همیشه چت اولسون\",\n\t\"pad.settings.chatandusers\": \"چت ایله ایشلدنلری گؤستر\",\n\t\"pad.settings.colorcheck\": \"یازیچی رنگ لری\",\n\t\"pad.settings.linenocheck\": \"خطوط شماره سی\",\n\t\"pad.settings.rtlcheck\": \"ایچینده کیلری ساغدان یوخسا سولدان اوخوسون؟\",\n\t\"pad.settings.fontType\": \"قلم نوعی\",\n\t\"pad.settings.fontType.normal\": \"نورمال\",\n\t\"pad.settings.language\": \"دیل:\",\n\t\"pad.importExport.import_export\": \"ایچری توکمه /ائشیگه توکمه\",\n\t\"pad.importExport.import\": \"سند یا دا متنی پرونده یوکله\",\n\t\"pad.importExport.importSuccessful\": \"باشاریلی اولدو!\",\n\t\"pad.importExport.export\": \"بو یادداشت دفترچه سی عنوانا ایچری توکمه\",\n\t\"pad.importExport.exportetherpad\": \"اترپد\",\n\t\"pad.importExport.exporthtml\": \"اچ تی ام ال\",\n\t\"pad.importExport.exportplain\": \"ساده متن\",\n\t\"pad.importExport.exportword\": \"مایکروسافت وورد\",\n\t\"pad.importExport.exportpdf\": \"پی دی اف\",\n\t\"pad.importExport.exportopen\": \"او دی اف\",\n\t\"pad.modals.connected\": \"باغلاندی.\",\n\t\"pad.modals.reconnecting\": \"یادداشت دفترچه‌نیزه یئنی‌دن باغلانمایا چالیشیلیر...\",\n\t\"pad.modals.forcereconnect\": \"تکرار باغلانماق اوچون زوْرلاما\",\n\t\"pad.modals.reconnecttimer\": \"یئنیدن باغلانمایا چالیشیلیر\",\n\t\"pad.modals.cancel\": \"وازگئچ\",\n\t\"pad.modals.userdup\": \"آیری پنجره ده آچیلدی\",\n\t\"pad.modals.userdup.advice\": \"بو پئنجره دن ایستفاده ائتمک اوچون یئنی دن متصیل اول\",\n\t\"pad.modals.unauth\": \"اوْلماز\",\n\t\"pad.modals.unauth.explanation\": \"سیزین ال چتما مسئله سی بو صفحه نین گورونوش زمانیندا دییشیلیب دیر .\\nسعی ائدین یئنی دن متصیل اولاسینیز\",\n\t\"pad.modals.looping.explanation\": \"ارتیباطی موشکیل بیر ائتمه سرور ده وار دیر\",\n\t\"pad.modals.looping.cause\": \"بلکه سیز دوز دئمیین بیر فایروال یادا پروکسی طریقی ایله متصیل اولوب سینیز\",\n\t\"pad.modals.initsocketfail\": \"سرور الده دئییلدیر.\",\n\t\"pad.modals.initsocketfail.explanation\": \"بیرلشدیریلمه سرور لرینه متصیل اولا بیلمه دی\",\n\t\"pad.modals.slowcommit.explanation\": \"سرور جواب وئرمه ییر.\",\n\t\"pad.modals.slowcommit.cause\": \"بو، شبکه باغلانتیسیندا خطالار اوچون اولا بیلر.\",\n\t\"pad.modals.corruptPad.explanation\": \"ال تاپماغا چالیشدیغینیز پد کورلانیبدیر.\",\n\t\"pad.modals.corruptPad.cause\": \"بو، غلط سرور تنظیملری یوخسا آیری بیر گوزلنیلمز بیر داورانیشدان عمله گله بیلر. لوطفا سرویس ایداره چیسی ایله تماس توتون.\",\n\t\"pad.modals.deleted\": \"سیلیندی.\",\n\t\"pad.modals.deleted.explanation\": \"بۇ یادداشت دفترچه‌سی سیلینیبدیر.\",\n\t\"pad.modals.disconnected\": \"سیزین باغلانتینیز کسیلیبدیر.\",\n\t\"pad.modals.disconnected.explanation\": \"سروره باغلانتی کسیلیبدیر.\",\n\t\"pad.modals.disconnected.cause\": \"سرور ال چاتماز اولا بیلر. بئله قالیرسا سرویس ایداره چیسینی آییق سالین.\",\n\t\"pad.share\": \"بو نوت دفترچه سینی پایلاش\",\n\t\"pad.share.readonly\": \"ساده‌جه اوْخومالی\",\n\t\"pad.share.link\": \"باغلانتی\",\n\t\"pad.share.emebdcode\": \"یۇآرالی یئرلشدیرمک\",\n\t\"pad.chat\": \"چت\",\n\t\"pad.chat.title\": \"بو یادداشت دفترچه‌سینه چتی آچ.\",\n\t\"pad.chat.loadmessages\": \"داها آرتیق پیام یوکله\",\n\t\"timeslider.pageTitle\": \"{{appTitle}}زمان اسلایدری\",\n\t\"timeslider.toolbar.returnbutton\": \"یادداشت دفترچه‌سینه قاییت.\",\n\t\"timeslider.toolbar.authors\": \"یازیچیلار\",\n\t\"timeslider.toolbar.authorsList\": \"یازیچی‌سیز\",\n\t\"timeslider.toolbar.exportlink.title\": \"ائشیگه آپارماق\",\n\t\"timeslider.exportCurrent\": \"موجود نوسخه نی بو عونوانلا ائشیگه چیخارت：\",\n\t\"timeslider.version\": \"{{version}} ورژنی\",\n\t\"timeslider.saved\": \"ساخلانیلدی {{day}} {{month}}, {{year}}\",\n\t\"timeslider.playPause\": \"پد ایچینده‌کیلری یئنه اوْخوت/دۇردور\",\n\t\"timeslider.month.january\": \"ژانویه\",\n\t\"timeslider.month.february\": \"فوریه\",\n\t\"timeslider.month.march\": \"مارس\",\n\t\"timeslider.month.april\": \"آوریل\",\n\t\"timeslider.month.may\": \"مئی\",\n\t\"timeslider.month.june\": \"ژوئن\",\n\t\"timeslider.month.july\": \"جولای\",\n\t\"timeslider.month.august\": \"آقوست\",\n\t\"timeslider.month.september\": \"سپتامبر\",\n\t\"timeslider.month.october\": \"اوْکتوبر\",\n\t\"timeslider.month.november\": \"نوْوامبر\",\n\t\"timeslider.month.december\": \"دسامبر\",\n\t\"pad.savedrevs.marked\": \"بۇ نوسخه ایندی ذخیره اوْلونموش کیمی علامتلندی.\",\n\t\"pad.userlist.entername\": \"آدینیزی یازین\",\n\t\"pad.userlist.unnamed\": \"آدسیز\",\n\t\"pad.editbar.clearcolors\": \"بوتون سندلرده یازار بوْیالاری سیلینسین می؟\",\n\t\"pad.impexp.importbutton\": \"ایندی ایچری گتیر\",\n\t\"pad.impexp.importing\": \"ایچری گتیریلیر...\",\n\t\"pad.impexp.uploadFailed\": \"آپلود اولونمادی، یئنه چالیشین\",\n\t\"pad.impexp.importfailed\": \"ایچری گتیرمه اولونمادی\",\n\t\"pad.impexp.copypaste\": \"لوطفن کوپی ائدیب، یاپیشدیرین\"\n}\n"
  },
  {
    "path": "src/locales/bcc.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Baloch Afghanistan\",\n\t\t\t\"Moshtank\",\n\t\t\t\"Sultanselim baloch\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"کارمسترءِ کُرسی - اترپَد\",\n\t\"admin_plugins\": \"گݔشانکانءِ کار ءُ بار\",\n\t\"admin_plugins.available\": \"دسترسݔن گݔشانک\",\n\t\"admin_plugins.available_not-found\": \"گݔشانکے نݔست اَت۔\",\n\t\"admin_plugins.available_install.value\": \"پِررݔنَگ\",\n\t\"admin_plugins.available_search.placeholder\": \"گݔشانکان شۏھاز پہ پِررݔنَگا\",\n\t\"admin_plugins.description\": \"سرۏشتادی\",\n\t\"admin_plugins.installed\": \"گݔشانک پِررݔنگ بیت\",\n\t\"admin_plugins.installed_fetching\": \"پِررݔنتَگݔن گݔشانکانءِ پچ کنگ\",\n\t\"admin_plugins.installed_nothing\": \"شما ھنگت ھچ گݔشانکے نہ پِررݔنتَگ۔\",\n\t\"admin_plugins.installed_uninstall.value\": \"پِررݔنتَگݔنءِ بند کنگ\",\n\t\"admin_plugins.last-update\": \"گُڈی پہ رۏچان\",\n\t\"admin_plugins.name\": \"نام\",\n\t\"admin_plugins.page-title\": \"گݔشانکءِ کارمستری - اترپد\",\n\t\"admin_plugins.version\": \"ورژن\",\n\t\"index.newPad\": \"دفترچه یادداشت تازه\",\n\t\"index.createOpenPad\": \"یا ایجاد/بازکردن یک دفترچه یادداشت با نام:\",\n\t\"pad.toolbar.bold.title\": \"پررنگ (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"کَش (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"زیرخط (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"خط خورده\",\n\t\"pad.toolbar.ol.title\": \"فهرست مرتب شده\",\n\t\"pad.toolbar.ul.title\": \"فهرست مرتب نشده\",\n\t\"pad.toolbar.indent.title\": \"تورفتگی (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"بیرون رفتگی (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"باطل‌کردن (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"از نو (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"پاک‌کردن رنگ‌های نویسندگی\",\n\t\"pad.toolbar.import_export.title\": \"درون‌ریزی/برون‌ریزی از/به قالب‌های مختلف\",\n\t\"pad.toolbar.timeslider.title\": \"لغزندهٔ زمان\",\n\t\"pad.toolbar.savedRevision.title\": \"ذخیره‌سازی نسخه\",\n\t\"pad.toolbar.settings.title\": \"ردانکان\",\n\t\"pad.toolbar.embed.title\": \"اشتراک و جاسازی این دفترچه یادداشت\",\n\t\"pad.toolbar.showusers.title\": \"نمایش کاربران در این دفترچه یادداشت\",\n\t\"pad.colorpicker.save\": \"سَپت\",\n\t\"pad.colorpicker.cancel\": \"بجَگ\",\n\t\"pad.loading\": \"بییگئن...\",\n\t\"pad.permissionDenied\": \"شرمنده، شما را اجازت په دسترسی ای صفحه نیست.\",\n\t\"pad.settings.padSettings\": \"تنظیمات دفترچه یادداشت\",\n\t\"pad.settings.myView\": \"منی سۏج\",\n\t\"pad.settings.stickychat\": \"گفتگو همیشه روی صفحه نمایش باشد\",\n\t\"pad.settings.colorcheck\": \"رنگ‌های نویسندگی\",\n\t\"pad.settings.linenocheck\": \"شماره‌ی خطوط\",\n\t\"pad.settings.rtlcheck\": \"خواندن محتوا از راست به چپ؟\",\n\t\"pad.settings.fontType\": \"نوع قلم:\",\n\t\"pad.settings.fontType.normal\": \"نرمال\",\n\t\"pad.settings.language\": \"زبان:\",\n\t\"pad.importExport.import_export\": \"درون‌ریزی/برون‌ریزی\",\n\t\"pad.importExport.import\": \"بارگذاری پرونده‌ی متنی یا سند\",\n\t\"pad.importExport.importSuccessful\": \"سۏبݔن بیت!\",\n\t\"pad.importExport.export\": \"برون‌ریزی این دفترچه یادداشت با قالب:\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"سادگین متن\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (قالب سند باز)\",\n\t\"pad.importExport.abiword.innerHTML\": \"شما تنها می‌توانید از قالب متن ساده یا اچ‌تی‌ام‌ال درون‌ریزی کنید. برای بیشتر شدن ویژگی‌های درون‌ریزی پیشرفته <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">AbiWord</a> را نصب کنید.\",\n\t\"pad.modals.connected\": \"متصل شد.\",\n\t\"pad.modals.reconnecting\": \"در حال اتصال دوباره به دفترچه یادداشت شما..\",\n\t\"pad.modals.forcereconnect\": \"واداشتن به اتصال دوباره\",\n\t\"pad.modals.userdup\": \"در پنجره‌ای دیگر باز شد\",\n\t\"pad.modals.userdup.explanation\": \"گمان می‌رود این دفترچه یادداشت در بیش از یک پنجره‌ی مرورگر باز شده‌است.\",\n\t\"pad.modals.userdup.advice\": \"برای استفاده از این پنجره دوباره وصل شوید.\",\n\t\"pad.modals.unauth\": \"مجاز نیست\",\n\t\"pad.modals.unauth.explanation\": \"دسترسی شما در حین مشاهده‌ی این برگه تغییر یافته‌است. دوباره متصل شوید.\",\n\t\"pad.modals.looping.explanation\": \"مشکلاتی ارتباطی با سرور همگام‌سازی وجود دارد.\",\n\t\"pad.modals.looping.cause\": \"شاید شما از طریق یک فایروال یا پروکسی ناسازگار متصل شده‌اید.\",\n\t\"pad.modals.initsocketfail\": \"سرور در دسترس نیست.\",\n\t\"pad.modals.initsocketfail.explanation\": \"نمی‌توان به سرور همگام سازی وصل شد.\",\n\t\"pad.modals.initsocketfail.cause\": \"شاید این به خاطر مشکلی در مرورگر یا اتصال اینترنتی شما باشد.\",\n\t\"pad.modals.slowcommit.explanation\": \"سرور پاسخ نمی‌دهد.\",\n\t\"pad.modals.slowcommit.cause\": \"این می‌تواند به خاطر مشکلاتی در اتصال به شبکه باشد.\",\n\t\"pad.modals.badChangeset.explanation\": \"ویرایشی که شما انجام داده‌اید توسط سرور همگام‌سازی نادرست طیقه‌بندی شده‌است.\",\n\t\"pad.modals.badChangeset.cause\": \"این می‌تواند به دلیل پیکربندی اشتباه یا سایر رفتارهای غیرمنتظره باشد. اگر فکر می‌کنید این یک خطا است لطفاً با مدیر خدمت تماس بگیرید. برای ادامهٔ ویرایش سعی کنید که دوباره متصل شوید.\",\n\t\"pad.modals.corruptPad.explanation\": \"پدی که شما سعی دارید دسترسی پیدا کنید خراب است.\",\n\t\"pad.modals.corruptPad.cause\": \"این احتمالاً به دلیل تنظیمات اشتباه کارساز یا سایر رفتارهای غیرمنتظره است. لطفاً با مدیر خدمت تماس حاصل کنید.\",\n\t\"pad.modals.deleted\": \"گار بیت۔\",\n\t\"pad.modals.deleted.explanation\": \"اے یادداشت پاک کنگ بیتگ۔\",\n\t\"pad.modals.disconnected\": \"شمئی سکّی کھت اِنت۔\",\n\t\"pad.modals.disconnected.explanation\": \"اتصال به سرور قطع شده‌است.\",\n\t\"pad.modals.disconnected.cause\": \"ممکن است سرور در دسترس نباشد. اگر این مشکل باز هم رخ داد مدیر حدمت را آگاه کنید.\",\n\t\"pad.share\": \"به اشتراک‌گذاری این دفترچه یادداشت\",\n\t\"pad.share.readonly\": \"فقط خواندنی\",\n\t\"pad.share.link\": \"لینک\",\n\t\"pad.share.emebdcode\": \"جاسازی نشانی\",\n\t\"pad.chat\": \"گفتگو\",\n\t\"pad.chat.title\": \"بازکردن گفتگو برای این دفترچه یادداشت\",\n\t\"pad.chat.loadmessages\": \"گݔشترݔں پیگامء چارگ\",\n\t\"timeslider.pageTitle\": \"لغزندهٔ زمان {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"چَھر کنگ پہ یاددپترا\",\n\t\"timeslider.toolbar.authors\": \"لککۏک:\",\n\t\"timeslider.toolbar.authorsList\": \"بدون نویسنده\",\n\t\"timeslider.toolbar.exportlink.title\": \"درگیزگ\",\n\t\"timeslider.exportCurrent\": \"برون‌ریزی نگارش کنونی به عنوان:\",\n\t\"timeslider.version\": \"نگارش {{version}}\",\n\t\"timeslider.saved\": \"{{month}} {{day}}، {{year}} ذخیره شد\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"جنوری\",\n\t\"timeslider.month.february\": \"پیبروری\",\n\t\"timeslider.month.march\": \"مارچ\",\n\t\"timeslider.month.april\": \"آپریل\",\n\t\"timeslider.month.may\": \"می\",\n\t\"timeslider.month.june\": \"جون\",\n\t\"timeslider.month.july\": \"جولای\",\n\t\"timeslider.month.august\": \"آگوست\",\n\t\"timeslider.month.september\": \"سپٹامبر\",\n\t\"timeslider.month.october\": \"اکتوبر\",\n\t\"timeslider.month.november\": \"نوامبر\",\n\t\"timeslider.month.december\": \"دسمبر\",\n\t\"timeslider.unnamedauthors\": \"{{num}} نویسندهٔ بی‌نام\",\n\t\"pad.savedrevs.marked\": \"این بازنویسی هم اکنون به عنوان ذخیره شده علامت‌گذاری شد\",\n\t\"pad.userlist.entername\": \"وتی یوزرنامء بلک ات\",\n\t\"pad.userlist.unnamed\": \"بدون نام\",\n\t\"pad.editbar.clearcolors\": \"رنگ نویسندگی از همه‌ی سند پاک شود؟\",\n\t\"pad.impexp.importbutton\": \"هم اکنون درون‌ریزی کن\",\n\t\"pad.impexp.importing\": \"در حال درون‌ریزی...\",\n\t\"pad.impexp.confirmimport\": \"با درون‌ریزی یک پرونده نوشتهٔ کنونی دفترچه پاک می‌شود. آیا می‌خواهید ادامه دهید؟\",\n\t\"pad.impexp.convertFailed\": \"ما نمی‌توانیم این پرونده را درون‌ریزی کنیم. خواهشمندیم قالب دیگری برای سندتان انتخاب کرده یا بصورت دستی آنرا کپی کنید\",\n\t\"pad.impexp.uploadFailed\": \"آپلود انجام نشد، دوباره تلاش کنید\",\n\t\"pad.impexp.importfailed\": \"درون‌ریزی انجام نشد\",\n\t\"pad.impexp.copypaste\": \"کپی پیست کنید\",\n\t\"pad.impexp.exportdisabled\": \"برون‌ریزی با قالب {{type}} از کار افتاده است. برای جزئیات بیشتر با مدیر سیستمتان تماس بگیرید.\"\n}\n"
  },
  {
    "path": "src/locales/be-tarask.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Jim-by\",\n\t\t\t\"Red Winged Duck\",\n\t\t\t\"Renessaince\",\n\t\t\t\"Ucukor\",\n\t\t\t\"Wizardist\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Адміністрацыйная панэль — Etherpad\",\n\t\"admin_plugins\": \"Кіраўнік плагінаў\",\n\t\"admin_plugins.available\": \"Даступныя плагіны\",\n\t\"admin_plugins.available_not-found\": \"Плагіны ня знойдзеныя.\",\n\t\"admin_plugins.available_fetching\": \"Атрымліваем…\",\n\t\"admin_plugins.available_install.value\": \"Усталяваць\",\n\t\"admin_plugins.available_search.placeholder\": \"Шукаць пашырэньні для ўсталяваньня\",\n\t\"admin_plugins.description\": \"Апісаньне\",\n\t\"admin_plugins.installed\": \"Усталяваныя пашырэньні\",\n\t\"admin_plugins.installed_fetching\": \"Атрыманьне ўсталяваных пашырэньняў…\",\n\t\"admin_plugins.installed_nothing\": \"Вы пакуль не ўсталявалі ніводнага пашырэньня.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Выдаліць\",\n\t\"admin_plugins.last-update\": \"Апошняе абнаўленьне\",\n\t\"admin_plugins.name\": \"Назва\",\n\t\"admin_plugins.page-title\": \"Кіраўнік пашырэньняў — Etherpad\",\n\t\"admin_plugins.version\": \"Вэрсія\",\n\t\"admin_plugins_info\": \"Інфармацыя пра вырашэньне няспраўнасьцяў\",\n\t\"admin_plugins_info.hooks\": \"Усталяваныя кручкі\",\n\t\"admin_plugins_info.hooks_client\": \"Кліенцкія кручкі\",\n\t\"admin_plugins_info.hooks_server\": \"Сэрвэрныя кручкі\",\n\t\"admin_plugins_info.parts\": \"Усталяваныя часткі\",\n\t\"admin_plugins_info.plugins\": \"Усталяваныя пашырэньні\",\n\t\"admin_plugins_info.page-title\": \"Інфармацыя пра пашырэньне — Etherpad\",\n\t\"admin_plugins_info.version\": \"Вэрсія Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Апошняя даступная вэрсія\",\n\t\"admin_plugins_info.version_number\": \"Нумар вэрсіі\",\n\t\"admin_settings\": \"Налады\",\n\t\"admin_settings.current\": \"Цяперашняя канфігурацыя\",\n\t\"admin_settings.current_example-devel\": \"Прыклад шаблёну наладаў распрацоўкі\",\n\t\"admin_settings.current_example-prod\": \"Прыклад шаблёну наладаў вытворчасьці\",\n\t\"admin_settings.current_restart.value\": \"Перазапуск Etherpad\",\n\t\"admin_settings.current_save.value\": \"Захаваць налады\",\n\t\"admin_settings.page-title\": \"Налады — Etherpad\",\n\t\"index.newPad\": \"Стварыць\",\n\t\"index.createOpenPad\": \"Адкрыць дакумэнт паводле назвы\",\n\t\"index.openPad\": \"адкрыць існы Нататнік з назваю:\",\n\t\"index.recentPads\": \"Нядаўнія дакумэнты\",\n\t\"index.recentPadsEmpty\": \"Нядаўнія дакумэнты ня знойдзеныя.\",\n\t\"index.generateNewPad\": \"Стварыць выпадковую назву дакумэнта\",\n\t\"index.labelPad\": \"Назва дакумэнта (неабавязкова)\",\n\t\"index.placeholderPadEnter\": \"Калі ласка, увядзіце назву дакумэнта…\",\n\t\"index.createAndShareDocuments\": \"Стварайце і дзяліцеся дакумэнтамі ў рэальным часе\",\n\t\"pad.toolbar.bold.title\": \"Тоўсты (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Курсіў (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Падкрэсьліваньне (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Закрэсьліваньне (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Упарадкаваны сьпіс (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Неўпарадкаваны сьпіс (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Водступ (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Водступ (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Скасаваць(Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Вярнуць (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Прыбраць колер дакумэнту (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Імпарт/Экспарт з выкарыстаньне розных фарматаў файлаў\",\n\t\"pad.toolbar.timeslider.title\": \"Шкала часу\",\n\t\"pad.toolbar.savedRevision.title\": \"Захаваць вэрсію\",\n\t\"pad.toolbar.settings.title\": \"Налады\",\n\t\"pad.toolbar.embed.title\": \"Падзяліцца і ўбудаваць гэты дакумэнт\",\n\t\"pad.toolbar.home.title\": \"Вярнуцца ў пачатак\",\n\t\"pad.toolbar.showusers.title\": \"Паказаць карыстальнікаў у гэтым дакумэнце\",\n\t\"pad.colorpicker.save\": \"Захаваць\",\n\t\"pad.colorpicker.cancel\": \"Скасаваць\",\n\t\"pad.loading\": \"Загрузка…\",\n\t\"pad.noCookie\": \"Кукі ня знойдзеныя. Калі ласка, дазвольце кукі ў вашым браўзэры! Паміж наведваньнямі вашая сэсія і налады ня будуць захаваныя. Гэта можа адбывацца таму, што ў некаторых броўзэрах Etherpad заключаны ўнутры iFrame. Праверце, калі ласка, што Etherpad знаходзіцца ў тым жа паддамэне/дамэне, што і бацькоўскі iFrame\",\n\t\"pad.permissionDenied\": \"Вы ня маеце дазволу на доступ да гэтага дакумэнта\",\n\t\"pad.settings.padSettings\": \"Налады дакумэнта\",\n\t\"pad.settings.myView\": \"Мой выгляд\",\n\t\"pad.settings.stickychat\": \"Заўсёды паказваць чат\",\n\t\"pad.settings.chatandusers\": \"Паказаць чат і ўдзельнікаў\",\n\t\"pad.settings.colorcheck\": \"Колеры аўтарства\",\n\t\"pad.settings.linenocheck\": \"Нумары радкоў\",\n\t\"pad.settings.rtlcheck\": \"Тэкст справа-налева\",\n\t\"pad.settings.fontType\": \"Тып шрыфту:\",\n\t\"pad.settings.fontType.normal\": \"Звычайны\",\n\t\"pad.settings.language\": \"Мова:\",\n\t\"pad.settings.deletePad\": \"Выдаліць нататнік\",\n\t\"pad.delete.confirm\": \"Вы ўпэўненыя, што хочаце выдаліць гэты нататнік?\",\n\t\"pad.settings.about\": \"Пра\",\n\t\"pad.settings.poweredBy\": \"Працуе на\",\n\t\"pad.importExport.import_export\": \"Імпарт/Экспарт\",\n\t\"pad.importExport.import\": \"Даслаць будзь-які тэкставы файл ці дакумэнт\",\n\t\"pad.importExport.importSuccessful\": \"Пасьпяхова!\",\n\t\"pad.importExport.export\": \"Экспартаваць бягучы дакумэнт як:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Просты тэкст\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Вы можаце імпартаваць толькі з звычайнага тэксту або HTML. Дзеля больш пашыраных магчымасьцяў імпарту, калі ласка, <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">усталюйце AbiWord альбо LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Падлучыліся.\",\n\t\"pad.modals.reconnecting\": \"Перападлучэньне да вашага дакумэнта…\",\n\t\"pad.modals.forcereconnect\": \"Прымусовае перападлучэньне\",\n\t\"pad.modals.reconnecttimer\": \"Спрабуем перападключыцца праз\",\n\t\"pad.modals.cancel\": \"Скасаваць\",\n\t\"pad.modals.userdup\": \"Адкрыта ў іншым акне\",\n\t\"pad.modals.userdup.explanation\": \"Падобна, дакумэнт адкрыты больш чым у адным акне браўзэра на гэтым кампутары.\",\n\t\"pad.modals.userdup.advice\": \"Паўторна падключыць з выкарыстаньнем гэтага акна.\",\n\t\"pad.modals.unauth\": \"Не аўтарызаваны\",\n\t\"pad.modals.unauth.explanation\": \"Вашыя правы былі зьмененыя ў часе прагляду гэтай старонкі. Паспрабуйце перападключыцца.\",\n\t\"pad.modals.looping.explanation\": \"Праблемы далучэньня да сэрвэра сынхранізацыі.\",\n\t\"pad.modals.looping.cause\": \"Магчыма, вы падключыліся празь несумяшчальны брандмаўэр або проксі.\",\n\t\"pad.modals.initsocketfail\": \"Сэрвэр недаступны.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Не атрымалася падлучыцца да сэрвэра сынхранізацыі.\",\n\t\"pad.modals.initsocketfail.cause\": \"Імаверна, гэта зьвязана з праблемамі з вашым браўзэрам або інтэрнэт-злучэньнем.\",\n\t\"pad.modals.slowcommit.explanation\": \"Сэрвэр не адказвае.\",\n\t\"pad.modals.slowcommit.cause\": \"Гэта можа быць выклікана праблемамі зь сеткавым падлучэньнем.\",\n\t\"pad.modals.badChangeset.explanation\": \"Сэрвэр сынхранізацыі вызначыў зробленае вамі рэдагаваньне як недапушчальнае.\",\n\t\"pad.modals.badChangeset.cause\": \"Гэта можа адбывацца празь няслушную канфіґурацыю сэрвэра або празь іншыя нечаканыя дзеяньні. Калі ласка, скантактуйцеся з адміністратарам, калі вы думаеце, што гэта памылка. Паспрабуйце перападлучыцца, каб працягнуць рэдагаваньне.\",\n\t\"pad.modals.corruptPad.explanation\": \"Дакумэнт, да якога вы спрабуеце атрымаць доступ, пашкоджаны.\",\n\t\"pad.modals.corruptPad.cause\": \"Гэта можа быць выклікана няправільнай канфігурацыяй сэрвэру або іншымі нечаканымі дзеяньнямі. Калі ласка, скантактуйцеся з адміністратарам службы.\",\n\t\"pad.modals.deleted\": \"Выдалены.\",\n\t\"pad.modals.deleted.explanation\": \"Гэты дакумэнт быў выдалены.\",\n\t\"pad.modals.rateLimited\": \"Хуткасьць абмежаваная.\",\n\t\"pad.modals.rateLimited.explanation\": \"Вы адаслалі так шмат паведамленьняў, што гэты дакумэнт вас адключыў.\",\n\t\"pad.modals.rejected.explanation\": \"Сэрвэр адхіліў паведамленьне, адасланае вашым броўзэрам.\",\n\t\"pad.modals.disconnected\": \"Вы былі адключаныя.\",\n\t\"pad.modals.disconnected.explanation\": \"Злучэньне з сэрвэрам было страчанае\",\n\t\"pad.modals.disconnected.cause\": \"Магчыма, сэрвэр недаступны. Калі ласка, абвясьціце адміністратара службы, калі праблема будзе паўтарацца.\",\n\t\"pad.share\": \"Падзяліцца дакумэнтам\",\n\t\"pad.share.readonly\": \"Толькі для чытаньня\",\n\t\"pad.share.link\": \"Спасылка\",\n\t\"pad.share.emebdcode\": \"Укласьці URL\",\n\t\"pad.chat\": \"Чат\",\n\t\"pad.chat.title\": \"Адкрыць чат для гэтага дакумэнту.\",\n\t\"pad.chat.loadmessages\": \"Загрузіць болей паведамленьняў\",\n\t\"pad.chat.stick.title\": \"Замацаваць чат на экране\",\n\t\"pad.chat.writeMessage.placeholder\": \"Напішыце вашае паведамленьне тут\",\n\t\"timeslider.pageTitle\": \"Часавая шкала {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Вярнуцца да дакумэнту\",\n\t\"timeslider.toolbar.authors\": \"Аўтары:\",\n\t\"timeslider.toolbar.authorsList\": \"Няма аўтараў\",\n\t\"timeslider.toolbar.exportlink.title\": \"Экспарт\",\n\t\"timeslider.exportCurrent\": \"Экспартаваць актуальную вэрсію як:\",\n\t\"timeslider.version\": \"Вэрсія {{version}}\",\n\t\"timeslider.saved\": \"Захавана {{day}}.{{month}}.{{year}}\",\n\t\"timeslider.playPause\": \"Прайграць / спыніць зьмест дакумэнту\",\n\t\"timeslider.backRevision\": \"Вярнуць рэдагаваньне гэтага дакумэнту\",\n\t\"timeslider.forwardRevision\": \"Перайсьці да наступнага рэдагаваньня гэтага дакумэнту\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"студзень\",\n\t\"timeslider.month.february\": \"люты\",\n\t\"timeslider.month.march\": \"сакавік\",\n\t\"timeslider.month.april\": \"красавік\",\n\t\"timeslider.month.may\": \"травень\",\n\t\"timeslider.month.june\": \"чэрвень\",\n\t\"timeslider.month.july\": \"ліпень\",\n\t\"timeslider.month.august\": \"жнівень\",\n\t\"timeslider.month.september\": \"верасень\",\n\t\"timeslider.month.october\": \"кастрычнік\",\n\t\"timeslider.month.november\": \"лістапад\",\n\t\"timeslider.month.december\": \"сьнежань\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: безыменны аўтар, few: безыменныя аўтары, many: безыменных аўтараў, other: безыменных аўтараў ]}\",\n\t\"pad.savedrevs.marked\": \"Гэтая вэрсія цяпер пазначаная як захаваная\",\n\t\"pad.savedrevs.timeslider\": \"Вы можаце пабачыць захаваныя вэрсіі з дапамогай шкалы часу\",\n\t\"pad.userlist.entername\": \"Увядзіце вашае імя\",\n\t\"pad.userlist.unnamed\": \"безыменны\",\n\t\"pad.editbar.clearcolors\": \"Ачысьціць аўтарскія колеры ва ўсім дакумэнце? Гэта немагчыма будзе скасаваць\",\n\t\"pad.impexp.importbutton\": \"Імпартаваць зараз\",\n\t\"pad.impexp.importing\": \"Імпартаваньне…\",\n\t\"pad.impexp.confirmimport\": \"Імпарт файла перазапіша цяперашні тэкст дакумэнту. Вы ўпэўненыя, што хочаце працягваць?\",\n\t\"pad.impexp.convertFailed\": \"Не атрымалася імпартаваць гэты файл. Калі ласка, выкарыстайце іншы фармат дакумэнту або скапіюйце ўручную.\",\n\t\"pad.impexp.padHasData\": \"Мы не змаглі імпартаваць гэты файл, бо дакумэнт ужо мае зьмены, калі ласка, імпартуйце ў новы дакумэнт\",\n\t\"pad.impexp.uploadFailed\": \"Загрузка не атрымалася, калі ласка, паспрабуйце яшчэ раз\",\n\t\"pad.impexp.importfailed\": \"Памылка імпарту\",\n\t\"pad.impexp.copypaste\": \"Калі ласка, скапіюйце і ўстаўце\",\n\t\"pad.impexp.exportdisabled\": \"Экспарт у фармаце {{type}} адключаны. Калі ласка, зьвярніцеся да вашага сыстэмнага адміністратара па падрабязнасьці.\",\n\t\"pad.impexp.maxFileSize\": \"Файл завялікі. Зьвярніцеся да адміністратара сайту, каб павялічыць дазволены памер файлаў для імпарту\"\n}\n"
  },
  {
    "path": "src/locales/bg.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"StanProg\",\n\t\t\t\"Vlad5250\",\n\t\t\t\"Vodnokon4e\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Нов пад\",\n\t\"index.createOpenPad\": \"или създаване/отваряне на пад с име:\",\n\t\"pad.toolbar.bold.title\": \"Получер (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Наклонен (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Подчертан (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Зачеркнат (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Подреден списък (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Неподреден списък (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Отстъп (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Премахване на отстъпа (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Отмяна (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Връщане (Ctrl+Y)\",\n\t\"pad.toolbar.settings.title\": \"Настройки\",\n\t\"pad.colorpicker.save\": \"Съхраняване\",\n\t\"pad.colorpicker.cancel\": \"Отказ\",\n\t\"pad.loading\": \"Зареждане...\",\n\t\"pad.settings.language\": \"Език:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Обикновен текст\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.modals.cancel\": \"Отказ\",\n\t\"pad.modals.userdup\": \"Отворен в друг прозорец\",\n\t\"pad.modals.userdup.explanation\": \"Изглежда, че този пад е отворен на повече от един раздел в браузъра на компютъра.\",\n\t\"pad.modals.looping.explanation\": \"Има проблеми с комуникацията със сървъра за синхронизация.\",\n\t\"pad.modals.looping.cause\": \"Може би сте свързани чрез несъвместима защитна стена или прокси.\",\n\t\"pad.modals.initsocketfail\": \"Сървърът е недостъпен.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Свързването със сървъра за синхронизация е неуспешно.\",\n\t\"pad.modals.initsocketfail.cause\": \"Това вероятно се дължи на проблем с браузъра Ви или връзката Ви с Интернет.\",\n\t\"pad.modals.slowcommit.explanation\": \"Сървърът не отговаря.\",\n\t\"pad.modals.slowcommit.cause\": \"Това може да се дължи на проблеми с мрежовите връзки.\",\n\t\"pad.modals.deleted\": \"Изтрито.\",\n\t\"pad.share.readonly\": \"Само за четене\",\n\t\"pad.share.link\": \"Препратка\",\n\t\"pad.share.emebdcode\": \"Постави URL\",\n\t\"pad.chat\": \"Чат\",\n\t\"pad.chat.title\": \"Отваряне на чат за този пад.\",\n\t\"pad.chat.loadmessages\": \"Зареждане на повече съобщения\",\n\t\"pad.chat.stick.title\": \"Залепяне на разговора на екрана\",\n\t\"pad.chat.writeMessage.placeholder\": \"Тук напишете съобщение\",\n\t\"timeslider.toolbar.returnbutton\": \"Връщане към пада\",\n\t\"timeslider.toolbar.authors\": \"Автори:\",\n\t\"timeslider.toolbar.authorsList\": \"Няма автори\",\n\t\"timeslider.toolbar.exportlink.title\": \"Изнасяне\",\n\t\"timeslider.exportCurrent\": \"Изнасяне на текущата версия като:\",\n\t\"timeslider.version\": \"Версия {{version}}\",\n\t\"timeslider.month.january\": \"януари\",\n\t\"timeslider.month.february\": \"февруари\",\n\t\"timeslider.month.march\": \"март\",\n\t\"timeslider.month.april\": \"април\",\n\t\"timeslider.month.may\": \"май\",\n\t\"timeslider.month.june\": \"юни\",\n\t\"timeslider.month.july\": \"юли\",\n\t\"timeslider.month.august\": \"август\",\n\t\"timeslider.month.september\": \"септември\",\n\t\"timeslider.month.october\": \"октомври\",\n\t\"timeslider.month.november\": \"ноември\",\n\t\"timeslider.month.december\": \"декември\",\n\t\"pad.userlist.entername\": \"Въведете вашето име\"\n}\n"
  },
  {
    "path": "src/locales/bgn.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Baloch Afghanistan\"\n\t\t]\n\t},\n\t\"index.newPad\": \"یاداشتی نوکین کتابچه\",\n\t\"index.createOpenPad\": \"یا جوڑ\\t کورتین/پاچ کورتین یک کتابچه ئی یاداشتی بی نام:\",\n\t\"pad.toolbar.bold.title\": \"پررنگ (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"چوّٹ (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"جهلگ خط (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"خط وارته (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"ترتیب بوتگین لر لیست (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"ترتیب نه بوتگین لر لیست (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"بیئتئ بوتگین (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"در آتگی (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"باطل‌کورتین (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"شه نوک (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"نویسوکئ رنگانی پاک کورتین (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"بی تئ کورتین/دَر کورتین شه/بی رکم رکمین قالیبان\",\n\t\"pad.toolbar.timeslider.title\": \"وختئ لَگوشوک\",\n\t\"pad.toolbar.savedRevision.title\": \"نسخه ئی ذخیره کورتین\",\n\t\"pad.toolbar.settings.title\": \"تنظیمات\",\n\t\"pad.colorpicker.save\": \"ذخیره\",\n\t\"pad.colorpicker.cancel\": \"کنسیل\",\n\t\"pad.loading\": \"لودینگ...\",\n\t\"pad.settings.padSettings\": \"یاداشتئ دفترچه ئی تنظیمات\",\n\t\"pad.settings.myView\": \"نئ دیست\",\n\t\"pad.settings.stickychat\": \"هبر موچین وختا بی دیستئ تاکدیمئ سرا بیئت\",\n\t\"pad.settings.colorcheck\": \"نویسوکی رنگ ئان\",\n\t\"pad.settings.linenocheck\": \"خط ئانی نمبر\",\n\t\"pad.settings.rtlcheck\": \"محتوایی وانتین شه راست بی چپا؟\",\n\t\"pad.settings.fontType\": \"قلم رکم:\",\n\t\"pad.settings.fontType.normal\": \"ساددگ\",\n\t\"pad.settings.language\": \"زبان:\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"ساده گین متن\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (پاچین سندئ قالب)\",\n\t\"pad.importExport.abiword.innerHTML\": \"شما تا توانیت که شه ساده گین متنی ئین قالب یا اچ‌تی‌ام‌ال بی تئ کنیت . په گیشتیرین کارا ئییان پیشرفته ئین بی تئ کورتینا  <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">AbiWord</a> نصب کنیت.\",\n\t\"pad.modals.connected\": \"وصل بوت.\",\n\t\"pad.modals.userdup\": \"نوکین دروازه گئ پاچ کورتین\",\n\t\"pad.modals.unauth\": \"مجاز نه اینت\",\n\t\"pad.modals.deleted.explanation\": \"ای یاداشتی دفترچه پاک بوته.\",\n\t\"pad.share.readonly\": \"فقط وانتین\",\n\t\"pad.share.link\": \"لینک\",\n\t\"pad.chat\": \"چت وهبر\",\n\t\"timeslider.toolbar.exportlink.title\": \"دَر کورتین\",\n\t\"timeslider.month.january\": \"جنوری\",\n\t\"timeslider.month.february\": \"فیبروری\",\n\t\"timeslider.month.march\": \"مارچ\",\n\t\"timeslider.month.april\": \"اپریل\",\n\t\"timeslider.month.may\": \"می\",\n\t\"timeslider.month.june\": \"جون\",\n\t\"timeslider.month.july\": \"جولای\",\n\t\"timeslider.month.august\": \"اگوست\",\n\t\"timeslider.month.september\": \"سیپٹمبر\",\n\t\"timeslider.month.october\": \"اکتوبر\",\n\t\"timeslider.month.november\": \"نوامبر\",\n\t\"timeslider.month.december\": \"ڈ\\tسمبر\",\n\t\"timeslider.unnamedauthors\": \"{{num}} بی نامین نویسوک\",\n\t\"pad.userlist.entername\": \"وتئ ناما نیویشته بکنیت\",\n\t\"pad.userlist.unnamed\": \"بی نام\",\n\t\"pad.impexp.importbutton\": \"انون بی تئ کن\",\n\t\"pad.impexp.importing\": \"بی بی تئ کورتینی حالا...\",\n\t\"pad.impexp.uploadFailed\": \"آپلود انجام نه بوت، پدا کوشش کن\",\n\t\"pad.impexp.copypaste\": \"کپی پیست کَنیت\"\n}\n"
  },
  {
    "path": "src/locales/bn.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Aftab1995\",\n\t\t\t\"Aftabuzzaman\",\n\t\t\t\"Aishik Rehman\",\n\t\t\t\"Al Riaz Uddin Ripon\",\n\t\t\t\"Bellayet\",\n\t\t\t\"Greatder\",\n\t\t\t\"Nasir8891\",\n\t\t\t\"RiazACU\",\n\t\t\t\"Sankarshan\",\n\t\t\t\"Sibabrata Banerjee\",\n\t\t\t\"আজিজ\",\n\t\t\t\"আফতাবুজ্জামান\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"প্রশাসক কেন্দ্র - ইথারপ্যাড\",\n\t\"admin_plugins\": \"প্লাগিন ব্যবস্থাপক\",\n\t\"admin_plugins.available\": \"বিদ্যমান প্লাগিন\",\n\t\"admin_plugins.available_not-found\": \"প্লাগিন পাওয়া যায়নি।\",\n\t\"admin_plugins.available_fetching\": \"আনা হচ্ছে...\",\n\t\"admin_plugins.available_install.value\": \"ইনস্টল করুন\",\n\t\"admin_plugins.available_search.placeholder\": \"ইনস্টল করার জন্য প্লাগইন অনুসন্ধান করুন\",\n\t\"admin_plugins.description\": \"বিবরণ\",\n\t\"admin_plugins.installed\": \"ইন্সটল হওয়া প্লাগিনসমূহ\",\n\t\"admin_plugins.installed_fetching\": \"ইন্সটলকৃত প্লাগিন আনা হচ্ছে\",\n\t\"admin_plugins.installed_nothing\": \"আপনি এখনও কোনো প্লাগইন ইনস্টল করেননি।\",\n\t\"admin_plugins.installed_uninstall.value\": \"আনইনস্টল করুন\",\n\t\"admin_plugins.last-update\": \"সর্বশেষ হালনাগাদ\",\n\t\"admin_plugins.name\": \"নাম\",\n\t\"admin_plugins.page-title\": \"প্লাগিন ব্যবস্থাপনা - ইথারপ্যাড\",\n\t\"admin_plugins.version\": \"সংস্করণ\",\n\t\"admin_plugins_info\": \"সমস্যা সমাধানের তথ্য\",\n\t\"admin_plugins_info.hooks\": \"ইন্সটলকৃত হুক\",\n\t\"admin_plugins_info.hooks_client\": \"গ্রাহক পার্শ্বের হুক\",\n\t\"admin_plugins_info.hooks_server\": \"সার্ভার পার্শ্বের হুক\",\n\t\"admin_plugins_info.parts\": \"ইন্সটলকৃত অংশ\",\n\t\"admin_plugins_info.plugins\": \"ইন্সটলকৃত প্লাগিন\",\n\t\"admin_plugins_info.page-title\": \"প্লাগিন তথ্য - ইথারপ্যাড\",\n\t\"admin_plugins_info.version\": \"ইথারপ্যাড সংস্করণ\",\n\t\"admin_plugins_info.version_latest\": \"সাম্প্রতিক উপলব্ধ সংস্করণ\",\n\t\"admin_plugins_info.version_number\": \"সংস্করণ সংখ্যা\",\n\t\"admin_settings\": \"সেটিংসমূহ\",\n\t\"admin_settings.current\": \"বর্তমান কনফিগারেশন\",\n\t\"admin_settings.current_example-devel\": \"উদাহরণ ডেভেলপমেন্ট সেটিংস টেমপ্লেট\",\n\t\"admin_settings.current_example-prod\": \"উদাহরণ উৎপাদন সেটিংস টেমপ্লেট\",\n\t\"admin_settings.current_restart.value\": \"ইথারপ্যাড পুনরায় চালু করুন\",\n\t\"admin_settings.current_save.value\": \"সেটিংসমূহ সংরক্ষণ করুন\",\n\t\"admin_settings.page-title\": \"সেটিংস - ইথারপ্যাড\",\n\t\"index.newPad\": \"নতুন প্যাড\",\n\t\"index.createOpenPad\": \"অথবা নাম লিখে প্যাড খুলুন/তৈরী করুন:\",\n\t\"index.openPad\": \"নাম সহ একটি বিদ্যমান প্যাড খুলুন:\",\n\t\"pad.toolbar.bold.title\": \"গাঢ় (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"বাঁকা (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"নিম্নরেখা (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"অবচ্ছেদন (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"সারিবদ্ধ তালিকা (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"অসারিবদ্ধ তালিকা (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"প্রান্তিককরণ (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"আউটডেন্ট (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"বাতিল করুন (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"পুনরায় করুন (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"কৃতি রং পরিষ্কার করুন  (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"ভিন্ন ফাইল বিন্যাসে আমদানি/রপ্তানি করুন\",\n\t\"pad.toolbar.timeslider.title\": \"টাইমস্লাইডার\",\n\t\"pad.toolbar.savedRevision.title\": \"সংস্করণ সংরক্ষণ করুন\",\n\t\"pad.toolbar.settings.title\": \"সেটিং\",\n\t\"pad.toolbar.embed.title\": \"এই প্যাডটি শেয়ার ও এম্বেড করুন\",\n\t\"pad.toolbar.showusers.title\": \"এই প্যাডের ব্যবহারকারীদের দেখান\",\n\t\"pad.colorpicker.save\": \"সংরক্ষণ\",\n\t\"pad.colorpicker.cancel\": \"বাতিল\",\n\t\"pad.loading\": \"লোড হচ্ছে...\",\n\t\"pad.noCookie\": \"কুকি পাওয়া যায়নি। দয়া করে আপনার ব্রাউজারে কুকি অনুমতি দিন!\",\n\t\"pad.permissionDenied\": \"দুঃখিত, এ প্যাড-টি দেখার অধিকার আপনার নেই\",\n\t\"pad.settings.padSettings\": \"প্যাডের স্থাপন\",\n\t\"pad.settings.myView\": \"আমার দৃশ্য\",\n\t\"pad.settings.stickychat\": \"সর্বদা পর্দায় চ্যাট দেখান\",\n\t\"pad.settings.chatandusers\": \"চ্যাট এবং ব্যবহারকারী দেখান\",\n\t\"pad.settings.colorcheck\": \"লেখকদের নিজস্ব নির্বাচিত রং\",\n\t\"pad.settings.linenocheck\": \"লাইন নম্বর\",\n\t\"pad.settings.rtlcheck\": \"ডান থেকে বামে বিষয়বস্তু পড়বেন?\",\n\t\"pad.settings.fontType\": \"ফন্টের প্রকার:\",\n\t\"pad.settings.fontType.normal\": \"সাধারণ\",\n\t\"pad.settings.language\": \"ভাষা:\",\n\t\"pad.settings.about\": \"পরিচিতি\",\n\t\"pad.settings.poweredBy\": \"এটি দ্বারা চালিত:\",\n\t\"pad.importExport.import_export\": \"আমদানি/রপ্তানি\",\n\t\"pad.importExport.import\": \"কোনো টেক্সট ফাইল বা নথি আপলোড করুন\",\n\t\"pad.importExport.importSuccessful\": \"সফল!\",\n\t\"pad.importExport.export\": \"এইরূপে এই প্যাডটি রপ্তানি করুন:\",\n\t\"pad.importExport.exportetherpad\": \"ইথারপ্যাড\",\n\t\"pad.importExport.exporthtml\": \"এইচটিএমএল\",\n\t\"pad.importExport.exportplain\": \"সাধারণ লেখা\",\n\t\"pad.importExport.exportword\": \"মাইক্রোসফট ওয়ার্ড\",\n\t\"pad.importExport.exportpdf\": \"পিডিএফ\",\n\t\"pad.importExport.exportopen\": \"ওডিএফ (ওপেন ডকুমেন্ট ফরম্যাট)\",\n\t\"pad.modals.connected\": \"সংযোগস্থাপন করা হয়েছে।\",\n\t\"pad.modals.reconnecting\": \"আপনার প্যাডের সাথে সংযোগস্থাপন করা হচ্ছে…\",\n\t\"pad.modals.forcereconnect\": \"পুনরায় সংযোগস্থাপনের চেষ্টা\",\n\t\"pad.modals.reconnecttimer\": \"পুনঃসংযোগের চেষ্টা করা হচ্ছে\",\n\t\"pad.modals.cancel\": \"বাতিল\",\n\t\"pad.modals.userdup\": \"অন্য উইন্ডো-তে খোলা হয়েছে\",\n\t\"pad.modals.userdup.explanation\": \"এই প্যাডটি এই কম্পিউটারে একাধিক ব্রাউজার উইন্ডোতে খোলা হয়েছে বলে মনে হচ্ছে৷\",\n\t\"pad.modals.userdup.advice\": \"পরিবর্তে এই উইন্ডোটি ব্যবহার করতে পুনঃসংযোগ করুন৷\",\n\t\"pad.modals.unauth\": \"আপনার অধিকার নেই\",\n\t\"pad.modals.unauth.explanation\": \"এই পৃষ্ঠাটি দেখার সময় আপনার অনুমতি পরিবর্তিত হয়েছে৷ পুনঃসংযোগের চেষ্টা করুন।\",\n\t\"pad.modals.looping.explanation\": \"সিঙ্ক্রোনাইজেশন সার্ভারের সাথে যোগাযোগের সমস্যা রয়েছে।\",\n\t\"pad.modals.looping.cause\": \"সম্ভবত আপনি একটি বেমানান ফায়ারওয়াল বা প্রক্সির মাধ্যমে সংযুক্ত হয়েছেন৷\",\n\t\"pad.modals.initsocketfail\": \"সার্ভারে পৌঁছানো যাচ্ছে না।\",\n\t\"pad.modals.initsocketfail.explanation\": \"সিঙ্ক্রোনাইজেশন সার্ভারের সাথে সংযোগ করা যায়নি৷\",\n\t\"pad.modals.initsocketfail.cause\": \"এটি সম্ভবত আপনার ব্রাউজার বা আপনার ইন্টারনেট সংযোগের কোনও সমস্যার কারণে হয়েছে৷\",\n\t\"pad.modals.slowcommit.explanation\": \"সার্ভার সাড়া দিচ্ছে না।\",\n\t\"pad.modals.slowcommit.cause\": \"এটি নেটওয়ার্ক সংযোগের সমস্যার কারণে হতে পারে।\",\n\t\"pad.modals.badChangeset.explanation\": \"আপনার করা একটি সম্পাদনা সিঙ্ক্রোনাইজেশন সার্ভার কর্তৃক বেআইনি হিসেবে শ্রেণীবদ্ধ করা হয়েছে৷\",\n\t\"pad.modals.corruptPad.explanation\": \"আপনি যে প্যাডে প্রবেশ করার চেষ্টা করছেন সেটি দূষিত।\",\n\t\"pad.modals.corruptPad.cause\": \"এটি একটি ভুল সার্ভার কনফিগারেশন বা অন্য কোনও অপ্রত্যাশিত আচরণের কারণে হতে পারে। পরিষেবা প্রশাসকের সাথে যোগাযোগ করুন।\",\n\t\"pad.modals.deleted\": \"অপসারিত।\",\n\t\"pad.modals.deleted.explanation\": \"এই প্যাডটি অপসারণ করা হয়েছে।\",\n\t\"pad.modals.rateLimited.explanation\": \"আপনি এই প্যাডে অনেকগুলি বার্তা পাঠিয়েছেন তাই এটি আপনাকে সংযোগ বিচ্ছিন্ন করেছে৷\",\n\t\"pad.modals.rejected.explanation\": \"সার্ভার আপনার ব্রাউজারের পাঠানো একটি বার্তা প্রত্যাখ্যান করেছে৷\",\n\t\"pad.modals.disconnected\": \"আপনি সংযোগ বিচ্ছিন্ন হয়েছে গেছে।\",\n\t\"pad.modals.disconnected.explanation\": \"সার্ভারের সাথে যোগাযোগ করা যাচ্ছে না\",\n\t\"pad.share\": \"শেয়ার করুন\",\n\t\"pad.share.readonly\": \"শুধু পড়া\",\n\t\"pad.share.link\": \"লিংক\",\n\t\"pad.share.emebdcode\": \"ইউআরএল সংযোজন\",\n\t\"pad.chat\": \"চ্যাট\",\n\t\"pad.chat.title\": \"এই প্যাডের জন্য চ্যাট চালু করুন।\",\n\t\"pad.chat.loadmessages\": \"আরও বার্তা লোড করুন\",\n\t\"pad.chat.writeMessage.placeholder\": \"আপনার বার্তাটি এখানে লিখুন\",\n\t\"timeslider.toolbar.returnbutton\": \"প্যাডে ফিরে যাও\",\n\t\"timeslider.toolbar.authors\": \"লেখকগণ:\",\n\t\"timeslider.toolbar.authorsList\": \"কোনো লেখক নেই\",\n\t\"timeslider.toolbar.exportlink.title\": \"রপ্তানি\",\n\t\"timeslider.exportCurrent\": \"বর্তমান সংস্করণটি রপ্তানি করুন:\",\n\t\"timeslider.version\": \"সংস্করণ {{version}}\",\n\t\"timeslider.saved\": \"সংরক্ষিত হয় {{month}} {{day}}, {{year}}\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"জানুয়ারি\",\n\t\"timeslider.month.february\": \"ফেব্রুয়ারি\",\n\t\"timeslider.month.march\": \"মার্চ\",\n\t\"timeslider.month.april\": \"এপ্রিল\",\n\t\"timeslider.month.may\": \"মে\",\n\t\"timeslider.month.june\": \"জুন\",\n\t\"timeslider.month.july\": \"জুলাই\",\n\t\"timeslider.month.august\": \"আগস্ট\",\n\t\"timeslider.month.september\": \"সেপ্টেম্বর\",\n\t\"timeslider.month.october\": \"অক্টোবর\",\n\t\"timeslider.month.november\": \"নভেম্বর\",\n\t\"timeslider.month.december\": \"ডিসেম্বর\",\n\t\"timeslider.unnamedauthors\": \"নামবিহীন {{num}} জন {[plural(num) one: লেখক, other: লেখক ]}\",\n\t\"pad.savedrevs.marked\": \"এই সংশোধনটি এখন সংরক্ষিত সংশোধন হিসেবে চিহ্নিত করা হয়েছে\",\n\t\"pad.userlist.entername\": \"আপনার নাম লিখুন\",\n\t\"pad.userlist.unnamed\": \"কোনো নাম নির্বাচন করা হয়নি\",\n\t\"pad.impexp.importbutton\": \"এখন আমদানি করুন\",\n\t\"pad.impexp.importing\": \"আমদানি হচ্ছে...\",\n\t\"pad.impexp.padHasData\": \"আমরা এই ফাইলটি আমদানি করতে সক্ষম হয়নি কারণ এই প্যাড ইতিমধ্যে পরিবর্তিত হয়েছে, দয়া করে একটি নতুন প্যাডে অামদানি করুন।\",\n\t\"pad.impexp.uploadFailed\": \"আপলোড করতে ব্যর্থ, দয়া করে আবার চেষ্টা করুন\",\n\t\"pad.impexp.importfailed\": \"আমদানি ব্যর্থ\",\n\t\"pad.impexp.copypaste\": \"দয়া করে অনুলিপি প্রতিলেপন করুন\",\n\t\"pad.impexp.exportdisabled\": \"{{type}} হিসেবে রপ্তানি করা নিষ্ক্রিয় আছে। বিস্তারিত জানার জন্য আপনার সিস্টেম প্রশাসকের সাথে যোগাযোগ করুন।\",\n\t\"pad.impexp.maxFileSize\": \"ফাইল খুব বড়। আমদানির জন্য অনুমোদিত ফাইলের আকার বাড়াতে আপনার সাইট প্রশাসকের সাথে যোগাযোগ করুন\"\n}\n"
  },
  {
    "path": "src/locales/br.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Fohanno\",\n\t\t\t\"Fulup\",\n\t\t\t\"Gwenn-Ael\",\n\t\t\t\"Huñvreüs\",\n\t\t\t\"Y-M D\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Pad nevez\",\n\t\"index.createOpenPad\": \"pe krouiñ/digeriñ ur Pad gant an anv :\",\n\t\"pad.toolbar.bold.title\": \"Tev (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Italek (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Islinennañ (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Barrennet(Ktrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Listenn urzhiet (Ktrl+Pennlizherenn+N)\",\n\t\"pad.toolbar.ul.title\": \"Listenn en dizurzh (Ktrl+Pennlizherenn+L)\",\n\t\"pad.toolbar.indent.title\": \"Endantañ (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Diendantañ (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Dizober (Ktrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Adober (Ktrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Diverkañ al livioù oc'h anaout an aozerien (Ktrl+Pennlizherenn+C)\",\n\t\"pad.toolbar.import_export.title\": \"Enporzhiañ/Ezporzhiañ eus/war-zu ur furmad restr disheñvel\",\n\t\"pad.toolbar.timeslider.title\": \"Istor dinamek\",\n\t\"pad.toolbar.savedRevision.title\": \"Enrollañ an adweladenn\",\n\t\"pad.toolbar.settings.title\": \"Arventennoù\",\n\t\"pad.toolbar.embed.title\": \"Rannañ hag enframmañ ar pad-mañ\",\n\t\"pad.toolbar.showusers.title\": \"Diskwelet implijerien ar Pad\",\n\t\"pad.colorpicker.save\": \"Enrollañ\",\n\t\"pad.colorpicker.cancel\": \"Nullañ\",\n\t\"pad.loading\": \"O kargañ...\",\n\t\"pad.noCookie\": \"N'eus ket gallet kavout an toupin. Aotreit an toupinoù en ho merdeer, mar plij !\",\n\t\"pad.permissionDenied\": \"\\nN'oc'h ket aotreet da vont d'ar pad-mañ\",\n\t\"pad.settings.padSettings\": \"Arventennoù Pad\",\n\t\"pad.settings.myView\": \"Ma diskwel\",\n\t\"pad.settings.stickychat\": \"Diskwel ar flap bepred\",\n\t\"pad.settings.chatandusers\": \"Diskouez ar gaoz hag an implijerien\",\n\t\"pad.settings.colorcheck\": \"Livioù anaout\",\n\t\"pad.settings.linenocheck\": \"Niverennoù linennoù\",\n\t\"pad.settings.rtlcheck\": \"Lenn an danvez a-zehou da gleiz ?\",\n\t\"pad.settings.fontType\": \"Seurt font :\",\n\t\"pad.settings.fontType.normal\": \"Reizh\",\n\t\"pad.settings.language\": \"Yezh :\",\n\t\"pad.importExport.import_export\": \"Enporzhiañ/Ezporzhiañ\",\n\t\"pad.importExport.import\": \"Enkargañ un destenn pe ur restr\",\n\t\"pad.importExport.importSuccessful\": \"Deuet eo ganeoc'h !\",\n\t\"pad.importExport.export\": \"Ezporzhiañ ar pad bremañ evel :\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Testenn blaen\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Ne c'hallit enporzhiañ nemet furmadoù testennoù plaen pe HTML. Evit arc'hwelioù enporzhiañ emdroetoc'h, staliit <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">Abiword pe LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Kevreet.\",\n\t\"pad.modals.reconnecting\": \"Adkevreañ war-zu ho pad...\",\n\t\"pad.modals.forcereconnect\": \"Adkevreañ dre heg\",\n\t\"pad.modals.reconnecttimer\": \"O klask adkevreañ\",\n\t\"pad.modals.cancel\": \"Nullañ\",\n\t\"pad.modals.userdup\": \"Digor en ur prenestr all\",\n\t\"pad.modals.userdup.explanation\": \"Digor eo ho pad, war a seblant, e meur a brenestr eus ho merdeer en urzhiataer-mañ.\",\n\t\"pad.modals.userdup.advice\": \"Kevreañ en-dro en ur implijout ar prenestr-mañ.\",\n\t\"pad.modals.unauth\": \"N'eo ket aotreet\",\n\t\"pad.modals.unauth.explanation\": \"Kemmet e vo hoc'h aotreoù pa vo diskwelet ar bajenn.-mañ Klaskit kevreañ en-dro.\",\n\t\"pad.modals.looping.explanation\": \"Kudennoù kehentiñ zo gant ar servijer sinkronelekaat.\",\n\t\"pad.modals.looping.cause\": \"Posupl eo e vefe gwarezet ho kevreadur gant ur maltouter diembreget pe ur servijer proksi\",\n\t\"pad.modals.initsocketfail\": \"Ne c'haller ket tizhout ar servijer.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Ne c'haller ket kevreañ ouzh ar servijer sinkronelaat.\",\n\t\"pad.modals.initsocketfail.cause\": \"Gallout a ra ar gudenn dont eus ho merdeer Web pe eus ho kevreadur Internet.\",\n\t\"pad.modals.slowcommit.explanation\": \"Ne respont ket ar serveur.\",\n\t\"pad.modals.slowcommit.cause\": \"Gallout a ra dont diwar kudennoù kevreañ gant ar rouedad.\",\n\t\"pad.modals.badChangeset.explanation\": \"Graet ho peus ur c'hemm met rummet eo bet evel e-maez lezenn gant ar servijer sinkronelaat\",\n\t\"pad.modals.badChangeset.cause\": \"Dont a ra marteze eus ur c'hefluniadur fall eus ar servijer pe eus un emzalc'h dic'hortoz all. Kit e darempred, mar plij, gant merour ar servijer, ma soñj deoc'h ez eo ur fazi. Klaskit kevreañ en-dro evit kenderc'hel da gemmañ.\",\n\t\"pad.modals.corruptPad.explanation\": \"Breinet eo ar bloc'h emaoc'h o klask tizhout.\",\n\t\"pad.modals.corruptPad.cause\": \"Dont a ra marteze eus ur c'hefluniadur fall eus ar servijer pe eus un emzalc'h dic'hortoz all. Kit e darempred, mar plij, gant merour ar servijer.\",\n\t\"pad.modals.deleted\": \"Dilamet.\",\n\t\"pad.modals.deleted.explanation\": \"Lamet eo bet ar pad-mañ.\",\n\t\"pad.modals.disconnected\": \"Digevreet oc'h bet.\",\n\t\"pad.modals.disconnected.explanation\": \"Kollet eo bet ar c'hevreadur gant ar servijer\",\n\t\"pad.modals.disconnected.cause\": \"Dizimplijadus eo ar servijer marteze. Kelaouit ar servij merañ ma pad ar gudenn.\",\n\t\"pad.share\": \"Rannañ ar pad-mañ.\",\n\t\"pad.share.readonly\": \"Lenn hepken\",\n\t\"pad.share.link\": \"Liamm\",\n\t\"pad.share.emebdcode\": \"Enframmañ an URL\",\n\t\"pad.chat\": \"Flap\",\n\t\"pad.chat.title\": \"Digeriñ ar flap kevelet gant ar pad-mañ.\",\n\t\"pad.chat.loadmessages\": \"Kargañ muioc'h a gemennadennoù\",\n\t\"pad.chat.stick.title\": \"Gwriziennañ an diviz war ar skramm\",\n\t\"pad.chat.writeMessage.placeholder\": \"Skrivañ ho kemennadenn amañ\",\n\t\"timeslider.pageTitle\": \"Istor dinamek eus {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Distreiñ d'ar pad-mañ.\",\n\t\"timeslider.toolbar.authors\": \"Aozerien :\",\n\t\"timeslider.toolbar.authorsList\": \"Aozer ebet\",\n\t\"timeslider.toolbar.exportlink.title\": \"Ezporzhiañ\",\n\t\"timeslider.exportCurrent\": \"Ezporzhiañ an doare bremañ evel :\",\n\t\"timeslider.version\": \"Stumm {{version}}\",\n\t\"timeslider.saved\": \"Enrollañ {{day}} {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Lenn / Ehan endalc'hoù ar pad\",\n\t\"timeslider.backRevision\": \"Kilit eus un adweladenn er pad-mañ\",\n\t\"timeslider.forwardRevision\": \"Araogiñ un adweladenn er pad-mañ\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Genver\",\n\t\"timeslider.month.february\": \"C'hwevrer\",\n\t\"timeslider.month.march\": \"Meurzh\",\n\t\"timeslider.month.april\": \"Ebrel\",\n\t\"timeslider.month.may\": \"Mae\",\n\t\"timeslider.month.june\": \"Mezheven\",\n\t\"timeslider.month.july\": \"Gouere\",\n\t\"timeslider.month.august\": \"Eost\",\n\t\"timeslider.month.september\": \"Gwengolo\",\n\t\"timeslider.month.october\": \"Here\",\n\t\"timeslider.month.november\": \"Du\",\n\t\"timeslider.month.december\": \"Kerzu\",\n\t\"timeslider.unnamedauthors\": \"{{num}} dianav {[plural(num) one: aozer, other: aozerien ]}\",\n\t\"pad.savedrevs.marked\": \"Merket eo an adweladenn-mañ evel adweladenn gwiriet\",\n\t\"pad.savedrevs.timeslider\": \"Gallout a reot gwelet an adweladurioù enrollet en ur weladenniñ ar bignerez amzerel\",\n\t\"pad.userlist.entername\": \"Ebarzhit hoc'h anv\",\n\t\"pad.userlist.unnamed\": \"dizanv\",\n\t\"pad.editbar.clearcolors\": \"Diverkañ al livioù stag ouzh an aozerien en teul a-bezh ? Ne c'hallo ket bezañ disc'hraet\",\n\t\"pad.impexp.importbutton\": \"Enporzhiañ bremañ\",\n\t\"pad.impexp.importing\": \"Oc'h enporzhiañ...\",\n\t\"pad.impexp.confirmimport\": \"Ma vez enporzhiet ur restr e vo diverket ar pezh zo en teul a-vremañ. Ha sur oc'h e fell deoc'h mont betek penn ?\",\n\t\"pad.impexp.convertFailed\": \"N'eus ket bet gallet enporzhiañ ar restr. Ober gant ur furmad teul all pe eilañ/pegañ gant an dorn.\",\n\t\"pad.impexp.padHasData\": \"N'hon eus ket gallet enporzhiañ ar restr-mañ dre ma'z eus bet degaset kemmoù er bloc'h-se ; enporzhiit anezhi war-zu ur bloc'h nevez, mar plij.\",\n\t\"pad.impexp.uploadFailed\": \"C'hwitet eo bet an enporzhiañ. Klaskit en-dro.\",\n\t\"pad.impexp.importfailed\": \"C'hwitet eo an enporzhiadenn\",\n\t\"pad.impexp.copypaste\": \"Eilit/pegit, mar plij\",\n\t\"pad.impexp.exportdisabled\": \"Diweredekaet eo ezporzhiañ d'ar furmad {{type}}. Kit e darempred gant merour ar reizhiad evit gouzout hiroc'h.\",\n\t\"pad.impexp.maxFileSize\": \"Re vras eo ar restr. Kit e daremrepd gant merour ho lec'hienn evit kreskiñ ment aoteet ar restroù evit enporzhiañ\"\n}\n"
  },
  {
    "path": "src/locales/bs.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Edinwiki\",\n\t\t\t\"Semina x\",\n\t\t\t\"Srdjan m\",\n\t\t\t\"Srđan\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Novi Pad\",\n\t\"index.createOpenPad\": \"ili napravite/otvorite Pad sa imenom:\",\n\t\"pad.toolbar.bold.title\": \"Podebljano (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Ukošeno (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Podvučeno (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Precrtano (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Poredani spisak (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Neporedani spisak (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Uvučeno (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Izvučeno (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Poništi (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Ponovi (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Očisti autorske boje (Ctrl+Shift+C)\",\n\t\"pad.toolbar.timeslider.title\": \"Historijski pregled\",\n\t\"pad.toolbar.savedRevision.title\": \"Sačuvaj Reviziju\",\n\t\"pad.toolbar.settings.title\": \"Postavke\",\n\t\"pad.toolbar.embed.title\": \"Podijeli i ugradi ovaj pad\",\n\t\"pad.toolbar.showusers.title\": \"Pokaži korisnike na ovom padu\",\n\t\"pad.colorpicker.save\": \"Sačuvaj\",\n\t\"pad.colorpicker.cancel\": \"Otkaži\",\n\t\"pad.loading\": \"Učitavam...\",\n\t\"pad.noCookie\": \"Kolačić nije pronađen. Molimo Vas dozvolite kolačiće u Vašem pregledniku!\",\n\t\"pad.permissionDenied\": \"Nemate dopuštenje da pistupite ovom padu\",\n\t\"pad.settings.padSettings\": \"Postavke stranice\",\n\t\"pad.settings.myView\": \"Moj prikaz\",\n\t\"pad.settings.stickychat\": \"Ćaskanje uvijek na ekranu\",\n\t\"pad.settings.chatandusers\": \"Prikaži ćaskanje i korisnike\",\n\t\"pad.settings.colorcheck\": \"Autorske boje\",\n\t\"pad.settings.linenocheck\": \"Brojevi redova\",\n\t\"pad.settings.rtlcheck\": \"Da prikažem sadržaj zdesna ulijevo?\",\n\t\"pad.settings.fontType\": \"Vrsta fonta:\",\n\t\"pad.settings.fontType.normal\": \"Normalno\",\n\t\"pad.settings.language\": \"Jezik:\",\n\t\"pad.importExport.import_export\": \"Uvoz/Izvoz\",\n\t\"pad.importExport.import\": \"Postavite bilo koju tekstualnu datoteku ili dokument\",\n\t\"pad.importExport.importSuccessful\": \"Uspješno!\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Obični tekst\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.modals.connected\": \"Spojeno.\",\n\t\"pad.modals.forcereconnect\": \"Prisilno se ponovo poveži\",\n\t\"pad.modals.reconnecttimer\": \"Pokušavam se ponovo povezati\",\n\t\"pad.modals.cancel\": \"Otkaži\",\n\t\"pad.modals.userdup\": \"Otvoreno u drugom prozoru\",\n\t\"pad.modals.userdup.advice\": \"Ponovo se povežite da biste koristili ovaj prozor.\",\n\t\"pad.modals.unauth\": \"Niste ovlašteni\",\n\t\"pad.modals.initsocketfail\": \"Server je nedostupan.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Ne mogu se povezati sa sinhronizacijskim serverom.\",\n\t\"pad.modals.slowcommit.explanation\": \"Server se ne odaziva.\",\n\t\"pad.modals.deleted\": \"Obrisano.\",\n\t\"pad.modals.disconnected\": \"Veza je prekinuta.\",\n\t\"pad.modals.disconnected.explanation\": \"Izgubljena je veza sa serverom\",\n\t\"pad.modals.disconnected.cause\": \"Moguće je da server nije dostupan. Obavijestite administratora ako se ovo nastavi dešavati.\",\n\t\"pad.share\": \"Podijeli ovaj pad\",\n\t\"pad.share.readonly\": \"Samo za čitanje\",\n\t\"pad.share.link\": \"Link\",\n\t\"pad.share.emebdcode\": \"URL za ugradnju\",\n\t\"pad.chat\": \"Ćaskanje\",\n\t\"pad.chat.title\": \"Otvori chat za ovaj pad.\",\n\t\"pad.chat.loadmessages\": \"Učitaj više poruka\",\n\t\"pad.chat.stick.title\": \"Zalijepi chat na screen\",\n\t\"pad.chat.writeMessage.placeholder\": \"Napišite Vašu poruku ovjde\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Historijski pregled\",\n\t\"timeslider.toolbar.returnbutton\": \"Vrati se na pad\",\n\t\"timeslider.toolbar.authors\": \"Autori:\",\n\t\"timeslider.toolbar.authorsList\": \"Nema autora\",\n\t\"timeslider.toolbar.exportlink.title\": \"Izvoz\",\n\t\"timeslider.exportCurrent\": \"Izvezi trenutnu verziju kao:\",\n\t\"timeslider.version\": \"Verzija {{version}}\",\n\t\"timeslider.saved\": \"Sačuvano na datum {{day}}. {{month}} {{year}}\",\n\t\"timeslider.month.january\": \"januar\",\n\t\"timeslider.month.february\": \"februar\",\n\t\"timeslider.month.march\": \"mart\",\n\t\"timeslider.month.april\": \"april\",\n\t\"timeslider.month.may\": \"maj\",\n\t\"timeslider.month.june\": \"juni\",\n\t\"timeslider.month.july\": \"juli\",\n\t\"timeslider.month.august\": \"august\",\n\t\"timeslider.month.september\": \"septembar\",\n\t\"timeslider.month.october\": \"oktobar\",\n\t\"timeslider.month.november\": \"novembar\",\n\t\"timeslider.month.december\": \"decembar\",\n\t\"pad.savedrevs.marked\": \"Ova revizija je sada označena kao sačuvana revizija\",\n\t\"pad.userlist.entername\": \"Upišite svoje ime\",\n\t\"pad.userlist.unnamed\": \"neimenovano\",\n\t\"pad.editbar.clearcolors\": \"Očisti autorske boje na čitavom dokumentu?\",\n\t\"pad.impexp.importbutton\": \"Uvezi odmah\",\n\t\"pad.impexp.importing\": \"Uvozim...\",\n\t\"pad.impexp.uploadFailed\": \"Postavljanje nije uspjelo. Pokušajte ponovo\",\n\t\"pad.impexp.importfailed\": \"Uvoz neuspješan\",\n\t\"pad.impexp.copypaste\": \"Molimo Vas copy/paste\"\n}\n"
  },
  {
    "path": "src/locales/ca.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Alvaro Vidal-Abarca\",\n\t\t\t\"Eduardo Martinez\",\n\t\t\t\"Jaumeortola\",\n\t\t\t\"Joan manel\",\n\t\t\t\"Macofe\",\n\t\t\t\"Mguix\",\n\t\t\t\"Pginer\",\n\t\t\t\"Pitort\",\n\t\t\t\"Ssola\",\n\t\t\t\"Toniher\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Panell administratiu - Etherpad\",\n\t\"admin_plugins\": \"Gestor de connectors\",\n\t\"admin_plugins.available\": \"Connectors disponibles\",\n\t\"admin_plugins.available_not-found\": \"No s'ha trobat cap complement.\",\n\t\"admin_plugins.available_fetching\": \"Obtenint...\",\n\t\"admin_plugins.available_install.value\": \"Instal·la\",\n\t\"admin_plugins.available_search.placeholder\": \"Cerca connectors per instal·lar\",\n\t\"admin_plugins.description\": \"Descripció\",\n\t\"admin_plugins.installed\": \"Connectors instal·lats\",\n\t\"admin_plugins.installed_fetching\": \"S'estan recuperant els connectors instal·lats...\",\n\t\"admin_plugins.installed_nothing\": \"Encara no heu instal·lat cap connector.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Desinstal·la\",\n\t\"admin_plugins.last-update\": \"Darrera actualització\",\n\t\"admin_plugins.name\": \"Nom\",\n\t\"admin_plugins.page-title\": \"Gestor de connectors - Etherpad\",\n\t\"admin_plugins.version\": \"Versió\",\n\t\"admin_plugins_info\": \"Informació de resolució de problemes\",\n\t\"admin_plugins_info.hooks\": \"Actuadors instal·lats\",\n\t\"admin_plugins_info.hooks_client\": \"Actuadors de la banda client\",\n\t\"admin_plugins_info.hooks_server\": \"Actuadors del costat servidor\",\n\t\"admin_plugins_info.parts\": \"Parts instal·lades\",\n\t\"admin_plugins_info.plugins\": \"Connectors instal·lats\",\n\t\"admin_plugins_info.page-title\": \"Informació del connector - Etherpad\",\n\t\"admin_plugins_info.version\": \"Versió de l'Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Última versió disponible\",\n\t\"admin_plugins_info.version_number\": \"Número de versió\",\n\t\"admin_settings\": \"Configuració\",\n\t\"admin_settings.current\": \"Configuració actual\",\n\t\"admin_settings.current_example-devel\": \"Plantilla d'exemple de configuració de desenvolupament\",\n\t\"admin_settings.current_example-prod\": \"Exemple de model de paràmetres de producció\",\n\t\"admin_settings.current_restart.value\": \"Reprèn Etherpad\",\n\t\"admin_settings.current_save.value\": \"Desa la configuració\",\n\t\"admin_settings.page-title\": \"Configuració - Etherpad\",\n\t\"index.newPad\": \"Nou pad\",\n\t\"index.createOpenPad\": \"o crea/obre un pad amb el nom:\",\n\t\"index.openPad\": \"obre un Pad existent amb el nom:\",\n\t\"pad.toolbar.bold.title\": \"Negreta (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Cursiva (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Subratllat (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Ratllat (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Llista ordenada (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Llista sense ordenar (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Sagnat (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Sagnat invers (Majúsc+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Desfés (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Refés (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Neteja els colors d'autoria (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importa/exporta a partir de diferents formats de fitxer\",\n\t\"pad.toolbar.timeslider.title\": \"Línia temporal\",\n\t\"pad.toolbar.savedRevision.title\": \"Desa la revisió\",\n\t\"pad.toolbar.settings.title\": \"Configuració\",\n\t\"pad.toolbar.embed.title\": \"Comparteix i incrusta aquest pad\",\n\t\"pad.toolbar.showusers.title\": \"Mostra els usuaris d’aquest pad\",\n\t\"pad.colorpicker.save\": \"Desa\",\n\t\"pad.colorpicker.cancel\": \"Cancel·la\",\n\t\"pad.loading\": \"S'està carregant...\",\n\t\"pad.noCookie\": \"No s'ha trobat la galeta. Permeteu les galetes en el navegador!\",\n\t\"pad.permissionDenied\": \"No teniu permisos per a accedir a aquest pad\",\n\t\"pad.settings.padSettings\": \"Paràmetres del pad\",\n\t\"pad.settings.myView\": \"La meva vista\",\n\t\"pad.settings.stickychat\": \"Xateja sempre a la pantalla\",\n\t\"pad.settings.chatandusers\": \"Mostra el xat i els usuaris\",\n\t\"pad.settings.colorcheck\": \"Colors d'autoria\",\n\t\"pad.settings.linenocheck\": \"Números de línia\",\n\t\"pad.settings.rtlcheck\": \"Llegir el contingut de dreta a esquerra?\",\n\t\"pad.settings.fontType\": \"Tipus de lletra:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Llengua:\",\n\t\"pad.settings.about\": \"Sobre\",\n\t\"pad.settings.poweredBy\": \"Funciona amb\",\n\t\"pad.importExport.import_export\": \"Importació/exportació\",\n\t\"pad.importExport.import\": \"Puja qualsevol fitxer de text o document\",\n\t\"pad.importExport.importSuccessful\": \"Hi ha hagut èxit!\",\n\t\"pad.importExport.export\": \"Exporta el pad actual com a:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Text sense format\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Només podeu importar de text sense format o HTML. Per a opcions d'importació més avançades <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">instal·leu l'Abiword</a>.\",\n\t\"pad.modals.connected\": \"Connectat.\",\n\t\"pad.modals.reconnecting\": \"S'està tornant a connectar al vostre pad…\",\n\t\"pad.modals.forcereconnect\": \"Força tornar a connectar\",\n\t\"pad.modals.reconnecttimer\": \"Intentant reconnectar en\",\n\t\"pad.modals.cancel\": \"Cancel·la\",\n\t\"pad.modals.userdup\": \"Obert en una altra finestra\",\n\t\"pad.modals.userdup.explanation\": \"Aquest pad sembla que està obert en més d'una finestra de navegador de l'ordinador.\",\n\t\"pad.modals.userdup.advice\": \"Torneu a connectar-vos per a utilitzar aquesta finestra.\",\n\t\"pad.modals.unauth\": \"No autoritzat\",\n\t\"pad.modals.unauth.explanation\": \"Els vostres permisos han canviat mentre es visualitzava la pàgina. Proveu de reconnectar-vos.\",\n\t\"pad.modals.looping.explanation\": \"Hi ha problemes de comunicació amb el servidor de sincronització.\",\n\t\"pad.modals.looping.cause\": \"Potser us heu connectat a través d'un tallafocs o servidor intermediari incompatible.\",\n\t\"pad.modals.initsocketfail\": \"El servidor no és accessible.\",\n\t\"pad.modals.initsocketfail.explanation\": \"No s'ha pogut connectar amb el servidor de sincronització.\",\n\t\"pad.modals.initsocketfail.cause\": \"Això és probablement a causa d'un problema amb el navegador o la connexió a Internet.\",\n\t\"pad.modals.slowcommit.explanation\": \"El servidor no respon.\",\n\t\"pad.modals.slowcommit.cause\": \"Això podria ser a causa de problemes amb la connectivitat de la xarxa.\",\n\t\"pad.modals.badChangeset.explanation\": \"El servidor de sincronització ha classificat com a il·legat una edició que heu fet.\",\n\t\"pad.modals.badChangeset.cause\": \"Això pot ser degut a una configuració errònia del servidor o a algun altre comportament inesperat. Si considereu que és un error, contacteu amb l'administrador del servei. Intenteu reconnectar-vos per a continuar editant.\",\n\t\"pad.modals.corruptPad.explanation\": \"El pad al qual esteu intentant accedir està corrupte.\",\n\t\"pad.modals.corruptPad.cause\": \"Això pot ser degut a una configuració errònia del servidor o a algun altre comportament inesperat. Si us plau, contacteu amb l'administrador del servei.\",\n\t\"pad.modals.deleted\": \"Suprimit.\",\n\t\"pad.modals.deleted.explanation\": \"S'ha suprimit el pad.\",\n\t\"pad.modals.rateLimited\": \"Tarifa limitada.\",\n\t\"pad.modals.rateLimited.explanation\": \"Heu enviat massa missatges a aquest pad per això us han desconnectat.\",\n\t\"pad.modals.rejected.explanation\": \"El servidor ha rebutjat un missatge enviat pel seu navegador.\",\n\t\"pad.modals.rejected.cause\": \"Pot ser que el servidor s'hagi actualitzat mentre estàveu veient la plataforma, o potser hi ha un error a Etherpad.  Intenta tornar a carregar la pàgina.\",\n\t\"pad.modals.disconnected\": \"Heu estat desconnectat.\",\n\t\"pad.modals.disconnected.explanation\": \"S'ha perdut la connexió amb el servidor\",\n\t\"pad.modals.disconnected.cause\": \"El servidor sembla que no està disponible. Notifiqueu a l'administrador del servei si continua passant.\",\n\t\"pad.share\": \"Comparteix el pad\",\n\t\"pad.share.readonly\": \"Només de lectura\",\n\t\"pad.share.link\": \"Enllaç\",\n\t\"pad.share.emebdcode\": \"Incrusta l'URL\",\n\t\"pad.chat\": \"Xat\",\n\t\"pad.chat.title\": \"Obre el xat d'aquest pad.\",\n\t\"pad.chat.loadmessages\": \"Carrega més missatges\",\n\t\"pad.chat.stick.title\": \"Ancora el xat a la pantalla\",\n\t\"pad.chat.writeMessage.placeholder\": \"Escriviu el vostre missatge a continuació\",\n\t\"timeslider.followContents\": \"Fer seguiment de les actualitzacions de contingut del bloc\",\n\t\"timeslider.pageTitle\": \"Línia temporal — {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Torna al pad\",\n\t\"timeslider.toolbar.authors\": \"Autors:\",\n\t\"timeslider.toolbar.authorsList\": \"No hi ha autors\",\n\t\"timeslider.toolbar.exportlink.title\": \"Exporta\",\n\t\"timeslider.exportCurrent\": \"Exporta la versió actual com a:\",\n\t\"timeslider.version\": \"Versió {{version}}\",\n\t\"timeslider.saved\": \"Desat {{month}} {{day}}, {{year}}\",\n\t\"timeslider.playPause\": \"Reproducció / Pausa els continguts del pad\",\n\t\"timeslider.backRevision\": \"Tornar una revisió enrere en aquest Pad\",\n\t\"timeslider.forwardRevision\": \"Anar una revisió endavant en aquest Pad\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Gener\",\n\t\"timeslider.month.february\": \"Febrer\",\n\t\"timeslider.month.march\": \"Març\",\n\t\"timeslider.month.april\": \"Abril\",\n\t\"timeslider.month.may\": \"Maig\",\n\t\"timeslider.month.june\": \"Juny\",\n\t\"timeslider.month.july\": \"Juliol\",\n\t\"timeslider.month.august\": \"Agost\",\n\t\"timeslider.month.september\": \"Setembre\",\n\t\"timeslider.month.october\": \"Octubre\",\n\t\"timeslider.month.november\": \"Novembre\",\n\t\"timeslider.month.december\": \"Desembre\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: autor, other: autors ]} sense nom\",\n\t\"pad.savedrevs.marked\": \"Aquesta revisió està marcada ara com a revisió desada\",\n\t\"pad.savedrevs.timeslider\": \"Les revisions que s'han desat les podeu veure amb la línia de temps\",\n\t\"pad.userlist.entername\": \"Introduïu el vostre nom\",\n\t\"pad.userlist.unnamed\": \"sense nom\",\n\t\"pad.editbar.clearcolors\": \"Netejar els colors d'autor del document sencer?\",\n\t\"pad.impexp.importbutton\": \"Importa ara\",\n\t\"pad.impexp.importing\": \"Important...\",\n\t\"pad.impexp.confirmimport\": \"En importar un fitxer se sobreescriurà el text actual del pad. Esteu segur que voleu continuar?\",\n\t\"pad.impexp.convertFailed\": \"No és possible d'importar aquest fitxer. Si us plau, podeu provar d'utilitzar un format diferent o copiar i enganxar manualment.\",\n\t\"pad.impexp.padHasData\": \"No vam poder importar el fitxer perquè el pad ja tenia canvis. Importeu-lo a un nou pad\",\n\t\"pad.impexp.uploadFailed\": \"Ha fallat la càrrega. Torneu-ho a provar\",\n\t\"pad.impexp.importfailed\": \"Ha fallat la importació\",\n\t\"pad.impexp.copypaste\": \"Si us plau, copieu i enganxeu\",\n\t\"pad.impexp.exportdisabled\": \"Està inhabilitada l'exportació com a {{type}}. Contacteu amb el vostre administrador de sistemes per a més informació.\",\n\t\"pad.impexp.maxFileSize\": \"Arxiu massa gran. Poseu-vos en contacte amb l'administrador del vostre lloc per augmentar la mida màxima dels fitxers importats\"\n}\n"
  },
  {
    "path": "src/locales/ce.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Умар\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Администраторан панель — Etherpad\",\n\t\"admin_plugins\": \"Плагинийн менеджер\",\n\t\"admin_plugins.available\": \"ТӀекхочуш йолу плагинаш\",\n\t\"admin_plugins.available_not-found\": \"Плагинаш ца карийна.\",\n\t\"admin_plugins.available_fetching\": \"Схьаоьцуш...\",\n\t\"admin_plugins.available_install.value\": \"ДӀахӀоттайе\",\n\t\"admin_plugins.installed_uninstall.value\": \"ДӀайаккха\",\n\t\"admin_plugins.last-update\": \"ТӀаьххьара карлайаккхар\",\n\t\"admin_plugins.name\": \"ЦӀе\",\n\t\"admin_plugins.page-title\": \"Плагинийн менеджер — Etherpad\",\n\t\"admin_plugins.version\": \"Верси\",\n\t\"admin_plugins_info.version_number\": \"Версин лоьмар\",\n\t\"admin_settings\": \"Нисдаран гӀирс\",\n\t\"admin_settings.current\": \"Карара конфигураци\",\n\t\"pad.colorpicker.save\": \"Ӏалашйан\",\n\t\"pad.colorpicker.cancel\": \"Йухайаккхар\",\n\t\"pad.loading\": \"Чуйолуш…\",\n\t\"pad.permissionDenied\": \"Хьан бакъонаш йац тӀекхача\",\n\t\"pad.settings.padSettings\": \"Документан нисдаран гӀирс\",\n\t\"pad.settings.myView\": \"Сан васт\",\n\t\"pad.settings.stickychat\": \"Гуттара а гайта чат\",\n\t\"pad.settings.language\": \"Мотт:\",\n\t\"pad.settings.about\": \"Проектах лаьцна\",\n\t\"pad.importExport.importSuccessful\": \"Кхиамца!\",\n\t\"pad.modals.cancel\": \"Йухайаккхар\",\n\t\"pad.share.link\": \"Хьажорг\",\n\t\"pad.chat\": \"Чат\",\n\t\"timeslider.toolbar.authors\": \"Авторш:\",\n\t\"timeslider.toolbar.exportlink.title\": \"Экспорт\",\n\t\"timeslider.month.january\": \"январь\",\n\t\"timeslider.month.february\": \"февраль\",\n\t\"timeslider.month.march\": \"март\",\n\t\"timeslider.month.april\": \"апрель\",\n\t\"timeslider.month.may\": \"май\",\n\t\"timeslider.month.june\": \"июнь\",\n\t\"timeslider.month.july\": \"июль\",\n\t\"timeslider.month.august\": \"август\",\n\t\"timeslider.month.september\": \"сентябрь\",\n\t\"timeslider.month.october\": \"октябрь\",\n\t\"timeslider.month.november\": \"ноябрь\",\n\t\"timeslider.month.december\": \"декабрь\",\n\t\"pad.impexp.importing\": \"Импорт йар...\"\n}\n"
  },
  {
    "path": "src/locales/cs.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Aktron\",\n\t\t\t\"Clon\",\n\t\t\t\"Dvorapa\",\n\t\t\t\"Jakubt\",\n\t\t\t\"Jezevec\",\n\t\t\t\"Juandev\",\n\t\t\t\"Leanes\",\n\t\t\t\"Mormegil\",\n\t\t\t\"Peldrjan\",\n\t\t\t\"Quinn\",\n\t\t\t\"Spotter\",\n\t\t\t\"The astrea\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Ovládací panel Správce - Etherpad\",\n\t\"admin_plugins\": \"Správce zásuvných moodulů\",\n\t\"admin_plugins.available\": \"Dostupné zásuvné moduly\",\n\t\"admin_plugins.available_not-found\": \"Nejsou žádné zásuvné moduly\",\n\t\"admin_plugins.available_fetching\": \"Načítání...\",\n\t\"admin_plugins.available_install.value\": \"Instalovat\",\n\t\"admin_plugins.available_search.placeholder\": \"Vyhledat zásuvné moduly k instalaci\",\n\t\"admin_plugins.description\": \"Popis\",\n\t\"admin_plugins.installed\": \"Nainstalované zásuvné moduly\",\n\t\"admin_plugins.installed_fetching\": \"Načítání instalovaných zásuvných modulů...\",\n\t\"admin_plugins.installed_nothing\": \"Dosud jste nenainstalovali žádné zásuvné moduly.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Odinstalovat\",\n\t\"admin_plugins.last-update\": \"Poslední aktualizace\",\n\t\"admin_plugins.name\": \"Název\",\n\t\"admin_plugins.page-title\": \"Správce zásuvných modulů - Etherpad\",\n\t\"admin_plugins.version\": \"Verze\",\n\t\"admin_plugins_info\": \"Informace o řešení problému\",\n\t\"admin_plugins_info.hooks\": \"Instalované hooks\",\n\t\"admin_plugins_info.hooks_client\": \"hooks na straně klienta\",\n\t\"admin_plugins_info.hooks_server\": \"hooks na straně serveru\",\n\t\"admin_plugins_info.parts\": \"Nainstalované součásti\",\n\t\"admin_plugins_info.plugins\": \"Nainstalované zásuvné moduly\",\n\t\"admin_plugins_info.page-title\": \"Informace o zásuvných modulech - Etherpad\",\n\t\"admin_plugins_info.version\": \"Verze Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Poslední dostupná verze\",\n\t\"admin_plugins_info.version_number\": \"Číslo verze\",\n\t\"admin_settings\": \"Nastavení\",\n\t\"admin_settings.current\": \"Aktuální konfugurace\",\n\t\"admin_settings.current_example-devel\": \"Příklad ukázkové vývojové šablony\",\n\t\"admin_settings.current_example-prod\": \"Příklad šablony nastavení výroby\",\n\t\"admin_settings.current_restart.value\": \"Restartovat Etherpad\",\n\t\"admin_settings.current_save.value\": \"Uložit nastavení\",\n\t\"admin_settings.page-title\": \"Nastavení - Etherpad\",\n\t\"index.newPad\": \"Založ nový Pad\",\n\t\"index.settings\": \"Nastavení\",\n\t\"index.transferSessionTitle\": \"relace Přenosu\",\n\t\"index.receiveSessionTitle\": \"Přijmout relaci\",\n\t\"index.receiveSessionDescription\": \"Zde můžete přijímat relaci Etherpad z jiného prohlížeče nebo zařízení. Upozorňujeme však, že tím se smaže vaše aktuální relace, pokud nějaká existuje.\",\n\t\"index.transferSession\": \"1. Přenos relace\",\n\t\"index.transferSessionNow\": \"Přenést relaci nyní\",\n\t\"index.copyLink\": \"2. Zkopírovat odkaz\",\n\t\"index.copyLinkDescription\": \"Kliknutím na tlačítko níže zkopírujete odkaz do schránky.\",\n\t\"index.copyLinkButton\": \"Kopírovat odkaz do schránky\",\n\t\"index.transferToSystem\": \"3. Zkopírujte relaci do nového systému\",\n\t\"index.transferToSystemDescription\": \"Otevřete zkopírovaný odkaz v cílovém prohlížeči nebo zařízení a přeneste svou relaci.\",\n\t\"index.transferSessionDescription\": \"Přeneste svou aktuální relaci do prohlížeče nebo zařízení kliknutím na tlačítko níže. Tím se zkopíruje odkaz na stránku, která přenese vaši relaci po otevření v cílovém prohlížeči nebo zařízení.\",\n\t\"index.createOpenPad\": \"Otevřít pad podle jména\",\n\t\"index.openPad\": \"otevřít existující Pad se jménem:\",\n\t\"index.recentPads\": \"Poslední Pady\",\n\t\"index.recentPadsEmpty\": \"Nebyly nalezeny žádné nedávné pady.\",\n\t\"index.generateNewPad\": \"Generovat náhodný název padu\",\n\t\"index.labelPad\": \"Název Padu (volitelné)\",\n\t\"index.placeholderPadEnter\": \"Zadejte prosím název padu...\",\n\t\"index.createAndShareDocuments\": \"Vytvářejte a sdílejte dokumenty v reálném čase\",\n\t\"index.createAndShareDocumentsDescription\": \"Etherpad umožňuje kolaborativní úpravu dokumentů v reálném čase, podobně jako živý multiplayerový editor, který běží ve vašem prohlížeči.\",\n\t\"pad.toolbar.bold.title\": \"Tučný text (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Kurzíva (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Podtržené písmo (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Přeškrtnuto (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Číslovaný seznam\",\n\t\"pad.toolbar.ul.title\": \"Nečíslovaný seznam\",\n\t\"pad.toolbar.indent.title\": \"Odsazení (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Předsazení (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Zpět (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Opakovat (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Vymazat barvy autorů\",\n\t\"pad.toolbar.import_export.title\": \"Importovat/Exportovat z/do jiných formátů\",\n\t\"pad.toolbar.timeslider.title\": \"Časová osa\",\n\t\"pad.toolbar.savedRevision.title\": \"Uložit revizi\",\n\t\"pad.toolbar.settings.title\": \"Nastavení\",\n\t\"pad.toolbar.embed.title\": \"Sdílet a umístit tento Pad\",\n\t\"pad.toolbar.home.title\": \"Zpět domů\",\n\t\"pad.toolbar.showusers.title\": \"Zobrazit uživatele u tohoto Padu\",\n\t\"pad.colorpicker.save\": \"Uložit\",\n\t\"pad.colorpicker.cancel\": \"Zrušit\",\n\t\"pad.loading\": \"Načítání...\",\n\t\"pad.noCookie\": \"Soubor cookie nebyl nalezen. Povolte prosím cookies ve svém prohlížeči! Vaše relace a nastavení se mezi návštěvami neuloží. Může to být způsobeno tím, že je Etherpad v některých prohlížečích zahrnut do iFrame. Zkontrolujte, zda je Etherpad ve stejné subdoméně / doméně jako nadřazený iFrame\",\n\t\"pad.permissionDenied\": \"Nemáte oprávnění pro přístup k tomuto Padu\",\n\t\"pad.settings.padSettings\": \"Nastavení Padu\",\n\t\"pad.settings.myView\": \"Vlastní pohled\",\n\t\"pad.settings.stickychat\": \"Chat vždy na obrazovce\",\n\t\"pad.settings.chatandusers\": \"Ukázat Chat a Uživatele\",\n\t\"pad.settings.colorcheck\": \"Barvy autorů\",\n\t\"pad.settings.linenocheck\": \"Čísla řádků\",\n\t\"pad.settings.rtlcheck\": \"Číst obsah zprava doleva?\",\n\t\"pad.settings.fontType\": \"Typ písma:\",\n\t\"pad.settings.fontType.normal\": \"Normální\",\n\t\"pad.settings.language\": \"Jazyk:\",\n\t\"pad.settings.deletePad\": \"Smazat pad\",\n\t\"pad.delete.confirm\": \"Opravdu chcete tento pad smazat?\",\n\t\"pad.settings.about\": \"O projektu\",\n\t\"pad.settings.poweredBy\": \"Běží na\",\n\t\"pad.importExport.import_export\": \"Import/Export\",\n\t\"pad.importExport.import\": \"Nahrát libovolný textový soubor nebo dokument\",\n\t\"pad.importExport.importSuccessful\": \"Úspěšně!\",\n\t\"pad.importExport.export\": \"Exportovat stávající Pad jako:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Prostý text\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Importovat lze pouze z formátů prostého textu nebo HTML. Pokročilejší funkce pro import naleznete v <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">instalaci AbiWord nebo LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Připojeno.\",\n\t\"pad.modals.reconnecting\": \"Opětovné připojení k Padu...\",\n\t\"pad.modals.forcereconnect\": \"Vynutit znovupřipojení\",\n\t\"pad.modals.reconnecttimer\": \"Zkouším se znovu připojit\",\n\t\"pad.modals.cancel\": \"Zrušit\",\n\t\"pad.modals.userdup\": \"Otevřeno v jiném okně\",\n\t\"pad.modals.userdup.explanation\": \"Zdá se, že tento Pad je na tomto počítači otevřen ve více než jednom okně.\",\n\t\"pad.modals.userdup.advice\": \"Pro použití tohoto okna je třeba se znovu připojit.\",\n\t\"pad.modals.unauth\": \"Nemáte autorizaci\",\n\t\"pad.modals.unauth.explanation\": \"Vaše oprávnění se změnila, zatímco jste si prohlížel/a toto okno. Zkuste se znovu připojit.\",\n\t\"pad.modals.looping.explanation\": \"Nastaly problémy při komunikaci se synchronizačním serverem.\",\n\t\"pad.modals.looping.cause\": \"Možná jste připojeni přes nekompaktní firewall nebo proxy.\",\n\t\"pad.modals.initsocketfail\": \"Server je nedostupný.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Nepodařilo se připojit k synchronizačnímu serveru.\",\n\t\"pad.modals.initsocketfail.cause\": \"Toto je pravděpodobně způsobeno vaším prohlížečem nebo připojením k internetu.\",\n\t\"pad.modals.slowcommit.explanation\": \"Server neodpovídá.\",\n\t\"pad.modals.slowcommit.cause\": \"Může to být způsobeno problémy s internetovým připojením.\",\n\t\"pad.modals.badChangeset.explanation\": \"Editace, kterou jste učinili byla vyhodnocena jako zakázaná syncronizací serveru.\",\n\t\"pad.modals.badChangeset.cause\": \"To může být způsobeno špatnou konfigurací serveru, nebo jiným neočekávaným chováním. Kontaktujte prosím správce služby, pokud i myslíte, že se jedná o chybu. Zkuste se připojit znovu, pokud chcete pokračovat v psaní.\",\n\t\"pad.modals.corruptPad.explanation\": \"Pad, ke kterému se snažíte připojit je poškozen.\",\n\t\"pad.modals.corruptPad.cause\": \"To může být kvůli špatné konfiguraci serveru, nebo kvůli jinému neočekávanému chování. Kontaktujte prosím správce služby.\",\n\t\"pad.modals.deleted\": \"Odstraněno.\",\n\t\"pad.modals.deleted.explanation\": \"Tento Pad byl odebrán.\",\n\t\"pad.modals.rateLimited\": \"Rychlost je omezená.\",\n\t\"pad.modals.rateLimited.explanation\": \"Na tento Pad jste poslali příliš mnoho zpráv, takže vás odpojil.\",\n\t\"pad.modals.rejected.explanation\": \"Server odmítl zprávu odeslanou vaším prohlížečem.\",\n\t\"pad.modals.rejected.cause\": \"Server mohl být aktualizován, když jste sledovali podložku, nebo možná došlo k chybě v Etherpadu. Zkuste stránku znovu načíst.\",\n\t\"pad.modals.disconnected\": \"Byl jste odpojen.\",\n\t\"pad.modals.disconnected.explanation\": \"Připojení k serveru bylo přerušeno\",\n\t\"pad.modals.disconnected.cause\": \"Server může být nedostupný. Upozorněte administrátora služby, pokud se to bude opakovat.\",\n\t\"pad.share\": \"Sdílet tento Pad\",\n\t\"pad.share.readonly\": \"Jen pro čtení\",\n\t\"pad.share.link\": \"Odkaz\",\n\t\"pad.share.emebdcode\": \"Vložit URL\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Otevřít chat tohoto Padu.\",\n\t\"pad.chat.loadmessages\": \"Načíst více zpráv\",\n\t\"pad.chat.stick.title\": \"Přichytit chat k obrazovce\",\n\t\"pad.chat.writeMessage.placeholder\": \"Zde napište zprávu\",\n\t\"timeslider.followContents\": \"Sledovat aktualizace obsahu Padu\",\n\t\"timeslider.pageTitle\": \"Časová osa {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Návrat do Padu\",\n\t\"timeslider.toolbar.authors\": \"Autoři:\",\n\t\"timeslider.toolbar.authorsList\": \"Bez autorů\",\n\t\"timeslider.toolbar.exportlink.title\": \"Exportovat\",\n\t\"timeslider.exportCurrent\": \"Exportovat nynější verzi jako:\",\n\t\"timeslider.version\": \"Verze {{version}}\",\n\t\"timeslider.saved\": \"Uloženo {{day}} {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Pustit / pozastavit obsah padu\",\n\t\"timeslider.backRevision\": \"Jít v tomto padu o revizi zpět\",\n\t\"timeslider.forwardRevision\": \"Jít v tomto padu o revizi vpřed\",\n\t\"timeslider.dateformat\": \"{{day}} {{month}} {{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"leden\",\n\t\"timeslider.month.february\": \"únor\",\n\t\"timeslider.month.march\": \"březen\",\n\t\"timeslider.month.april\": \"duben\",\n\t\"timeslider.month.may\": \"květen\",\n\t\"timeslider.month.june\": \"červen\",\n\t\"timeslider.month.july\": \"červenec\",\n\t\"timeslider.month.august\": \"srpen\",\n\t\"timeslider.month.september\": \"září\",\n\t\"timeslider.month.october\": \"říjen\",\n\t\"timeslider.month.november\": \"listopad\",\n\t\"timeslider.month.december\": \"prosinec\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: nejmenovaný autor, few: nejmenovaní autoři, other: nejmenovaných autorů ]}\",\n\t\"pad.savedrevs.marked\": \"Tato revize je nyní označena jako uložená\",\n\t\"pad.savedrevs.timeslider\": \"Návštěvou časové osy zobrazíte uložené revize\",\n\t\"pad.userlist.entername\": \"Zadejte své jméno\",\n\t\"pad.userlist.unnamed\": \"nejmenovaný\",\n\t\"pad.editbar.clearcolors\": \"Odstranit barvy autorů z celého dokumentu? Tuto změnu nelze vrátit!\",\n\t\"pad.impexp.importbutton\": \"Importovat\",\n\t\"pad.impexp.importing\": \"Importování…\",\n\t\"pad.impexp.confirmimport\": \"Import souboru přepíše aktuální text v padu. Opravdu chcete tuto akci provést?\",\n\t\"pad.impexp.convertFailed\": \"Tento soubor nelze importovat. Použijte prosím jiný formát dokumentu nebo nakopírujte text ručně\",\n\t\"pad.impexp.padHasData\": \"Tento soubor jsme nebyly schopni importovat, protože tento Pad již obsahoval změny. Importujte ho prosím do nového padu\",\n\t\"pad.impexp.uploadFailed\": \"Nahrávání selhalo, zkuste to znovu\",\n\t\"pad.impexp.importfailed\": \"Import selhal\",\n\t\"pad.impexp.copypaste\": \"Vložte prosím kopii\",\n\t\"pad.impexp.exportdisabled\": \"Export do formátu {{type}} je zakázán. Kontaktujte svého administrátora pro zjištění detailů.\",\n\t\"pad.impexp.maxFileSize\": \"Soubor je příliš velký. Požádejte svého správce webu o zvýšení povolené velikosti souboru pro import\"\n}\n"
  },
  {
    "path": "src/locales/da.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Christian List\",\n\t\t\t\"Joedalton\",\n\t\t\t\"Peter Alberti\",\n\t\t\t\"Peterleth\",\n\t\t\t\"Saederup92\",\n\t\t\t\"Steenth\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Admin Dashboard - Etherpad\",\n\t\"admin_plugins\": \"Plugin manager\",\n\t\"admin_plugins.available\": \"Tilgængelige Plugins\",\n\t\"admin_plugins.available_not-found\": \"Ingen plugins fundet.\",\n\t\"admin_plugins.available_fetching\": \"Henter...\",\n\t\"admin_plugins.available_install.value\": \"Installer\",\n\t\"admin_plugins.available_search.placeholder\": \"Søg efter plugins der kan installeres\",\n\t\"admin_plugins.description\": \"Beskrivelse\",\n\t\"admin_plugins.installed\": \"Installerede plugins\",\n\t\"admin_plugins.installed_fetching\": \"Henter installerede plugins...\",\n\t\"admin_plugins.installed_nothing\": \"Du har ikke installeret nogen plugins endnu.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Afinstaller\",\n\t\"admin_plugins.last-update\": \"Sidst opdateret\",\n\t\"admin_plugins.name\": \"Navn\",\n\t\"admin_plugins.page-title\": \"Plugin manager - Etherpad\",\n\t\"admin_plugins.version\": \"Version\",\n\t\"admin_plugins_info\": \"Fejlfindingsoplysninger\",\n\t\"admin_plugins_info.hooks\": \"Installerede hooks\",\n\t\"admin_settings\": \"Indstillinger\",\n\t\"index.newPad\": \"Ny Pad\",\n\t\"index.createOpenPad\": \"Åbn pad efter navn\",\n\t\"pad.toolbar.bold.title\": \"Fed (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Kursiv (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Understregning (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Gennemstregning (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Sorteret liste (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Usorteret liste (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Indrykning (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Ryk ud (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Fortryd (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Annuller Fortryd (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Fjern farver for forfatterskab (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Import/eksport fra/til forskellige filformater\",\n\t\"pad.toolbar.timeslider.title\": \"Timeslider\",\n\t\"pad.toolbar.savedRevision.title\": \"Gem Revision\",\n\t\"pad.toolbar.settings.title\": \"Indstillinger\",\n\t\"pad.toolbar.embed.title\": \"Del og integrer denne pad\",\n\t\"pad.toolbar.showusers.title\": \"Vis brugere på denne pad\",\n\t\"pad.colorpicker.save\": \"Gem\",\n\t\"pad.colorpicker.cancel\": \"Afbryd\",\n\t\"pad.loading\": \"Indlæser ...\",\n\t\"pad.noCookie\": \"Cookie kunne ikke findes. Tillad venligst cookies i din browser! Din session og dine indstillinger gemmes ikke mellem besøg. Dette kan skyldes, at Etherpad er inkluderet i en iFrame i nogle browsere. Sørg for, at Etherpad er på samme underdomæne/domæne som den overordnede iFrame.\",\n\t\"pad.permissionDenied\": \"Du har ikke tilladelse til at få adgang til denne pad.\",\n\t\"pad.settings.padSettings\": \"Pad indstillinger\",\n\t\"pad.settings.myView\": \"Min visning\",\n\t\"pad.settings.stickychat\": \"Chat altid på skærmen\",\n\t\"pad.settings.chatandusers\": \"Vis snak (chat) og brugere\",\n\t\"pad.settings.colorcheck\": \"Forfatterskabsfarver\",\n\t\"pad.settings.linenocheck\": \"Linjenumre\",\n\t\"pad.settings.rtlcheck\": \"Læse indhold fra højre mod venstre?\",\n\t\"pad.settings.fontType\": \"Skrifttype:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Sprog:\",\n\t\"pad.settings.about\": \"Om\",\n\t\"pad.settings.poweredBy\": \"Drevet af\",\n\t\"pad.importExport.import_export\": \"Import/Eksport\",\n\t\"pad.importExport.import\": \"Uploade en tekstfil eller dokument\",\n\t\"pad.importExport.importSuccessful\": \"Vellykket!\",\n\t\"pad.importExport.export\": \"Eksporter aktuelle pad som:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Almindelig tekst\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Du kan kun importere fra almindelig tekst eller HTML-formater. For mere avancerede importfunktioner skal du <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">installere AbiWord eller LibreOffice</a> .\",\n\t\"pad.modals.connected\": \"Forbundet.\",\n\t\"pad.modals.reconnecting\": \"Genopretter forbindelsen til din pad...\",\n\t\"pad.modals.forcereconnect\": \"Gennemtving genoprettelse af forbindelsen\",\n\t\"pad.modals.reconnecttimer\": \"Prøver at tilkoble igen\",\n\t\"pad.modals.cancel\": \"Afbryd\",\n\t\"pad.modals.userdup\": \"Åbnet i et andet vindue\",\n\t\"pad.modals.userdup.explanation\": \"Denne pad synes at være åbnet i mere end ét browservindue på denne computer.\",\n\t\"pad.modals.userdup.advice\": \"Tilslut igen for at bruge dette vindue i stedet.\",\n\t\"pad.modals.unauth\": \"Ikke tilladt\",\n\t\"pad.modals.unauth.explanation\": \"Dine rettigheder er ændret mens du ser på denne side. Prøv at oprette forbindelsen igen.\",\n\t\"pad.modals.looping.explanation\": \"Der er kommunikationsproblemer med synkroniseringsserveren.\",\n\t\"pad.modals.looping.cause\": \"Måske tilsluttede du via en inkompatibel firewall eller proxy.\",\n\t\"pad.modals.initsocketfail\": \"Serveren er ikke tilgængelig.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Kunne ikke oprette forbindelse til synkroniseringsserveren.\",\n\t\"pad.modals.initsocketfail.cause\": \"Det skyldes sandsynligvis et problem med din browser eller din internetforbindelse.\",\n\t\"pad.modals.slowcommit.explanation\": \"Serveren svarer ikke.\",\n\t\"pad.modals.slowcommit.cause\": \"Det kan skyldes problemer med netværksforbindelsen.\",\n\t\"pad.modals.badChangeset.explanation\": \"En redigering, du har foretaget, blev klassificeret ulovlig af synkroniseringsserveren.\",\n\t\"pad.modals.badChangeset.cause\": \"Dette kan skyldes en forkert konfiguration af serveren eller en anden uventet adfærd. Kontakt venligst service administratoren, hvis du føler, at dette er en fejl. Prøv at oprette forbindelsen igen for at fortsætte med at redigere.\",\n\t\"pad.modals.corruptPad.explanation\": \"Den pad du prøver at få adgang til er beskadiget.\",\n\t\"pad.modals.corruptPad.cause\": \"Dette kan skyldes en forkert konfiguration af serveren eller en anden uventet adfærd. Kontakt venligst service administratoren.\",\n\t\"pad.modals.deleted\": \"Slettet.\",\n\t\"pad.modals.deleted.explanation\": \"Denne pad er blevet fjernet.\",\n\t\"pad.modals.disconnected\": \"Du har fået afbrudt forbindelsen.\",\n\t\"pad.modals.disconnected.explanation\": \"Forbindelsen til serveren blev afbrudt\",\n\t\"pad.modals.disconnected.cause\": \"Serveren er muligvis ikke tilgængelig. Informer service administratoren hvis dette fortsætter med at ske.\",\n\t\"pad.share\": \"Del denne pad\",\n\t\"pad.share.readonly\": \"Skrivebeskyttet\",\n\t\"pad.share.link\": \"Link\",\n\t\"pad.share.emebdcode\": \"Integrerings URL\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Åben chat for denne pad.\",\n\t\"pad.chat.loadmessages\": \"Indlæs flere meddelelser\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Timeslider\",\n\t\"timeslider.toolbar.returnbutton\": \"Tilbage til pad\",\n\t\"timeslider.toolbar.authors\": \"Forfattere:\",\n\t\"timeslider.toolbar.authorsList\": \"Ingen forfattere\",\n\t\"timeslider.toolbar.exportlink.title\": \"Eksportér\",\n\t\"timeslider.exportCurrent\": \"Eksporter aktuelle version som:\",\n\t\"timeslider.version\": \"Version {{version}}\",\n\t\"timeslider.saved\": \"Gemt den {{day}}.{{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Afspil eller sæt padindhold på pause\",\n\t\"timeslider.backRevision\": \"Gå en revision tilbage i denne pad\",\n\t\"timeslider.forwardRevision\": \"Gå en revision fremad i denne pad\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"januar\",\n\t\"timeslider.month.february\": \"februar\",\n\t\"timeslider.month.march\": \"marts\",\n\t\"timeslider.month.april\": \"april\",\n\t\"timeslider.month.may\": \"maj\",\n\t\"timeslider.month.june\": \"juni\",\n\t\"timeslider.month.july\": \"juli\",\n\t\"timeslider.month.august\": \"august\",\n\t\"timeslider.month.september\": \"september\",\n\t\"timeslider.month.october\": \"oktober\",\n\t\"timeslider.month.november\": \"november\",\n\t\"timeslider.month.december\": \"december\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: unavngiven forfatter, other: unavngivne forfattere]}\",\n\t\"pad.savedrevs.marked\": \"Denne revision er nu markeret som en gemt revision\",\n\t\"pad.savedrevs.timeslider\": \"Du kan se gemte revisioner ved at besøge tidslinjen\",\n\t\"pad.userlist.entername\": \"Indtast dit navn\",\n\t\"pad.userlist.unnamed\": \"ikke-navngivet\",\n\t\"pad.editbar.clearcolors\": \"Fjern farver for ophavsmand i hele dokumentet? Dette kan ikke fortrydes\",\n\t\"pad.impexp.importbutton\": \"Importer nu\",\n\t\"pad.impexp.importing\": \"Importerer...\",\n\t\"pad.impexp.confirmimport\": \"At importere en fil, vil overskrives den aktuelle pad tekst. Er du sikker på du vil fortsætte?\",\n\t\"pad.impexp.convertFailed\": \"Vi var ikke i stand til at importere denne fil. Brug et andet dokument-format eller kopier og sæt ind manuelt\",\n\t\"pad.impexp.padHasData\": \"Vi kunne ikke importere denne fil, da denne pad allerede har haft ændringer; lav venligst import til en ny pad\",\n\t\"pad.impexp.uploadFailed\": \"Upload mislykkedes, prøv igen\",\n\t\"pad.impexp.importfailed\": \"Importen mislykkedes\",\n\t\"pad.impexp.copypaste\": \"Venligst kopier og sæt ind\",\n\t\"pad.impexp.exportdisabled\": \"Eksportere i {{type}} format er deaktiveret. Kontakt din systemadministrator for mere information.\"\n}\n"
  },
  {
    "path": "src/locales/de.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Bjarncraft\",\n\t\t\t\"Brettchenweber\",\n\t\t\t\"Dom\",\n\t\t\t\"Justman10000\",\n\t\t\t\"Killarnee\",\n\t\t\t\"Metalhead64\",\n\t\t\t\"Mklehr\",\n\t\t\t\"Mukeber\",\n\t\t\t\"Nipsky\",\n\t\t\t\"Predatorix\",\n\t\t\t\"SamTV\",\n\t\t\t\"Sebastian Wallroth\",\n\t\t\t\"Ssgl\",\n\t\t\t\"Thargon\",\n\t\t\t\"Tim.krieger\",\n\t\t\t\"Wikinaut\",\n\t\t\t\"Zunkelty\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Admin Dashboard - Etherpad\",\n\t\"admin_plugins\": \"Pluginverwaltung\",\n\t\"admin_plugins.available\": \"Verfügbare Plugins\",\n\t\"admin_plugins.available_not-found\": \"Keine Plugins gefunden.\",\n\t\"admin_plugins.available_fetching\": \"Wird abgerufen...\",\n\t\"admin_plugins.available_install.value\": \"Installieren\",\n\t\"admin_plugins.available_search.placeholder\": \"Suche nach Plugins zum Installieren\",\n\t\"admin_plugins.description\": \"Beschreibung\",\n\t\"admin_plugins.installed\": \"Installierte Plugins\",\n\t\"admin_plugins.installed_fetching\": \"Rufe installierte Plugins ab...\",\n\t\"admin_plugins.installed_nothing\": \"Du hast bisher noch keine Plugins installiert.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Deinstallieren\",\n\t\"admin_plugins.last-update\": \"Letze Aktualisierung\",\n\t\"admin_plugins.name\": \"Name\",\n\t\"admin_plugins.page-title\": \"Plugin Manager - Etherpad\",\n\t\"admin_plugins.version\": \"Version\",\n\t\"admin_plugins_info\": \"Hilfestellung\",\n\t\"admin_plugins_info.hooks\": \"Installierte Hooks\",\n\t\"admin_plugins_info.hooks_client\": \"Client-seitige Hooks\",\n\t\"admin_plugins_info.hooks_server\": \"Server-seitige Hooks\",\n\t\"admin_plugins_info.parts\": \"Installierte Teile\",\n\t\"admin_plugins_info.plugins\": \"Installierte Plugins\",\n\t\"admin_plugins_info.page-title\": \"Plugin Informationen - Etherpad\",\n\t\"admin_plugins_info.version\": \"Etherpad Version\",\n\t\"admin_plugins_info.version_latest\": \"Neueste verfügbare Version\",\n\t\"admin_plugins_info.version_number\": \"Versionsnummer\",\n\t\"admin_settings\": \"Einstellungen\",\n\t\"admin_settings.current\": \"Derzeitige Konfiguration\",\n\t\"admin_settings.current_example-devel\": \"Beispielhafte Entwicklungseinstellungs-Templates\",\n\t\"admin_settings.current_example-prod\": \"Beispiel eines produktiven Templates\",\n\t\"admin_settings.current_restart.value\": \"Etherpad neustarten\",\n\t\"admin_settings.current_save.value\": \"Einstellungen speichern\",\n\t\"admin_settings.page-title\": \"Einstellungen - Etherpad\",\n\t\"index.newPad\": \"Neues Pad\",\n\t\"index.settings\": \"Einstellungen\",\n\t\"index.transferSessionTitle\": \"Sitzung übertragen\",\n\t\"index.receiveSessionTitle\": \"Sitzung empfangen\",\n\t\"index.receiveSessionDescription\": \"Hier kannst du eine Etherpad-Sitzung aus einem anderen Browser oder Gerät empfangen. Bedenke allerdings, dass dadurch deine aktuelle Sitzung, falls vorhanden, gelöscht wird.\",\n\t\"index.transferSession\": \"1. Sitzung übertragen\",\n\t\"index.transferSessionNow\": \"Jetzt übertragen\",\n\t\"index.copyLink\": \"2. Link kopieren\",\n\t\"index.copyLinkDescription\": \"Klicke auf den untenstehenden Button, um den Übertragungscode in deine Zwischenablage zu kopieren.\",\n\t\"index.copyLinkButton\": \"Übertragungscode kopieren\",\n\t\"index.transferToSystem\": \"3. Sitzung einfügen\",\n\t\"index.transferToSystemDescription\": \"Öffne den kopierten Link in dem neuen Browser oder Gerät, um deine aktuelle Etherpad-Sitzung zu übertragen.\",\n\t\"index.transferSessionDescription\": \"Übertrage deine aktuelle Etherpad-Sitzung zu einem anderen Browser oder Gerät, indem du den untenstehenden Button klickst. Dabei wird ein Link in deine Zwischenablage kopiert, den du im neuen Browser oder Gerät öffnen kannst, um deine Sitzung zu übertragen.\",\n\t\"index.createOpenPad\": \"Pad öffnen\",\n\t\"index.openPad\": \"Öffne ein vorhandenes Pad mit folgendem Namen:\",\n\t\"index.recentPads\": \"Zuletzt bearbeitete Pads\",\n\t\"index.recentPadsEmpty\": \"Keine kürzlich bearbeiteten Pads gefunden.\",\n\t\"index.generateNewPad\": \"Neues Pad generieren\",\n\t\"index.labelPad\": \"Padname (optional)\",\n\t\"index.placeholderPadEnter\": \"Gib den Namen des Pads ein...\",\n\t\"index.createAndShareDocuments\": \"Erstelle und teile Dokumente in Echtzeit\",\n\t\"index.createAndShareDocumentsDescription\": \"Etherpad ermöglicht die gemeinsame Bearbeitung von Dokumenten in Echtzeit, ähnlich wie ein Live-Multiplayer-Editor, der in Ihrem Browser läuft.\",\n\t\"pad.toolbar.bold.title\": \"Fett (Strg-B)\",\n\t\"pad.toolbar.italic.title\": \"Kursiv (Strg-I)\",\n\t\"pad.toolbar.underline.title\": \"Unterstrichen (Strg-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Durchgestrichen (Strg+5)\",\n\t\"pad.toolbar.ol.title\": \"Nummerierte Liste (Strg+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Ungeordnete Liste (Strg+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Einrücken (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Ausrücken (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Rückgängig (Strg-Z)\",\n\t\"pad.toolbar.redo.title\": \"Wiederholen (Strg-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Autorenfarben zurücksetzen (Strg+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Import/Export von/zu verschiedenen Dateiformaten\",\n\t\"pad.toolbar.timeslider.title\": \"Bearbeitungsverlauf\",\n\t\"pad.toolbar.savedRevision.title\": \"Version speichern\",\n\t\"pad.toolbar.settings.title\": \"Einstellungen\",\n\t\"pad.toolbar.embed.title\": \"Dieses Pad teilen oder einbetten\",\n\t\"pad.toolbar.home.title\": \"Zurück zur Startseite\",\n\t\"pad.toolbar.showusers.title\": \"Benutzer dieses Pads anzeigen\",\n\t\"pad.colorpicker.save\": \"Speichern\",\n\t\"pad.colorpicker.cancel\": \"Abbrechen\",\n\t\"pad.loading\": \"Laden …\",\n\t\"pad.noCookie\": \"Das Cookie konnte nicht gefunden werden. Bitte erlaube Cookies in deinem Browser! Deine Sitzung und Einstellungen werden zwischen den Besuchen nicht gespeichert.  Dies kann darauf zurückzuführen sein, dass Etherpad in einigen Browsern in einem iFrame enthalten ist.  Bitte stelle sicher, dass sich Etherpad auf der gleichen Subdomain/Domain wie der übergeordnete iFrame befindet.\",\n\t\"pad.permissionDenied\": \"Du hast keine Berechtigung, um auf dieses Pad zuzugreifen.\",\n\t\"pad.settings.padSettings\": \"Pad-Einstellungen\",\n\t\"pad.settings.myView\": \"Eigene Ansicht\",\n\t\"pad.settings.stickychat\": \"Chat immer anzeigen\",\n\t\"pad.settings.chatandusers\": \"Chat und Benutzer anzeigen\",\n\t\"pad.settings.colorcheck\": \"Autorenfarben anzeigen\",\n\t\"pad.settings.linenocheck\": \"Zeilennummern\",\n\t\"pad.settings.rtlcheck\": \"Inhalt von rechts nach links lesen?\",\n\t\"pad.settings.fontType\": \"Schriftart:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Sprache:\",\n\t\"pad.settings.deletePad\": \"Pad löschen\",\n\t\"pad.delete.confirm\": \"Möchtest du dieses Pad wirklich löschen?\",\n\t\"pad.settings.about\": \"Über\",\n\t\"pad.settings.poweredBy\": \"Betrieben von\",\n\t\"pad.importExport.import_export\": \"Import/Export\",\n\t\"pad.importExport.import\": \"Textdatei oder Dokument hochladen\",\n\t\"pad.importExport.importSuccessful\": \"Erfolgreich!\",\n\t\"pad.importExport.export\": \"Aktuelles Pad exportieren als:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Textdatei\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Du kannst nur aus reinen Text- oder HTML-Formaten importieren. Für umfangreichere Importfunktionen <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">muss AbiWord oder LibreOffice auf dem Server installiert werden</a>.\",\n\t\"pad.modals.connected\": \"Verbunden.\",\n\t\"pad.modals.reconnecting\": \"Dein Pad wird neu verbunden...\",\n\t\"pad.modals.forcereconnect\": \"Erneutes Verbinden erzwingen\",\n\t\"pad.modals.reconnecttimer\": \"Versuche Neuverbindung in\",\n\t\"pad.modals.cancel\": \"Abbrechen\",\n\t\"pad.modals.userdup\": \"In einem anderen Fenster geöffnet\",\n\t\"pad.modals.userdup.explanation\": \"Dieses Pad scheint in mehr als einem Browser-Fenster auf diesem Rechner geöffnet zu sein.\",\n\t\"pad.modals.userdup.advice\": \"Verbinde dich erneut, um stattdessen dieses Fenster zu verwenden.\",\n\t\"pad.modals.unauth\": \"Nicht berechtigt\",\n\t\"pad.modals.unauth.explanation\": \"Deine Zugriffsberechtigung für dieses Pad hat sich zwischenzeitlich geändert. Versuche dich erneut zu verbinden.\",\n\t\"pad.modals.looping.explanation\": \"Es gibt Verbindungsprobleme mit dem Server.\",\n\t\"pad.modals.looping.cause\": \"Möglicherweise bist du durch eine inkompatible Firewall oder über einen inkompatiblen Proxy mit dem Server verbunden.\",\n\t\"pad.modals.initsocketfail\": \"Der Server ist nicht erreichbar.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Es konnte keine Verbindung zum Server hergestellt werden.\",\n\t\"pad.modals.initsocketfail.cause\": \"Dies könnte an deinem Browser oder deiner Internet-Verbindung liegen.\",\n\t\"pad.modals.slowcommit.explanation\": \"Der Server antwortet nicht.\",\n\t\"pad.modals.slowcommit.cause\": \"Dies könnte ein Netzwerkverbindungsproblem sein oder eine momentane Überlastung des Servers.\",\n\t\"pad.modals.badChangeset.explanation\": \"Eine von dir gemachte Änderung wurde vom Server als ungültig eingestuft.\",\n\t\"pad.modals.badChangeset.cause\": \"Dies könnte aufgrund einer falschen Serverkonfiguration oder eines anderen unerwarteten Verhaltens passiert sein. Bitte kontaktiere den Diensteadministrator, falls du glaubst, dass es sich um einen Fehler handelt. Versuche dich erneut zu verbinden, um mit dem Bearbeiten fortzufahren.\",\n\t\"pad.modals.corruptPad.explanation\": \"Das Pad, auf das du versuchst zuzugreifen, ist beschädigt.\",\n\t\"pad.modals.corruptPad.cause\": \"Dies könnte an einer falschen Serverkonfiguration oder einem anderen unerwarteten Verhalten liegen. Bitte kontaktiere den Administrator dieses Dienstes.\",\n\t\"pad.modals.deleted\": \"Gelöscht.\",\n\t\"pad.modals.deleted.explanation\": \"Dieses Pad wurde entfernt.\",\n\t\"pad.modals.rateLimited\": \"Durchsatzratenbegrenzt\",\n\t\"pad.modals.rateLimited.explanation\": \"Sie haben zu viele Nachrichten an dieses Pad gesendet, so dass die Verbindung unterbrochen wurde.\",\n\t\"pad.modals.rejected.explanation\": \"Der Server hat eine Nachricht abgelehnt, die von deinem Browser gesendet wurde.\",\n\t\"pad.modals.rejected.cause\": \"Möglicherweise wurde der Server aktualisiert, während du das Pad angesehen hast, oder es existiert ein Fehler in Etherpad. Versuche, die Seite neu zu laden.\",\n\t\"pad.modals.disconnected\": \"Ihre Verbindung wurde getrennt.\",\n\t\"pad.modals.disconnected.explanation\": \"Die Verbindung zum Server wurde unterbrochen.\",\n\t\"pad.modals.disconnected.cause\": \"Möglicherweise ist der Server nicht erreichbar. Bitte benachrichtige den Dienstadministrator, falls dies weiterhin passiert.\",\n\t\"pad.share\": \"Dieses Pad teilen\",\n\t\"pad.share.readonly\": \"Eingeschränkter Nur-Lese-Zugriff\",\n\t\"pad.share.link\": \"Verknüpfung\",\n\t\"pad.share.emebdcode\": \"In Webseite einbetten\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Den Chat für dieses Pad öffnen.\",\n\t\"pad.chat.loadmessages\": \"Weitere Nachrichten laden\",\n\t\"pad.chat.stick.title\": \"Chat an den Bildschirm anheften\",\n\t\"pad.chat.writeMessage.placeholder\": \"Schreibe hier deine Nachricht\",\n\t\"timeslider.followContents\": \"Aktualisierungen des Pad-Inhalts verfolgen\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Bearbeitungsverlauf\",\n\t\"timeslider.toolbar.returnbutton\": \"Zurück zum Pad\",\n\t\"timeslider.toolbar.authors\": \"Autoren:\",\n\t\"timeslider.toolbar.authorsList\": \"Keine Autoren\",\n\t\"timeslider.toolbar.exportlink.title\": \"Diese Version exportieren\",\n\t\"timeslider.exportCurrent\": \"Exportiere diese Version als:\",\n\t\"timeslider.version\": \"Version {{version}}\",\n\t\"timeslider.saved\": \"Gespeichert am {{day}}. {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Padbearbeitung abspielen/pausieren\",\n\t\"timeslider.backRevision\": \"Eine Version in diesem Pad zurückgehen\",\n\t\"timeslider.forwardRevision\": \"Eine Version in diesem Pad vorwärtsgehen\",\n\t\"timeslider.dateformat\": \"{{day}}.{{month}}.{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Januar\",\n\t\"timeslider.month.february\": \"Februar\",\n\t\"timeslider.month.march\": \"März\",\n\t\"timeslider.month.april\": \"April\",\n\t\"timeslider.month.may\": \"Mai\",\n\t\"timeslider.month.june\": \"Juni\",\n\t\"timeslider.month.july\": \"Juli\",\n\t\"timeslider.month.august\": \"August\",\n\t\"timeslider.month.september\": \"September\",\n\t\"timeslider.month.october\": \"Oktober\",\n\t\"timeslider.month.november\": \"November\",\n\t\"timeslider.month.december\": \"Dezember\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: unbenannter Autor, other: unbenannte Autoren ]}\",\n\t\"pad.savedrevs.marked\": \"Diese Version wurde jetzt als gespeicherte Version gekennzeichnet\",\n\t\"pad.savedrevs.timeslider\": \"Du kannst gespeicherte Versionen durch den Aufruf des Bearbeitungsverlaufs ansehen.\",\n\t\"pad.userlist.entername\": \"Dein Name?\",\n\t\"pad.userlist.unnamed\": \"unbenannt\",\n\t\"pad.editbar.clearcolors\": \"Autorenfarben im gesamten Dokument zurücksetzen? Dies kann nicht rückgängig gemacht werden\",\n\t\"pad.impexp.importbutton\": \"Jetzt importieren\",\n\t\"pad.impexp.importing\": \"Importiere …\",\n\t\"pad.impexp.confirmimport\": \"Das Importieren einer Datei überschreibt den aktuellen Text des Pads. Willst du wirklich fortfahren?\",\n\t\"pad.impexp.convertFailed\": \"Diese Datei konnte nicht importiert werden. Bitte verwende ein anderes Dokumentformat oder übertrage den Text manuell.\",\n\t\"pad.impexp.padHasData\": \"Diese Datei konnte nicht importiert werden, da dieses Pad bereits Änderungen enthält. Bitte importiere die Datei in ein neues Pad.\",\n\t\"pad.impexp.uploadFailed\": \"Das Hochladen ist fehlgeschlagen. Bitte versuche es erneut.\",\n\t\"pad.impexp.importfailed\": \"Import fehlgeschlagen\",\n\t\"pad.impexp.copypaste\": \"Bitte kopieren und einfügen\",\n\t\"pad.impexp.exportdisabled\": \"Der Export im {{type}}-Format ist deaktiviert. Für Einzelheiten kontaktiere bitte deinen Systemadministrator.\",\n\t\"pad.impexp.maxFileSize\": \"Die Datei ist zu groß. Kontaktiere bitte deinen Administrator, um das Limit für den Dateiimport zu erhöhen.\"\n}\n"
  },
  {
    "path": "src/locales/diq.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"1917 Ekim Devrimi\",\n\t\t\t\"Erdemaslancan\",\n\t\t\t\"GolyatGeri\",\n\t\t\t\"Gorizon\",\n\t\t\t\"Gırd\",\n\t\t\t\"Kumkumuk\",\n\t\t\t\"Mirzali\",\n\t\t\t\"Orbot707\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Panoyê İdarekari - Etherpad\",\n\t\"admin_plugins\": \"Gıredayışê raverberi\",\n\t\"admin_plugins.available\": \"Mewcud Dekerdeki\",\n\t\"admin_plugins.available_not-found\": \"Dekerdek nevineya\",\n\t\"admin_plugins.available_fetching\": \"Aniyeno...\",\n\t\"admin_plugins.available_install.value\": \"Bar ke\",\n\t\"admin_plugins.available_search.placeholder\": \"Barbıyaye dekerdeka bıvinê\",\n\t\"admin_plugins.description\": \"Şınasnayış\",\n\t\"admin_plugins.installed\": \"Dekerdekê bariyayê\",\n\t\"admin_plugins.installed_fetching\": \"Bariyayê dekerdeki gêriyenê...\",\n\t\"admin_plugins.installed_nothing\": \"Heena şıma qet yew dekerdek bar nêkerdo\",\n\t\"admin_plugins.installed_uninstall.value\": \"Wedarnê\",\n\t\"admin_plugins.last-update\": \"Resnayışo Peyên\",\n\t\"admin_plugins.name\": \"Name\",\n\t\"admin_plugins.page-title\": \"İdarekarê dekerdeka - Etherpad\",\n\t\"admin_plugins.version\": \"Versiyon\",\n\t\"admin_plugins_info\": \"Melumatê xetay timari\",\n\t\"admin_plugins_info.hooks\": \"Bariyaye qancey\",\n\t\"admin_plugins_info.hooks_client\": \"Qancey kışta waşteri\",\n\t\"admin_plugins_info.hooks_server\": \"Qancey kışta serveri\",\n\t\"admin_plugins_info.parts\": \"Barbıyaye letey\",\n\t\"admin_plugins_info.plugins\": \"Dekerdekê bariyayê\",\n\t\"admin_plugins_info.page-title\": \"Melumatê dekerdeki - Etherpad\",\n\t\"admin_plugins_info.version\": \"Versiyonê Etherpadi\",\n\t\"admin_plugins_info.version_latest\": \"Mewcud versiyono peyên\",\n\t\"admin_plugins_info.version_number\": \"Numrey versiyoni\",\n\t\"admin_settings\": \"Eyari\",\n\t\"admin_settings.current\": \"Konfigurasyono ravêrde\",\n\t\"admin_settings.current_example-devel\": \"Şablonê emsalê ravêrberdışi eyari\",\n\t\"admin_settings.current_example-prod\": \"Şablonê emsalê vıraştışê eyari\",\n\t\"admin_settings.current_restart.value\": \"Etherpad'i reyna ake\",\n\t\"admin_settings.current_save.value\": \"Eyaran qeyd ke\",\n\t\"admin_settings.page-title\": \"Eyari - Etherpad\",\n\t\"index.newPad\": \"Bloknoto newe\",\n\t\"index.createOpenPad\": \"ya zi be nê nameyi ra yew bloknot vıraze/ake:\",\n\t\"index.openPad\": \"yew Padê biyayeyi be nê nameyi ra ake:\",\n\t\"pad.toolbar.bold.title\": \"Qalınd (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Namıte (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Bınxetın (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Serxetın (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Lista rêzkerdiye (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Lista rêznêkerdiye (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Serrêze (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Teberdayış (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Meke (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Newe ke (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Rengê Nuştoğiê Arıstey (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Babaetna tewranê dosyaya azere/ateber ke\",\n\t\"pad.toolbar.timeslider.title\": \"Ğızagê zemani\",\n\t\"pad.toolbar.savedRevision.title\": \"Çımraviyarnayışi qeyd ke\",\n\t\"pad.toolbar.settings.title\": \"Eyari\",\n\t\"pad.toolbar.embed.title\": \"Na bloknot  degusn u bıhesrne\",\n\t\"pad.toolbar.showusers.title\": \"Karbera ena bloknot dı bımotné\",\n\t\"pad.colorpicker.save\": \"Qeyd ke\",\n\t\"pad.colorpicker.cancel\": \"Bıtexelne\",\n\t\"pad.loading\": \"Bar beno...\",\n\t\"pad.noCookie\": \"Çerez nêvineya. Rovıter de çereza aktiv kerê.Ronıştısê u eyarê şıma mabênê ziyareti qeyd nêbenê.Çıkı, Etherpad tay rovıteran de tewrê yew iFrame belka biyo. Kerem ke Etherpad corên iFrame ya wa eyni bınca/ca de zey pê bo.\",\n\t\"pad.permissionDenied\": \"Ena bloknot resayışi rê icazeta şıma çıni ya\",\n\t\"pad.settings.padSettings\": \"Sazkerdışê Pedi\",\n\t\"pad.settings.myView\": \"Asayışê mı\",\n\t\"pad.settings.stickychat\": \"Ekran de tım mıhebet bıkerê\",\n\t\"pad.settings.chatandusers\": \"Werênayış û Karberan bımocne\",\n\t\"pad.settings.colorcheck\": \"Rengê nuştekariye\",\n\t\"pad.settings.linenocheck\": \"Nımreyê xeter\",\n\t\"pad.settings.rtlcheck\": \"Zerrek heto raşt ra be heto çep bıwaniyo?\",\n\t\"pad.settings.fontType\": \"Babeta nuşti:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Zıwan:\",\n\t\"pad.settings.deletePad\": \"Defteri bıesterê\",\n\t\"pad.delete.confirm\": \"Şıma raşti wazenê ke nê defteri bıesterên?\",\n\t\"pad.settings.about\": \"Heqa\",\n\t\"pad.settings.poweredBy\": \"Pheştidayoğ\",\n\t\"pad.importExport.import_export\": \"Zerredayış/Teberdayış\",\n\t\"pad.importExport.import\": \"Dosya ya zi dokumanê meqaleyê de tesadufi bar ke\",\n\t\"pad.importExport.importSuccessful\": \"Mıwafaq biye\",\n\t\"pad.importExport.export\": \"Mewcud bloknoti ateberd:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Metno pan\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Şıma şenê tenya metınanê zelalan ya zi formatanê HTML-i biyarê. Seba vêşi xısusiyetanê arezekerdışi ra gırey <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">AbiWordi ya zi LibreOfficeyi bar kerên</a>.\",\n\t\"pad.modals.connected\": \"Gıre diya.\",\n\t\"pad.modals.reconnecting\": \"Pada şıma rê fına irtibat kewê no\",\n\t\"pad.modals.forcereconnect\": \"Mecbur anciya gırê de\",\n\t\"pad.modals.reconnecttimer\": \"Anciya gırê beno\",\n\t\"pad.modals.cancel\": \"Bıtexelne\",\n\t\"pad.modals.userdup\": \"Zewbina pençere de bi a\",\n\t\"pad.modals.userdup.explanation\": \"Ena bloknot ena komputer de yew ra zeder penceran dı akerde asena\",\n\t\"pad.modals.userdup.advice\": \"Ena pencera ra kar finayışi rê fına irtibat kewê\",\n\t\"pad.modals.unauth\": \"Selahiyetdar niyo\",\n\t\"pad.modals.unauth.explanation\": \"Ena pela asenayış de mısadey şıma vuriyay. Fına irtibat kewtışi bıcerebne\",\n\t\"pad.modals.looping.explanation\": \"Bahdê takêş kerdışi problemê irtibati esta\",\n\t\"pad.modals.looping.cause\": \"Belki zi dêsê emeley hewl niyo yana şımayê proksi ya kenê dekewê de\",\n\t\"pad.modals.initsocketfail\": \"Nêresneyêno ciyageyroği.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Rovıterê takêş kerdışi ya irtibato nêbeno.\",\n\t\"pad.modals.initsocketfail.cause\": \"Ena probleme muhtemelen komputer yana grebıyayışa ibter da şıma ra  bıya\",\n\t\"pad.modals.slowcommit.explanation\": \"Server cewab nêdano.\",\n\t\"pad.modals.slowcommit.cause\": \"Ena xeta gındık ta greyan de şıma ameya meydan\",\n\t\"pad.modals.badChangeset.explanation\": \"Ena vurriyayışa şıma tereftê rovıterê tekêş kerdışi ra bêkaide deyne liste biya\",\n\t\"pad.modals.badChangeset.cause\": \"Eno, xırab vıraziyena rovıteri yana nezanaye xırab yew faktori ra amrya meydan. Ena şıma çımdı xeta yena se  idarekaran de sisteniya irtibat  kewê. Dewam kerdışi re fına grebıyayışi bıcerebne\",\n\t\"pad.modals.corruptPad.explanation\": \"Bloknota ke şımayê kenê cıresê xerpiyayi ya\",\n\t\"pad.modals.corruptPad.cause\": \"Eno, xırab vıraziyena rovıteri yana nezanaye xırab yew faktori ra amrya meydan. Ena şıma çımdı xeta yena se  idarekaran de sisteniya irtibat  kewê\",\n\t\"pad.modals.deleted\": \"Esteriya.\",\n\t\"pad.modals.deleted.explanation\": \"Ena ped wedariye\",\n\t\"pad.modals.rateLimited\": \"Nısbeto kemeyeyın\",\n\t\"pad.modals.rateLimited.explanation\": \"Na pad re ßıma vêşi mesac rışto, coki ra irtibat bıriyayo.\",\n\t\"pad.modals.rejected.explanation\": \"Server, terefê browseri ra rışiyaye yew mesac red kerdo.\",\n\t\"pad.modals.rejected.cause\": \"Şıma wexto ke ped weyniyayış de server belka biyo rocane ya ziEtherpad de yew xeta bena. Pela reyna bar kerê.\",\n\t\"pad.modals.disconnected\": \"İrtibata şıma reyê\",\n\t\"pad.modals.disconnected.explanation\": \"Rovıteri ya irtibata şıma reyyê\",\n\t\"pad.modals.disconnected.cause\": \"Qay rovıtero nêkarên o.  Ena xerpey deqam kena se idarekaranê sistemiya irtibat kewê\",\n\t\"pad.share\": \"Na ped vıla ke\",\n\t\"pad.share.readonly\": \"Tenya bıwane\",\n\t\"pad.share.link\": \"Gıre\",\n\t\"pad.share.emebdcode\": \"Degusnaye URL\",\n\t\"pad.chat\": \"Mıhebet\",\n\t\"pad.chat.title\": \"Qandê ena ped mıhebet ake.\",\n\t\"pad.chat.loadmessages\": \"Dehana zaf mesaci bar keri\",\n\t\"pad.chat.stick.title\": \"Mobet ekran de bıvındarne\",\n\t\"pad.chat.writeMessage.placeholder\": \"Mesacê xo tiya bınusne\",\n\t\"timeslider.followContents\": \"Rocaney zerrekê padi taqib bıkerê\",\n\t\"timeslider.pageTitle\": \"Ğızagê zemani {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Peyser şo bloknot\",\n\t\"timeslider.toolbar.authors\": \"Nuştoği:\",\n\t\"timeslider.toolbar.authorsList\": \"Nuştekari çıniyê\",\n\t\"timeslider.toolbar.exportlink.title\": \"Teberdayış\",\n\t\"timeslider.exportCurrent\": \"Versiyonê enewki teber de:\",\n\t\"timeslider.version\": \"Versiyonê {{version}}\",\n\t\"timeslider.saved\": \"{{day}} {{month}}, {{year}} de biyo qeyd\",\n\t\"timeslider.playPause\": \"Zerrekê bloknoti kayfi/vındarn\",\n\t\"timeslider.backRevision\": \"Peyser şo çımraviyarnayışê na bloknoti\",\n\t\"timeslider.forwardRevision\": \"Ena bloknot de şo revizyonê bini\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Çele\",\n\t\"timeslider.month.february\": \"Sıbate\",\n\t\"timeslider.month.march\": \"Adar\",\n\t\"timeslider.month.april\": \"Nisane\",\n\t\"timeslider.month.may\": \"Gulane\",\n\t\"timeslider.month.june\": \"Heziran\",\n\t\"timeslider.month.july\": \"Temuz\",\n\t\"timeslider.month.august\": \"Tebaxe\",\n\t\"timeslider.month.september\": \"Keşkelun\",\n\t\"timeslider.month.october\": \"Tışrino Verên\",\n\t\"timeslider.month.november\": \"Tışrino Peyên\",\n\t\"timeslider.month.december\": \"Kanun\",\n\t\"timeslider.unnamedauthors\": \"{{num}} unnamed {[plural(num) zu: nuştoğ, zewbi: nustoği ]}\",\n\t\"pad.savedrevs.marked\": \"Eno vurriyayış henda qeyd bıyaye yew vurriyayış deyne nışan bıyo\",\n\t\"pad.savedrevs.timeslider\": \"Xızberê zemani ziyer kerdış ra şıma şenê revizyonanê qeyd bıyayan bıvinê\",\n\t\"pad.userlist.entername\": \"Namey xo cıkewe\",\n\t\"pad.userlist.unnamed\": \"bêname\",\n\t\"pad.editbar.clearcolors\": \"Wesiqa de renge nuştoğey bıesternê yê? No kar peyser nêgêrêno\",\n\t\"pad.impexp.importbutton\": \"Nıka miyan ke\",\n\t\"pad.impexp.importing\": \"Deyeno azere...\",\n\t\"pad.impexp.confirmimport\": \"Yu dosya azere kerdış de mewcud bloknoti sero nuşiye no. Şıma qayılê dewam bıkerê?\",\n\t\"pad.impexp.convertFailed\": \"Ena dosya azere kerdış mıkum niyo. Babetna namey dokumani weçinê yana xo desti kopya kerê u pronê.\",\n\t\"pad.impexp.padHasData\": \"Ma nêşa dosya azere kem,  çıkı ena bloknot xora  vurriya ya. Xorê yewna bloknot azere kerê\",\n\t\"pad.impexp.uploadFailed\": \"Barkerdış nêbi, kerem ke anciya bıcerebne\",\n\t\"pad.impexp.importfailed\": \"Zer kerdış mıwafaq nebı\",\n\t\"pad.impexp.copypaste\": \"Reca keme kopya pronayış bıkeri\",\n\t\"pad.impexp.exportdisabled\": \"Formatta {{type}} ya ateber kerdış dewra vıciya yo. Qandé teferruati idarekarana irtibat kewê\",\n\t\"pad.impexp.maxFileSize\": \"Dosya zêde gırsa, azere kerdışi rê mısade deyaye ebatê dosyay zeydınayışi rê idarekarê siteya irtibat kewê\"\n}\n"
  },
  {
    "path": "src/locales/dsb.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Michawiki\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Administratorowa delka – Etherpad\",\n\t\"admin_plugins\": \"Zastojnik tykacow\",\n\t\"admin_plugins.available\": \"K dispoziciji stojece tykace\",\n\t\"admin_plugins.available_not-found\": \"Žedne tykace namakane.\",\n\t\"admin_plugins.available_fetching\": \"Wobstarujo se …\",\n\t\"admin_plugins.available_install.value\": \"Instalěrowaś\",\n\t\"admin_plugins.available_search.placeholder\": \"Tykace za instalaciju pytaś\",\n\t\"admin_plugins.description\": \"Wopisanje\",\n\t\"admin_plugins.installed\": \"Zainstalěrowane tykace\",\n\t\"admin_plugins.installed_fetching\": \"Zainstalěrowane tykace se wobstaruju …\",\n\t\"admin_plugins.installed_nothing\": \"Hyšći njejsćo zainstalěrował tykace.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Wótinstalěrowaś\",\n\t\"admin_plugins.last-update\": \"Slědna aktualizacija\",\n\t\"admin_plugins.name\": \"Mě\",\n\t\"admin_plugins.page-title\": \"Zastojnik tykacow – Etherpad\",\n\t\"admin_plugins.version\": \"Wersija\",\n\t\"admin_plugins_info\": \"Informacije wó rozwězanju problemow\",\n\t\"admin_plugins_info.hooks\": \"Zainstalěrowane kokulki\",\n\t\"admin_plugins_info.hooks_client\": \"Kokulki z boka klienta\",\n\t\"admin_plugins_info.hooks_server\": \"Kokulki z boka serwera\",\n\t\"admin_plugins_info.parts\": \"Zainstalěrowane źěle\",\n\t\"admin_plugins_info.plugins\": \"Zainstalěrowane tykace\",\n\t\"admin_plugins_info.page-title\": \"Tykacowe informacije – Ehterpad\",\n\t\"admin_plugins_info.version\": \"Wersija Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Nejnowša wersija\",\n\t\"admin_plugins_info.version_number\": \"Wersijowy numer\",\n\t\"admin_settings\": \"Nastajenja\",\n\t\"admin_settings.current\": \"Aktualna konfiguracija\",\n\t\"admin_settings.current_example-devel\": \"Pśikładowa pśedłoga wuwijańskich nastajenjow\",\n\t\"admin_settings.current_example-prod\": \"Pśikładowa pśedłoga produkciskich nastajenjow\",\n\t\"admin_settings.current_restart.value\": \"Etherpad znowego startowaś\",\n\t\"admin_settings.current_save.value\": \"Nastajenja składowaś\",\n\t\"admin_settings.page-title\": \"Nastajenja – Etherpad\",\n\t\"index.newPad\": \"Nowy zapisnik\",\n\t\"index.settings\": \"Nastajenja\",\n\t\"index.transferSessionTitle\": \"Pósejźenje pśenosowaś\",\n\t\"index.receiveSessionTitle\": \"Pósejźenje dostaś\",\n\t\"index.receiveSessionDescription\": \"How móžoš póseźenje Etherpad z drugego wobglědowaka abo rěda dostaś. Pšosym źiwaj na to, až to wašo aktualne pósejźenje wulašujo, jolic take eksistěrujo.\",\n\t\"index.transferSession\": \"1. Pósejźenje pśenosowaś\",\n\t\"index.transferSessionNow\": \"Pósejźenje něnto pśenosowaś\",\n\t\"index.copyLink\": \"2. Wótkaz kopěrowaś\",\n\t\"index.copyLinkDescription\": \"Klikni na slědujucy tłocašk, aby wótkaz do mjazywótkłada kopěrował.\",\n\t\"index.copyLinkButton\": \"Wótkaz do mjazywótkłada kopěrowaś\",\n\t\"index.transferToSystem\": \"3. Pósejźenje do nowego systema kopěrowaś\",\n\t\"index.transferToSystemDescription\": \"Wócyń kopěrowany wótkaz w celowem wobglědowaku abo rěźe, aby swóje pósejźenje pśenosował.\",\n\t\"index.transferSessionDescription\": \"Klikni na slědujucy tłocašk, aby swójo aktualne pósejźenje do wobglědowaka abo rěda pśenosował. To buźo wótkaz do boka kopěrowaś, kótaryž buźo wašo pósejźenje pśenosowaś, gaž se w celowem wobglědowaku abo rěźe woócynja.\",\n\t\"index.createOpenPad\": \"Zapisnik pó mjenju wócyniś\",\n\t\"index.openPad\": \"wócyńśo eksistěrujucy Pad z mjenim:\",\n\t\"index.recentPads\": \"Nejnowše zapisniki\",\n\t\"index.recentPadsEmpty\": \"Žedne nejnowše zapisniki namakane.\",\n\t\"index.generateNewPad\": \"Pśipadne mě zapisnika generěrowaś\",\n\t\"index.labelPad\": \"Mě zapisnika (pó žycenju)\",\n\t\"index.placeholderPadEnter\": \"Pšosym zapódaj mě zapisnika…\",\n\t\"index.createAndShareDocuments\": \"Napóraj a źěl dokumenty w napšawdnem casu\",\n\t\"index.createAndShareDocumentsDescription\": \"Etherpad wam zmóžnja, dokumenty zgromadnje w napšawdnem casu wobźěłaś, kaž editor live multi-player, kótaryž we wašom wobglědowaku běžy.\",\n\t\"pad.toolbar.bold.title\": \"Tucny (Strg-B)\",\n\t\"pad.toolbar.italic.title\": \"Kursiwny (Strg-I)\",\n\t\"pad.toolbar.underline.title\": \"Pódšmarnuś (Strg-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Pśešmarnuś (Strg+5)\",\n\t\"pad.toolbar.ol.title\": \"Numerěrowana lisćina (Strg+Umsch+N)\",\n\t\"pad.toolbar.ul.title\": \"Nalicenje (Strg+Umsch+L)\",\n\t\"pad.toolbar.indent.title\": \"Zasunuś (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Wusunuś (Umsch+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Anulěrowaś (Strg-Z)\",\n\t\"pad.toolbar.redo.title\": \"Wóspjetowaś (Strg-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Awtorowe barwy lašowaś (Strg+Umsch+C)\",\n\t\"pad.toolbar.import_export.title\": \"Import/Eksport z/do drugich datajowych formatow\",\n\t\"pad.toolbar.timeslider.title\": \"Wersijowa historija\",\n\t\"pad.toolbar.savedRevision.title\": \"Wersiju składowaś\",\n\t\"pad.toolbar.settings.title\": \"Nastajenja\",\n\t\"pad.toolbar.embed.title\": \"Toś ten zapisnik źěliś a zasajźiś\",\n\t\"pad.toolbar.home.title\": \"Slědk k startowemu bokoju\",\n\t\"pad.toolbar.showusers.title\": \"Wužywarje na toś tom zapisniku pokazaś\",\n\t\"pad.colorpicker.save\": \"Składowaś\",\n\t\"pad.colorpicker.cancel\": \"Pśetergnuś\",\n\t\"pad.loading\": \"Zacytujo se...\",\n\t\"pad.noCookie\": \"Cookie njejo se namakał. Pšosym dowólśo cookieje w swójom wobglědowaku! Wašo pósejźenje a waše nastajenja se mjazy dwěma woglědoma njeskładuju. To móžo se stas, gaž Etherpad jo w někotarych wobglědowakach w iFrame wopśimjony. Pšosym zawěsććo, až Etherpad jo na samskej póddomenje/domenje ako nadrědowany iFrame\",\n\t\"pad.permissionDenied\": \"Njamaš pśistupne pšawo za toś ten zapisnik.\",\n\t\"pad.settings.padSettings\": \"Nastajenja zapisnika\",\n\t\"pad.settings.myView\": \"Mój naglěd\",\n\t\"pad.settings.stickychat\": \"Chat pśecej na wobrazowce pokazaś\",\n\t\"pad.settings.chatandusers\": \"Chat a wužywarje pokazaś\",\n\t\"pad.settings.colorcheck\": \"Awtorowe barwy\",\n\t\"pad.settings.linenocheck\": \"Smužkowe numery\",\n\t\"pad.settings.rtlcheck\": \"Wopśimjeśe wótpšawa nalěwo cytaś?\",\n\t\"pad.settings.fontType\": \"Pismowa družyna:\",\n\t\"pad.settings.fontType.normal\": \"Normalny\",\n\t\"pad.settings.language\": \"Rěc:\",\n\t\"pad.settings.deletePad\": \"Zapisnik lašowaś\",\n\t\"pad.delete.confirm\": \"Cośo napšawdu toś ten zapisnik lašowaś?\",\n\t\"pad.settings.about\": \"Wó\",\n\t\"pad.settings.poweredBy\": \"Pódpěrany wót\",\n\t\"pad.importExport.import_export\": \"Import/Eksport\",\n\t\"pad.importExport.import\": \"Tekstowu dataju abo dokument nagraś\",\n\t\"pad.importExport.importSuccessful\": \"Wuspěšny!\",\n\t\"pad.importExport.export\": \"Aktualny zapisnik eksportěrowaś ako:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Lutny tekst\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Móžoš jano z fprmatow lutnego teksta abo z HTML-formata importěrowaś. Za wěcej rozšyrjone importěrowańske funkcije <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">instalěruj pšosym Abiword abo LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Zwězany.\",\n\t\"pad.modals.reconnecting\": \"Zwězujo se znowego z twójim zapisnikom...\",\n\t\"pad.modals.forcereconnect\": \"Znowego zwězaś\",\n\t\"pad.modals.reconnecttimer\": \"Wopytaj se znowego zwězaś w\",\n\t\"pad.modals.cancel\": \"Pśetergnuś\",\n\t\"pad.modals.userdup\": \"W drugem woknje wócynjony\",\n\t\"pad.modals.userdup.explanation\": \"Zda se, až toś ten zapisnik jo se we wěcej ako jadnem woknje wobglědowaka na toś tom licadłu wócynił.\",\n\t\"pad.modals.userdup.advice\": \"Zwězaj znowego, aby toś to wokno město togo wužywał.\",\n\t\"pad.modals.unauth\": \"Njeawtorizěrowany\",\n\t\"pad.modals.unauth.explanation\": \"Pśi wobglědowanju toś togo boka su se twóje pšawa změnili. Wopytaj se znowego zwězaś.\",\n\t\"pad.modals.looping.explanation\": \"Su komunikaciske problemy ze synchronizěrowańskim serwerom.\",\n\t\"pad.modals.looping.cause\": \"Snaź sy pśez njekompatibelnu wognjowu murju abo proksy zwězany.\",\n\t\"pad.modals.initsocketfail\": \"Serwer njejo dojśpiwajobny.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Zwisk ze synchronizěrowańskim serwerom njejo móžno.\",\n\t\"pad.modals.initsocketfail.cause\": \"To jo nejskerjej problem z twójim wobglědowakom abo twójim internetnym zwiskom.\",\n\t\"pad.modals.slowcommit.explanation\": \"Serwer njewótegranja.\",\n\t\"pad.modals.slowcommit.cause\": \"To by mógło problem seśowego zwiska byś.\",\n\t\"pad.modals.badChangeset.explanation\": \"Změna, kótaruž sy pśewjadł, jo se pśez synchronizěrowański serwer ako njedowólonu markěrowała.\",\n\t\"pad.modals.badChangeset.cause\": \"To jo se snaź wopacneje serweroweje konfiguracije dla abo drugego njewócakanego zaźaeržanja dla stało. Pšosym staj se ze słužbowym administratorom do zwiska, jolic se mysliš, až to jo zmólka. Wopytaj hyšći raz zwězaś, aby z wobźěłowanim pókšacował.\",\n\t\"pad.modals.corruptPad.explanation\": \"Zapisnik, na kótaryž coš pśistup měś, jo wobškóźony.\",\n\t\"pad.modals.corruptPad.cause\": \"To jo se snaź wopacneje serweroweje konfiguracije dla abo drugego njewócakanego zaźaržanja dla stało. Pšosym staj se ze słužbowym administratorom do zwiska.\",\n\t\"pad.modals.deleted\": \"Wulašowany.\",\n\t\"pad.modals.deleted.explanation\": \"Toś ten zapisnik jo se wótpórał.\",\n\t\"pad.modals.rateLimited\": \"Wobgranicowana rata.\",\n\t\"pad.modals.rateLimited.explanation\": \"Sćo pósłał pśewjele powěsćow na zapisnik, togodla jo se zwisk źělił.\",\n\t\"pad.modals.rejected.explanation\": \"Serwer jo wótpokazał powěsć, kótaraž jo se pósłał pśez waš wobglědowak pósłał.\",\n\t\"pad.modals.rejected.cause\": \"Serwer jo se snaź zaktualizěrował, mjaztym až sy se woglědał zapisnik, abo dajo snaź zmólku w Etherpad. Wopytaj bok znowego zacytaś.\",\n\t\"pad.modals.disconnected\": \"Zwisk jo pśetergnjony.\",\n\t\"pad.modals.disconnected.explanation\": \"Zwisk ze serwerom jo se zgubił\",\n\t\"pad.modals.disconnected.cause\": \"Serwer njestoj k dispoziciji. Pšosym informěruj słužbowego administratora, jolic to se dalej stawa.\",\n\t\"pad.share\": \"Toś ten zapisnik źěliś\",\n\t\"pad.share.readonly\": \"Jano cytajobny\",\n\t\"pad.share.link\": \"Wótkaz\",\n\t\"pad.share.emebdcode\": \"URL zasajźiś\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Chat za toś ten zapisnik wócyniś\",\n\t\"pad.chat.loadmessages\": \"Dalšne powěsći zacytaś\",\n\t\"pad.chat.stick.title\": \"Chat k wobrazowce pśipěś\",\n\t\"pad.chat.writeMessage.placeholder\": \"Piš swóju powěsć how\",\n\t\"timeslider.followContents\": \"Aktualizacijam wopśimjeśa zapisnika slědowaś\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} - wersijowa historija\",\n\t\"timeslider.toolbar.returnbutton\": \"Slědk k zapisnikoju\",\n\t\"timeslider.toolbar.authors\": \"Awtory:\",\n\t\"timeslider.toolbar.authorsList\": \"Žedne awtory\",\n\t\"timeslider.toolbar.exportlink.title\": \"Eksportěrowaś\",\n\t\"timeslider.exportCurrent\": \"Aktualnu wersiju eksportěrowaś ako:\",\n\t\"timeslider.version\": \"Wersija {{version}}\",\n\t\"timeslider.saved\": \"Składowany {{day}}. {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Wopśimjeśe zapisnika wótgraś/pawzěrowaś\",\n\t\"timeslider.backRevision\": \"Wó jadnu wersiju w toś tom dokumenśe slědk hyś\",\n\t\"timeslider.forwardRevision\": \"Wó jadnu wersiju w toś tom dokumenśe doprědka hyś\",\n\t\"timeslider.dateformat\": \"{{day}}. {{month}} {{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"januara\",\n\t\"timeslider.month.february\": \"februara\",\n\t\"timeslider.month.march\": \"měrca\",\n\t\"timeslider.month.april\": \"apryla\",\n\t\"timeslider.month.may\": \"maja\",\n\t\"timeslider.month.june\": \"junija\",\n\t\"timeslider.month.july\": \"julija\",\n\t\"timeslider.month.august\": \"awgusta\",\n\t\"timeslider.month.september\": \"septembra\",\n\t\"timeslider.month.october\": \"oktobra\",\n\t\"timeslider.month.november\": \"nowembra\",\n\t\"timeslider.month.december\": \"decembra\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: awtor, two: awtora, few: awtory, other: awtorow ]} bźez mjenja\",\n\t\"pad.savedrevs.marked\": \"Toś ta wersija jo se něnto ako składowana wersija markěrowała\",\n\t\"pad.savedrevs.timeslider\": \"Móžoš se skłaźone wersije woglědowaś, gaž se k historiji dokumenta woglědujoś.\",\n\t\"pad.userlist.entername\": \"Zapódaj swójo mě\",\n\t\"pad.userlist.unnamed\": \"bźez mjenja\",\n\t\"pad.editbar.clearcolors\": \"Awtorowe barwy w cełem dokumenśe lašowaś? To njedajo se anulěrowaś\",\n\t\"pad.impexp.importbutton\": \"Něnto importěrowaś\",\n\t\"pad.impexp.importing\": \"Importěrujo se...\",\n\t\"pad.impexp.confirmimport\": \"Importowanje dataje pśepišo aktualny tekst zapisnika. Coš napšawdu pókšacowaś?\",\n\t\"pad.impexp.convertFailed\": \"Njejsmy mógli toś tu dataju importěrowaś. Pšosym wužyj drugi dokumentowy format abo kopěruj manuelnje\",\n\t\"pad.impexp.padHasData\": \"Njejsmy mógli toś tu dataju importěrowaś, dokulaž toś ten dokument južo změny wopśimujo, pšosym importěruj nowy dokument.\",\n\t\"pad.impexp.uploadFailed\": \"Nagraśe njejo se raźiło, pšosym wopytaj hyšći raz\",\n\t\"pad.impexp.importfailed\": \"Import njejo se raził\",\n\t\"pad.impexp.copypaste\": \"Pšosym kopěrowaś a zasajźiś\",\n\t\"pad.impexp.exportdisabled\": \"Eksport ako format {{type}} jo znjemóžnjony. Pšosym staj se ze swójim systemowym administratorom za drobnostki do zwiska.\",\n\t\"pad.impexp.maxFileSize\": \"Dataja jo pśewjelika. Staj se ze swójim sedłowym administratorom do zwiska, aby dowólonu datajowu wjelikosć za import pówušył\"\n}\n"
  },
  {
    "path": "src/locales/dty.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Nirajan pant\",\n\t\t\t\"बडा काजी\",\n\t\t\t\"रमेश सिंह बोहरा\",\n\t\t\t\"राम प्रसाद जोशी\"\n\t\t]\n\t},\n\t\"index.newPad\": \"नौलो प्याड\",\n\t\"index.createOpenPad\": \"नाउँ सहितको नौलो प्याड सिर्जना गद्य्या / खोल्ल्या :\",\n\t\"pad.toolbar.bold.title\": \"मोटो (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"ढल्के (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"इसो रेखाङ्कन (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"बीचको धड्को (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"एकनासको सूची (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"अक्रमाङ्कित सूची (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"इन्डेन्ट (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"आउटडेन्ट (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"अण्डू  (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"दोसर्या:लागु (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"लेखकीय रङ्ग हटाउन्या (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"विविध फाइल फर्म्याटअन बठेइ/मी आयात/निर्यात\",\n\t\"pad.toolbar.timeslider.title\": \"टाइमस्लाइडर\",\n\t\"pad.toolbar.savedRevision.title\": \"पुनरावलोकन संग्रह गद्य्या\",\n\t\"pad.toolbar.settings.title\": \"सेटिङ्गअन\",\n\t\"pad.toolbar.embed.title\": \"यै प्याडलाई बाड्न्या यात इम्बेड गद्य्या\",\n\t\"pad.toolbar.showusers.title\": \"यै प्याडमि रयाका प्रयोगकर्ता देखाउन्या\",\n\t\"pad.colorpicker.save\": \"सङ्ग्रह गद्या\",\n\t\"pad.colorpicker.cancel\": \"खारेजी\",\n\t\"pad.loading\": \"लोड हुन्नाछ़....\",\n\t\"pad.noCookie\": \"कुकी पाउन नाइ सकियो। तमरा ब्राउजरमी कुकी राख्दाइ अनुमति दिय!\",\n\t\"pad.permissionDenied\": \"तमलाईँ यै प्याड खोल्लाकी अनुमति नाइथिन\",\n\t\"pad.settings.padSettings\": \"प्याड सेटिङ्गअन\",\n\t\"pad.settings.myView\": \"मेरि हेराइ\",\n\t\"pad.settings.stickychat\": \"जबलई पर्दामी कुरडी गद्य्या\",\n\t\"pad.settings.chatandusers\": \"वार्ता और प्रयोगकर्ताअन देखाउन्या\",\n\t\"pad.settings.colorcheck\": \"लेखकीय रङ्ग\",\n\t\"pad.settings.linenocheck\": \"हरफ संख्या\",\n\t\"pad.settings.rtlcheck\": \"सामग्री दाहिना बठे देब्रे पढ्न्या हो कि?\",\n\t\"pad.settings.fontType\": \"फन्ट प्रकार:\",\n\t\"pad.settings.language\": \"भाषा:\",\n\t\"pad.importExport.import_export\": \"आयात/निर्यात\",\n\t\"pad.importExport.import\": \"कोइलै पाठ फाइल और कागजात अपलोड अरऽ\",\n\t\"pad.importExport.importSuccessful\": \"सफल भयो!\",\n\t\"pad.importExport.export\": \"निम्न रुपमि प्याड निर्यात:\",\n\t\"pad.importExport.exportetherpad\": \"इथरप्याड\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"सादा पाठ\",\n\t\"pad.importExport.exportword\": \"माइक्रोसफ्ट वर्ड\",\n\t\"pad.importExport.exportpdf\": \"पिडिएफ\",\n\t\"pad.importExport.exportopen\": \"ओडिएफ (खुल्ला कागजात ढाँचा)\",\n\t\"pad.importExport.abiword.innerHTML\": \"तम सादा पाठ या HTML ढाँचा बठेइ मात्तरी आयात अरीसकन्छऽ। विस्तारित आयात विशेषता खिलाई कृपया <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">abiword स्थापना अरऽ</a>।\",\n\t\"pad.modals.connected\": \"जोडीयाको।\",\n\t\"pad.modals.reconnecting\": \"तमरा प्याडमि दोबरा जडान अद्‍दाछ़..\",\n\t\"pad.modals.forcereconnect\": \"बलात् पुन:जडान\",\n\t\"pad.modals.reconnecttimer\": \"दोबरा जोड्‍डाइ प्रयास अद्‍दाछ़\",\n\t\"pad.modals.cancel\": \"रद्द\",\n\t\"pad.modals.userdup\": \"अर्खा विण्डोमी खुलिरैछ\",\n\t\"pad.modals.userdup.explanation\": \"यो प्याड येइ कम्प्युटरमी एक़ है बर्ता ब्राउजर सञ्झ्यालमी खोल्याऽ धेकीँछ।\",\n\t\"pad.modals.userdup.advice\": \"बरु यो विण्डो प्रयोग अद्दाइ दोसर्‍याँ जोणिय।\",\n\t\"pad.modals.unauth\": \"अनुमति नदियीयाऽ\",\n\t\"pad.modals.unauth.explanation\": \"येइ पन्ना हेरनज्याँ तमरा अधिकार बदेलिया। दोसर्‍याँ जोणिन्या प्रयास अरऽ।\",\n\t\"pad.modals.looping.explanation\": \"सिक्रोनाइजेसन सर्भर सित सञ्चार समस्या धेकिन्नाछ़।\",\n\t\"pad.modals.looping.cause\": \"शायद तम यक असंगत फायरवाल या प्रोक्सी का माध्यम बठेइ जोणीरैछऽ।\",\n\t\"pad.modals.initsocketfail\": \"सर्भरमी पहुँच पुर्‍याउन नाइसकियो।\",\n\t\"pad.modals.initsocketfail.explanation\": \"सिङ्क्रोनाइजेसन सर्भर सित जोणीन नाइ सकियो।\",\n\t\"pad.modals.initsocketfail.cause\": \"यो शायद तमरा ब्राउजर या इन्टरनेट जडान सित सम्बन्धित समस्याऽ कारणले होइ सकन्छ़।\",\n\t\"pad.modals.slowcommit.explanation\": \"सर्भर प्रत्युत्तर दिन्नारेन।\",\n\t\"pad.modals.slowcommit.cause\": \"यो नेटवर्क कनेक्टिविटी सङ्ङ सम्बन्धित समस्याऽ कारण ले होइसकन्छ।\",\n\t\"pad.modals.badChangeset.explanation\": \"तमले अर्‍याऽ यक सम्पादन समक्रमण सर्भर हताँ अवैध वर्गीकृत अरियाऽ थ्यो।\",\n\t\"pad.modals.badChangeset.cause\": \"यो यक गलत सर्भर विन्यास या केइ और अप्रत्याशित चालचलनाऽ कारण़ ले होइसकन्छ। यदि तमलाई यो गल्ती हो भण्ण्या लागन्छ भँण्या, कृपया सेवा व्यवस्थापकलाई सम्पर्क अरऽ। सम्पादन चालु राख्दाइ दोसर्‍याँ जोणिन्या प्रयास अरऽ।\",\n\t\"pad.modals.corruptPad.explanation\": \"तमले उपयोग अद्द़ खोज्याऽ प्याड बिगण्योऽ छ।\",\n\t\"pad.modals.corruptPad.cause\": \"यो गलत सर्भर विन्यास या केइ और नसोच्याऽ चालचलनले होइसकन्छ। कृपया सेवा व्यवस्थापकलाई सम्पर्क अरऽ।\",\n\t\"pad.modals.deleted\": \"मेटियाको।\",\n\t\"pad.modals.deleted.explanation\": \"यो प्याड हटाइसकीरैछ।\",\n\t\"pad.modals.disconnected\": \"तमरो जडान अवरुद्ध भयो।\",\n\t\"pad.modals.disconnected.explanation\": \"तमरो सर्भरसितको जडान अवरुद्ध भयो\",\n\t\"pad.modals.disconnected.cause\": \"सर्भर अनुपलब्ध होइसकन्छ। यदि यो हुनोइ रयाबर कृपया सेवा व्यवस्थापकलाई सूचित अरऽ।\",\n\t\"pad.share\": \"यस प्याडलाई बाड्न्या\",\n\t\"pad.share.readonly\": \"पड्‍ड्या मात्तरै\",\n\t\"pad.share.link\": \"कडी\",\n\t\"pad.share.emebdcode\": \"URL थप्प्या\",\n\t\"pad.chat\": \"कुरणिकानी\",\n\t\"pad.chat.title\": \"येइ प्याड खिलाइ गफ खोलऽ\",\n\t\"pad.chat.loadmessages\": \"जेदा सन्देश लोड अरऽ\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} समय स्लाइडर\",\n\t\"timeslider.toolbar.returnbutton\": \"प्याडमी फर्कऽ\",\n\t\"timeslider.toolbar.authors\": \"लेखकअन:\",\n\t\"timeslider.toolbar.authorsList\": \"लेखकअन आथीनन\",\n\t\"timeslider.toolbar.exportlink.title\": \"निर्यात\",\n\t\"timeslider.exportCurrent\": \"हालआ शंसोधनलाई इस्याँ निर्यात अरऽ:\",\n\t\"timeslider.version\": \"संस्करण {{version}}\",\n\t\"timeslider.saved\": \"भँणार अरीयाऽ {{month}} {{day}}, {{year}}\",\n\t\"timeslider.playPause\": \"प्याडआ सामाग्रीइनलाई प्लेब्याक/पउज अरऽ\",\n\t\"timeslider.backRevision\": \"येइ प्याडमी यक शंसोधन पछा जाऽ\",\n\t\"timeslider.forwardRevision\": \"येइ शंसोधनमी यक शंसोधन अघा जाऽ\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"जनवरी\",\n\t\"timeslider.month.february\": \"फेब्रुअरी\",\n\t\"timeslider.month.march\": \"मार्च\",\n\t\"timeslider.month.april\": \"अप्रिल\",\n\t\"timeslider.month.may\": \"मे\",\n\t\"timeslider.month.june\": \"जुन\",\n\t\"timeslider.month.july\": \"जुलाई\",\n\t\"timeslider.month.august\": \"अगस्ट\",\n\t\"timeslider.month.september\": \"सेप्टेम्बर\",\n\t\"timeslider.month.october\": \"अक्टोबर\",\n\t\"timeslider.month.november\": \"नोभेम्बर\",\n\t\"timeslider.month.december\": \"डिसेम्बर\",\n\t\"timeslider.unnamedauthors\": \"{{num}} बिननाउँइको {[plural(num) one: author, other: authors ]}\",\n\t\"pad.savedrevs.marked\": \"आब येइ संशोधनलाई सङ्ग्रहित संशोधनआ रूपमी चिनो लायियो\",\n\t\"pad.savedrevs.timeslider\": \"समयस्लाइडर भेटिबर तम भँणार अरीयाऽ शंसोधनअनलाई हेरि सकन्छऽ\",\n\t\"pad.userlist.entername\": \"तमरो नाउँ हाल\",\n\t\"pad.userlist.unnamed\": \"बिननाउँइको\",\n\t\"pad.editbar.clearcolors\": \"सङताइ कागताजमी है लेखक रङ्ङअन साप अद्द्या?\",\n\t\"pad.impexp.importbutton\": \"ऐलै आयात अरऽ\",\n\t\"pad.impexp.importing\": \"आयात अद्दाछ़...\",\n\t\"pad.impexp.confirmimport\": \"फाइल आयात़ ले प्याडओ अइलओ पाठ बदेलिन्या हो। तम ऐतिऱ बड्ड चाहन्छ भणिबर पक्का छऽ?\",\n\t\"pad.impexp.convertFailed\": \"एइ फाइललाई आयात अद्द नाइसक्यो। कृपया जुदोइ कागजात फर्याट प्रयोग अरऽ या नकल पेस्ट अरऽ\",\n\t\"pad.impexp.padHasData\": \"हम एइ फाइलाई आयात अद्दाइ असमर्थ छौँ क्याइकि एइ प्याडमी पैली अरीयाऽ फेलबदेल छन्, कृपया नयाँ प्याडमी आयात अरऽ\",\n\t\"pad.impexp.uploadFailed\": \"अपलोड असफल, कृपया दोसर्‍याँ प्रयास अरऽ\",\n\t\"pad.impexp.importfailed\": \"आयात असफल\",\n\t\"pad.impexp.copypaste\": \"कृपया नकल सार अरऽ\",\n\t\"pad.impexp.exportdisabled\": \"{{type}} फर्म्याटमी निर्यात अक्षम अरीरैछ। विवरण खिलाइ कृपया तमरा संयन्त्र प्रशासकलाई सम्पर्क अर:।\"\n}\n"
  },
  {
    "path": "src/locales/el.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Evropi\",\n\t\t\t\"Geraki\",\n\t\t\t\"Glavkos\",\n\t\t\t\"Jimkats\",\n\t\t\t\"Monopatis\",\n\t\t\t\"Norhorn\",\n\t\t\t\"Papspyr\",\n\t\t\t\"Protnet\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Πίνακας ελέγχου διαχειριστή - Etherpad\",\n\t\"admin_plugins\": \"Διαχειριστής πρόσθετων\",\n\t\"admin_plugins.available\": \"Διαθέσιμα πρόσθετα\",\n\t\"admin_plugins.available_not-found\": \"Δεν βρέθηκαν πρόσθετα.\",\n\t\"admin_plugins.available_fetching\": \"Ανακτάται...\",\n\t\"admin_plugins.available_install.value\": \"Εγκατάσταση\",\n\t\"admin_plugins.available_search.placeholder\": \"Αναζητήστε πρόσθετα για εγκατάσταση\",\n\t\"admin_plugins.description\": \"Περιγραφή\",\n\t\"admin_plugins.installed\": \"Εγκατεστημένα πρόσθετα\",\n\t\"admin_plugins.installed_fetching\": \"Ανάκτηση εγκατεστημένων προσθηκών…\",\n\t\"admin_plugins.installed_nothing\": \"Δεν έχετε εγκαταστήσει πρόσθετα ακόμη.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Απεγκατάσταση\",\n\t\"admin_plugins.last-update\": \"Τελευταία ενημέρωση\",\n\t\"admin_plugins.name\": \"Όνομα\",\n\t\"admin_plugins.page-title\": \"Διαχειριστής πρόσθετων - Etherpad\",\n\t\"admin_plugins.version\": \"Έκδοση\",\n\t\"admin_plugins_info\": \"Πληροφορίες αντιμετώπισης προβλημάτων\",\n\t\"admin_plugins_info.hooks\": \"Εγκατεστημένα άγκιστρα\",\n\t\"admin_plugins_info.hooks_server\": \"Άγκιστρα από την πλευρά του διακομιστή\",\n\t\"admin_plugins_info.parts\": \"Εγκατεστημένα εξαρτήματα\",\n\t\"admin_plugins_info.plugins\": \"Εγκατεστημένα πρόσθετα\",\n\t\"admin_plugins_info.page-title\": \"Πληροφορίες πρόσθετου - Etherpad\",\n\t\"admin_plugins_info.version\": \"Έκδοση Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Τελευταία διαθέσιμη έκδοση\",\n\t\"admin_plugins_info.version_number\": \"Αριθμός έκδοσης\",\n\t\"admin_settings\": \"Ρυθμίσεις\",\n\t\"admin_settings.current\": \"Τρέχουσα διαμόρφωση\",\n\t\"admin_settings.current_example-devel\": \"Παράδειγμα προτύπου ρυθμίσεων ανάπτυξης\",\n\t\"admin_settings.current_example-prod\": \"Παράδειγμα προτύπου ρυθμίσεων παραγωγής\",\n\t\"admin_settings.current_restart.value\": \"Επανεκκινήστε το Etherpad\",\n\t\"admin_settings.current_save.value\": \"Αποθήκευση Ρυθμίσεων\",\n\t\"admin_settings.page-title\": \"Ρυθμίσεις - Etherpad\",\n\t\"index.newPad\": \"Νέος Κοινόχρηστος Πίνακας\",\n\t\"index.settings\": \"Ρυθμίσεις\",\n\t\"index.transferSessionTitle\": \"Μεταφορά συνεδρίας\",\n\t\"index.receiveSessionTitle\": \"Λήψη συνεδρίας\",\n\t\"index.transferSession\": \"1. Μεταφορά συνεδρίας\",\n\t\"index.transferSessionNow\": \"Μεταφορά συνεδρίας τώρα\",\n\t\"index.copyLink\": \"2. Αντιγραφή συνδέσμου\",\n\t\"index.copyLinkDescription\": \"Πατήστε στο παρακάτω κουμπί για να αντιγράψετε τον σύνδεσμο στο πρόχειρό σας.\",\n\t\"index.copyLinkButton\": \"Αντιγραφή συνδέσμου στο πρόχειρο\",\n\t\"index.transferToSystem\": \"3. Αντιγραφή συνεδρίας στο νέο σύστημα\",\n\t\"index.transferToSystemDescription\": \"Ανοίξτε τον αντιγραμμένο σύνδεσμο στο πρόγραμμα περιήγησης ή στη συσκευή προορισμού για να μεταφέρετε την συνεδρία σας.\",\n\t\"index.createOpenPad\": \"ή δημιουργία/άνοιγμα ενός κοινόχρηστου πίνακα με όνομα:\",\n\t\"index.openPad\": \"άνοιγμα υπάρχοντος κοινόχρηστού πίνακα με όνομα:\",\n\t\"pad.toolbar.bold.title\": \"Έντονα (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Πλάγια (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Υπογραμμισμένα (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Διακριτή διαγραφή (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Ταξινομημένη λίστα (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Λίστα χωρίς ταξινόμηση (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Εσοχή (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Εσοχή (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Αναίρεση (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Επανάληψη (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Εκκαθάριση χρωμάτων σύνταξης κειμένου (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Εισαγωγή/Εξαγωγή από/σε διαφορετικούς τύπους αρχείων\",\n\t\"pad.toolbar.timeslider.title\": \"Χρονοδιάγραμμα\",\n\t\"pad.toolbar.savedRevision.title\": \"Αποθήκευση Αναθεώρησης\",\n\t\"pad.toolbar.settings.title\": \"Ρυθμίσεις\",\n\t\"pad.toolbar.embed.title\": \"Διαμοίραση και Ενσωμάτωση αυτού του κοινόχρηστου πίνακα\",\n\t\"pad.toolbar.home.title\": \"Επιστροφή στην αρχή\",\n\t\"pad.toolbar.showusers.title\": \"Εμφάνιση των χρηστών αυτού του κοινόχρηστου πίνακα\",\n\t\"pad.colorpicker.save\": \"Αποθήκευση\",\n\t\"pad.colorpicker.cancel\": \"Ακύρωση\",\n\t\"pad.loading\": \"Φόρτωση...\",\n\t\"pad.noCookie\": \"Το cookie δε βρέθηκε. Παρακαλούμε επιτρέψτε τα cookies στο φυλλομετρητή σας! Η περίοδος σύνδεσης και οι ρυθμίσεις σας δε θα αποθηκευτούν μεταξύ των επισκέψεων. Αυτό μπορεί να οφείλεται επειδή το Etherpad περιλαμβάνεται στο iFrame σε ορισμένα προγράμματα πλοήγησης. Παρακαλούμε βεβαιωθείτε ότι το Etherpad βρίσκεται στον ίδιο υποτομέα/τομέα με το iFrame\",\n\t\"pad.permissionDenied\": \"Δεν έχετε δικαίωμα πρόσβασης σε αυτόν τον κοινόχρηστο πίνακα\",\n\t\"pad.settings.padSettings\": \"Ρυθμίσεις κοινόχρηστου πίνακα\",\n\t\"pad.settings.myView\": \"Η προβολή μου\",\n\t\"pad.settings.stickychat\": \"Να είναι πάντα ορατή η συνομιλία\",\n\t\"pad.settings.chatandusers\": \"Εμφάνιση Συνομιλίας και Χρηστών\",\n\t\"pad.settings.colorcheck\": \"Χρώματα συντάκτη\",\n\t\"pad.settings.linenocheck\": \"Αριθμοί γραμμών\",\n\t\"pad.settings.rtlcheck\": \"Διαβάζεται το περιεχόμενο από δεξιά προς τα αριστερά;\",\n\t\"pad.settings.fontType\": \"Τύπος γραμματοσειράς:\",\n\t\"pad.settings.fontType.normal\": \"Κανονική\",\n\t\"pad.settings.language\": \"Γλώσσα:\",\n\t\"pad.settings.deletePad\": \"Διαγραφή Pad\",\n\t\"pad.delete.confirm\": \"Θέλετε πραγματικά να διαγράψετε αυτό το pad;\",\n\t\"pad.settings.about\": \"Σχετικά\",\n\t\"pad.settings.poweredBy\": \"Υποστηρίζεται από\",\n\t\"pad.importExport.import_export\": \"Εισαγωγή/Εξαγωγή\",\n\t\"pad.importExport.import\": \"Μεταφόρτωση οποιουδήποτε αρχείου κειμένου ή εγγράφου\",\n\t\"pad.importExport.importSuccessful\": \"Επιτυχής!\",\n\t\"pad.importExport.export\": \"Εξαγωγή τρέχοντος pad ως:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Απλό κείμενο\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Μπορείτε να εισάγετε απλό κείμενο ή HTML. Για προηγμένες δυνατότητες εισαγωγής παρακαλούμε <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">εγκαταστήστε το AbiWord ή το LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Συνδεμένοι.\",\n\t\"pad.modals.reconnecting\": \"Επανασύνδεση στο pad σας…\",\n\t\"pad.modals.forcereconnect\": \"Επιβολή επανασύνδεσης\",\n\t\"pad.modals.reconnecttimer\": \"Προσπάθεια επανασύνδεσης σε\",\n\t\"pad.modals.cancel\": \"Ακύρωση\",\n\t\"pad.modals.userdup\": \"Ανοιγμένο σε άλλο παράθυρο\",\n\t\"pad.modals.userdup.explanation\": \"Αυτός ο κοινόχρηστος πίνακας φαίνεται να είναι ανοιχτός σε περισσότερα από ένα παράθυρο του προγράμματος περιήγησης σε αυτόν τον υπολογιστή.\",\n\t\"pad.modals.userdup.advice\": \"Επανασυνδεθείτε για να χρησιμοποιήσετε αυτό το παράθυρο.\",\n\t\"pad.modals.unauth\": \"Δεν επιτρέπεται\",\n\t\"pad.modals.unauth.explanation\": \"Τα δικαιώματά σας άλλαξαν όσο βλέπατε αυτήν τη σελίδα. Δοκιμάστε να επανασυνδεθείτε.\",\n\t\"pad.modals.looping.explanation\": \"Υπάρχουν προβλήματα επικοινωνίας με τον διακομιστή συγχρονισμού.\",\n\t\"pad.modals.looping.cause\": \"Ίσως συνδεθήκατε μέσω ενός μη συμβατού τείχους προστασίας ή διακομιστή μεσολάβησης.\",\n\t\"pad.modals.initsocketfail\": \"Αδύνατη ή επικοινωνία με τον διακομιστή.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Δεν ήταν δυνατή η σύνδεση με τον διακομιστή συγχρονισμού.\",\n\t\"pad.modals.initsocketfail.cause\": \"Αυτό οφείλεται πιθανώς σε πρόβλημα με το πρόγραμμα περιήγησης ή της σύνδεσής σας στο διαδίκτυο.\",\n\t\"pad.modals.slowcommit.explanation\": \"Ο διακομιστής δεν αποκρίνεται.\",\n\t\"pad.modals.slowcommit.cause\": \"Αυτό μπορεί να οφείλεται σε προβλήματα σύνδεσης δικτύου.\",\n\t\"pad.modals.badChangeset.explanation\": \"Μια επεξεργασία που κάνατε χαρακτηρίστηκε ως παράνομη από τον διακομιστή συγχρονισμού.\",\n\t\"pad.modals.badChangeset.cause\": \"Αυτό μπορεί να οφείλεται σε ένα λάθος στη ρύθμιση του διακομιστή ή κάποια άλλη απρόβλεπτη συμπεριφορά. Παρακαλώ επικοινωνήστε με τον διαχειριστή της υπηρεσίας, εάν πιστεύετε πως αυτό οφείλεται σε σφάλμα. Δοκιμάστε να επανασυνδεθείτε για να συνεχίσετε την επεξεργασία.\",\n\t\"pad.modals.corruptPad.explanation\": \"Το pad που προσπαθείτε να επισκεφτείτε είναι κατεστραμμένο.\",\n\t\"pad.modals.corruptPad.cause\": \"Αυτό μπορεί να οφείλεται σε ένα λάθος στη ρύθμιση του διακομιστή ή κάποια άλλη απρόβλεπτη συμπεριφορά. Παρακαλώ επικοινωνήστε με τον διαχειριστή της υπηρεσίας.\",\n\t\"pad.modals.deleted\": \"Διεγράφη.\",\n\t\"pad.modals.deleted.explanation\": \"Αυτό το pad έχει καταργηθεί.\",\n\t\"pad.modals.rateLimited.explanation\": \"Στείλατε πάρα πολλά μηνύματα σε αυτό το pad, επομένως σας αποσύνδεσε.\",\n\t\"pad.modals.rejected.explanation\": \"Ο διακομιστής απέρριψε ένα μήνυμα που στάλθηκε από το πρόγραμμα περιήγησής σας.\",\n\t\"pad.modals.rejected.cause\": \"Ο διακομιστής μπορεί να έχει ενημερωθεί ενώ προβάλλατε το pad ή ίσως υπάρχει σφάλμα στο Etherpad. Δοκιμάστε να φορτώσετε ξανά τη σελίδα.\",\n\t\"pad.modals.disconnected\": \"Είστε αποσυνδεδεμένοι.\",\n\t\"pad.modals.disconnected.explanation\": \"Χάθηκε η σύνδεση με τον διακομιστή\",\n\t\"pad.modals.disconnected.cause\": \"Ο διακομιστής μπορεί να μην είναι διαθέσιμος. Παρακαλούμε ειδοποιήστε τον διαχειριστή της υπηρεσίας εάν εξακολουθεί να συμβαίνει αυτό.\",\n\t\"pad.share\": \"Μοιραστείτε αυτό το pad\",\n\t\"pad.share.readonly\": \"Μόνο για ανάγνωση\",\n\t\"pad.share.link\": \"Σύνδεσμος\",\n\t\"pad.share.emebdcode\": \"URL ενσωμάτωσης\",\n\t\"pad.chat\": \"Συνομιλία\",\n\t\"pad.chat.title\": \"Άνοιγμα της συνομιλίας για αυτό το pad.\",\n\t\"pad.chat.loadmessages\": \"Φόρτωση περισσότερων μηνυμάτων\",\n\t\"pad.chat.stick.title\": \"Κρατήστε τη συνομιλία στην οθόνη\",\n\t\"pad.chat.writeMessage.placeholder\": \"Γράψτε το μήνυμα σας εδώ\",\n\t\"timeslider.followContents\": \"Ακολουθήστε τις ενημερώσεις περιεχομένου του pad\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Χρονοδιάγραμμα\",\n\t\"timeslider.toolbar.returnbutton\": \"Επιστροφή στο pad\",\n\t\"timeslider.toolbar.authors\": \"Συντάκτες:\",\n\t\"timeslider.toolbar.authorsList\": \"Κανένας Συντάκτης\",\n\t\"timeslider.toolbar.exportlink.title\": \"Εξαγωγή\",\n\t\"timeslider.exportCurrent\": \"Εξαγωγή τρέχουσας έκδοσης ως:\",\n\t\"timeslider.version\": \"Έκδοση {{version}}\",\n\t\"timeslider.saved\": \"Αποθηκεύτηκε στις {{day}} {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Αναπαραγωγή / Παύση των περιεχομένων αυτού του Pad\",\n\t\"timeslider.backRevision\": \"Επιστροφή σε μια έκδοση αυτού του Pad\",\n\t\"timeslider.forwardRevision\": \"Μια έκδοση μπροστά αυτού του Pad\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Ιανουαρίου\",\n\t\"timeslider.month.february\": \"Φεβρουαρίου\",\n\t\"timeslider.month.march\": \"Μαρτίου\",\n\t\"timeslider.month.april\": \"Απριλίου\",\n\t\"timeslider.month.may\": \"Μαΐου\",\n\t\"timeslider.month.june\": \"Ιουνίου\",\n\t\"timeslider.month.july\": \"Ιουλίου\",\n\t\"timeslider.month.august\": \"Αυγούστου\",\n\t\"timeslider.month.september\": \"Σεπτεμβρίου\",\n\t\"timeslider.month.october\": \"Οκτωβρίου\",\n\t\"timeslider.month.november\": \"Νοεμβρίου\",\n\t\"timeslider.month.december\": \"Δεκεμβρίου\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: ανώνυμος συντάκτης, other: ανώνυμοι συντάκτες]}\",\n\t\"pad.savedrevs.marked\": \"Αυτή η έκδοση επισημάνθηκε ως αποθηκευμένη έκδοση\",\n\t\"pad.savedrevs.timeslider\": \"Μπορείτε να δείτε αποθηκευμένες αναθεωρήσεις στο χρονοδιάγραμμα\",\n\t\"pad.userlist.entername\": \"Εισάγετε το όνομά σας\",\n\t\"pad.userlist.unnamed\": \"ανώνυμος\",\n\t\"pad.editbar.clearcolors\": \"Να γίνει εκκαθάριση χρωμάτων σύνταξης σε ολόκληρο το έγγραφο; Αυτό δεν μπορεί να αναιρεθεί\",\n\t\"pad.impexp.importbutton\": \"Εισαγωγή τώρα\",\n\t\"pad.impexp.importing\": \"Εισάγεται...\",\n\t\"pad.impexp.confirmimport\": \"Η εισαγωγή ενός αρχείου θα αντικαταστήσει το κείμενο του pad. Είστε βέβαιοι ότι θέλετε να συνεχίσετε;\",\n\t\"pad.impexp.convertFailed\": \"Δεν καταφέραμε να εισάγουμε αυτό το αρχείο. Παρακαλώ χρησιμοποιήστε διαφορετικό τύπο αρχείου ή αντιγράψτε και επικολλήστε χειροκίνητα\",\n\t\"pad.impexp.padHasData\": \"Δεν μπορέσαμε να εισάγουμε το αρχείο επειδή το Pad είχε ήδη αλλαγές. Παρακαλούμε εισαγάγετε το αρχείο σε νέο pad\",\n\t\"pad.impexp.uploadFailed\": \"Η μεταφόρτωση απέτυχε, παρακαλούμε προσπαθήστε ξανά\",\n\t\"pad.impexp.importfailed\": \"Η εισαγωγή απέτυχε\",\n\t\"pad.impexp.copypaste\": \"Παρακαλώ αντιγράψτε και επικολλήστε\",\n\t\"pad.impexp.exportdisabled\": \"Η εξαγωγή σε μορφή {{type}} έχει απενεργοποιηθεί. Επικοινωνήστε με τον διαχειριστή του συστήματός σας για λεπτομέρειες.\",\n\t\"pad.impexp.maxFileSize\": \"Πολύ μεγάλο αρχείο. Επικοινωνήστε με τον διαχειριστή για να αυξήσετε το επιτρεπόμενο μέγεθος αρχείου\"\n}\n"
  },
  {
    "path": "src/locales/en-gb.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Andibing\",\n\t\t\t\"Bjh21\",\n\t\t\t\"Cblair91\",\n\t\t\t\"Chase me ladies, I'm the Cavalry\",\n\t\t\t\"HairyFotr\",\n\t\t\t\"Shirayuki\"\n\t\t]\n\t},\n\t\"index.newPad\": \"New Pad\",\n\t\"index.createOpenPad\": \"or create/open a Pad with the name:\",\n\t\"pad.toolbar.bold.title\": \"Bold (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Italic (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Underline (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Strikethrough (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Ordered list (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Unordered List (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Indent (Tab)\",\n\t\"pad.toolbar.unindent.title\": \"Outdent (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Undo (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Redo (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Clear Authorship Colours (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Import/Export from/to different file formats\",\n\t\"pad.toolbar.timeslider.title\": \"Timeslider\",\n\t\"pad.toolbar.savedRevision.title\": \"Save Revision\",\n\t\"pad.toolbar.settings.title\": \"Settings\",\n\t\"pad.toolbar.embed.title\": \"Share and Embed this pad\",\n\t\"pad.toolbar.showusers.title\": \"Show the users on this pad\",\n\t\"pad.colorpicker.save\": \"Save\",\n\t\"pad.colorpicker.cancel\": \"Cancel\",\n\t\"pad.loading\": \"Loading...\",\n\t\"pad.noCookie\": \"Cookie could not be found. Please allow cookies in your browser!  Your session and settings will not be saved between visits.  This may be due to Etherpad being included in an iFrame in some Browsers.  Please ensure Etherpad is on the same subdomain/domain as the parent iFrame\",\n\t\"pad.permissionDenied\": \"You do not have permission to access this pad\",\n\t\"pad.settings.padSettings\": \"Pad Settings\",\n\t\"pad.settings.myView\": \"My View\",\n\t\"pad.settings.stickychat\": \"Chat always on screen\",\n\t\"pad.settings.chatandusers\": \"Show Chat and Users\",\n\t\"pad.settings.colorcheck\": \"Authorship colours\",\n\t\"pad.settings.linenocheck\": \"Line numbers\",\n\t\"pad.settings.rtlcheck\": \"Read content from right to left?\",\n\t\"pad.settings.fontType\": \"Font type:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Language:\",\n\t\"pad.importExport.import_export\": \"Import/Export\",\n\t\"pad.importExport.import\": \"Upload any text file or document\",\n\t\"pad.importExport.importSuccessful\": \"Successful!\",\n\t\"pad.importExport.export\": \"Export current pad as:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Plain text\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"You only can import from plain text or HTML formats. For more advanced import features please <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">install AbiWord or LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Connected.\",\n\t\"pad.modals.reconnecting\": \"Reconnecting to your pad…\",\n\t\"pad.modals.forcereconnect\": \"Force reconnect\",\n\t\"pad.modals.reconnecttimer\": \"Trying to reconnect in\",\n\t\"pad.modals.cancel\": \"Cancel\",\n\t\"pad.modals.userdup\": \"Opened in another window\",\n\t\"pad.modals.userdup.explanation\": \"This pad seems to be opened in more than one browser window on this computer.\",\n\t\"pad.modals.userdup.advice\": \"Reconnect to use this window instead.\",\n\t\"pad.modals.unauth\": \"Not authorised\",\n\t\"pad.modals.unauth.explanation\": \"Your permissions have changed while viewing this page. Try to reconnect.\",\n\t\"pad.modals.looping.explanation\": \"There are communication problems with the synchronisation server.\",\n\t\"pad.modals.looping.cause\": \"Perhaps you connected through an incompatible firewall or proxy.\",\n\t\"pad.modals.initsocketfail\": \"Server is unreachable.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Couldn't connect to the synchronisation server.\",\n\t\"pad.modals.initsocketfail.cause\": \"This is probably due to a problem with your browser or your internet connection.\",\n\t\"pad.modals.slowcommit.explanation\": \"The server is not responding.\",\n\t\"pad.modals.slowcommit.cause\": \"This could be due to problems with network connectivity.\",\n\t\"pad.modals.badChangeset.explanation\": \"An edit you have made was classified illegal by the synchronisation server.\",\n\t\"pad.modals.badChangeset.cause\": \"This could be due to a wrong server configuration or some other unexpected behaviour. Please contact the service administrator, if you feel this is an error. Try to reconnect in order to continue editing.\",\n\t\"pad.modals.corruptPad.explanation\": \"The pad you are trying to access is corrupt.\",\n\t\"pad.modals.corruptPad.cause\": \"This may be due to a wrong server configuration or some other unexpected behaviour. Please contact the service administrator.\",\n\t\"pad.modals.deleted\": \"Deleted.\",\n\t\"pad.modals.deleted.explanation\": \"This pad has been removed.\",\n\t\"pad.modals.disconnected\": \"You have been disconnected.\",\n\t\"pad.modals.disconnected.explanation\": \"The connection to the server was lost\",\n\t\"pad.modals.disconnected.cause\": \"The server may be unavailable. Please notify the service administrator if this continues to happen.\",\n\t\"pad.share\": \"Share this pad\",\n\t\"pad.share.readonly\": \"Read only\",\n\t\"pad.share.link\": \"Link\",\n\t\"pad.share.emebdcode\": \"Embed URL\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Open the chat for this pad.\",\n\t\"pad.chat.loadmessages\": \"Load more messages\",\n\t\"pad.chat.stick.title\": \"Stick chat to screen\",\n\t\"pad.chat.writeMessage.placeholder\": \"Write your message here\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Timeslider\",\n\t\"timeslider.toolbar.returnbutton\": \"Return to pad\",\n\t\"timeslider.toolbar.authors\": \"Authors:\",\n\t\"timeslider.toolbar.authorsList\": \"No Authors\",\n\t\"timeslider.toolbar.exportlink.title\": \"Export\",\n\t\"timeslider.exportCurrent\": \"Export current version as:\",\n\t\"timeslider.version\": \"Version {{version}}\",\n\t\"timeslider.saved\": \"Saved {{month}} {{day}}, {{year}}\",\n\t\"timeslider.playPause\": \"Playback/Pause Pad Contents\",\n\t\"timeslider.backRevision\": \"Go back a revision in this Pad\",\n\t\"timeslider.forwardRevision\": \"Go forward a revision in this Pad\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"January\",\n\t\"timeslider.month.february\": \"February\",\n\t\"timeslider.month.march\": \"March\",\n\t\"timeslider.month.april\": \"April\",\n\t\"timeslider.month.may\": \"May\",\n\t\"timeslider.month.june\": \"June\",\n\t\"timeslider.month.july\": \"July\",\n\t\"timeslider.month.august\": \"August\",\n\t\"timeslider.month.september\": \"September\",\n\t\"timeslider.month.october\": \"October\",\n\t\"timeslider.month.november\": \"November\",\n\t\"timeslider.month.december\": \"December\",\n\t\"timeslider.unnamedauthors\": \"{{num}} unnamed {[plural(num) one: author, other: authors ]}\",\n\t\"pad.savedrevs.marked\": \"This revision is now marked as a saved revision\",\n\t\"pad.savedrevs.timeslider\": \"You can see saved revisions by visiting the timeslider\",\n\t\"pad.userlist.entername\": \"Enter your name\",\n\t\"pad.userlist.unnamed\": \"unnamed\",\n\t\"pad.editbar.clearcolors\": \"Clear authorship colours on entire document? This cannot be undone\",\n\t\"pad.impexp.importbutton\": \"Import Now\",\n\t\"pad.impexp.importing\": \"Importing...\",\n\t\"pad.impexp.confirmimport\": \"Importing a file will overwrite the current text of the pad. Are you sure you want to proceed?\",\n\t\"pad.impexp.convertFailed\": \"We were not able to import this file. Please use a different document format or copy & paste manually\",\n\t\"pad.impexp.padHasData\": \"We were not able to import this file because this Pad has already had changes, please import to a new pad\",\n\t\"pad.impexp.uploadFailed\": \"The upload failed, please try again\",\n\t\"pad.impexp.importfailed\": \"Import failed\",\n\t\"pad.impexp.copypaste\": \"Please copy & paste\",\n\t\"pad.impexp.exportdisabled\": \"Exporting as {{type}} format is disabled. Please contact your system administrator for details.\"\n}\n"
  },
  {
    "path": "src/locales/en.json",
    "content": "{\n  \"admin.page-title\": \"Admin Dashboard - Etherpad\",\n  \"admin_plugins\": \"Plugin manager\",\n  \"admin_plugins.available\": \"Available plugins\",\n  \"admin_plugins.available_not-found\": \"No plugins found.\",\n  \"admin_plugins.available_fetching\": \"Fetching…\",\n  \"admin_plugins.available_install.value\": \"Install\",\n  \"admin_plugins.available_search.placeholder\": \"Search for plugins to install\",\n  \"admin_plugins.description\": \"Description\",\n  \"admin_plugins.installed\": \"Installed plugins\",\n  \"admin_plugins.installed_fetching\": \"Fetching installed plugins…\",\n  \"admin_plugins.installed_nothing\": \"You haven't installed any plugins yet.\",\n  \"admin_plugins.installed_uninstall.value\": \"Uninstall\",\n  \"admin_plugins.last-update\": \"Last update\",\n  \"admin_plugins.name\": \"Name\",\n  \"admin_plugins.page-title\": \"Plugin manager - Etherpad\",\n  \"admin_plugins.version\": \"Version\",\n  \"admin_plugins_info\": \"Troubleshooting information\",\n  \"admin_plugins_info.hooks\": \"Installed hooks\",\n  \"admin_plugins_info.hooks_client\": \"Client-side hooks\",\n  \"admin_plugins_info.hooks_server\": \"Server-side hooks\",\n  \"admin_plugins_info.parts\": \"Installed parts\",\n  \"admin_plugins_info.plugins\": \"Installed plugins\",\n  \"admin_plugins_info.page-title\": \"Plugin information - Etherpad\",\n  \"admin_plugins_info.version\": \"Etherpad version\",\n  \"admin_plugins_info.version_latest\": \"Latest available version\",\n  \"admin_plugins_info.version_number\": \"Version number\",\n  \"admin_settings\": \"Settings\",\n  \"admin_settings.current\": \"Current configuration\",\n  \"admin_settings.current_example-devel\": \"Example development settings template\",\n  \"admin_settings.current_example-prod\": \"Example production settings template\",\n  \"admin_settings.current_restart.value\": \"Restart Etherpad\",\n  \"admin_settings.current_save.value\": \"Save Settings\",\n  \"admin_settings.page-title\": \"Settings - Etherpad\",\n\n  \"index.newPad\": \"New Pad\",\n  \"index.settings\": \"Settings\",\n  \"index.transferSessionTitle\": \"Transfer session\",\n  \"index.receiveSessionTitle\": \"Receive session\",\n  \"index.receiveSessionDescription\": \"Here you can receive an Etherpad session from another browser or device. Please note, however, that this will delete your current session, if any.\",\n  \"index.transferSession\": \"1. Transfer session\",\n  \"index.transferSessionNow\": \"Transfer session now\",\n  \"index.copyLink\": \"2. Copy link\",\n  \"index.copyLinkDescription\": \"Click on the button below to copy the link to your clipboard.\",\n  \"index.copyLinkButton\": \"Copy link to clipboard\",\n  \"index.transferToSystem\": \"3. Copy session to new system\",\n  \"index.transferToSystemDescription\": \"Open the copied link in the target browser or device to transfer your session.\",\n  \"index.transferSessionDescription\": \"Transfer your current session to browser or device by clicking the button below. This will copy a link to a page that will transfer your session when opened in the target browser or device.\",\n  \"index.createOpenPad\": \"Open pad by name\",\n  \"index.openPad\": \"open an existing Pad with the name:\",\n  \"index.recentPads\": \"Recent Pads\",\n  \"index.recentPadsEmpty\": \"No recent pads found.\",\n  \"index.generateNewPad\": \"Generate random pad name\",\n  \"index.labelPad\": \"Pad name (optional)\",\n  \"index.placeholderPadEnter\": \"Please enter a pad name...\",\n  \"index.createAndShareDocuments\": \"Create and share documents in real time\",\n  \"index.createAndShareDocumentsDescription\": \"Etherpad allows you to edit documents collaboratively in real-time, much like a live multi-player editor that runs in your browser.\",\n\n\n  \"pad.toolbar.bold.title\": \"Bold (Ctrl+B)\",\n  \"pad.toolbar.italic.title\": \"Italic (Ctrl+I)\",\n  \"pad.toolbar.underline.title\": \"Underline (Ctrl+U)\",\n  \"pad.toolbar.strikethrough.title\": \"Strikethrough (Ctrl+5)\",\n  \"pad.toolbar.ol.title\": \"Ordered list (Ctrl+Shift+N)\",\n  \"pad.toolbar.ul.title\": \"Unordered List (Ctrl+Shift+L)\",\n  \"pad.toolbar.indent.title\": \"Indent (TAB)\",\n  \"pad.toolbar.unindent.title\": \"Outdent (Shift+TAB)\",\n  \"pad.toolbar.undo.title\": \"Undo (Ctrl+Z)\",\n  \"pad.toolbar.redo.title\": \"Redo (Ctrl+Y)\",\n  \"pad.toolbar.clearAuthorship.title\": \"Clear Authorship Colors (Ctrl+Shift+C)\",\n  \"pad.toolbar.import_export.title\": \"Import/Export from/to different file formats\",\n  \"pad.toolbar.timeslider.title\": \"Timeslider\",\n  \"pad.toolbar.savedRevision.title\": \"Save Revision\",\n  \"pad.toolbar.settings.title\": \"Settings\",\n  \"pad.toolbar.embed.title\": \"Share and Embed this pad\",\n  \"pad.toolbar.home.title\": \"Back to home\",\n  \"pad.toolbar.showusers.title\": \"Show the users on this pad\",\n\n  \"pad.colorpicker.save\": \"Save\",\n  \"pad.colorpicker.cancel\": \"Cancel\",\n\n  \"pad.loading\": \"Loading...\",\n  \"pad.noCookie\": \"Cookie could not be found. Please allow cookies in your browser!  Your session and settings will not be saved between visits.  This may be due to Etherpad being included in an iFrame in some Browsers.  Please ensure Etherpad is on the same subdomain/domain as the parent iFrame\",\n  \"pad.permissionDenied\": \"You do not have permission to access this pad\",\n\n  \"pad.settings.padSettings\": \"Pad Settings\",\n  \"pad.settings.myView\": \"My View\",\n  \"pad.settings.stickychat\": \"Chat always on screen\",\n  \"pad.settings.chatandusers\": \"Show Chat and Users\",\n  \"pad.settings.colorcheck\": \"Authorship colors\",\n  \"pad.settings.linenocheck\": \"Line numbers\",\n  \"pad.settings.rtlcheck\": \"Read content from right to left?\",\n  \"pad.settings.fontType\": \"Font type:\",\n  \"pad.settings.fontType.normal\": \"Normal\",\n  \"pad.settings.language\": \"Language:\",\n  \"pad.settings.deletePad\": \"Delete Pad\",\n  \"pad.delete.confirm\": \"Do you really want to delete this pad?\",\n  \"pad.settings.about\": \"About\",\n  \"pad.settings.poweredBy\": \"Powered by\",\n\n  \"pad.importExport.import_export\": \"Import/Export\",\n  \"pad.importExport.import\": \"Upload any text file or document\",\n  \"pad.importExport.importSuccessful\": \"Successful!\",\n  \"pad.importExport.export\": \"Export current pad as:\",\n  \"pad.importExport.exportetherpad\": \"Etherpad\",\n  \"pad.importExport.exporthtml\": \"HTML\",\n  \"pad.importExport.exportplain\": \"Plain text\",\n  \"pad.importExport.exportword\": \"Microsoft Word\",\n  \"pad.importExport.exportpdf\": \"PDF\",\n  \"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n  \"pad.importExport.abiword.innerHTML\": \"You only can import from plain text or HTML formats. For more advanced import features please <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">install AbiWord or LibreOffice</a>.\",\n\n  \"pad.modals.connected\": \"Connected.\",\n  \"pad.modals.reconnecting\": \"Reconnecting to your pad…\",\n  \"pad.modals.forcereconnect\": \"Force reconnect\",\n  \"pad.modals.reconnecttimer\": \"Trying to reconnect in\",\n  \"pad.modals.cancel\": \"Cancel\",\n\n  \"pad.modals.userdup\": \"Opened in another window\",\n  \"pad.modals.userdup.explanation\": \"This pad seems to be opened in more than one browser window on this computer.\",\n  \"pad.modals.userdup.advice\": \"Reconnect to use this window instead.\",\n\n  \"pad.modals.unauth\": \"Not authorized\",\n  \"pad.modals.unauth.explanation\": \"Your permissions have changed while viewing this page. Try to reconnect.\",\n\n  \"pad.modals.looping.explanation\": \"There are communication problems with the synchronization server.\",\n  \"pad.modals.looping.cause\": \"Perhaps you connected through an incompatible firewall or proxy.\",\n\n  \"pad.modals.initsocketfail\": \"Server is unreachable.\",\n  \"pad.modals.initsocketfail.explanation\": \"Couldn't connect to the synchronization server.\",\n  \"pad.modals.initsocketfail.cause\": \"This is probably due to a problem with your browser or your internet connection.\",\n\n  \"pad.modals.slowcommit.explanation\": \"The server is not responding.\",\n  \"pad.modals.slowcommit.cause\": \"This could be due to problems with network connectivity.\",\n\n  \"pad.modals.badChangeset.explanation\": \"An edit you have made was classified illegal by the synchronization server.\",\n  \"pad.modals.badChangeset.cause\": \"This could be due to a wrong server configuration or some other unexpected behavior. Please contact the service administrator, if you feel this is an error. Try to reconnect in order to continue editing.\",\n\n  \"pad.modals.corruptPad.explanation\": \"The pad you are trying to access is corrupt.\",\n  \"pad.modals.corruptPad.cause\": \"This may be due to a wrong server configuration or some other unexpected behavior. Please contact the service administrator.\",\n\n  \"pad.modals.deleted\": \"Deleted.\",\n  \"pad.modals.deleted.explanation\": \"This pad has been removed.\",\n\n  \"pad.modals.rateLimited\": \"Rate Limited.\",\n  \"pad.modals.rateLimited.explanation\": \"You sent too many messages to this pad so it disconnected you.\",\n\n  \"pad.modals.rejected.explanation\": \"The server rejected a message that was sent by your browser.\",\n  \"pad.modals.rejected.cause\": \"The server may have been updated while you were viewing the pad, or maybe there is a bug in Etherpad. Try reloading the page.\",\n\n  \"pad.modals.disconnected\": \"You have been disconnected.\",\n  \"pad.modals.disconnected.explanation\": \"The connection to the server was lost\",\n  \"pad.modals.disconnected.cause\": \"The server may be unavailable. Please notify the service administrator if this continues to happen.\",\n\n  \"pad.share\": \"Share this pad\",\n  \"pad.share.readonly\": \"Read only\",\n  \"pad.share.link\": \"Link\",\n  \"pad.share.emebdcode\": \"Embed URL\",\n  \"pad.chat\": \"Chat\",\n  \"pad.chat.title\": \"Open the chat for this pad.\",\n  \"pad.chat.loadmessages\": \"Load more messages\",\n  \"pad.chat.stick.title\": \"Stick chat to screen\",\n  \"pad.chat.writeMessage.placeholder\": \"Write your message here\",\n\n  \"timeslider.followContents\": \"Follow pad content updates\",\n  \"timeslider.pageTitle\": \"{{appTitle}} Timeslider\",\n  \"timeslider.toolbar.returnbutton\": \"Return to pad\",\n  \"timeslider.toolbar.authors\": \"Authors:\",\n  \"timeslider.toolbar.authorsList\": \"No Authors\",\n  \"timeslider.toolbar.exportlink.title\": \"Export\",\n  \"timeslider.exportCurrent\": \"Export current version as:\",\n  \"timeslider.version\": \"Version {{version}}\",\n  \"timeslider.saved\": \"Saved {{month}} {{day}}, {{year}}\",\n\n  \"timeslider.playPause\": \"Playback / Pause Pad Contents\",\n  \"timeslider.backRevision\":\"Go back a revision in this Pad\",\n  \"timeslider.forwardRevision\":\"Go forward a revision in this Pad\",\n\n  \"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n  \"timeslider.month.january\": \"January\",\n  \"timeslider.month.february\": \"February\",\n  \"timeslider.month.march\": \"March\",\n  \"timeslider.month.april\": \"April\",\n  \"timeslider.month.may\": \"May\",\n  \"timeslider.month.june\": \"June\",\n  \"timeslider.month.july\": \"July\",\n  \"timeslider.month.august\": \"August\",\n  \"timeslider.month.september\": \"September\",\n  \"timeslider.month.october\": \"October\",\n  \"timeslider.month.november\": \"November\",\n  \"timeslider.month.december\": \"December\",\n\n  \"timeslider.unnamedauthors\": \"{{num}} unnamed {[plural(num) one: author, other: authors ]}\",\n  \"pad.savedrevs.marked\": \"This revision is now marked as a saved revision\",\n  \"pad.savedrevs.timeslider\": \"You can see saved revisions by visiting the timeslider\",\n  \"pad.userlist.entername\": \"Enter your name\",\n  \"pad.userlist.unnamed\": \"unnamed\",\n  \"pad.editbar.clearcolors\": \"Clear authorship colors on entire document? This cannot be undone\",\n\n  \"pad.impexp.importbutton\": \"Import Now\",\n  \"pad.impexp.importing\": \"Importing...\",\n  \"pad.impexp.confirmimport\": \"Importing a file will overwrite the current text of the pad. Are you sure you want to proceed?\",\n  \"pad.impexp.convertFailed\": \"We were not able to import this file. Please use a different document format or copy paste manually\",\n  \"pad.impexp.padHasData\": \"We were not able to import this file because this Pad has already had changes, please import to a new pad\",\n  \"pad.impexp.uploadFailed\": \"The upload failed, please try again\",\n  \"pad.impexp.importfailed\": \"Import failed\",\n  \"pad.impexp.copypaste\": \"Please copy paste\",\n  \"pad.impexp.exportdisabled\": \"Exporting as {{type}} format is disabled. Please contact your system administrator for details.\",\n  \"pad.impexp.maxFileSize\": \"File too big. Contact your site administrator to increase the allowed file size for import\"\n}\n"
  },
  {
    "path": "src/locales/eo.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Eliovir\",\n\t\t\t\"Mirin\",\n\t\t\t\"Mschmitt\",\n\t\t\t\"Objectivesea\",\n\t\t\t\"Robin van der Vliet\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Nova Teksto\",\n\t\"index.createOpenPad\": \"aŭ krei/malfermi novan tekston kun la nomo:\",\n\t\"pad.toolbar.bold.title\": \"Grasa (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Kursiva (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Substrekita (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Trastrekita (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Ordigita listo (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Neordigita Listo (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Enŝovi (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Elŝovi (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Malfari (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Refari (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Forigi kolorojn de aŭtoreco (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Enporti/elporti de/al aliaj dosierformatoj\",\n\t\"pad.toolbar.timeslider.title\": \"Tempoŝovilo\",\n\t\"pad.toolbar.savedRevision.title\": \"Konservi version\",\n\t\"pad.toolbar.settings.title\": \"Agordoj\",\n\t\"pad.toolbar.embed.title\": \"Kunhavigi kaj enigi ĉi tiun tekston\",\n\t\"pad.toolbar.showusers.title\": \"Montri la redaktantojn sur ĉi tiu teksto\",\n\t\"pad.colorpicker.save\": \"Konservi\",\n\t\"pad.colorpicker.cancel\": \"Nuligi\",\n\t\"pad.loading\": \"Ŝargante...\",\n\t\"pad.noCookie\": \"Kuketo ne estis trovigebla. Bonvolu permesi kuketojn en via retumilo!\",\n\t\"pad.permissionDenied\": \"Vi ne havas permeson por aliri ĉi tiun tekston\",\n\t\"pad.settings.padSettings\": \"Redaktilaj Agordoj\",\n\t\"pad.settings.myView\": \"Mia vido\",\n\t\"pad.settings.stickychat\": \"Babilejo ĉiam videbla\",\n\t\"pad.settings.chatandusers\": \"Montri babilejon kaj uzantojn\",\n\t\"pad.settings.colorcheck\": \"Koloroj de aŭtoreco\",\n\t\"pad.settings.linenocheck\": \"Liniaj nombroj\",\n\t\"pad.settings.rtlcheck\": \"Legi dekstre-maldekstren?\",\n\t\"pad.settings.fontType\": \"Tiparo:\",\n\t\"pad.settings.fontType.normal\": \"Normala\",\n\t\"pad.settings.language\": \"Lingvo:\",\n\t\"pad.importExport.import_export\": \"Enporti/Elporti\",\n\t\"pad.importExport.import\": \"Alŝuti ajnan dosieron aŭ dokumenton\",\n\t\"pad.importExport.importSuccessful\": \"Sukceso!\",\n\t\"pad.importExport.export\": \"Elporti la nunan tekston kiel:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Plata teksto\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Formato “OpenDocument”)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Nur kapablas enporti de plata teksto aŭ HTML. Por pli speciala importkapablo, bonvolu <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">instalu la programon, Abiword</a>.\",\n\t\"pad.modals.connected\": \"Konektita.\",\n\t\"pad.modals.reconnecting\": \"Rekonektanta al via redaktilo..\",\n\t\"pad.modals.forcereconnect\": \"Perforte rekonekti\",\n\t\"pad.modals.reconnecttimer\": \"Provos rekonekti post\",\n\t\"pad.modals.cancel\": \"Nuligi\",\n\t\"pad.modals.userdup\": \"Malfermita en alia fenestro\",\n\t\"pad.modals.userdup.explanation\": \"Ĉi tiu teksto ŝajne estas malferma en pli ol unu retumilo sur ĉi tiu komputilo.\",\n\t\"pad.modals.userdup.advice\": \"Rekonekti por anstataŭe uzi ĉi tiun fenestron.\",\n\t\"pad.modals.unauth\": \"Ne permesita\",\n\t\"pad.modals.unauth.explanation\": \"Viaj permesoj ŝanĝis dum kiam vi rigardis ĉi tiun paĝon. Provu rekonekti.\",\n\t\"pad.modals.looping.explanation\": \"Okazas problemoj dum komunikado kun la sinkronigservilo.\",\n\t\"pad.modals.looping.cause\": \"Eble vi konektis per malkongrua fajroŝirmilo aŭ retperanto.\",\n\t\"pad.modals.initsocketfail\": \"Servilo ne estas atingebla.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Ne kapablis konekti al la sinkronigservilo.\",\n\t\"pad.modals.initsocketfail.cause\": \"Tio verŝajne okazas pro problemo kun via retumilo aŭ via retkonekto.\",\n\t\"pad.modals.slowcommit.explanation\": \"La servilo ne respondas.\",\n\t\"pad.modals.slowcommit.cause\": \"Tio eble estas pro problemoj kun retkonekto.\",\n\t\"pad.modals.badChangeset.explanation\": \"La sinkronigservilo decidis ke redakto de vi estas malpermesita.\",\n\t\"pad.modals.badChangeset.cause\": \"Tio eble okazis pro malĝustaj agordoj aŭ alia neatendita teniĝo sur la servilo. Se vi pensas ke estas eraro, bonvolu kontakti la servoadminstranton. Provu rekonekti por denove redakti.\",\n\t\"pad.modals.corruptPad.explanation\": \"La teksto kiun vi provas atingi estas difekta.\",\n\t\"pad.modals.corruptPad.cause\": \"Tio eble okazis pro malĝustaj agordoj aŭ alia neatendita teniĝo sur la servilo. Bonvolu kontakti la servoadminstranton.\",\n\t\"pad.modals.deleted\": \"Forigita.\",\n\t\"pad.modals.deleted.explanation\": \"Ĉi tiu teksto estis forigita.\",\n\t\"pad.modals.disconnected\": \"Vi estas malkonektita.\",\n\t\"pad.modals.disconnected.explanation\": \"La konekto al la servilo perdiĝis\",\n\t\"pad.modals.disconnected.cause\": \"Eble la servilo ne estas disponebla. Bonvolu kontakti la servoadministranton se tio daŭre okazas.\",\n\t\"pad.share\": \"Kunhavigi ĉi tiun tekston\",\n\t\"pad.share.readonly\": \"Nur legebla\",\n\t\"pad.share.link\": \"Ligilo\",\n\t\"pad.share.emebdcode\": \"Enfiksi URL-on\",\n\t\"pad.chat\": \"Babilejo\",\n\t\"pad.chat.title\": \"Malfermi la babilejon por ĉi tiu teksto.\",\n\t\"pad.chat.loadmessages\": \"Ŝargi pliajn mesaĝojn\",\n\t\"pad.chat.stick.title\": \"Alpingli babilejon al ekrano\",\n\t\"pad.chat.writeMessage.placeholder\": \"Verki vian mesaĝon ĉi tie\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Tempoŝovilo\",\n\t\"timeslider.toolbar.returnbutton\": \"Reiri al teksto\",\n\t\"timeslider.toolbar.authors\": \"Aŭtoroj:\",\n\t\"timeslider.toolbar.authorsList\": \"Neniu aŭtoro\",\n\t\"timeslider.toolbar.exportlink.title\": \"Elporti\",\n\t\"timeslider.exportCurrent\": \"Elporti la nunan version kiel:\",\n\t\"timeslider.version\": \"Versio {{version}}\",\n\t\"timeslider.saved\": \"Konservita la {{day}}an de {{month}}, {{year}}\",\n\t\"timeslider.playPause\": \"Ludi / paŭzi la enhavojn de la teksto\",\n\t\"timeslider.backRevision\": \"Reiri unu version en ĉi tiu teksto\",\n\t\"timeslider.forwardRevision\": \"Antaŭeniri unu version en ĉi tiu teksto\",\n\t\"timeslider.dateformat\": \"{{day}}-{{month}}-{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"januaro\",\n\t\"timeslider.month.february\": \"februaro\",\n\t\"timeslider.month.march\": \"marto\",\n\t\"timeslider.month.april\": \"aprilo\",\n\t\"timeslider.month.may\": \"majo\",\n\t\"timeslider.month.june\": \"junio\",\n\t\"timeslider.month.july\": \"julio\",\n\t\"timeslider.month.august\": \"aŭgusto\",\n\t\"timeslider.month.september\": \"septembro\",\n\t\"timeslider.month.october\": \"oktobro\",\n\t\"timeslider.month.november\": \"novembro\",\n\t\"timeslider.month.december\": \"decembro\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: sennoma aŭtoro, other: sennomaj aŭtoroj ]}\",\n\t\"pad.savedrevs.marked\": \"Ĉi tiu versio nun estas markita kiel konservita versio\",\n\t\"pad.savedrevs.timeslider\": \"Vi povas rigardi konservitajn versiojn per la tempoŝovilo\",\n\t\"pad.userlist.entername\": \"Entajpu vian nomon\",\n\t\"pad.userlist.unnamed\": \"sennoma\",\n\t\"pad.editbar.clearcolors\": \"Forigi kolorojn de aŭtoreco en la tuta dokumento?\",\n\t\"pad.impexp.importbutton\": \"Enporti Nun\",\n\t\"pad.impexp.importing\": \"Enportante...\",\n\t\"pad.impexp.confirmimport\": \"Enporti dosieron superskribos la nunan tekston en la redaktilo. Ĉu vi certe volas daŭrigi?\",\n\t\"pad.impexp.convertFailed\": \"Ni ne kapablis enporti tiun dosieron. Bonvolu uzi alian dokumentformaton aŭ permane kopii kaj alglui.\",\n\t\"pad.impexp.padHasData\": \"Ni ne kapablis enporti tiun dosieron ĉar la teksto jam estas ŝanĝita. Bonvolu enporti en novan tekston.\",\n\t\"pad.impexp.uploadFailed\": \"La alŝuto malsukcesis, bonvolu provi denove.\",\n\t\"pad.impexp.importfailed\": \"Enporti malsukcesis.\",\n\t\"pad.impexp.copypaste\": \"Bonvolu kopii kaj alglui\",\n\t\"pad.impexp.exportdisabled\": \"Elporti en {{type}} formato estas malŝalta. Bonvolu kontakti la sistremadministranton pro pliaj informoj.\"\n}\n"
  },
  {
    "path": "src/locales/es.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Armando-Martin\",\n\t\t\t\"Atzerritik\",\n\t\t\t\"DDPAT\",\n\t\t\t\"Dgstranz\",\n\t\t\t\"Fitoschido\",\n\t\t\t\"Ice bulldog\",\n\t\t\t\"Jacobo\",\n\t\t\t\"Joker\",\n\t\t\t\"Larjona\",\n\t\t\t\"Luzcaru\",\n\t\t\t\"Macofe\",\n\t\t\t\"MartaEgea\",\n\t\t\t\"Mklehr\",\n\t\t\t\"Ovruni\",\n\t\t\t\"Rubenwap\",\n\t\t\t\"Tiberius1701\",\n\t\t\t\"VegaDark\",\n\t\t\t\"Vivaelcelta\",\n\t\t\t\"Xuacu\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Panel administrativo. Etherpad\",\n\t\"admin_plugins\": \"Gestor de complementos\",\n\t\"admin_plugins.available\": \"Complementos disponibles\",\n\t\"admin_plugins.available_not-found\": \"No se encontró ningún complemento.\",\n\t\"admin_plugins.available_fetching\": \"Recuperando…\",\n\t\"admin_plugins.available_install.value\": \"Instalar\",\n\t\"admin_plugins.available_search.placeholder\": \"Buscar complementos para instalar\",\n\t\"admin_plugins.description\": \"Descripción\",\n\t\"admin_plugins.installed\": \"Complementos instalados\",\n\t\"admin_plugins.installed_fetching\": \"Recuperando los complementos instalados…\",\n\t\"admin_plugins.installed_nothing\": \"No se ha instalado ningún complemento aún.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Desinstalar\",\n\t\"admin_plugins.last-update\": \"Última actualización\",\n\t\"admin_plugins.name\": \"Nombre\",\n\t\"admin_plugins.page-title\": \"Gestor de complementos. Etherpad\",\n\t\"admin_plugins.version\": \"Versión\",\n\t\"admin_plugins_info\": \"Información para solucionar problemas\",\n\t\"admin_plugins_info.hooks\": \"Actuadores instalados\",\n\t\"admin_plugins_info.hooks_client\": \"Actuadores del lado cliente\",\n\t\"admin_plugins_info.hooks_server\": \"Actuadores del lado servidor\",\n\t\"admin_plugins_info.parts\": \"Partes instaladas\",\n\t\"admin_plugins_info.plugins\": \"Complementos instalados\",\n\t\"admin_plugins_info.page-title\": \"Información del complemento. Etherpad\",\n\t\"admin_plugins_info.version\": \"Versión de Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Versión más reciente disponible\",\n\t\"admin_plugins_info.version_number\": \"Número de versión\",\n\t\"admin_settings\": \"Configuración\",\n\t\"admin_settings.current\": \"Configuración actual\",\n\t\"admin_settings.current_example-devel\": \"Plantilla de ejemplo de configuración de desarrollo\",\n\t\"admin_settings.current_example-prod\": \"Ejemplo de plantilla de configuración de producción\",\n\t\"admin_settings.current_restart.value\": \"Reiniciar Etherpad\",\n\t\"admin_settings.current_save.value\": \"Guardar configuración\",\n\t\"admin_settings.page-title\": \"Configuración. Etherpad\",\n\t\"index.newPad\": \"Nuevo pad\",\n\t\"index.createOpenPad\": \"o crea/abre un pad con el nombre:\",\n\t\"index.openPad\": \"abrir un pad existente con el nombre:\",\n\t\"pad.toolbar.bold.title\": \"Negrita (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Cursiva (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Subrayado (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Tachado (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Lista ordenada (Ctrl+Mayús+N)\",\n\t\"pad.toolbar.ul.title\": \"Lista desordenada (Ctrl+Mayús+L)\",\n\t\"pad.toolbar.indent.title\": \"Sangría (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Eliminar sangría (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Deshacer (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Rehacer (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Eliminar los colores de autoría (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importar/Exportar a diferentes formatos de archivos\",\n\t\"pad.toolbar.timeslider.title\": \"Línea de tiempo\",\n\t\"pad.toolbar.savedRevision.title\": \"Guardar revisión\",\n\t\"pad.toolbar.settings.title\": \"Configuración\",\n\t\"pad.toolbar.embed.title\": \"Compartir e incrustar este pad\",\n\t\"pad.toolbar.showusers.title\": \"Mostrar los usuarios de este pad\",\n\t\"pad.colorpicker.save\": \"Guardar\",\n\t\"pad.colorpicker.cancel\": \"Cancelar\",\n\t\"pad.loading\": \"Cargando...\",\n\t\"pad.noCookie\": \"No se pudo encontrar la galleta. ¡Por favor, permita las cookies en su navegador! Su sesión y configuración no se guardarán entre las visitas. Esto puede deberse a que Etherpad está incluido en un iFrame en algunos navegadores. Por favor asegúrese de que Etherpad está en el mismo subdominio/dominio que el iFrame padre\",\n\t\"pad.permissionDenied\": \"No tienes permiso para acceder a este pad\",\n\t\"pad.settings.padSettings\": \"Configuración del pad\",\n\t\"pad.settings.myView\": \"Preferencias personales\",\n\t\"pad.settings.stickychat\": \"Chat siempre en pantalla\",\n\t\"pad.settings.chatandusers\": \"Mostrar el chat y los usuarios\",\n\t\"pad.settings.colorcheck\": \"Colores de autoría\",\n\t\"pad.settings.linenocheck\": \"Números de línea\",\n\t\"pad.settings.rtlcheck\": \"¿Leer contenido de derecha a izquierda?\",\n\t\"pad.settings.fontType\": \"Tipografía:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Idioma:\",\n\t\"pad.settings.deletePad\": \"Eliminar pad\",\n\t\"pad.delete.confirm\": \"¿De verdad quieres borrar este pad?\",\n\t\"pad.settings.about\": \"Acerca de\",\n\t\"pad.settings.poweredBy\": \"Funciona con\",\n\t\"pad.importExport.import_export\": \"Importar/Exportar\",\n\t\"pad.importExport.import\": \"Subir cualquier texto o documento\",\n\t\"pad.importExport.importSuccessful\": \"¡Éxito!\",\n\t\"pad.importExport.export\": \"Exporta el pad actual como:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Texto sin formato\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Solo se puede importar desde texto plano o formatos HTML. Para obtener funciones de importación más avanzadas, <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">instale AbiWord o LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Conectado.\",\n\t\"pad.modals.reconnecting\": \"Reconectando a tu pad...\",\n\t\"pad.modals.forcereconnect\": \"Forzar reconexión\",\n\t\"pad.modals.reconnecttimer\": \"Se intentará reconectar en\",\n\t\"pad.modals.cancel\": \"Cancelar\",\n\t\"pad.modals.userdup\": \"Abierto en otra ventana\",\n\t\"pad.modals.userdup.explanation\": \"Este pad parece estar abierto en más de una ventana de tu navegador.\",\n\t\"pad.modals.userdup.advice\": \"Reconectar para usar esta ventana.\",\n\t\"pad.modals.unauth\": \"No autorizado.\",\n\t\"pad.modals.unauth.explanation\": \"Tus permisos han cambiado mientras estabas viendo esta página. Intenta reconectarte.\",\n\t\"pad.modals.looping.explanation\": \"Hay problemas con el servidor de sincronización.\",\n\t\"pad.modals.looping.cause\": \"Puede deberse a que te conectes a través de un proxy o un cortafuegos incompatible.\",\n\t\"pad.modals.initsocketfail\": \"Servidor incalcanzable.\",\n\t\"pad.modals.initsocketfail.explanation\": \"No se pudo conectar con el servidor de sincronización.\",\n\t\"pad.modals.initsocketfail.cause\": \"Probablemente debido a un problema en tu navegador o en tu conexión a Internet.\",\n\t\"pad.modals.slowcommit.explanation\": \"El servidor no responde.\",\n\t\"pad.modals.slowcommit.cause\": \"Puede deberse a problemas con tu conexión de red.\",\n\t\"pad.modals.badChangeset.explanation\": \"Has hecho una edición clasificada como ilegal por el servidor de sincronización.\",\n\t\"pad.modals.badChangeset.cause\": \"Esto podría deberse a una mala configuración del servidor o algún otro comportamiento inesperado. Contacta con administrador del servicio, si piensas que esto es un error. Intenta volver a conectar para continuar editando.\",\n\t\"pad.modals.corruptPad.explanation\": \"El pad que intentas acceder está dañado.\",\n\t\"pad.modals.corruptPad.cause\": \"Esto puede deberse a una mala configuración del servidor o algún otro comportamiento inesperado. Contacta con el administrador del servicio.\",\n\t\"pad.modals.deleted\": \"Borrado.\",\n\t\"pad.modals.deleted.explanation\": \"Este pad ha sido borrado.\",\n\t\"pad.modals.rateLimited\": \"Límite de solicitudes.\",\n\t\"pad.modals.rateLimited.explanation\": \"Enviaste demasiados mensajes a este pad por lo que te desconectó.\",\n\t\"pad.modals.rejected.explanation\": \"El servidor rechazó un mensaje que envió tu navegador.\",\n\t\"pad.modals.rejected.cause\": \"Es posible que el servidor se haya actualizado mientras veías el panel, o que haya un error en Etherpad. Intenta recargar la página.\",\n\t\"pad.modals.disconnected\": \"Te has desconectado.\",\n\t\"pad.modals.disconnected.explanation\": \"Se perdió la conexión con el servidor\",\n\t\"pad.modals.disconnected.cause\": \"El servidor podría no estar disponible. Contacta con el administrador del servicio si esto continúa sucediendo.\",\n\t\"pad.share\": \"Compatir este pad\",\n\t\"pad.share.readonly\": \"Solo lectura\",\n\t\"pad.share.link\": \"Enlace\",\n\t\"pad.share.emebdcode\": \"Incrustar URL\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Abrir el chat para este pad.\",\n\t\"pad.chat.loadmessages\": \"Cargar más mensajes\",\n\t\"pad.chat.stick.title\": \"Ampliar\",\n\t\"pad.chat.writeMessage.placeholder\": \"Enviar un mensaje\",\n\t\"timeslider.followContents\": \"Sigue las actualizaciones de contenido del pad\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Línea de tiempo\",\n\t\"timeslider.toolbar.returnbutton\": \"Volver al pad\",\n\t\"timeslider.toolbar.authors\": \"Autores:\",\n\t\"timeslider.toolbar.authorsList\": \"Sin autores\",\n\t\"timeslider.toolbar.exportlink.title\": \"Exportar\",\n\t\"timeslider.exportCurrent\": \"Exportar la versión actual como:\",\n\t\"timeslider.version\": \"Versión {{version}}\",\n\t\"timeslider.saved\": \"Guardado el {{day}} de {{month}} de {{year}}\",\n\t\"timeslider.playPause\": \"Reproducir/pausar los contenidos del pad\",\n\t\"timeslider.backRevision\": \"Ir a la revisión anterior en este pad\",\n\t\"timeslider.forwardRevision\": \"Ir a la revisión posterior en este pad\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"enero\",\n\t\"timeslider.month.february\": \"febrero\",\n\t\"timeslider.month.march\": \"marzo\",\n\t\"timeslider.month.april\": \"abril\",\n\t\"timeslider.month.may\": \"mayo\",\n\t\"timeslider.month.june\": \"junio\",\n\t\"timeslider.month.july\": \"julio\",\n\t\"timeslider.month.august\": \"agosto\",\n\t\"timeslider.month.september\": \"septiembre\",\n\t\"timeslider.month.october\": \"octubre\",\n\t\"timeslider.month.november\": \"noviembre\",\n\t\"timeslider.month.december\": \"diciembre\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: autor desconocido, other: autores desconocidos]}\",\n\t\"pad.savedrevs.marked\": \"Revisión guardada\",\n\t\"pad.savedrevs.timeslider\": \"Puedes ver revisiones guardadas visitando la línea de tiempo\",\n\t\"pad.userlist.entername\": \"Escribe tu nombre\",\n\t\"pad.userlist.unnamed\": \"anónimo\",\n\t\"pad.editbar.clearcolors\": \"¿Desea borrar los colores de autoría en todo el documento? Esto no se puede deshacer\",\n\t\"pad.impexp.importbutton\": \"Importar ahora\",\n\t\"pad.impexp.importing\": \"Importando...\",\n\t\"pad.impexp.confirmimport\": \"Al importar un archivo se borrará el contenido actual del pad. ¿Estás seguro de que quieres continuar?\",\n\t\"pad.impexp.convertFailed\": \"No pudimos importar este archivo. Inténtalo con un formato diferente o copia y pega manualmente.\",\n\t\"pad.impexp.padHasData\": \"No hemos podido importar este archivo porque este pad ya ha tenido cambios. Importa a un nuevo pad.\",\n\t\"pad.impexp.uploadFailed\": \"El envío falló. Inténtalo de nuevo.\",\n\t\"pad.impexp.importfailed\": \"Fallo al importar\",\n\t\"pad.impexp.copypaste\": \"Intenta copiar y pegar\",\n\t\"pad.impexp.exportdisabled\": \"La exportación al formato {{type}} está desactivada. Contacta con tu administrador del sistema.\",\n\t\"pad.impexp.maxFileSize\": \"El archivo es demasiado grande. Contacte al administrador del sitio para aumentar el tamaño de archivo permitido para la importación.\"\n}\n"
  },
  {
    "path": "src/locales/et.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Kristian.kankainen\",\n\t\t\t\"Tiblu\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Uus klade\",\n\t\"index.createOpenPad\": \"loo või rööptoimeta kladet nimega:\",\n\t\"pad.toolbar.bold.title\": \"Rasvane (Ctrl + B)\",\n\t\"pad.toolbar.italic.title\": \"Kaldkiri (Ctrl + I)\",\n\t\"pad.toolbar.underline.title\": \"Allakriipsutus (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Läbikriipsutus\",\n\t\"pad.toolbar.ol.title\": \"Nummerdatud loend\",\n\t\"pad.toolbar.ul.title\": \"Täppidega loend\",\n\t\"pad.toolbar.indent.title\": \"Suurenda taanet (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Vähenda taanet (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Võta tagasi (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Tee uuesti (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Kustuta eri autorite värvid\",\n\t\"pad.toolbar.import_export.title\": \"Impordi-ekspordi eri failivormingutesse\",\n\t\"pad.toolbar.timeslider.title\": \"Ajajoon\",\n\t\"pad.toolbar.savedRevision.title\": \"Salvesta versioon\",\n\t\"pad.toolbar.settings.title\": \"Seaded\",\n\t\"pad.toolbar.embed.title\": \"Jaga ja põimi seda kladet\",\n\t\"pad.toolbar.showusers.title\": \"Näita klade kasutajaid\",\n\t\"pad.colorpicker.save\": \"Salvesta\",\n\t\"pad.colorpicker.cancel\": \"Loobu\",\n\t\"pad.loading\": \"Laadimine...\",\n\t\"pad.permissionDenied\": \"Sul puuduvad ligipääsuõigused selle klade rööptoimetamiseks\",\n\t\"pad.settings.padSettings\": \"Klade seadistused\",\n\t\"pad.settings.myView\": \"Minu vaade\",\n\t\"pad.settings.stickychat\": \"Näita vestlust alatiselt ekraanil\",\n\t\"pad.settings.colorcheck\": \"Autorite värvid\",\n\t\"pad.settings.linenocheck\": \"Reanumbrid\",\n\t\"pad.settings.rtlcheck\": \"Näita sisu paremalt vasakule?\",\n\t\"pad.settings.fontType\": \"Šrifti tüüp:\",\n\t\"pad.settings.fontType.normal\": \"Normaalne\",\n\t\"pad.settings.language\": \"Keel:\",\n\t\"pad.importExport.import_export\": \"Import-eksport\",\n\t\"pad.importExport.import\": \"Laadi üles mistahes tekstifail või dokument\",\n\t\"pad.importExport.importSuccessful\": \"Edukalt laaditud!\",\n\t\"pad.importExport.export\": \"Ekspordi käesolev klade kui:\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Lihttekst\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Paraku on ainult lihttekstis voi HTML-vormingus dokumentide importimine võimaldatud. Rohkem võimaluste jaoks peab <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">paigaldama abiword</a>.\",\n\t\"pad.modals.connected\": \"Ühendatud.\",\n\t\"pad.modals.reconnecting\": \"Proovitakse luua ühendus klade juurde...\",\n\t\"pad.modals.forcereconnect\": \"Sunni ühenduse taasloomist\",\n\t\"pad.modals.userdup\": \"Käesolev klade on avatud teises aknas\",\n\t\"pad.modals.userdup.explanation\": \"Käesolev klade paistab olevat avatud rohkem kui ühes brauseriaknas selles arvutis.\",\n\t\"pad.modals.userdup.advice\": \"Kasuta käesolevat akent teiste asemel.\",\n\t\"pad.modals.unauth\": \"Pole lubatud\",\n\t\"pad.modals.unauth.explanation\": \"Sinu ligipääsuõigused on muutunud. Proovi ühendada uuesti.\",\n\t\"pad.modals.looping.explanation\": \"Serveriga sünkroniseerimine tundub olevat takistatud.\",\n\t\"pad.modals.looping.cause\": \"Äkki oled ühendatud tulemüüri või puhverserveri kaudu?\",\n\t\"pad.modals.initsocketfail\": \"Server pole kättesaadaval.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Ei saadud ühendust sünkroniseerimisserveriga.\",\n\t\"pad.modals.initsocketfail.cause\": \"See on tõenäoliselt su brauserist või internetiühendusest tingitud.\",\n\t\"pad.modals.slowcommit.explanation\": \"Server ei vasta.\",\n\t\"pad.modals.slowcommit.cause\": \"See on tõenäoliselt võrguühendusest tingitud.\",\n\t\"pad.modals.badChangeset.explanation\": \"Sünkroniseerimisserver keeldus vastuvõtmast tehtud muudatuse.\",\n\t\"pad.modals.badChangeset.cause\": \"See võib sõltuda serveri valest seadistusest või mõnest muust tõrkest. Palun kontakteeru teenuse haldajaga või proovi uuesti.\",\n\t\"pad.modals.corruptPad.explanation\": \"Klade, millele püüad ligi pääseda, on rikkis.\",\n\t\"pad.modals.corruptPad.cause\": \"See võib sõltuda serveri valest seadistusest või mõnest muust tõrkest. Palun kontakteeru teenuse haldajaga või proovi uuesti.\",\n\t\"pad.modals.deleted\": \"Kustutatud.\",\n\t\"pad.modals.deleted.explanation\": \"Klade on kustutatud.\",\n\t\"pad.modals.disconnected\": \"Sa ei ole ühendatud.\",\n\t\"pad.modals.disconnected.explanation\": \"Ühendus serveriga katkes\",\n\t\"pad.modals.disconnected.cause\": \"Server ei ole saadaval. Palun kontakteeru teenuse haldajaga või proovi uuesti.\",\n\t\"pad.share\": \"Jaga kladet\",\n\t\"pad.share.readonly\": \"Kirjutuskaitstud\",\n\t\"pad.share.link\": \"Link\",\n\t\"pad.share.emebdcode\": \"Põimi URL\",\n\t\"pad.chat\": \"Vestle\",\n\t\"pad.chat.title\": \"Ava klade vestlusaken.\",\n\t\"pad.chat.loadmessages\": \"Laadi rohkem sõnumeid\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} ajajoon\",\n\t\"timeslider.toolbar.returnbutton\": \"Tagasi kladele\",\n\t\"timeslider.toolbar.authors\": \"Autoridː\",\n\t\"timeslider.toolbar.authorsList\": \"Autor puudub\",\n\t\"timeslider.toolbar.exportlink.title\": \"Eksport\",\n\t\"timeslider.exportCurrent\": \"Ekspordi käesolev versioon kuiː\",\n\t\"timeslider.version\": \"Versioon {{version}}\",\n\t\"timeslider.saved\": \"Salvestatud {{day}} {{month}} {{year}}\",\n\t\"timeslider.dateformat\": \"{{day}}.{{month}}.{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Jaanuar\",\n\t\"timeslider.month.february\": \"Veebruar\",\n\t\"timeslider.month.march\": \"Märts\",\n\t\"timeslider.month.april\": \"Aprill\",\n\t\"timeslider.month.may\": \"Mai\",\n\t\"timeslider.month.june\": \"Juuni\",\n\t\"timeslider.month.july\": \"Juuli\",\n\t\"timeslider.month.august\": \"August\",\n\t\"timeslider.month.september\": \"September\",\n\t\"timeslider.month.october\": \"Oktoober\",\n\t\"timeslider.month.november\": \"November\",\n\t\"timeslider.month.december\": \"Detsember\",\n\t\"timeslider.unnamedauthors\": \"{{num}} nimetamata {[plural(num) one: autor, other: autorit ]}\",\n\t\"pad.savedrevs.marked\": \"Versioon märgiti salvestatuna\",\n\t\"pad.userlist.entername\": \"Sisesta oma nimi\",\n\t\"pad.userlist.unnamed\": \"Nimetu\",\n\t\"pad.editbar.clearcolors\": \"Kas soovid kustutada autorite värvid dokumendist?\",\n\t\"pad.impexp.importbutton\": \"Impordi\",\n\t\"pad.impexp.importing\": \"Importimine...\",\n\t\"pad.impexp.confirmimport\": \"Faili importimine kustutab praeguse versiooni. Kas kindlasti importida?\",\n\t\"pad.impexp.convertFailed\": \"Antud faili pole võimalik importida. Palun kasuta teist vormingut või kopeeri-kleebi käsitsi\",\n\t\"pad.impexp.uploadFailed\": \"Üleslaadimine nurjus, proovi uuesti\",\n\t\"pad.impexp.importfailed\": \"Importimine nurjus\",\n\t\"pad.impexp.copypaste\": \"Palun kopeeri ja kleebi\",\n\t\"pad.impexp.exportdisabled\": \"Eksportimine vormingusse {{type}} on hetkel keelatud. Üksikasjade saamiseks pöördu oma süsteemiadministraatori poole.\"\n}\n"
  },
  {
    "path": "src/locales/eu.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Alexgabi\",\n\t\t\t\"An13sa\",\n\t\t\t\"Atzerritik\",\n\t\t\t\"HairyFotr\",\n\t\t\t\"Izendegi\",\n\t\t\t\"Mikel Ibaiba\",\n\t\t\t\"Subi\",\n\t\t\t\"Theklan\",\n\t\t\t\"Xabier Armendaritz\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Kudeaketa panela - Etherpad\",\n\t\"admin_plugins\": \"Plugin-en kudeaketa\",\n\t\"admin_plugins.available\": \"Eskuragarri dauden plugin-ak\",\n\t\"admin_plugins.available_not-found\": \"Ez da plugin-ik aurkitu\",\n\t\"admin_plugins.available_fetching\": \"Eskuratzen...\",\n\t\"admin_plugins.available_install.value\": \"Instalatu\",\n\t\"admin_plugins.available_search.placeholder\": \"Bilatu instalatzeko plugin-ak\",\n\t\"admin_plugins.description\": \"Deskribapena\",\n\t\"admin_plugins.installed\": \"Instalatutako plugin-ak\",\n\t\"admin_plugins.installed_fetching\": \"Instalatutako plugin-ak eskuratzen...\",\n\t\"admin_plugins.installed_nothing\": \"Oraindik ez duzu inolako plugin-ik instalatu.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Desinstalatu\",\n\t\"admin_plugins.last-update\": \"Azken eguneratzea\",\n\t\"admin_plugins.name\": \"Izena\",\n\t\"admin_plugins.page-title\": \"Plugin-en kudeaketa - Etherpad\",\n\t\"admin_plugins.version\": \"Bertsioa\",\n\t\"admin_plugins_info\": \"Arazoak konpontzeko informazioa\",\n\t\"admin_plugins_info.hooks\": \"Instalatutako kakoak\",\n\t\"admin_plugins_info.hooks_client\": \"Bezeroaren aldeko kakoak\",\n\t\"admin_plugins_info.hooks_server\": \"Zerbitzari aldeko kakoak\",\n\t\"admin_plugins_info.parts\": \"Instalatutako atalaka\",\n\t\"admin_plugins_info.plugins\": \"Instalatutako plugin-ak\",\n\t\"admin_plugins_info.page-title\": \"Plugin-en informazioa - Etherpad\",\n\t\"admin_plugins_info.version\": \"Etherpad bertsioa\",\n\t\"admin_plugins_info.version_latest\": \"Eskuragarri dagoen bertsio berriena\",\n\t\"admin_plugins_info.version_number\": \"Bertsio-zenbakia\",\n\t\"admin_settings\": \"Ezarpenak\",\n\t\"admin_settings.current\": \"Oraingo konfigurazioa\",\n\t\"admin_settings.current_example-devel\": \"Adibiderako garapenerako ezarpenen txantiloia\",\n\t\"admin_settings.current_example-prod\": \"Adibiderako lanerako ezarpenen txantiloia\",\n\t\"admin_settings.current_restart.value\": \"Berrabiarazi Etherpad\",\n\t\"admin_settings.current_save.value\": \"Gorde Ezarpenak\",\n\t\"admin_settings.page-title\": \"Ezarpenak - Etherpad\",\n\t\"index.newPad\": \"Pad berria\",\n\t\"index.settings\": \"Ezarpenak\",\n\t\"index.receiveSessionDescription\": \"Hemen beste nabigatzaile batetik edo gailu batetik Etherpad saioa jaso ahal izango duzu. Kontuan izan, ordea, honek zure egungo saioa ezabatuko duela.\",\n\t\"index.copyLink\": \"2. Kopiatu esteka\",\n\t\"index.copyLinkDescription\": \"Egin klik beheko botoian esteka arbelean kopiatzeko.\",\n\t\"index.copyLinkButton\": \"Kopiatu esteka arbelean\",\n\t\"index.transferToSystem\": \"3. Kopiatu saioa sistema berrira\",\n\t\"index.transferToSystemDescription\": \"Ireki kopiatutako esteka helburuko nabigatzailean edo gailuan zure saioa transferitzeko.\",\n\t\"index.transferSessionDescription\": \"Transferitu zure uneko saioa nabigatzailera edo gailura beheko botoian klik eginez. Honek zure saioa transferituko duen orrialde baterako esteka bat kopiatuko du helburuko nabigatzailean edo gailuan irekitzean.\",\n\t\"index.createOpenPad\": \"Ireki Pad bat honako izenarekin:\",\n\t\"index.openPad\": \"ireki existitzen den eta hurrengo izena duen Pad-a:\",\n\t\"index.recentPadsEmpty\": \"Ez da aurkitu duela gutxiko pad-ik\",\n\t\"index.generateNewPad\": \"Sortu pad izen aleatorioa\",\n\t\"index.labelPad\": \"Pad izena (aukerakoa)\",\n\t\"index.placeholderPadEnter\": \"Mesedez sartu pad izen bat...\",\n\t\"index.createAndShareDocuments\": \"Sortu eta partekatu dokumentuak denbora errealean\",\n\t\"index.createAndShareDocumentsDescription\": \"Etherpadek aukera ematen dizu dokumentuak elkarlanean editatzeko zure nabigatzailean exekutatzen den idazle anitzeko editore bat bezala.\",\n\t\"pad.toolbar.bold.title\": \"Lodia (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Etzana (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Azpimarratua (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Marratua (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Zerrenda ordenatua (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Zerrenda ez-ordenatua (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Koska (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Koska kendu (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Desegin (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Berregin (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Ezabatu Egiletza Koloreak (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Inportatu/Esportatu fitxategi formatu ezberdinetara/ezberdinetatik\",\n\t\"pad.toolbar.timeslider.title\": \"Denbora-lerroa\",\n\t\"pad.toolbar.savedRevision.title\": \"Gorde berrikuspena\",\n\t\"pad.toolbar.settings.title\": \"Ezarpenak\",\n\t\"pad.toolbar.embed.title\": \"Partekatu eta Txertatu pad hau\",\n\t\"pad.toolbar.home.title\": \"Atzera hasierara\",\n\t\"pad.toolbar.showusers.title\": \"Erakutsi pad honetako erabiltzaileak\",\n\t\"pad.colorpicker.save\": \"Gorde\",\n\t\"pad.colorpicker.cancel\": \"Utzi\",\n\t\"pad.loading\": \"Kargatzen...\",\n\t\"pad.noCookie\": \"Cookiea ez da aurkitu. Mesedez, gaitu cookieak zure nabigatzailean! Zure saioa eta ezarpenak ez dira bisiten artean gordeko. Nabigatzaile batzuetan baliteke hau Etherpad iFrame bitartez txertatuta egoteagatik izatea. Ziurtatu ezazu mesedez Etherpad iFrame-aren jatorrizko orriaren azpidomeinu/domeinu berean daudela.\",\n\t\"pad.permissionDenied\": \"Ez duzu baimenik pad honetara sartzeko\",\n\t\"pad.settings.padSettings\": \"Pad Ezarpenak\",\n\t\"pad.settings.myView\": \"Nire Ikuspegia\",\n\t\"pad.settings.stickychat\": \"Txata beti pantailan\",\n\t\"pad.settings.chatandusers\": \"Erakutsi txata eta erabiltzaileak\",\n\t\"pad.settings.colorcheck\": \"Egiletzaren koloreak\",\n\t\"pad.settings.linenocheck\": \"Lerro zenbakiak\",\n\t\"pad.settings.rtlcheck\": \"Irakurri edukia eskuinetik ezkerrera?\",\n\t\"pad.settings.fontType\": \"Letra-mota:\",\n\t\"pad.settings.fontType.normal\": \"Arrunta\",\n\t\"pad.settings.language\": \"Hizkuntza:\",\n\t\"pad.settings.deletePad\": \"Ezabatu pad-a\",\n\t\"pad.delete.confirm\": \"Benetan pad hau ezabatu nahi duzu?\",\n\t\"pad.settings.about\": \"Honi buruz\",\n\t\"pad.settings.poweredBy\": \"Honek garatua:\",\n\t\"pad.importExport.import_export\": \"Inportatu/Esportatu\",\n\t\"pad.importExport.import\": \"Igo edozein testu-fitxategi edo dokumentu\",\n\t\"pad.importExport.importSuccessful\": \"Ondo!\",\n\t\"pad.importExport.export\": \"Esportatu oraingo pad hau honela:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Testu laua\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Testu laua edo HTML formatudun testuak bakarrik inporta ditzakezu. Aurreratuagoak diren inportazio aukerak izateko <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">AbiWord edo LibreOffice instala ezazu</a>.\",\n\t\"pad.modals.connected\": \"Konektatuta.\",\n\t\"pad.modals.reconnecting\": \"Zure pad-era birkonektatzen...\",\n\t\"pad.modals.forcereconnect\": \"Behartu berkonexioa\",\n\t\"pad.modals.reconnecttimer\": \"Berriz konektatzen saiatzen\",\n\t\"pad.modals.cancel\": \"Utzi\",\n\t\"pad.modals.userdup\": \"Beste leiho batean ireki da\",\n\t\"pad.modals.userdup.explanation\": \"Pad hau zure nabigatzailearen beste leiho batean irekita dagoela ematen du.\",\n\t\"pad.modals.userdup.advice\": \"Berriro konektatu bestearen ordez leiho hau erabiltzeko.\",\n\t\"pad.modals.unauth\": \"Baimenik gabe\",\n\t\"pad.modals.unauth.explanation\": \"Orrialdea ikusten ari zinela zure baimenak aldatu dira. Saia zaitez berriro konektatzen.\",\n\t\"pad.modals.looping.explanation\": \"Sinkronizazio zerbitzariarekin komunikazioa arazoak daude.\",\n\t\"pad.modals.looping.cause\": \"Agian firewall edo proxy ez-bateragarri baten bidez konektatu zara.\",\n\t\"pad.modals.initsocketfail\": \"Zerbitzarira ezin da iritsi.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Ezin izan da konektatu sinkronizazio zerbitzarira.\",\n\t\"pad.modals.initsocketfail.cause\": \"Ziurrenik hau zure nabigatzailearen edo internet konexioaren arazo bat dela-eta izango da.\",\n\t\"pad.modals.slowcommit.explanation\": \"Zerbitzariak ez du erantzuten.\",\n\t\"pad.modals.slowcommit.cause\": \"Baliteke hau sarearen konexio arazoak direla-eta izatea.\",\n\t\"pad.modals.badChangeset.explanation\": \"Sinkronizazio zerbitzariak, zuk egindako aldaketa bat legez kanpokotzat jo du.\",\n\t\"pad.modals.badChangeset.cause\": \"Hau zerbitzariaren konfigurazio okerra edo ustekabeko beste jokabidearen baten ondorio izan liteke. Jarri harremanetan zerbitzu-administratzailearekin, errore bat dela uste baduzu. Saiatu berriro konektatzen edizioarekin jarraitzeko.\",\n\t\"pad.modals.corruptPad.explanation\": \"Sartzen saiatzen ari zaren Pad-a hondatuta dago.\",\n\t\"pad.modals.corruptPad.cause\": \"Baliteke zerbitzari okerreko konfigurazioagatik edo beste ustekabeko portaera batengatik izatea. Jarri harremanetan zerbitzu-administratzailearekin.\",\n\t\"pad.modals.deleted\": \"Ezabatua.\",\n\t\"pad.modals.deleted.explanation\": \"Pad hau ezabatu da.\",\n\t\"pad.modals.rateLimited\": \"Baloratzea Mugatuta.\",\n\t\"pad.modals.rateLimited.explanation\": \"Pad honetara mezu gehiegi bidali dituzu eta ondorioz deskonektatu zaizu.\",\n\t\"pad.modals.rejected.explanation\": \"Zerbitzariak zure nabigatzailetik bidali den mezu bat baztertu du.\",\n\t\"pad.modals.rejected.cause\": \"Baliteke pad-a ikusten ari zinen bitartean zerbitzaria eguneratu izana, edo bestela Etherpad-en arazo bat egon liteke. Orria freskatzen saiatu zaitez.\",\n\t\"pad.modals.disconnected\": \"Deskonektatua izan zara.\",\n\t\"pad.modals.disconnected.explanation\": \"Zerbitzariarekiko konexioa galdu da\",\n\t\"pad.modals.disconnected.cause\": \"Baliteke zerbitzaria eskuragarri ez egotea. Mesedez, jakinarazi zerbitzuko administratzaileari honek gertatzen jarraitzen badu.\",\n\t\"pad.share\": \"Partekatu pad hau\",\n\t\"pad.share.readonly\": \"Irakurtzeko bakarrik\",\n\t\"pad.share.link\": \"Esteka\",\n\t\"pad.share.emebdcode\": \"Txertatu URLa\",\n\t\"pad.chat\": \"Txata\",\n\t\"pad.chat.title\": \"Ireki pad honentzako txata.\",\n\t\"pad.chat.loadmessages\": \"Kargatu mezu gehiago\",\n\t\"pad.chat.stick.title\": \"Itsatsi txata pantailan\",\n\t\"pad.chat.writeMessage.placeholder\": \"Idatzi hemen zure mezua\",\n\t\"timeslider.followContents\": \"Jarraitu pad-aren edukien eguneratzeak\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Denbora-lerroa\",\n\t\"timeslider.toolbar.returnbutton\": \"Itzuli pad-era\",\n\t\"timeslider.toolbar.authors\": \"Egileak:\",\n\t\"timeslider.toolbar.authorsList\": \"Egilerik gabe\",\n\t\"timeslider.toolbar.exportlink.title\": \"Esportatu\",\n\t\"timeslider.exportCurrent\": \"Esportatu bertsio hau honela:\",\n\t\"timeslider.version\": \"{{version}} bertsioa\",\n\t\"timeslider.saved\": \"{{year}}(e)ko {{month}}ren {{day}}(e)an gordeta\",\n\t\"timeslider.playPause\": \"Erreproduzitu / Gelditu Pad-eko edukiak\",\n\t\"timeslider.backRevision\": \"Joan berrikusketa bat atzerago Pad honetan\",\n\t\"timeslider.forwardRevision\": \"Joan berrikusketa bat aurrerago Pad honetan\",\n\t\"timeslider.dateformat\": \"{{year}}/{{month}}/{{day}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Urtarrila\",\n\t\"timeslider.month.february\": \"Otsaila\",\n\t\"timeslider.month.march\": \"Martxoa\",\n\t\"timeslider.month.april\": \"Apirila\",\n\t\"timeslider.month.may\": \"Maiatza\",\n\t\"timeslider.month.june\": \"Ekaina\",\n\t\"timeslider.month.july\": \"Uztaila\",\n\t\"timeslider.month.august\": \"Abuztua\",\n\t\"timeslider.month.september\": \"Iraila\",\n\t\"timeslider.month.october\": \"Urria\",\n\t\"timeslider.month.november\": \"Azaroa\",\n\t\"timeslider.month.december\": \"Abendua\",\n\t\"timeslider.unnamedauthors\": \"izenik gabeko {{num}} {[plural(num) one: egile, other: egile]}\",\n\t\"pad.savedrevs.marked\": \"Berrikuspen hau gordetako berrikuspen gisa markatua dago orain\",\n\t\"pad.savedrevs.timeslider\": \"Gordetako berrikusketak denbora-lerroa bisitatuz ikus ditzakezu\",\n\t\"pad.userlist.entername\": \"Sartu zure izena\",\n\t\"pad.userlist.unnamed\": \"izenik gabe\",\n\t\"pad.editbar.clearcolors\": \"Ezabatu egile koloreak dokumentu osoan? Honek ez du atzera bueltarik\",\n\t\"pad.impexp.importbutton\": \"Inportatu orain\",\n\t\"pad.impexp.importing\": \"Inportatzen...\",\n\t\"pad.impexp.confirmimport\": \"Fitxategi bat inportatzen baduzu oraingo pad honen testua ezabatuko da. Ziur zaude jarraitu nahi duzula?\",\n\t\"pad.impexp.convertFailed\": \"Ez gara fitxategi hau inportatzeko gai izan. Erabil ezazu, mesedez, beste dokumentu formatu bat edo eskuz kopiatu eta itsatsi ezazu.\",\n\t\"pad.impexp.padHasData\": \"Artxibo hau ezin izan dugu inportatu Pad honek dagoeneko aldaketak izan dituelako, Pad berri batera inportatu mesedez.\",\n\t\"pad.impexp.uploadFailed\": \"Igotzeak huts egin du, saia zaitez berriro\",\n\t\"pad.impexp.importfailed\": \"Inportazioak huts egin du\",\n\t\"pad.impexp.copypaste\": \"Mesedez kopiatu eta itsatsi ezazu\",\n\t\"pad.impexp.exportdisabled\": \"{{type}} formatuarekin esportatzea desgaituta dago. Xehetasun gehiagorako zure sistemako administratzailearekin harremanetan jarri zaitez.\",\n\t\"pad.impexp.maxFileSize\": \"Fitxategia handiegia da. Zure guneko administratzailearekin harremanetan jarri zaitez inportatu daitezkeen fitxategien gehienezko tamaina handitzeko\"\n}\n"
  },
  {
    "path": "src/locales/fa.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"BMRG14\",\n\t\t\t\"Beginneruser\",\n\t\t\t\"Dalba\",\n\t\t\t\"Darafsh\",\n\t\t\t\"Ebrahim\",\n\t\t\t\"Ebraminio\",\n\t\t\t\"FarsiNevis\",\n\t\t\t\"Jeeputer\",\n\t\t\t\"Omid.koli\",\n\t\t\t\"Reza1615\",\n\t\t\t\"ZxxZxxZ\",\n\t\t\t\"الناز\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"پیش‌خوان مدیر - اترپد\",\n\t\"admin_plugins\": \"مدیریت افزونه\",\n\t\"admin_plugins.available\": \"افزونه‌های موجود\",\n\t\"admin_plugins.available_not-found\": \"هیچ افزونه‌ای یافت نشد.\",\n\t\"admin_plugins.available_fetching\": \"در حال واکشی...\",\n\t\"admin_plugins.available_install.value\": \"نصب\",\n\t\"admin_plugins.available_search.placeholder\": \"جستجوی افزونه‌ها برای نصب\",\n\t\"admin_plugins.description\": \"توضیحات\",\n\t\"admin_plugins.installed\": \"افزونه‌های نصب‌شده\",\n\t\"admin_plugins.installed_fetching\": \"در حال واکشی افزونه‌های نصب‌شده...\",\n\t\"admin_plugins.installed_nothing\": \"شما هنوز هیچ افزونه‌ای را نصب نکرده‌اید.\",\n\t\"admin_plugins.installed_uninstall.value\": \"از کار انداختن\",\n\t\"admin_plugins.last-update\": \"آخرین به‌روزرسانی\",\n\t\"admin_plugins.name\": \"نام\",\n\t\"admin_plugins.page-title\": \"مدیریت افزونه‌ها - اترپد\",\n\t\"admin_plugins.version\": \"نسخه\",\n\t\"admin_plugins_info\": \"اطلاعات عیب‌یابی\",\n\t\"admin_plugins_info.hooks\": \"قلاب‌های نصب‌شده\",\n\t\"admin_plugins_info.hooks_client\": \"قلاب‌های سمت مشتری\",\n\t\"admin_plugins_info.hooks_server\": \"قلاب‌های سمت سرور\",\n\t\"admin_plugins_info.parts\": \"قسمت‌های نصب‌شده\",\n\t\"admin_plugins_info.plugins\": \"افزونه‌های نصب‌شده\",\n\t\"admin_plugins_info.page-title\": \"اطلاعات افزونه - اترپد\",\n\t\"admin_plugins_info.version\": \"نسخهٔ اترپد\",\n\t\"admin_plugins_info.version_latest\": \"آخرین نسخهٔ موجود\",\n\t\"admin_plugins_info.version_number\": \"شمارهٔ نسخه\",\n\t\"admin_settings\": \"تنظیمات\",\n\t\"admin_settings.current\": \"پیکربندی کنونی\",\n\t\"admin_settings.current_example-devel\": \"نمونهٔ الگوی تنظیمات توسعه\",\n\t\"admin_settings.current_example-prod\": \"نمونهٔ الگوی تنظیمات تولید\",\n\t\"admin_settings.current_restart.value\": \"راه‌اندازی دوبارهٔ اترپد\",\n\t\"admin_settings.current_save.value\": \"ذخیرهٔ تنظیمات\",\n\t\"admin_settings.page-title\": \"تنظیمات - اترپد\",\n\t\"index.newPad\": \"دفترچه یادداشت تازه\",\n\t\"index.createOpenPad\": \"یا ایجاد/بازکردن یک دفترچه یادداشت با نام:\",\n\t\"index.openPad\": \"باز کردن یک پد موجود با نام:\",\n\t\"pad.toolbar.bold.title\": \"پررنگ (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"کج (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"زیرخط (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"خط خورده (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"فهرست مرتب شده (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"فهرست مرتب نشده (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"تورفتگی (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"تورفتگی (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"باطل‌کردن (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"از نو (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"پاک‌کردن رنگ‌های نویسندگی (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"درون‌ریزی/برون‌بری از/به قالب‌های مختلف پرونده\",\n\t\"pad.toolbar.timeslider.title\": \"لغزندهٔ زمان\",\n\t\"pad.toolbar.savedRevision.title\": \"ذخیره‌سازی نسخه\",\n\t\"pad.toolbar.settings.title\": \"تنظیمات\",\n\t\"pad.toolbar.embed.title\": \"اشتراک و جاسازی این دفترچه یادداشت\",\n\t\"pad.toolbar.home.title\": \"بازگشت به صفحهٔ اصلی\",\n\t\"pad.toolbar.showusers.title\": \"نمایش کاربران در این دفترچه یادداشت\",\n\t\"pad.colorpicker.save\": \"ذخیره\",\n\t\"pad.colorpicker.cancel\": \"لغو\",\n\t\"pad.loading\": \"در حال بارگذاری...\",\n\t\"pad.noCookie\": \"کلوچک یافت نشد. لطفاً اجارهٔ استفاده از کلوچک را در مرورگر خود تأیید کنید! نشست و تنظیمات شما در میان بازدیدها ذخیره نخواهد شد. این می‌تواند به این دلیل باشد که اترپد در برخی مرورگرها در یک iFrame قرار می‌گیرد. لطفاً مطمئن شوید که اترپد در زیردامنه/دامنهٔ یکسان با iFrame والد قرار دارد\",\n\t\"pad.permissionDenied\": \"شما اجازهٔ دسترسی به این دفترچه یادداشت را ندارید\",\n\t\"pad.settings.padSettings\": \"تنظیمات دفترچه یادداشت\",\n\t\"pad.settings.myView\": \"نمای من\",\n\t\"pad.settings.stickychat\": \"گفتگو همیشه روی صفحه نمایش باشد\",\n\t\"pad.settings.chatandusers\": \"نمایش چت و کاربران\",\n\t\"pad.settings.colorcheck\": \"رنگ‌های نویسندگی\",\n\t\"pad.settings.linenocheck\": \"شمارهٔ خطوط\",\n\t\"pad.settings.rtlcheck\": \"خواندن محتوا از راست به چپ؟\",\n\t\"pad.settings.fontType\": \"نوع قلم:\",\n\t\"pad.settings.fontType.normal\": \"ساده\",\n\t\"pad.settings.language\": \"زبان:\",\n\t\"pad.settings.deletePad\": \"حذف پد\",\n\t\"pad.delete.confirm\": \"آیا واقعاً می‌خواهید این پد را حذف کنید؟\",\n\t\"pad.settings.about\": \"درباره\",\n\t\"pad.settings.poweredBy\": \"قدرت‌گرفته از\",\n\t\"pad.importExport.import_export\": \"درون‌ریزی/برون‌بری\",\n\t\"pad.importExport.import\": \"بارگذاری پروندهٔ متنی یا سند\",\n\t\"pad.importExport.importSuccessful\": \"موفقیت آمیز بود!\",\n\t\"pad.importExport.export\": \"برون‌بری این دفترچه یادداشت با قالب:\",\n\t\"pad.importExport.exportetherpad\": \"اترپد\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"متن ساده\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (قالب سند باز)\",\n\t\"pad.importExport.abiword.innerHTML\": \"شما تنها می‌توانید از قالب متن ساده یا اچ‌تی‌ام‌ال درون‌ریزی کنید. برای بیشتر شدن ویژگی‌های درون‌ریزی پیشرفته <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">AbiWord یا LibreOffice را نصب کنید</a>.\",\n\t\"pad.modals.connected\": \"متصل شد.\",\n\t\"pad.modals.reconnecting\": \"در حال اتصال دوباره به پد شما...\",\n\t\"pad.modals.forcereconnect\": \"واداشتن به اتصال دوباره\",\n\t\"pad.modals.reconnecttimer\": \"تلاش برای اتصال مجدد\",\n\t\"pad.modals.cancel\": \"لغو\",\n\t\"pad.modals.userdup\": \"در پنجره‌ای دیگر باز شد\",\n\t\"pad.modals.userdup.explanation\": \"گمان می‌رود این دفترچه یادداشت در بیش از یک پنجرهٔ مرورگر باز شده است.\",\n\t\"pad.modals.userdup.advice\": \"برای استفاده از این پنجره دوباره وصل شوید.\",\n\t\"pad.modals.unauth\": \"مجاز نیست\",\n\t\"pad.modals.unauth.explanation\": \"دسترسی شما در حین مشاهدهٔ این برگه تغییر یافته است. دوباره متصل شوید.\",\n\t\"pad.modals.looping.explanation\": \"مشکلاتی ارتباطی با سرور همگام‌سازی وجود دارد.\",\n\t\"pad.modals.looping.cause\": \"شاید شما از طریق یک فایروال یا پروکسی ناسازگار متصل شده‌اید.\",\n\t\"pad.modals.initsocketfail\": \"سرور در دسترس نیست.\",\n\t\"pad.modals.initsocketfail.explanation\": \"نمی‌توان به سرور همگام سازی وصل شد.\",\n\t\"pad.modals.initsocketfail.cause\": \"شاید این به خاطر مشکلی در مرورگر یا اتصال اینترنتی شما باشد.\",\n\t\"pad.modals.slowcommit.explanation\": \"سرور پاسخ نمی‌دهد.\",\n\t\"pad.modals.slowcommit.cause\": \"این می‌تواند به خاطر مشکلاتی در اتصال به شبکه باشد.\",\n\t\"pad.modals.badChangeset.explanation\": \"ویرایشی که شما انجام داده‌اید توسط سرور همگام‌سازی نادرست طیقه‌بندی شده است.\",\n\t\"pad.modals.badChangeset.cause\": \"این می‌تواند به دلیل پیکربندی اشتباه یا سایر رفتارهای غیرمنتظره باشد. اگر فکر می‌کنید این یک خطا است لطفاً با مدیر خدمت تماس بگیرید. برای ادامهٔ ویرایش سعی کنید که دوباره متصل شوید.\",\n\t\"pad.modals.corruptPad.explanation\": \"پدی که شما سعی دارید دسترسی پیدا کنید خراب است.\",\n\t\"pad.modals.corruptPad.cause\": \"این احتمالاً به دلیل تنظیمات اشتباه کارساز یا سایر رفتارهای غیرمنتظره است. لطفاً با مدیر خدمت تماس حاصل کنید.\",\n\t\"pad.modals.deleted\": \"پاک شد.\",\n\t\"pad.modals.deleted.explanation\": \"این دفترچه یادداشت پاک شده است.\",\n\t\"pad.modals.rateLimited\": \"نرخ محدود شده است.\",\n\t\"pad.modals.rateLimited.explanation\": \"پیام‌های زیادی به این پد فرستادید، بنابراین اتصال شما قطع شد.\",\n\t\"pad.modals.rejected.explanation\": \"سرور پیامی را که مرورگرتان فرستاده بود، رد کرد.\",\n\t\"pad.modals.rejected.cause\": \"ممکن است سرور هنگام مشاهدهٔ پد به‌روزرسانی شده باشد یا شاید باگی در اترپد وجود داشته باشد. صفحه را دوباره بارگذاری کنید.\",\n\t\"pad.modals.disconnected\": \"اتصال شما قطع شده است.\",\n\t\"pad.modals.disconnected.explanation\": \"اتصال به سرور قطع شده است.\",\n\t\"pad.modals.disconnected.cause\": \"ممکن است سرور در دسترس نباشد. اگر این مشکل باز هم رخ داد مدیر حدمت را آگاه کنید.\",\n\t\"pad.share\": \"به اشتراک‌گذاری این دفترچه یادداشت\",\n\t\"pad.share.readonly\": \"فقط خواندنی\",\n\t\"pad.share.link\": \"پیوند\",\n\t\"pad.share.emebdcode\": \"جاسازی نشانی\",\n\t\"pad.chat\": \"گفتگو\",\n\t\"pad.chat.title\": \"بازکردن گفتگو برای این دفترچه یادداشت\",\n\t\"pad.chat.loadmessages\": \"بارگیری پیام‌های بیشتر\",\n\t\"pad.chat.stick.title\": \"چسباندن چت به صفحه\",\n\t\"pad.chat.writeMessage.placeholder\": \"پیام خود را این‌جا بنویسید\",\n\t\"timeslider.followContents\": \"پیگیری به‌روزرسانی‌های محتوای پد\",\n\t\"timeslider.pageTitle\": \"لغزندهٔ زمان {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"بازگشت به دفترچه یادداشت\",\n\t\"timeslider.toolbar.authors\": \"نویسندگان:\",\n\t\"timeslider.toolbar.authorsList\": \"بدون نویسنده\",\n\t\"timeslider.toolbar.exportlink.title\": \"برون‌بری\",\n\t\"timeslider.exportCurrent\": \"برون‌ریزی نگارش کنونی به عنوان:\",\n\t\"timeslider.version\": \"نگارش {{version}}\",\n\t\"timeslider.saved\": \"{{month}} {{day}}، {{year}} ذخیره شد\",\n\t\"timeslider.playPause\": \"اجرای مجدد/متوقف کردن پخش\",\n\t\"timeslider.backRevision\": \"رفتن به نسخهٔ پیشین در این دفترچه\",\n\t\"timeslider.forwardRevision\": \"رفتن به نسخهٔ بعدی در این دفترچه\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"ژانویه\",\n\t\"timeslider.month.february\": \"فوریه\",\n\t\"timeslider.month.march\": \"مارس\",\n\t\"timeslider.month.april\": \"آوریل\",\n\t\"timeslider.month.may\": \"مه\",\n\t\"timeslider.month.june\": \"ژوئن\",\n\t\"timeslider.month.july\": \"ژوئیه\",\n\t\"timeslider.month.august\": \"اوت\",\n\t\"timeslider.month.september\": \"سپتامبر\",\n\t\"timeslider.month.october\": \"اکتبر\",\n\t\"timeslider.month.november\": \"نوامبر\",\n\t\"timeslider.month.december\": \"دسامبر\",\n\t\"timeslider.unnamedauthors\": \"{{num}} نویسندهٔ بی‌نام\",\n\t\"pad.savedrevs.marked\": \"این بازنویسی هم اکنون به عنوان ذخیره شده علامت‌گذاری شد\",\n\t\"pad.savedrevs.timeslider\": \"شما می‌توانید نسخه‌های ذخیره شده را با دیدن نوار زمان ببنید\",\n\t\"pad.userlist.entername\": \"نام خود را بنویسید\",\n\t\"pad.userlist.unnamed\": \"بدون نام\",\n\t\"pad.editbar.clearcolors\": \"رنگ نویسندگی از همهٔ سند پاک شود؟\",\n\t\"pad.impexp.importbutton\": \"هم اکنون درون‌ریزی کن\",\n\t\"pad.impexp.importing\": \"در حال درون‌ریزی...\",\n\t\"pad.impexp.confirmimport\": \"با درون‌ریزی یک پرونده نوشتهٔ کنونی دفترچه پاک می‌شود. آیا می‌خواهید ادامه دهید؟\",\n\t\"pad.impexp.convertFailed\": \"ما نمی‌توانیم این پرونده را درون‌ریزی کنیم. خواهشمندیم قالب دیگری برای سندتان انتخاب کرده یا بصورت دستی آنرا کپی کنید\",\n\t\"pad.impexp.padHasData\": \"امکان درون‌ریزی این پرونده نیست زیرا این پد تغییر کرده است. لطفاً در پد جدید درون‌ریزی کنید.\",\n\t\"pad.impexp.uploadFailed\": \"آپلود انجام نشد، دوباره تلاش کنید\",\n\t\"pad.impexp.importfailed\": \"درون‌ریزی انجام نشد\",\n\t\"pad.impexp.copypaste\": \"کپی پیست کنید\",\n\t\"pad.impexp.exportdisabled\": \"برون‌ریزی با قالب {{type}} از کار افتاده است. برای جزئیات بیشتر با مدیر سامانه خودتان تماس بگیرید.\",\n\t\"pad.impexp.maxFileSize\": \"پرونده خیلی بزرگ است. با مدیر سایت تماس بگیرید تا اندازهٔ مجاز برای وارد کردن پرونده را افزایش دهد.\"\n}\n"
  },
  {
    "path": "src/locales/ff.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Ibrahima Malal Sarr\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Tiimtorde Jiiloowo - Etherpad\",\n\t\"admin_plugins\": \"Toppitorde Ceŋe\",\n\t\"admin_plugins.available\": \"Ceŋe goodaaɗe\",\n\t\"admin_plugins.available_not-found\": \"Alaa ceŋe njiytaa.\",\n\t\"admin_plugins.available_fetching\": \"Nana balloo…\",\n\t\"admin_plugins.available_install.value\": \"Aaf\",\n\t\"admin_plugins.available_search.placeholder\": \"Yiylo ceŋe aafeteeɗe\",\n\t\"admin_plugins.description\": \"Cifol\",\n\t\"admin_plugins.installed\": \"Ceŋe aafaaɗe\",\n\t\"admin_plugins.installed_fetching\": \"Nana yiyloo ceŋe aafaaɗe…\",\n\t\"admin_plugins.installed_nothing\": \"A aafaani ceŋe tawo.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Aaftu\",\n\t\"admin_plugins.last-update\": \"Kesɗitinal cakkitiingal\",\n\t\"admin_plugins.name\": \"Innde\",\n\t\"admin_plugins.page-title\": \"Toppitorde ceŋe - Etherpad\",\n\t\"admin_plugins.version\": \"Yamre\",\n\t\"admin_plugins_info\": \"Humpito njiylaw caɗe\",\n\t\"admin_plugins_info.hooks\": \"Logge aafaaɗe\",\n\t\"admin_plugins_info.hooks_client\": \"Logge senngo-kuutoro\",\n\t\"admin_plugins_info.hooks_server\": \"Logge senngo-sarworde\",\n\t\"admin_plugins_info.parts\": \"Terɗe aafaaɗe\",\n\t\"admin_plugins_info.plugins\": \"Ceŋe aafaaɗe\",\n\t\"admin_plugins_info.page-title\": \"Humpito ceŋe - Etherpad\",\n\t\"admin_plugins_info.version\": \"Yamre Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Yamre sakkitiinde woodunde\",\n\t\"admin_plugins_info.version_number\": \"Tonngoode yamre\",\n\t\"admin_settings\": \"Teelte\",\n\t\"admin_settings.current\": \"Teeltannde wonaango\",\n\t\"admin_settings.current_example-devel\": \"Yeru tugnorgal teelte topagol\",\n\t\"admin_settings.current_example-prod\": \"Yeru tugnorgal teelte baayino\",\n\t\"admin_settings.current_restart.value\": \"Hurmitin Etherpad\",\n\t\"admin_settings.current_save.value\": \"Danndu Teelte\",\n\t\"admin_settings.page-title\": \"Teelte - Etherpad\",\n\t\"index.newPad\": \"Alluwal Kesal\",\n\t\"index.createOpenPad\": \"walla sos/uddit Alluwal e innde:\",\n\t\"index.openPad\": \"Uddit paɗo woodungo e ndee innde:\",\n\t\"pad.toolbar.bold.title\": \"Buutol (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Italik (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Diidoles (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Baar (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Doggol leemtangol(Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Doggol ngol lemtaaka\",\n\t\"pad.toolbar.indent.title\": \"Ɓeydu taaɓal (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Ruttu taaɓal (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Firtu (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Waɗtu (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Momtu Noone Wallifɓe (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Jiggo/Jiggito iwde/faade mbayka piille goɗɗe\",\n\t\"pad.toolbar.timeslider.title\": \"Daasorde tuma\",\n\t\"pad.toolbar.savedRevision.title\": \"Dannde Baylital\",\n\t\"pad.toolbar.settings.title\": \"Teelte\",\n\t\"pad.toolbar.embed.title\": \"Lollin maa soomtor ngoo faɗo\",\n\t\"pad.toolbar.showusers.title\": \"Hollu huutorɓe e ngoo faɗo\",\n\t\"pad.colorpicker.save\": \"Danndu\",\n\t\"pad.colorpicker.cancel\": \"Haaytin\",\n\t\"pad.loading\": \"Nana loowa...\",\n\t\"pad.noCookie\": \"Kukii yiytaaka. Tiiɗno yamir kukiije e wanngorde maa!\\nNaatal maa e teelte maa danndetaake hakkunde njulluuji. Ɗuum ena waawi tawa Etherpad ena soomaa e nder iFrame e won e banngorɗe. Tiiɗno ƴeewto so Etherpad woni ko e domen/lesdomen iFrame yumma oo.\",\n\t\"pad.permissionDenied\": \"A alaa yamiroore naatde e ngoo faɗo\",\n\t\"pad.settings.padSettings\": \"Teelte Faɗo\",\n\t\"pad.settings.myView\": \"Jiytol am\",\n\t\"pad.settings.stickychat\": \"Yeewtere e yaynirde sahaa kala\",\n\t\"pad.settings.chatandusers\": \"Hollu Yeewtere e Huutorɓe\",\n\t\"pad.settings.colorcheck\": \"Noone Wallifɓe\",\n\t\"pad.settings.linenocheck\": \"Tonngooɗe gori\",\n\t\"pad.settings.rtlcheck\": \"Tar loowdi iwde ñaamo faya nano?\",\n\t\"pad.settings.fontType\": \"Fannu binndi:\",\n\t\"pad.settings.language\": \"Ɗemngal:\",\n\t\"pad.settings.about\": \"Baɗte\",\n\t\"pad.settings.poweredBy\": \"Dognata ko\",\n\t\"pad.importExport.import_export\": \"Jiggo/Jiggito\",\n\t\"pad.importExport.import\": \"Yollu fiilde binndol maa fiilannde\",\n\t\"pad.importExport.importSuccessful\": \"Ɓennii no haaniri!\",\n\t\"pad.importExport.export\": \"Jiggito faɗo wonaango e innde:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Binndi ɓolɓolti\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Mbaaw-ɗaa jiggaade tan ko baykaaji binndi ɓolɓolti maa HTML. Ngam heɓde fannuuji jiggagol ɓurɗi seeɓde, tiiɗno yillo <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">install AbiWord or LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Seŋiima.\",\n\t\"pad.modals.reconnecting\": \"Nana seŋoo e faɗo maa…\",\n\t\"pad.modals.forcereconnect\": \"Forsu ceŋagol kadi\",\n\t\"pad.modals.reconnecttimer\": \"Nana etoo seŋaade kadi ɗoo e\",\n\t\"pad.modals.cancel\": \"Haaytin\",\n\t\"pad.modals.userdup\": \"Uddit e henorde woɗnde\",\n\t\"pad.modals.userdup.explanation\": \"Ngoo faɗo ellee ena udditii e ko ɓuri henorde wanngorde wootere e ndee komputere.\",\n\t\"pad.modals.userdup.advice\": \"Seŋo kadi ngam huutoraade ndee henorde kisa.\",\n\t\"pad.modals.unauth\": \"Yamiraaka\",\n\t\"pad.modals.unauth.explanation\": \"Jamirooje maa mbayliima tuma nde ƴeewataa ngoo hello. Eto seŋaade kadi.\",\n\t\"pad.modals.looping.explanation\": \"Caɗeele jokkondiral ena ngoodi faade e sarworde canngoɗinal ndee.\",\n\t\"pad.modals.looping.cause\": \"Ma a taw ko a ceŋoriiɗo proxy maa ɓalal-jayngue.\",\n\t\"pad.modals.initsocketfail\": \"Sorworde heɓotaako.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Horiima seŋaade e sarworde canngoɗinal ndee.\",\n\t\"pad.modals.initsocketfail.cause\": \"Ɗuum ena gasa waɗi ɗum ko saɗeede wonnde e wanngorde maa maa ceŋol Enternet maa.\",\n\t\"pad.modals.slowcommit.explanation\": \"Sarworde ndee jaabaaki.\",\n\t\"pad.modals.slowcommit.cause\": \"Ɗuum ena gasa ko caɗeele ceŋagol laylaytol.\",\n\t\"pad.modals.badChangeset.explanation\": \"Taƴtol ngol mbaɗ-ɗaa joopaama rewaani laawol to sarworde canngoɗinal ndee.\",\n\t\"pad.modals.badChangeset.cause\": \"Ɗuum ena gasa ko teeltol sarworde ngol moƴƴaani maa geɗel ngel tijjanooka. Tiiɗno jokkondir e jiiloowo sarwiis oo, so aɗa sikki ɗuum ko juumre. Eto seŋaade kadi ngam jokkude taƴtagol maa.\",\n\t\"pad.modals.corruptPad.explanation\": \"Faɗo ngo etoto-ɗaa naatde ngoo nattii moƴƴude.\",\n\t\"pad.modals.corruptPad.cause\": \"Ɗuum ena gasa addi ɗum ko teeltol sarworde ngol feewaani maa geɗel ngel tijjanooka. Tiiɗno jokkondir e jiiloowo.\",\n\t\"pad.modals.deleted\": \"Momtaama.\",\n\t\"pad.modals.deleted.explanation\": \"Ngoo faɗo ko momtaango.\",\n\t\"pad.modals.rateLimited\": \"Cookol happinaama.\",\n\t\"pad.modals.rateLimited.explanation\": \"A neldii ɓatakuuje keewɗe haa ɓurti e ngoo faɗo, wadde e seŋtaama.\",\n\t\"pad.modals.rejected.explanation\": \"Sarworde ndee riiwtii ɓatakuru ngu wanngorde maa neldunoo.\",\n\t\"pad.modals.rejected.cause\": \"Sarworde ndee ena gasa koko hesɗitinanoo tuma nde ngon-ɗaa e ƴeewde faɗo ngoo, walla ma a taw ena woodi buggere e Etherpad. Eto loowtude hello ngoo.\",\n\t\"pad.modals.disconnected\": \"A seŋtaama.\",\n\t\"pad.modals.disconnected.explanation\": \"Ceŋagol to sarworde waasaama\",\n\t\"pad.modals.disconnected.cause\": \"Sarworde ndee ena gasa heɓotaako. Tiiɗno habru jiiloowo sarwii soo so ɗum nattaani.\",\n\t\"pad.share\": \"Lollin ngoo faɗo\",\n\t\"pad.share.readonly\": \"Targol tan\",\n\t\"pad.share.link\": \"Jokkorde\",\n\t\"pad.share.emebdcode\": \"Soomtor URL\",\n\t\"pad.chat\": \"Yeewtere\",\n\t\"pad.chat.title\": \"Uddit yeewtere ngoo faɗo.\",\n\t\"pad.chat.loadmessages\": \"Loow ɓatakuuje goɗɗe\",\n\t\"pad.chat.stick.title\": \"Hedde e yaynirde yeewtere\",\n\t\"pad.chat.writeMessage.placeholder\": \"Winndu ɗoo ɓatakuru maa\",\n\t\"timeslider.followContents\": \"Rewindo kesɗitine loowdi faɗo\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Daasorde tuma\",\n\t\"timeslider.toolbar.returnbutton\": \"Rutto to faɗo\",\n\t\"timeslider.toolbar.authors\": \"Willifɓe:\",\n\t\"timeslider.toolbar.authorsList\": \"Alaa ballifo\",\n\t\"timeslider.toolbar.exportlink.title\": \"Jiggito\",\n\t\"timeslider.exportCurrent\": \"Jiggito yamre wonaande e innde:\",\n\t\"timeslider.version\": \"Yamre {{version}}\",\n\t\"timeslider.saved\": \"Danndaama {{month}} {{day}} {{year}}\",\n\t\"timeslider.playPause\": \"Tar / Dartin Loowdi Faɗo\",\n\t\"timeslider.backRevision\": \"Rutto to baylital e ngoo Faɗo\",\n\t\"timeslider.forwardRevision\": \"Yah yeeso to baylital en ngoo Faɗo\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Siilo\",\n\t\"timeslider.month.february\": \"Colte\",\n\t\"timeslider.month.march\": \"MBooy\",\n\t\"timeslider.month.april\": \"Seeɗto\",\n\t\"timeslider.month.may\": \"Duujal\",\n\t\"timeslider.month.june\": \"Korse\",\n\t\"timeslider.month.july\": \"Morso\",\n\t\"timeslider.month.august\": \"Juko\",\n\t\"timeslider.month.september\": \"Silto\",\n\t\"timeslider.month.october\": \"Yarkomaa\",\n\t\"timeslider.month.november\": \"Jolal\",\n\t\"timeslider.month.december\": \"Bowte\",\n\t\"timeslider.unnamedauthors\": \"{{num}} innitaaka {[plural(num) goo: ballifo, goɗɗo: wallifɓe]}\",\n\t\"pad.savedrevs.marked\": \"Ndee yamre maantaama jooni ko baylital danndangal\",\n\t\"pad.savedrevs.timeslider\": \"Aɗa waawi yiyde baylitte danndaaɗe so yillaade daasorde tuma ndee\",\n\t\"pad.userlist.entername\": \" Naatnu innde maa\",\n\t\"pad.userlist.unnamed\": \"innitaaki\",\n\t\"pad.editbar.clearcolors\": \"Momtu noone wallifɓe e fiilannde ndee fof? Ɗum waawaa firteede\",\n\t\"pad.impexp.importbutton\": \"Jiggito Jooni\",\n\t\"pad.impexp.importing\": \"Nana Jiggitoo...\",\n\t\"pad.impexp.confirmimport\": \"Jiggitaade fiilde maa winndito e dow winndannde wonaande ndee. Aɗa yenanaa yiɗde jokkude?\",\n\t\"pad.impexp.convertFailed\": \"Min koriima jiggitaade ndee fiilde. Tiiɗno huutoro mbayka fiilannde ngoɗka walla natto ɗakkiraa junngo\",\n\t\"pad.impexp.padHasData\": \"Min koriima jiggitaade ndee fiilde sabu ngoo Faɗo meeɗii wayleede, tiiɗno jiggito faade e faɗo heso\",\n\t\"pad.impexp.uploadFailed\": \"Jollugol woorii, tiiɗno fuɗɗito\",\n\t\"pad.impexp.importfailed\": \"Jiggitol woorii\",\n\t\"pad.impexp.copypaste\": \"Tiiɗno natto ɗakkaa\",\n\t\"pad.impexp.exportdisabled\": \"Jiggitaade e mbayka {{type}} koko daaƴaa. Tuuɗno jokkondir e jiiloowo yuɓɓo maa ngam ɓeydude faamade.\",\n\t\"pad.impexp.maxFileSize\": \"Fiilde ena mawni haa ɓurti. Jokkondir e jiiloowo ngam ɓeydude ɓetol fiilde jamirangol ngam jiggeede\"\n}\n"
  },
  {
    "path": "src/locales/fi.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Artnay\",\n\t\t\t\"Espeox\",\n\t\t\t\"Jl\",\n\t\t\t\"Lliehu\",\n\t\t\t\"MITO\",\n\t\t\t\"Maantietäjä\",\n\t\t\t\"Macofe\",\n\t\t\t\"Markus Mikkonen\",\n\t\t\t\"MrTapsa\",\n\t\t\t\"Nedergard\",\n\t\t\t\"Nike\",\n\t\t\t\"Pyscowicz\",\n\t\t\t\"Silvonen\",\n\t\t\t\"Stryn\",\n\t\t\t\"Tomi Toivio\",\n\t\t\t\"Veikk0.ma\",\n\t\t\t\"VezonThunder\",\n\t\t\t\"Yupik\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Ylläpitäjän kojelauta - Etherpad\",\n\t\"admin_plugins\": \"Lisäosien hallinta\",\n\t\"admin_plugins.available\": \"Saatavilla olevat liitännäiset\",\n\t\"admin_plugins.available_not-found\": \"Lisäosia ei löytynyt.\",\n\t\"admin_plugins.available_fetching\": \"Noudetaan…\",\n\t\"admin_plugins.available_install.value\": \"Asenna\",\n\t\"admin_plugins.available_search.placeholder\": \"Etsi asennettavia laajennuksia\",\n\t\"admin_plugins.description\": \"Kuvaus\",\n\t\"admin_plugins.installed\": \"Asennetut laajennukset\",\n\t\"admin_plugins.installed_fetching\": \"Haetaan asennettuja laajennuksia.\",\n\t\"admin_plugins.installed_nothing\": \"Et ole vielä asentanut laajennuksia.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Poista asennus\",\n\t\"admin_plugins.last-update\": \"Viimeisin päivitys\",\n\t\"admin_plugins.name\": \"Nimi\",\n\t\"admin_plugins.page-title\": \"Laajennusten hallinta - Etherpad\",\n\t\"admin_plugins.version\": \"Versio\",\n\t\"admin_plugins_info\": \"Vianmääritystietoja\",\n\t\"admin_plugins_info.hooks\": \"Asennetut koukut\",\n\t\"admin_plugins_info.hooks_client\": \"Asiakaspuolen koukut.\",\n\t\"admin_plugins_info.hooks_server\": \"Palvelinpuolen koukut.\",\n\t\"admin_plugins_info.parts\": \"Asennetut osat\",\n\t\"admin_plugins_info.plugins\": \"Asennetut laajennukset\",\n\t\"admin_plugins_info.page-title\": \"Laajennustiedot - Etherpad\",\n\t\"admin_plugins_info.version\": \"Etherpad-versio\",\n\t\"admin_plugins_info.version_latest\": \"Viimeisin saatavilla oleva versio\",\n\t\"admin_plugins_info.version_number\": \"Versionumero\",\n\t\"admin_settings\": \"Asetukset\",\n\t\"admin_settings.current\": \"Nykyinen kokoonpano\",\n\t\"admin_settings.current_example-devel\": \"Esimerkki kehitysasetusten mallista\",\n\t\"admin_settings.current_example-prod\": \"Esimerkkipohja tuotantoasetuksille\",\n\t\"admin_settings.current_restart.value\": \"Käynnistä Etherpad uudelleen\",\n\t\"admin_settings.current_save.value\": \"Tallenna asetukset\",\n\t\"admin_settings.page-title\": \"asetukset - Etherpad\",\n\t\"index.newPad\": \"Uusi muistio\",\n\t\"index.createOpenPad\": \"Avaa muistio nimellä\",\n\t\"index.openPad\": \"avaa olemassa oleva muistio nimellä:\",\n\t\"pad.toolbar.bold.title\": \"Lihavointi (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Kursivointi (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Alleviivaus (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Yliviivaus (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Numeroitu lista (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Numeroimaton lista (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Sisennä (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Ulonna (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Kumoa (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Tee uudelleen (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Poista kirjoittajavärit (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Tuo tai vie eri tiedostomuodoista tai -muotoihin\",\n\t\"pad.toolbar.timeslider.title\": \"Aikajana\",\n\t\"pad.toolbar.savedRevision.title\": \"Tallenna muutos\",\n\t\"pad.toolbar.settings.title\": \"Asetukset\",\n\t\"pad.toolbar.embed.title\": \"Jaa ja upota muistio\",\n\t\"pad.toolbar.home.title\": \"Takaisin kotiin\",\n\t\"pad.toolbar.showusers.title\": \"Näytä muistion käyttäjät\",\n\t\"pad.colorpicker.save\": \"Tallenna\",\n\t\"pad.colorpicker.cancel\": \"Peru\",\n\t\"pad.loading\": \"Ladataan…\",\n\t\"pad.noCookie\": \"Evästettä ei löytynyt. Ole hyvä, ja salli evästeet selaimessasi!  Istuntoasi ja asetuksiasi ei tulla tallentamaan vierailujen välillä.  Tämä voi johtua siitä, että Etherpad on sisällytetty iFrameen joissain selaimissa.  Varmistathan että Etherpad on samalla subdomainilla/domainilla kuin ylätason iFrame\",\n\t\"pad.permissionDenied\": \"Käyttöoikeutesi eivät riitä tämän muistion käyttämiseen.\",\n\t\"pad.settings.padSettings\": \"Muistion asetukset\",\n\t\"pad.settings.myView\": \"Oma näkymä\",\n\t\"pad.settings.stickychat\": \"Keskustelu aina näkyvissä\",\n\t\"pad.settings.chatandusers\": \"Näytä keskustelu ja käyttäjät\",\n\t\"pad.settings.colorcheck\": \"Kirjoittajavärit\",\n\t\"pad.settings.linenocheck\": \"Rivinumerot\",\n\t\"pad.settings.rtlcheck\": \"Luetaanko sisältö oikealta vasemmalle?\",\n\t\"pad.settings.fontType\": \"Fonttityyppi:\",\n\t\"pad.settings.fontType.normal\": \"normaali\",\n\t\"pad.settings.language\": \"Kieli:\",\n\t\"pad.settings.deletePad\": \"Poista muistio\",\n\t\"pad.delete.confirm\": \"Haluatko todella poistaa tämän muistion?\",\n\t\"pad.settings.about\": \"Tietoja\",\n\t\"pad.settings.poweredBy\": \"Palvelun mahdollistaa\",\n\t\"pad.importExport.import_export\": \"Tuonti/vienti\",\n\t\"pad.importExport.import\": \"Lähetä mikä tahansa tekstitiedosto tai asiakirja\",\n\t\"pad.importExport.importSuccessful\": \"Onnistui!\",\n\t\"pad.importExport.export\": \"Vie muistio muodossa:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Muotoilematon teksti\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Tuonti on tuettu vain HTML- ja raakatekstitiedostoista. Monipuoliset tuontiominaisuudet ovat käytettävissä <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">asentamalla AbiWordin tai LibreOfficen</a>.\",\n\t\"pad.modals.connected\": \"Yhdistetty.\",\n\t\"pad.modals.reconnecting\": \"Muodostetaan yhteyttä muistioon uudelleen…\",\n\t\"pad.modals.forcereconnect\": \"Pakota yhdistämään uudelleen\",\n\t\"pad.modals.reconnecttimer\": \"Yritetään yhdistää uudelleen\",\n\t\"pad.modals.cancel\": \"Peruuta\",\n\t\"pad.modals.userdup\": \"Avattu toisessa ikkunassa\",\n\t\"pad.modals.userdup.explanation\": \"Tämä muistio vaikuttaa olevan avoinna useammassa eri selainikkunassa tällä koneella.\",\n\t\"pad.modals.userdup.advice\": \"Yhdistä uudelleen, jos haluat käyttää tätä ikkunaa.\",\n\t\"pad.modals.unauth\": \"Oikeudet eivät riitä\",\n\t\"pad.modals.unauth.explanation\": \"Käyttöoikeutesi ovat muuttuneet katsellessasi tätä sivua. Yritä yhdistää uudelleen.\",\n\t\"pad.modals.looping.explanation\": \"Synkronointipalvelimen kanssa on yhteysongelmia.\",\n\t\"pad.modals.looping.cause\": \"Yhteytesi on mahdollisesti muodostettu yhteensopimattoman palomuurin tai välityspalvelimen kautta.\",\n\t\"pad.modals.initsocketfail\": \"Palvelimeen ei saada yhteyttä.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Synkronointipalvelimeen ei saatu yhteyttä.\",\n\t\"pad.modals.initsocketfail.cause\": \"Tämä johtuu mitä luultavimmin selaimestasi tai verkkoyhteydestäsi.\",\n\t\"pad.modals.slowcommit.explanation\": \"Palvelin ei vastaa.\",\n\t\"pad.modals.slowcommit.cause\": \"Tämä saattaa johtua verkkoyhteyden ongelmista.\",\n\t\"pad.modals.badChangeset.explanation\": \"Tekemäsi muutos määritettiin sääntöjen vastaiseksi synkronointipalvelimen toimesta.\",\n\t\"pad.modals.badChangeset.cause\": \"Tämä saattaa johtua virheellisistä palvelinmäärityksistä tai muusta odottamattomasta toiminnasta. Ota yhteys palvelun ylläpitäjään, jos kyseessä on mielestäsi virhe. Yritä jatkaa muokkausta yhdistämällä uudelleen.\",\n\t\"pad.modals.corruptPad.explanation\": \"Muistio jota yrität avata on vioittunut.\",\n\t\"pad.modals.corruptPad.cause\": \"Tämä saattaa johtua virheellisistä palvelinmäärityksistä tai muusta odottamattomasta toiminnasta. Ota yhteys palvelun ylläpitäjään.\",\n\t\"pad.modals.deleted\": \"Poistettu.\",\n\t\"pad.modals.deleted.explanation\": \"Tämä muistio on poistettu.\",\n\t\"pad.modals.disconnected\": \"Yhteytesi on katkaistu.\",\n\t\"pad.modals.disconnected.explanation\": \"Yhteys palvelimeen katkesi\",\n\t\"pad.modals.disconnected.cause\": \"Palvelin saattaa olla tavoittamattomissa. Ilmoita palvelun ylläpitäjälle, jos tilanne toistuu usein.\",\n\t\"pad.share\": \"Jaa muistio\",\n\t\"pad.share.readonly\": \"Vain luku\",\n\t\"pad.share.link\": \"Linkki\",\n\t\"pad.share.emebdcode\": \"Upotusosoite\",\n\t\"pad.chat\": \"Keskustelu\",\n\t\"pad.chat.title\": \"Avaa keskustelu nykyisestä muistiosta.\",\n\t\"pad.chat.loadmessages\": \"Lataa lisää viestejä\",\n\t\"pad.chat.stick.title\": \"Liimaa chatti ruutuun\",\n\t\"pad.chat.writeMessage.placeholder\": \"Kirjoita viestisi tähän\",\n\t\"timeslider.followContents\": \"Seuraa muistion sisällön päivityksiä\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} -aikajana\",\n\t\"timeslider.toolbar.returnbutton\": \"Palaa muistioon\",\n\t\"timeslider.toolbar.authors\": \"Tekijät:\",\n\t\"timeslider.toolbar.authorsList\": \"Ei tekijöitä\",\n\t\"timeslider.toolbar.exportlink.title\": \"Vie\",\n\t\"timeslider.exportCurrent\": \"Vie nykyinen versio muodossa:\",\n\t\"timeslider.version\": \"Versio {{version}}\",\n\t\"timeslider.saved\": \"Tallennettu {{day}}. {{month}}ta {{year}}\",\n\t\"timeslider.playPause\": \"Toista / pysäytä muistion sisältö\",\n\t\"timeslider.backRevision\": \"Palaa edelliseen muutokseen taaksepäin tässä muistiossa\",\n\t\"timeslider.forwardRevision\": \"Siirry seuraavaan muutokseen tässä muistiossa\",\n\t\"timeslider.dateformat\": \"{{day}}.{{month}}.{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"tammikuu\",\n\t\"timeslider.month.february\": \"helmikuu\",\n\t\"timeslider.month.march\": \"maaliskuu\",\n\t\"timeslider.month.april\": \"huhtikuu\",\n\t\"timeslider.month.may\": \"toukokuu\",\n\t\"timeslider.month.june\": \"kesäkuu\",\n\t\"timeslider.month.july\": \"heinäkuu\",\n\t\"timeslider.month.august\": \"elokuu\",\n\t\"timeslider.month.september\": \"syyskuu\",\n\t\"timeslider.month.october\": \"lokakuu\",\n\t\"timeslider.month.november\": \"marraskuu\",\n\t\"timeslider.month.december\": \"joulukuu\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: nimetön tekijä, other: nimetöntä tekijää ]}\",\n\t\"pad.savedrevs.marked\": \"Tämä versio on nyt merkitty tallennetuksi versioksi\",\n\t\"pad.savedrevs.timeslider\": \"Voit tarkastella tallennettuja versioita avaamalla aikajanan\",\n\t\"pad.userlist.entername\": \"Kirjoita nimesi\",\n\t\"pad.userlist.unnamed\": \"nimetön\",\n\t\"pad.editbar.clearcolors\": \"Poistetaanko asiakirjasta tekijävärit? Tätä ei voi perua\",\n\t\"pad.impexp.importbutton\": \"Tuo nyt\",\n\t\"pad.impexp.importing\": \"Tuodaan...\",\n\t\"pad.impexp.confirmimport\": \"Tiedoston tuonti korvaa kaiken muistiossa olevan tekstin. Haluatko varmasti jatkaa?\",\n\t\"pad.impexp.convertFailed\": \"TIedoston tuonti epäonnistui. Käytä eri tiedostomuotoa tai kopioi ja liitä käsin.\",\n\t\"pad.impexp.padHasData\": \"Tiedostoa ei voitu lisätä muistioon, koska muistiota on jo muokattu – ole hyvä ja lisää tiedosto uuteen muistioon\",\n\t\"pad.impexp.uploadFailed\": \"Lähetys epäonnistui. Yritä uudelleen.\",\n\t\"pad.impexp.importfailed\": \"Tuonti epäonnistui\",\n\t\"pad.impexp.copypaste\": \"Kopioi ja liitä\",\n\t\"pad.impexp.exportdisabled\": \"Vienti muotoon \\\"{{type}}\\\" ei ole käytössä. Ota yhteys ylläpitäjään saadaksesi lisätietoja.\",\n\t\"pad.impexp.maxFileSize\": \"Tiedosto on liian suuri. Ota yhteyttä sivustosi ylläpitäjään ja pyydä heitä korottomaan suurinta sallittua tiedostokokoa.\"\n}\n"
  },
  {
    "path": "src/locales/fo.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"EileenSanda\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Nýggjur teldil\",\n\t\"pad.toolbar.bold.title\": \"Við feitum (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Skráskrift (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Undirstrikað (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Gjøgnumstrikað\",\n\t\"pad.toolbar.ol.title\": \"Bíleggingarlisti\",\n\t\"pad.toolbar.undo.title\": \"Angra (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Ger umaftur (Ctrl-Y)\",\n\t\"pad.toolbar.import_export.title\": \"Innflyt/Útflyt frá/til ymiskar fílustøddir\",\n\t\"pad.toolbar.savedRevision.title\": \"Goym Endurskoðan\",\n\t\"pad.toolbar.settings.title\": \"Innstillingar\",\n\t\"pad.toolbar.embed.title\": \"Deil og Innset henda pad'in\",\n\t\"pad.toolbar.showusers.title\": \"Vís brúkarar á hesum paddi\",\n\t\"pad.colorpicker.save\": \"Goym\",\n\t\"pad.colorpicker.cancel\": \"Ógilda\",\n\t\"pad.loading\": \"Løðir...\",\n\t\"pad.permissionDenied\": \"Tú hevur ikki loyvi til at fáa atgongd til henda paddin\",\n\t\"pad.settings.padSettings\": \"Pad innstillingar\",\n\t\"pad.settings.myView\": \"Mín sýning\",\n\t\"pad.settings.stickychat\": \"Kjatta altíð á skerminum\",\n\t\"pad.settings.colorcheck\": \"Litir hjá rithøvundaskapinum\",\n\t\"pad.settings.linenocheck\": \"Linjunummur\",\n\t\"pad.settings.rtlcheck\": \"Vil tú lesa innihaldið frá høgru til vinstu?\",\n\t\"pad.settings.fontType\": \"Skriftslag:\",\n\t\"pad.settings.fontType.normal\": \"Vanligt\",\n\t\"pad.settings.language\": \"Mál:\",\n\t\"pad.importExport.import_export\": \"Innflyt/Útflyt\",\n\t\"pad.importExport.import\": \"Legg út onkra tekstfílu ella dokument\",\n\t\"pad.importExport.importSuccessful\": \"Tað eydnaðist!\",\n\t\"pad.importExport.export\": \"Útflyt verandi pad sum:\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Einfaldur tekstur\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Opið Dokument Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Tú kanst bert innflyta frá einføldum teksti ella html formatum. Fyri funksjónir til innflytan fyri víðarikomin vinarliga <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">installera abiword</a>.\",\n\t\"pad.modals.connected\": \"Tú hevur samband.\",\n\t\"pad.modals.reconnecting\": \"Roynir aftur at fáa samband við tín pad..\",\n\t\"pad.modals.forcereconnect\": \"Tvinga endurstovnan av sambandi.\",\n\t\"pad.modals.userdup\": \"Er latið upp í øðrum vindeyga\",\n\t\"pad.modals.userdup.explanation\": \"Tað sær út til at hesin paddurin er latin upp í meira enn einum brovsara vindeyga á hesari telduni.\",\n\t\"pad.modals.userdup.advice\": \"Endurstovna sambandi fyri at nýta hetta vindeyga í staðin.\",\n\t\"pad.modals.unauth\": \"Er ikki loyvt\",\n\t\"pad.modals.unauth.explanation\": \"Tíni loyvi eru broytt, meðan tú hevur hugt at hesi síðuni. Royn og endurstovna sambandi.\",\n\t\"pad.modals.initsocketfail\": \"Ambætarin er óatkomuligur.\",\n\t\"pad.modals.initsocketfail.cause\": \"Hetta skyldast mest sannlíkt ein trupulleika við tínum kaga/brovsara ella við tínum internetsambandi.\",\n\t\"pad.modals.slowcommit.explanation\": \"Ambætarin (servarin) svarar ikki.\",\n\t\"pad.modals.slowcommit.cause\": \"Hetta kann skyldast trupulleikar við netverkssambandinum.\",\n\t\"pad.modals.deleted\": \"Er strikað.\",\n\t\"pad.modals.deleted.explanation\": \"Hesin paddurin er fluttur.\",\n\t\"pad.modals.disconnected\": \"Tú hevur mist sambandi.\",\n\t\"pad.modals.disconnected.explanation\": \"Sambandið til ambætarin er avbrotið\",\n\t\"pad.share\": \"Deil henda paddin\",\n\t\"pad.share.readonly\": \"Vart fyri skriving\",\n\t\"pad.share.link\": \"Slóð\",\n\t\"timeslider.toolbar.returnbutton\": \"Vend aftur til pad'in\",\n\t\"timeslider.toolbar.authors\": \"Høvundar:\",\n\t\"timeslider.toolbar.authorsList\": \"Ongir høvundar\",\n\t\"timeslider.toolbar.exportlink.title\": \"Útflyt\",\n\t\"timeslider.exportCurrent\": \"Útflyt hesa versjóna sum:\",\n\t\"timeslider.version\": \"Versjón {{version}}\",\n\t\"timeslider.saved\": \"Goymt {{month}} {{day}}, {{year}}\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Januar\",\n\t\"timeslider.month.february\": \"Februar\",\n\t\"timeslider.month.march\": \"Mars\",\n\t\"timeslider.month.april\": \"Apríl\",\n\t\"timeslider.month.may\": \"Mai\",\n\t\"timeslider.month.june\": \"Juni\",\n\t\"timeslider.month.july\": \"Juli\",\n\t\"timeslider.month.august\": \"August\",\n\t\"timeslider.month.september\": \"September\",\n\t\"timeslider.month.october\": \"October\",\n\t\"timeslider.month.november\": \"November\",\n\t\"timeslider.month.december\": \"Desember\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: ónevndur rithøvundur, other: ónevndir rithøvundar ]}\",\n\t\"pad.savedrevs.marked\": \"Henda endurskoðanin er nú merkt sum ein goymd endurskoðan\",\n\t\"pad.userlist.entername\": \"Skriva títt navn\",\n\t\"pad.userlist.unnamed\": \"ikki-navngivið\"\n}\n"
  },
  {
    "path": "src/locales/fr.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Boniface\",\n\t\t\t\"C13m3n7\",\n\t\t\t\"Cquoi\",\n\t\t\t\"Crochet.david\",\n\t\t\t\"Derugon\",\n\t\t\t\"Envlh\",\n\t\t\t\"Framafan\",\n\t\t\t\"Fylip22\",\n\t\t\t\"Gomoko\",\n\t\t\t\"Goofy\",\n\t\t\t\"Goofy-bz\",\n\t\t\t\"Jean-Frédéric\",\n\t\t\t\"Leviathan\",\n\t\t\t\"Macofe\",\n\t\t\t\"Mahabarata\",\n\t\t\t\"Maxim21\",\n\t\t\t\"McDutchie\",\n\t\t\t\"Meaz\",\n\t\t\t\"Metroitendo\",\n\t\t\t\"Od1n\",\n\t\t\t\"Peter17\",\n\t\t\t\"Quenenni\",\n\t\t\t\"Rastus Vernon\",\n\t\t\t\"Spf\",\n\t\t\t\"Stephane Cottin\",\n\t\t\t\"Thibaut120094\",\n\t\t\t\"Tux-tn\",\n\t\t\t\"Urhixidur\",\n\t\t\t\"Verdy p\",\n\t\t\t\"Wladek92\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Tableau de bord administrateur — Etherpad\",\n\t\"admin_plugins\": \"Gestionnaire de greffons\",\n\t\"admin_plugins.available\": \"Greffons disponibles\",\n\t\"admin_plugins.available_not-found\": \"Aucun greffon trouvé.\",\n\t\"admin_plugins.available_fetching\": \"Récupération en cours...\",\n\t\"admin_plugins.available_install.value\": \"Installer\",\n\t\"admin_plugins.available_search.placeholder\": \"Rechercher des greffons à installer\",\n\t\"admin_plugins.description\": \"Description\",\n\t\"admin_plugins.installed\": \"Greffons installés\",\n\t\"admin_plugins.installed_fetching\": \"Récupération des greffons installés en cours...\",\n\t\"admin_plugins.installed_nothing\": \"Vous n’avez encore installé aucun greffon.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Désinstaller\",\n\t\"admin_plugins.last-update\": \"Dernière mise à jour\",\n\t\"admin_plugins.name\": \"Nom\",\n\t\"admin_plugins.page-title\": \"Gestionnaire de greffons — Etherpad\",\n\t\"admin_plugins.version\": \"Version\",\n\t\"admin_plugins_info\": \"Informations de résolution de problème\",\n\t\"admin_plugins_info.hooks\": \"Crochets installés\",\n\t\"admin_plugins_info.hooks_client\": \"Crochets côté client\",\n\t\"admin_plugins_info.hooks_server\": \"Crochets côté serveur\",\n\t\"admin_plugins_info.parts\": \"Parties installées\",\n\t\"admin_plugins_info.plugins\": \"Greffons installés\",\n\t\"admin_plugins_info.page-title\": \"Informations du greffon — Etherpad\",\n\t\"admin_plugins_info.version\": \"Version d’Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Dernière version disponible\",\n\t\"admin_plugins_info.version_number\": \"Numéro de version\",\n\t\"admin_settings\": \"Paramètres\",\n\t\"admin_settings.current\": \"Configuration actuelle\",\n\t\"admin_settings.current_example-devel\": \"Exemple de modèle de paramètres de développement\",\n\t\"admin_settings.current_example-prod\": \"Exemple de modèle de paramètres de production\",\n\t\"admin_settings.current_restart.value\": \"Redémarrer Etherpad\",\n\t\"admin_settings.current_save.value\": \"Enregistrer les paramètres\",\n\t\"admin_settings.page-title\": \"Paramètres — Etherpad\",\n\t\"index.newPad\": \"Nouveau bloc-notes\",\n\t\"index.settings\": \"Paramètres\",\n\t\"index.transferSessionTitle\": \"Session de transfert\",\n\t\"index.receiveSessionTitle\": \"Résumé de la séance\",\n\t\"index.receiveSessionDescription\": \"Vous pouvez ici recevoir une session Etherpad depuis un autre navigateur ou appareil. Veuillez noter toutefois que cela supprimera votre session actuelle, le cas échéant.\",\n\t\"index.transferSession\": \"1. Séance de transfert\",\n\t\"index.transferSessionNow\": \"Séance de transfert maintenant\",\n\t\"index.copyLink\": \"Copier le lien\",\n\t\"index.copyLinkDescription\": \"Cliquez sur le bouton ci-dessous pour copier le lien dans votre presse-papiers.\",\n\t\"index.copyLinkButton\": \"Copier le lien dans le presse-papiers\",\n\t\"index.transferToSystem\": \"3. Copier la séance sur le nouveau système\",\n\t\"index.transferToSystemDescription\": \"Ouvrir le lien copié dans le navigateur ou l'appareil cible pour transférer votre séance.\",\n\t\"index.transferSessionDescription\": \"Transférez votre session actuelle vers le navigateur ou l'appareil en cliquant sur le bouton ci-dessous. Cela copiera un lien vers une page qui transférera votre session lors de l'ouverture dans le navigateur ou l'appareil cible.\",\n\t\"index.createOpenPad\": \"Ouvrir le bloc-notes par son nom\",\n\t\"index.openPad\": \"ouvrir un bloc-note existant avec le nom :\",\n\t\"index.recentPads\": \"Bloc-notes récents\",\n\t\"index.recentPadsEmpty\": \"Aucun bloc-notes récents trouvés.\",\n\t\"index.generateNewPad\": \"Générer un nom de bloc-notes aléatoire\",\n\t\"index.labelPad\": \"Nom du bloc-notes (facultatif)\",\n\t\"index.placeholderPadEnter\": \"Veuillez saisir un nom de bloc-notes...\",\n\t\"index.createAndShareDocuments\": \"Créez et partagez des documents en temps réel\",\n\t\"index.createAndShareDocumentsDescription\": \"Etherpad vous permet d'éditer des documents de manière collaborative en temps réel, un peu comme un éditeur multijoueur en direct qui s'exécute dans votre navigateur.\",\n\t\"pad.toolbar.bold.title\": \"Gras (Ctrl + B)\",\n\t\"pad.toolbar.italic.title\": \"Italique (Ctrl + I)\",\n\t\"pad.toolbar.underline.title\": \"Souligné (Ctrl + U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Barré (Ctrl + 5)\",\n\t\"pad.toolbar.ol.title\": \"Liste ordonnée (Ctrl + Maj + N)\",\n\t\"pad.toolbar.ul.title\": \"Liste non ordonnée (Ctrl + Maj + L)\",\n\t\"pad.toolbar.indent.title\": \"Indenter (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Désindenter (Maj+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Annuler (Ctrl + Z)\",\n\t\"pad.toolbar.redo.title\": \"Rétablir (Ctrl + Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Effacer le surlignage par auteur (Ctrl + Maj + C)\",\n\t\"pad.toolbar.import_export.title\": \"Importer/Exporter des formats de fichiers différents\",\n\t\"pad.toolbar.timeslider.title\": \"Historique dynamique\",\n\t\"pad.toolbar.savedRevision.title\": \"Enregistrer la révision\",\n\t\"pad.toolbar.settings.title\": \"Paramètres\",\n\t\"pad.toolbar.embed.title\": \"Partager et intégrer ce bloc-notes\",\n\t\"pad.toolbar.home.title\": \"Retour à l’accueil\",\n\t\"pad.toolbar.showusers.title\": \"Afficher les utilisateurs du bloc-notes\",\n\t\"pad.colorpicker.save\": \"Enregistrer\",\n\t\"pad.colorpicker.cancel\": \"Annuler\",\n\t\"pad.loading\": \"Chargement en cours...\",\n\t\"pad.noCookie\": \"Un fichier témoin (ou ''cookie'') n’a pas pu être trouvé. Veuillez autoriser les fichiers témoins dans votre navigateur ! Votre session et vos paramètres ne seront pas enregistrés entre les visites. Cela peut être dû au fait qu’Etherpad est inclus dans un ''iFrame'' dans certains navigateurs. Veuillez vous assurer qu’Etherpad est dans le même sous-domaine/domaine que son ''iFrame'' parent.\",\n\t\"pad.permissionDenied\": \"Vous n’êtes pas autorisé à accéder à ce bloc-notes\",\n\t\"pad.settings.padSettings\": \"Paramètres du bloc-notes\",\n\t\"pad.settings.myView\": \"Ma vue\",\n\t\"pad.settings.stickychat\": \"Toujours afficher le clavardage\",\n\t\"pad.settings.chatandusers\": \"Afficher le clavardage et les utilisateurs\",\n\t\"pad.settings.colorcheck\": \"Surlignage par auteur\",\n\t\"pad.settings.linenocheck\": \"Numéros de lignes\",\n\t\"pad.settings.rtlcheck\": \"Le contenu doit-il être lu de droite à gauche ?\",\n\t\"pad.settings.fontType\": \"Type de police :\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Langue :\",\n\t\"pad.settings.deletePad\": \"Supprimer le bloc-notes\",\n\t\"pad.delete.confirm\": \"Voulez-vous vraiment supprimer ce bloc-notes ?\",\n\t\"pad.settings.about\": \"À propos\",\n\t\"pad.settings.poweredBy\": \"Propulsé par\",\n\t\"pad.importExport.import_export\": \"Importer/Exporter\",\n\t\"pad.importExport.import\": \"Téléverser un texte ou un document\",\n\t\"pad.importExport.importSuccessful\": \"Réussi !\",\n\t\"pad.importExport.export\": \"Exporter le bloc-notes actuel en :\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Texte brut\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Vous ne pouvez importer que des formats texte brut ou HTML. Pour des fonctionnalités d’importation plus évoluées, veuillez <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">installer AbiWord ou LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Connecté.\",\n\t\"pad.modals.reconnecting\": \"Reconnexion à votre bloc-notes en cours...\",\n\t\"pad.modals.forcereconnect\": \"Forcer la reconnexion\",\n\t\"pad.modals.reconnecttimer\": \"Essai de reconnexion\",\n\t\"pad.modals.cancel\": \"Annuler\",\n\t\"pad.modals.userdup\": \"Ouvert dans une autre fenêtre\",\n\t\"pad.modals.userdup.explanation\": \"Ce bloc-notes semble être ouvert dans plusieurs fenêtres sur cet ordinateur.\",\n\t\"pad.modals.userdup.advice\": \"Se reconnecter en utilisant plutôt cette fenêtre.\",\n\t\"pad.modals.unauth\": \"Non autorisé\",\n\t\"pad.modals.unauth.explanation\": \"Vos autorisations ont changées lors de l’affichage de cette page. Essayez de vous reconnecter.\",\n\t\"pad.modals.looping.explanation\": \"Nous éprouvons des problèmes de communication au serveur de synchronisation.\",\n\t\"pad.modals.looping.cause\": \"Il est possible que vous soyez connecté avec un pare-feu ou un mandataire incompatible.\",\n\t\"pad.modals.initsocketfail\": \"Le serveur est introuvable.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Impossible de se connecter au serveur de synchronisation.\",\n\t\"pad.modals.initsocketfail.cause\": \"Ceci est probablement dû à un problème avec votre navigateur ou votre connexion internet.\",\n\t\"pad.modals.slowcommit.explanation\": \"Le serveur ne répond pas.\",\n\t\"pad.modals.slowcommit.cause\": \"Ce problème peut venir d’une mauvaise connectivité au réseau.\",\n\t\"pad.modals.badChangeset.explanation\": \"Une modification que vous avez effectuée a été classée comme interdite par le serveur de synchronisation.\",\n\t\"pad.modals.badChangeset.cause\": \"Cela peut être dû à une mauvaise configuration du serveur ou à un autre comportement inattendu. Veuillez contacter l’administrateur du service si vous pensez que c’est une erreur. Essayez de vous reconnecter pour continuer à modifier.\",\n\t\"pad.modals.corruptPad.explanation\": \"Le bloc-notes auquel vous essayez d’accéder est corrompu.\",\n\t\"pad.modals.corruptPad.cause\": \"Cela peut être dû à une mauvaise configuration du serveur ou à un autre comportement inattendu. Veuillez contacter l’administrateur du service.\",\n\t\"pad.modals.deleted\": \"Supprimé.\",\n\t\"pad.modals.deleted.explanation\": \"Ce bloc-notes a été supprimé.\",\n\t\"pad.modals.rateLimited\": \"Débit limité.\",\n\t\"pad.modals.rateLimited.explanation\": \"Vous avez envoyé trop de messages à ce bloc-notes, il vous a donc déconnecté.\",\n\t\"pad.modals.rejected.explanation\": \"Le serveur a rejeté un message qui a été envoyé par votre navigateur.\",\n\t\"pad.modals.rejected.cause\": \"Le serveur peut avoir été mis à jour pendant que vous regardiez le bloc-notes, ou il y a peut-être une anomalie dans Etherpad. Essayez de recharger la page.\",\n\t\"pad.modals.disconnected\": \"Vous avez été déconnecté.\",\n\t\"pad.modals.disconnected.explanation\": \"La connexion au serveur a échoué\",\n\t\"pad.modals.disconnected.cause\": \"Il se peut que le serveur soit indisponible. Si le problème persiste, veuillez en informer l’administrateur du service.\",\n\t\"pad.share\": \"Partager ce bloc-notes\",\n\t\"pad.share.readonly\": \"Lecture seule\",\n\t\"pad.share.link\": \"Lien\",\n\t\"pad.share.emebdcode\": \"Incorporer un lien\",\n\t\"pad.chat\": \"Clavardage\",\n\t\"pad.chat.title\": \"Ouvrir le clavardage sur ce bloc-notes.\",\n\t\"pad.chat.loadmessages\": \"Charger davantage de messages\",\n\t\"pad.chat.stick.title\": \"Ancrer la discussion sur l’écran\",\n\t\"pad.chat.writeMessage.placeholder\": \"Entrez votre message ici\",\n\t\"timeslider.followContents\": \"Suivre les mises à jour de contenu du bloc-notes\",\n\t\"timeslider.pageTitle\": \"Historique dynamique de {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Retourner au bloc-notes\",\n\t\"timeslider.toolbar.authors\": \"Auteurs :\",\n\t\"timeslider.toolbar.authorsList\": \"Aucun auteur\",\n\t\"timeslider.toolbar.exportlink.title\": \"Exporter\",\n\t\"timeslider.exportCurrent\": \"Exporter la version actuelle sous :\",\n\t\"timeslider.version\": \"Version {{version}}\",\n\t\"timeslider.saved\": \"Enregistrée le {{day}} {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Lecture / Pause des contenus du bloc-notes\",\n\t\"timeslider.backRevision\": \"Reculer d’une révision dans ce bloc-notes\",\n\t\"timeslider.forwardRevision\": \"Avancer d’une révision dans ce bloc-notes\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"janvier\",\n\t\"timeslider.month.february\": \"février\",\n\t\"timeslider.month.march\": \"mars\",\n\t\"timeslider.month.april\": \"avril\",\n\t\"timeslider.month.may\": \"mai\",\n\t\"timeslider.month.june\": \"juin\",\n\t\"timeslider.month.july\": \"juillet\",\n\t\"timeslider.month.august\": \"août\",\n\t\"timeslider.month.september\": \"septembre\",\n\t\"timeslider.month.october\": \"octobre\",\n\t\"timeslider.month.november\": \"novembre\",\n\t\"timeslider.month.december\": \"décembre\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: auteur anonyme, other: auteurs anonymes ]}\",\n\t\"pad.savedrevs.marked\": \"Cette révision est maintenant marquée comme révision enregistrée\",\n\t\"pad.savedrevs.timeslider\": \"Vous pouvez voir les révisions enregistrées en ouvrant l’historique\",\n\t\"pad.userlist.entername\": \"Saisissez votre nom\",\n\t\"pad.userlist.unnamed\": \"anonyme\",\n\t\"pad.editbar.clearcolors\": \"Effacer le surlignage par auteur dans tout le document ? Cette action n'est pas réversible.\",\n\t\"pad.impexp.importbutton\": \"Importer maintenant\",\n\t\"pad.impexp.importing\": \"Importation en cours...\",\n\t\"pad.impexp.confirmimport\": \"Importer un fichier écrasera le contenu actuel du bloc-notes. Êtes-vous sûr de vouloir le faire ?\",\n\t\"pad.impexp.convertFailed\": \"Nous ne pouvons pas importer ce fichier. Veuillez utiliser un autre format de document ou faire manuellement\",\n\t\"pad.impexp.padHasData\": \"Nous n’avons pas pu importer ce fichier parce que ce bloc-notes a déjà été modifié ; veuillez l’importer vers un nouveau bloc-notes\",\n\t\"pad.impexp.uploadFailed\": \"Le téléversement a échoué, veuillez réessayer\",\n\t\"pad.impexp.importfailed\": \"Échec de l’import\",\n\t\"pad.impexp.copypaste\": \"Veuillez copier-coller\",\n\t\"pad.impexp.exportdisabled\": \"L’exportation au format {{type}} est désactivée. Veuillez contacter votre administrateur système pour plus de détails.\",\n\t\"pad.impexp.maxFileSize\": \"Fichier trop gros. Contactez votre administrateur de site pour augmenter la taille maximale des fichiers importés.\"\n}\n"
  },
  {
    "path": "src/locales/fy.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Robin van der Vliet\"\n\t\t]\n\t},\n\t\"pad.toolbar.bold.title\": \"Fet (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Kursyf (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Understreekje (Ctrl+U)\",\n\t\"pad.toolbar.settings.title\": \"Ynstellingen\",\n\t\"pad.colorpicker.save\": \"Bewarje\",\n\t\"pad.colorpicker.cancel\": \"Annulearje\",\n\t\"pad.settings.fontType.normal\": \"Normaal\",\n\t\"pad.settings.language\": \"Taal:\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.modals.connected\": \"Ferbûn.\",\n\t\"pad.modals.deleted\": \"Fuortsmiten.\",\n\t\"pad.share.link\": \"Keppeling\",\n\t\"timeslider.toolbar.authors\": \"Auteurs:\",\n\t\"timeslider.toolbar.authorsList\": \"Gjin auteurs\",\n\t\"timeslider.toolbar.exportlink.title\": \"Eksportearje\",\n\t\"timeslider.version\": \"Ferzje {{version}}\",\n\t\"timeslider.dateformat\": \"{{day}}-{{month}}-{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"jannewaris\",\n\t\"timeslider.month.february\": \"febrewaris\",\n\t\"timeslider.month.march\": \"maart\",\n\t\"timeslider.month.april\": \"april\",\n\t\"timeslider.month.may\": \"maaie\",\n\t\"timeslider.month.june\": \"juny\",\n\t\"timeslider.month.july\": \"july\",\n\t\"timeslider.month.august\": \"augustus\",\n\t\"timeslider.month.september\": \"septimber\",\n\t\"timeslider.month.october\": \"oktober\",\n\t\"timeslider.month.november\": \"novimber\",\n\t\"timeslider.month.december\": \"desimber\",\n\t\"pad.userlist.unnamed\": \"sûnder namme\"\n}\n"
  },
  {
    "path": "src/locales/ga.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Aindriu80\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Painéal Riaracháin - Etherpad\",\n\t\"admin_plugins\": \"Bainisteoir breiseán\",\n\t\"admin_plugins.available\": \"Breiseáin atá ar fáil\",\n\t\"admin_plugins.available_not-found\": \"Níor aimsíodh aon bhreiseáin.\",\n\t\"admin_plugins.available_fetching\": \"Ag fáil…\",\n\t\"admin_plugins.available_install.value\": \"Suiteáil\",\n\t\"admin_plugins.available_search.placeholder\": \"Cuardaigh breiseáin le suiteáil\",\n\t\"admin_plugins.description\": \"Cur síos\",\n\t\"admin_plugins.installed\": \"Breiseáin suiteáilte\",\n\t\"admin_plugins.installed_fetching\": \"Ag fáil breiseáin suiteáilte…\",\n\t\"admin_plugins.installed_nothing\": \"Níl aon bhreiseáin suiteáilte agat fós.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Díshuiteáil\",\n\t\"admin_plugins.last-update\": \"Nuashonrú deireanach\",\n\t\"admin_plugins.name\": \"Ainm\",\n\t\"admin_plugins.page-title\": \"Bainisteoir breiseán - Etherpad\",\n\t\"admin_plugins.version\": \"Leagan\",\n\t\"admin_plugins_info\": \"Faisnéis fabhtcheartaithe\",\n\t\"admin_plugins_info.hooks\": \"Crúcaí suiteáilte\",\n\t\"admin_plugins_info.hooks_client\": \"Crúcaí taobh an chliaint\",\n\t\"admin_plugins_info.hooks_server\": \"Crúcaí taobh an fhreastalaí\",\n\t\"admin_plugins_info.parts\": \"Páirteanna suiteáilte\",\n\t\"admin_plugins_info.plugins\": \"Breiseáin suiteáilte\",\n\t\"admin_plugins_info.page-title\": \"Faisnéis faoin mbreiseán - Etherpad\",\n\t\"admin_plugins_info.version\": \"Leagan Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"An leagan is déanaí atá ar fáil\",\n\t\"admin_plugins_info.version_number\": \"Uimhir leagan\",\n\t\"admin_settings\": \"Socruithe\",\n\t\"admin_settings.current\": \"Cumraíocht reatha\",\n\t\"admin_settings.current_example-devel\": \"Teimpléad socruithe forbartha samplach\",\n\t\"admin_settings.current_example-prod\": \"Teimpléad socruithe táirgeachta samplach\",\n\t\"admin_settings.current_restart.value\": \"Atosaigh Etherpad\",\n\t\"admin_settings.current_save.value\": \"Sábháil Socruithe\",\n\t\"admin_settings.page-title\": \"Socruithe - Etherpad\",\n\t\"index.newPad\": \"Ceap Nua\",\n\t\"index.settings\": \"Socruithe\",\n\t\"index.transferSessionTitle\": \"Seisiún aistrithe\",\n\t\"index.receiveSessionTitle\": \"Faigh seisiún\",\n\t\"index.receiveSessionDescription\": \"Anseo is féidir leat seisiún Etherpad a fháil ó bhrabhsálaí nó gléas eile. Tabhair faoi deara, áfach, go scriosfaidh sé seo do sheisiún reatha, más ann dó.\",\n\t\"index.transferSession\": \"1. Seisiún aistrithe\",\n\t\"index.transferSessionNow\": \"Seisiún aistrithe anois\",\n\t\"index.copyLink\": \"2. Cóipeáil nasc\",\n\t\"index.copyLinkDescription\": \"Cliceáil ar an gcnaipe thíos chun an nasc a chóipeáil chuig do ghearrthaisce.\",\n\t\"index.copyLinkButton\": \"Cóipeáil nasc chuig an ghearrthaisce\",\n\t\"index.transferToSystem\": \"3. Cóipeáil an seisiún chuig an gcóras nua\",\n\t\"index.transferToSystemDescription\": \"Oscail an nasc cóipeáilte sa bhrabhsálaí nó ar an ngléas sprice chun do sheisiún a aistriú.\",\n\t\"index.transferSessionDescription\": \"Aistrigh do sheisiún reatha chuig brabhsálaí nó gléas trí chliceáil ar an gcnaipe thíos. Cóipeálfaidh sé seo nasc chuig leathanach a aistreoidh do sheisiún nuair a osclófar é sa bhrabhsálaí nó sa ghléas sprice.\",\n\t\"index.createOpenPad\": \"Oscail ceap de réir ainm\",\n\t\"index.openPad\": \"oscail Pad atá ann cheana féin leis an ainm:\",\n\t\"index.recentPads\": \"Ceapanna Le Déanaí\",\n\t\"index.recentPadsEmpty\": \"Ní bhfuarthas aon pillíní le déanaí.\",\n\t\"index.generateNewPad\": \"Gin ainm ceap randamach\",\n\t\"index.labelPad\": \"Ainm an eochaircheap (roghnach)\",\n\t\"index.placeholderPadEnter\": \"Cuir isteach ainm ceap le do thoil...\",\n\t\"index.createAndShareDocuments\": \"Cruthaigh agus roinn doiciméid i bhfíor-am\",\n\t\"index.createAndShareDocumentsDescription\": \"Le Etherpad is féidir leat doiciméid a chur in eagar i gcomhar le chéile i bhfíor-am, cosúil le heagarthóir il-imreora beo a ritheann i do bhrabhsálaí.\",\n\t\"pad.toolbar.bold.title\": \"Trom (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Iodálach (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Folíne (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Streiceadh tríd (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Liosta ordaithe (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Liosta Gan Ordú (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Eangú (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Líon amach (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Cealaigh (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Athdhéan (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Glan Dathanna Údair (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Iompórtáil/Easpórtáil ó/chuig formáidí comhaid éagsúla\",\n\t\"pad.toolbar.timeslider.title\": \"Sleamhnán Ama\",\n\t\"pad.toolbar.savedRevision.title\": \"Sábháil Athbhreithniú\",\n\t\"pad.toolbar.settings.title\": \"Socruithe\",\n\t\"pad.toolbar.embed.title\": \"Comhroinn agus leabaigh an ceap seo\",\n\t\"pad.toolbar.home.title\": \"Ar ais sa bhaile\",\n\t\"pad.toolbar.showusers.title\": \"Taispeáin na húsáideoirí ar an eochaircheap seo\",\n\t\"pad.colorpicker.save\": \"Sábháil\",\n\t\"pad.colorpicker.cancel\": \"Cealaigh\",\n\t\"pad.loading\": \"Ag lódáil...\",\n\t\"pad.noCookie\": \"Níor aimsíodh fianán. Ceadaigh fianáin i do bhrabhsálaí le do thoil! Ní shábhálfar do sheisiún agus do shocruithe idir cuairteanna. D’fhéadfadh sé seo a bheith mar gheall ar Etherpad a bheith san áireamh in iFrame i roinnt Brabhsálaithe. Cinntigh le do thoil go bhfuil Etherpad ar an bhfo-fhearann/fearann ​​céanna leis an iFrame tuismitheora.\",\n\t\"pad.permissionDenied\": \"Níl cead agat rochtain a fháil ar an eochaircheap seo\",\n\t\"pad.settings.padSettings\": \"Socruithe Ceap\",\n\t\"pad.settings.myView\": \"Mo Radharc\",\n\t\"pad.settings.stickychat\": \"Comhrá i gcónaí ar an scáileán\",\n\t\"pad.settings.chatandusers\": \"Taispeáin Comhrá agus Úsáideoirí\",\n\t\"pad.settings.colorcheck\": \"Dathanna údair\",\n\t\"pad.settings.linenocheck\": \"Uimhreacha líne\",\n\t\"pad.settings.rtlcheck\": \"Léigh ábhar ó dheis go clé?\",\n\t\"pad.settings.fontType\": \"Cineál cló:\",\n\t\"pad.settings.language\": \"Teanga:\",\n\t\"pad.settings.deletePad\": \"Scrios Pad\",\n\t\"pad.delete.confirm\": \"An bhfuil tú cinnte gur mhaith leat an ceap seo a scriosadh?\",\n\t\"pad.settings.about\": \"Maidir\",\n\t\"pad.settings.poweredBy\": \"Cumhachtaithe ag\",\n\t\"pad.importExport.import_export\": \"Iompórtáil/Easpórtáil\",\n\t\"pad.importExport.import\": \"Uaslódáil aon chomhad téacs nó doiciméad\",\n\t\"pad.importExport.importSuccessful\": \"Rathúil!\",\n\t\"pad.importExport.export\": \"Easpórtáil an ceap reatha mar:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Téacs simplí\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Formáid Doiciméad Oscailte)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Ní féidir leat iompórtáil ach ó théacs simplí nó ó fhormáidí HTML. Chun gnéithe iompórtála níos forbartha a fháil, <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">suiteáil AbiWord nó LibreOffice</a> le do thoil.\",\n\t\"pad.modals.connected\": \"Ceangailte.\",\n\t\"pad.modals.reconnecting\": \"Ag athcheangal le do phainéal…\",\n\t\"pad.modals.forcereconnect\": \"Athcheangal fórsaithe\",\n\t\"pad.modals.reconnecttimer\": \"Ag iarraidh athcheangal\",\n\t\"pad.modals.cancel\": \"Cealaigh\",\n\t\"pad.modals.userdup\": \"Osclaíodh i bhfuinneog eile\",\n\t\"pad.modals.userdup.explanation\": \"Is cosúil go bhfuil an ceap seo oscailte i níos mó ná fuinneog brabhsálaí amháin ar an ríomhaire seo.\",\n\t\"pad.modals.userdup.advice\": \"Athcheangail chun an fhuinneog seo a úsáid ina ionad.\",\n\t\"pad.modals.unauth\": \"Gan údarú\",\n\t\"pad.modals.unauth.explanation\": \"Tá do cheadanna athraithe agus an leathanach seo á fheiceáil agat. Déan iarracht athcheangal.\",\n\t\"pad.modals.looping.explanation\": \"Tá fadhbanna cumarsáide ann leis an bhfreastalaí sioncrónaithe.\",\n\t\"pad.modals.looping.cause\": \"B’fhéidir gur cheangail tú trí bhalla dóiteáin nó seachfhreastalaí neamh-chomhoiriúnach.\",\n\t\"pad.modals.initsocketfail\": \"Níl an freastalaí inrochtana.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Níorbh fhéidir ceangal leis an bhfreastalaí sioncrónaithe.\",\n\t\"pad.modals.initsocketfail.cause\": \"Is dócha gur mar gheall ar fhadhb le do bhrabhsálaí nó le do nasc idirlín atá sé seo.\",\n\t\"pad.modals.slowcommit.explanation\": \"Níl an freastalaí ag freagairt.\",\n\t\"pad.modals.slowcommit.cause\": \"D’fhéadfadh sé seo a bheith mar gheall ar fhadhbanna le nascacht líonra.\",\n\t\"pad.modals.badChangeset.explanation\": \"Rangaíodh eagarthóireacht a rinne tú mar mhídhleathach ag an bhfreastalaí sioncrónaithe.\",\n\t\"pad.modals.badChangeset.cause\": \"D’fhéadfadh sé seo a bheith mar gheall ar chumraíocht mícheart an fhreastalaí nó ar iompar gan choinne eile. Téigh i dteagmháil leis an riarthóir seirbhíse, má cheapann tú gur earráid í seo. Déan iarracht athcheangal le go leanfaidh tú ar aghaidh leis an eagarthóireacht.\",\n\t\"pad.modals.corruptPad.explanation\": \"Tá an ceap atá tú ag iarraidh rochtain a fháil air truaillithe.\",\n\t\"pad.modals.corruptPad.cause\": \"D’fhéadfadh sé seo a bheith mar gheall ar chumraíocht mícheart an fhreastalaí nó ar iompar gan choinne eile. Téigh i dteagmháil le riarthóir na seirbhíse, le do thoil.\",\n\t\"pad.modals.deleted\": \"Scriosta.\",\n\t\"pad.modals.deleted.explanation\": \"Tá an ceap seo bainte.\",\n\t\"pad.modals.rateLimited\": \"Ráta Teoranta.\",\n\t\"pad.modals.rateLimited.explanation\": \"Chuir tú an iomarca teachtaireachtaí chuig an eochaircheap seo agus mar sin dícheangail sé thú.\",\n\t\"pad.modals.rejected.explanation\": \"Dhiúltaigh an freastalaí do theachtaireacht a sheol do bhrabhsálaí.\",\n\t\"pad.modals.rejected.cause\": \"B’fhéidir gur nuashonraíodh an freastalaí agus tú ag féachaint ar an eochaircheap, nó b’fhéidir go bhfuil fabht in Etherpad. Bain triail as an leathanach a athlódáil.\",\n\t\"pad.modals.disconnected\": \"Tá tú dícheangailte.\",\n\t\"pad.modals.disconnected.explanation\": \"Cailleadh an nasc leis an bhfreastalaí\",\n\t\"pad.modals.disconnected.cause\": \"B’fhéidir nach bhfuil an freastalaí ar fáil. Cuir an riarthóir seirbhíse ar an eolas má leanann sé seo ar aghaidh.\",\n\t\"pad.share\": \"Roinn an ceap seo\",\n\t\"pad.share.readonly\": \"Léamh amháin\",\n\t\"pad.share.link\": \"Nasc\",\n\t\"pad.share.emebdcode\": \"URL a leabú\",\n\t\"pad.chat\": \"Comhrá\",\n\t\"pad.chat.title\": \"Oscail an comhrá don eochaircheap seo.\",\n\t\"pad.chat.loadmessages\": \"Luchtaigh tuilleadh teachtaireachtaí\",\n\t\"pad.chat.stick.title\": \"Greamaigh an comhrá leis an scáileán\",\n\t\"pad.chat.writeMessage.placeholder\": \"Scríobh do theachtaireacht anseo\",\n\t\"timeslider.followContents\": \"Nuashonruithe ar ábhar an eochaircheap leantach\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Sleamhnán Ama\",\n\t\"timeslider.toolbar.returnbutton\": \"Fill ar ais chuig an eochaircheap\",\n\t\"timeslider.toolbar.authors\": \"Údair:\",\n\t\"timeslider.toolbar.authorsList\": \"Gan Údair\",\n\t\"timeslider.toolbar.exportlink.title\": \"Easpórtáil\",\n\t\"timeslider.exportCurrent\": \"Easpórtáil an leagan reatha mar:\",\n\t\"timeslider.version\": \"Leagan {{version}}\",\n\t\"timeslider.saved\": \"Sábháilte {{day}} {{month}}, {{year}}\",\n\t\"timeslider.playPause\": \"Ábhar an Cheap Athsheinm / Sos\",\n\t\"timeslider.backRevision\": \"Téigh siar athbhreithniú sa Pad seo\",\n\t\"timeslider.forwardRevision\": \"Téigh ar aghaidh le hathbhreithniú sa Pad seo\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Eanáir\",\n\t\"timeslider.month.february\": \"Feabhra\",\n\t\"timeslider.month.march\": \"Márta\",\n\t\"timeslider.month.april\": \"Aibreán\",\n\t\"timeslider.month.may\": \"Bealtaine\",\n\t\"timeslider.month.june\": \"Meitheamh\",\n\t\"timeslider.month.july\": \"Iúil\",\n\t\"timeslider.month.august\": \"Lúnasa\",\n\t\"timeslider.month.september\": \"Meán Fómhair\",\n\t\"timeslider.month.october\": \"Deireadh Fómhair\",\n\t\"timeslider.month.november\": \"Samhain\",\n\t\"timeslider.month.december\": \"Nollaig\",\n\t\"timeslider.unnamedauthors\": \"{{num}} gan ainm {[plural(num) one: údar, other: údair]}\",\n\t\"pad.savedrevs.marked\": \"Tá an t-athbhreithniú seo marcáilte anois mar athbhreithniú sábháilte\",\n\t\"pad.savedrevs.timeslider\": \"Is féidir leat athbhreithnithe sábháilte a fheiceáil trí chuairt a thabhairt ar an sleamhnán ama\",\n\t\"pad.userlist.entername\": \"Cuir isteach d'ainm\",\n\t\"pad.userlist.unnamed\": \"gan ainm\",\n\t\"pad.editbar.clearcolors\": \"Glan dathanna údair ar an doiciméad ar fad? Ní féidir é seo a chealú\",\n\t\"pad.impexp.importbutton\": \"Iompórtáil Anois\",\n\t\"pad.impexp.importing\": \"Ag allmhairiú...\",\n\t\"pad.impexp.confirmimport\": \"Scríobhfar téacs reatha an eochaircheap tríd an gcomhad a iompórtáil. An bhfuil tú cinnte gur mhaith leat dul ar aghaidh?\",\n\t\"pad.impexp.convertFailed\": \"Ní raibh muid in ann an comhad seo a allmhairiú. Úsáid formáid doiciméid dhifriúil nó cóipeáil agus greamaigh de láimh le do thoil.\",\n\t\"pad.impexp.padHasData\": \"Ní raibh muid in ann an comhad seo a allmhairiú mar tá athruithe déanta ar an Pad seo cheana féin, allmhairigh chuig pad nua le do thoil.\",\n\t\"pad.impexp.uploadFailed\": \"Theip ar an uaslódáil, déan iarracht arís\",\n\t\"pad.impexp.importfailed\": \"Theip ar an allmhairiú\",\n\t\"pad.impexp.copypaste\": \"Cóipeáil agus greamaigh le do thoil\",\n\t\"pad.impexp.exportdisabled\": \"Tá easpórtáil i bhformáid {{type}} díchumasaithe. Téigh i dteagmháil le riarthóir do chórais le haghaidh tuilleadh sonraí.\",\n\t\"pad.impexp.maxFileSize\": \"Comhad rómhór. Téigh i dteagmháil le riarthóir do shuíomh chun an méid comhaid ceadaithe le haghaidh allmhairithe a mhéadú.\"\n}\n"
  },
  {
    "path": "src/locales/gl.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Elisardojm\",\n\t\t\t\"Ghose\",\n\t\t\t\"Toliño\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Panel de administración - Etherpad\",\n\t\"admin_plugins\": \"Xestor de complementos\",\n\t\"admin_plugins.available\": \"Complementos dispoñibles\",\n\t\"admin_plugins.available_not-found\": \"Non se atopan complementos.\",\n\t\"admin_plugins.available_fetching\": \"Obtendo...\",\n\t\"admin_plugins.available_install.value\": \"Instalar\",\n\t\"admin_plugins.available_search.placeholder\": \"Buscar complementos para instalar\",\n\t\"admin_plugins.description\": \"Descrición\",\n\t\"admin_plugins.installed\": \"Complementos instalados\",\n\t\"admin_plugins.installed_fetching\": \"Obtendo os complementos instalados...\",\n\t\"admin_plugins.installed_nothing\": \"Aínda non instalaches ningún complemento.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Desinstalar\",\n\t\"admin_plugins.last-update\": \"Última actualización\",\n\t\"admin_plugins.name\": \"Nome\",\n\t\"admin_plugins.page-title\": \"Xestos de complementos - Etherpad\",\n\t\"admin_plugins.version\": \"Versión\",\n\t\"admin_plugins_info\": \"Información para resolver problemas\",\n\t\"admin_plugins_info.hooks\": \"Ganchos instalados\",\n\t\"admin_plugins_info.hooks_client\": \"Ganchos do lado do cliente\",\n\t\"admin_plugins_info.hooks_server\": \"Ganchos do lado do servidor\",\n\t\"admin_plugins_info.parts\": \"Partes instaladas\",\n\t\"admin_plugins_info.plugins\": \"Complementos instalados\",\n\t\"admin_plugins_info.page-title\": \"Información do complemento - Etherpad\",\n\t\"admin_plugins_info.version\": \"Versión de Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Última versión dispoñible\",\n\t\"admin_plugins_info.version_number\": \"Número da versión\",\n\t\"admin_settings\": \"Axustes\",\n\t\"admin_settings.current\": \"Configuración actual\",\n\t\"admin_settings.current_example-devel\": \"Modelo de exemplo dos axustes de desenvolvemento\",\n\t\"admin_settings.current_example-prod\": \"Modelo de exemplo dos axustes en produción\",\n\t\"admin_settings.current_restart.value\": \"Reiniciar Etherpad\",\n\t\"admin_settings.current_save.value\": \"Gardar axustes\",\n\t\"admin_settings.page-title\": \"Axustes - Etherpad\",\n\t\"index.newPad\": \"Novo documento\",\n\t\"index.settings\": \"Axustes\",\n\t\"index.transferSessionTitle\": \"Transferir a sesión\",\n\t\"index.receiveSessionTitle\": \"Recibir a sesión\",\n\t\"index.receiveSessionDescription\": \"Aquí podes recibir unha sesión de Etherpad desde outro navegador ou dispositivo. Ten en conta, non obstante, que isto eliminará a túa sesión actual, se a houbese.\",\n\t\"index.transferSession\": \"1. Transfire a sesión\",\n\t\"index.transferSessionNow\": \"Transfire a sesión agora\",\n\t\"index.copyLink\": \"2. Copia a ligazón\",\n\t\"index.copyLinkDescription\": \"Fai clic no botón de embaixo para copiar a ligazón no portapapeis.\",\n\t\"index.copyLinkButton\": \"Copiar a ligazón no portapapeis\",\n\t\"index.transferToSystem\": \"3. Copia a sesión no novo sistema\",\n\t\"index.transferToSystemDescription\": \"Abre a ligazón copiada no navegador ou dispositivo de destino para transferir a túa sesión.\",\n\t\"index.transferSessionDescription\": \"Transfire a túa sesión actual ao navegador ou dispositivo facendo clic no botón de embaixo. Isto copiará unha ligazón cara a unha páxina que transferirá a túa sesión cando se abra no navegador ou dispositivo de destino.\",\n\t\"index.createOpenPad\": \"Abrir un documento por nome\",\n\t\"index.openPad\": \"abrir un documento existente co nome:\",\n\t\"index.recentPads\": \"Documentos recentes\",\n\t\"index.recentPadsEmpty\": \"Non se atoparon documentos recentes.\",\n\t\"index.generateNewPad\": \"Xerar un nome de documento ao chou\",\n\t\"index.labelPad\": \"Nome do documento (opcional)\",\n\t\"index.placeholderPadEnter\": \"Escribe un nome para o documento...\",\n\t\"index.createAndShareDocuments\": \"Crea e comparte documentos en tempo real\",\n\t\"index.createAndShareDocumentsDescription\": \"Etherpad permite editar documentos de forma colaborativa en tempo real, de xeito semellante a un editor multixogador en directo que se executa no navegador.\",\n\t\"pad.toolbar.bold.title\": \"Grosa (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Cursiva (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Subliñar (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Riscar (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Lista ordenada (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Lista sen ordenar (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Sangrar (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Sen sangrar (Maiús.+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Desfacer (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Refacer (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Eliminar as cores que identifican ás participantes (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importar/Exportar desde/a diferentes formatos de ficheiro\",\n\t\"pad.toolbar.timeslider.title\": \"Cronoloxía\",\n\t\"pad.toolbar.savedRevision.title\": \"Gardar a revisión\",\n\t\"pad.toolbar.settings.title\": \"Axustes\",\n\t\"pad.toolbar.embed.title\": \"Compartir e incorporar este documento\",\n\t\"pad.toolbar.home.title\": \"Volver ao inicio\",\n\t\"pad.toolbar.showusers.title\": \"Mostrar as usuarias deste documento\",\n\t\"pad.colorpicker.save\": \"Gardar\",\n\t\"pad.colorpicker.cancel\": \"Cancelar\",\n\t\"pad.loading\": \"Cargando...\",\n\t\"pad.noCookie\": \"Non se puido atopar a cookie. Por favor, habilita as cookies no teu navegador! A túa sesión e axustes non se gardarán entre visitas. Esto podería deberse a que Etherpad está incluído nalgún iFrame nalgúns navegadores. Asegúrate de que Etherpad está no mesmo subdominio/dominio que o iFrame pai\",\n\t\"pad.permissionDenied\": \"Non tes permiso para acceder a este documento\",\n\t\"pad.settings.padSettings\": \"Configuracións do documento\",\n\t\"pad.settings.myView\": \"Ver\",\n\t\"pad.settings.stickychat\": \"Chat sempre visible\",\n\t\"pad.settings.chatandusers\": \"Mostrar o chat e as usuarias\",\n\t\"pad.settings.colorcheck\": \"Cores de identificación\",\n\t\"pad.settings.linenocheck\": \"Números de liña\",\n\t\"pad.settings.rtlcheck\": \"Queres ler o contido da dereita á esquerda?\",\n\t\"pad.settings.fontType\": \"Tipo de letra:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Lingua:\",\n\t\"pad.settings.deletePad\": \"Borrar o documento\",\n\t\"pad.delete.confirm\": \"Queres borrar este documento?\",\n\t\"pad.settings.about\": \"Acerca de\",\n\t\"pad.settings.poweredBy\": \"Grazas a\",\n\t\"pad.importExport.import_export\": \"Importar/Exportar\",\n\t\"pad.importExport.import\": \"Cargar un ficheiro de texto ou documento\",\n\t\"pad.importExport.importSuccessful\": \"Correcto!\",\n\t\"pad.importExport.export\": \"Exportar o documento actual en formato:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Texto simple\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Só podes importar texto simple ou formatos HTML. Para obter máis información sobre as características de importación avanzadas <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">instala AbiWord</a>.\",\n\t\"pad.modals.connected\": \"Conectado.\",\n\t\"pad.modals.reconnecting\": \"Reconectando co teu documento...\",\n\t\"pad.modals.forcereconnect\": \"Forzar a reconexión\",\n\t\"pad.modals.reconnecttimer\": \"Intentarase reconectar en\",\n\t\"pad.modals.cancel\": \"Cancelar\",\n\t\"pad.modals.userdup\": \"Aberto noutra ventá\",\n\t\"pad.modals.userdup.explanation\": \"Semella que este documento está aberto en varias ventás do navegador neste ordenador.\",\n\t\"pad.modals.userdup.advice\": \"Reconectar para usar esta ventá.\",\n\t\"pad.modals.unauth\": \"Non autorizado\",\n\t\"pad.modals.unauth.explanation\": \"Os seus permisos cambiaron mentres estaba nesta páxina. Intente a reconexión.\",\n\t\"pad.modals.looping.explanation\": \"Hai un problema de comunicación co servidor de sincronización.\",\n\t\"pad.modals.looping.cause\": \"Seica a súa conexión pasa a través dun firewall ou proxy incompatible.\",\n\t\"pad.modals.initsocketfail\": \"Non se pode alcanzar o servidor.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Non se pode conectar co servidor de sincronización.\",\n\t\"pad.modals.initsocketfail.cause\": \"Isto acontece probablemente debido a un problema co navegador ou coa conexión á internet.\",\n\t\"pad.modals.slowcommit.explanation\": \"O servidor non responde.\",\n\t\"pad.modals.slowcommit.cause\": \"Isto pode deberse a un problema de conexión á rede.\",\n\t\"pad.modals.badChangeset.explanation\": \"O servidor de sincronización clasificou como ilegal unha das súas edicións.\",\n\t\"pad.modals.badChangeset.cause\": \"Isto pode deberse a unha cofiguración errónea do servidor ou algún outro comportamento inesperado. Póñase en contacto co administrador do servizo, se pensa que isto é un erro. Intente reconectar para continuar editando.\",\n\t\"pad.modals.corruptPad.explanation\": \"O documento ao que intenta acceder está corrompido.\",\n\t\"pad.modals.corruptPad.cause\": \"Isto pode deberse a unha cofiguración errónea do servidor ou algún outro comportamento inesperado. Póñase en contacto co administrador do servizo.\",\n\t\"pad.modals.deleted\": \"Borrado.\",\n\t\"pad.modals.deleted.explanation\": \"Este documento foi eliminado.\",\n\t\"pad.modals.rateLimited\": \"Taxa limitada.\",\n\t\"pad.modals.rateLimited.explanation\": \"Enviaches demasiadas mensaxes a este documento polo que te desconectamos.\",\n\t\"pad.modals.rejected.explanation\": \"O servidor rexeitou unha mensaxe que o teu navegador enviou.\",\n\t\"pad.modals.rejected.cause\": \"O servidor podería ter sido actualizado mentras ollabas o documento, ou pode que sexa un fallo de Etherpad. Intenta recargar a páxina.\",\n\t\"pad.modals.disconnected\": \"Foi desconectado.\",\n\t\"pad.modals.disconnected.explanation\": \"Perdeuse a conexión co servidor\",\n\t\"pad.modals.disconnected.cause\": \"O servidor non está dispoñible. Póñase en contacto co administrador do servizo se o problema continúa.\",\n\t\"pad.share\": \"Compartir este documento\",\n\t\"pad.share.readonly\": \"Lectura só\",\n\t\"pad.share.link\": \"Ligazón\",\n\t\"pad.share.emebdcode\": \"Incorporar o URL\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Abrir o chat deste documento.\",\n\t\"pad.chat.loadmessages\": \"Cargar máis mensaxes\",\n\t\"pad.chat.stick.title\": \"Pegar a conversa á pantalla\",\n\t\"pad.chat.writeMessage.placeholder\": \"Escribe aquí a túa mensaxe\",\n\t\"timeslider.followContents\": \"Segue as actualizacións do contido\",\n\t\"timeslider.pageTitle\": \"Cronoloxía de {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Volver ao documento\",\n\t\"timeslider.toolbar.authors\": \"Editoras:\",\n\t\"timeslider.toolbar.authorsList\": \"Sen Editoras\",\n\t\"timeslider.toolbar.exportlink.title\": \"Exportar\",\n\t\"timeslider.exportCurrent\": \"Exportar a versión actual en formato:\",\n\t\"timeslider.version\": \"Versión {{version}}\",\n\t\"timeslider.saved\": \"Gardado o {{day}} de {{month}} de {{year}}\",\n\t\"timeslider.playPause\": \"Reproducir/pausar os contidos do pad\",\n\t\"timeslider.backRevision\": \"Ir á revisión anterior neste pad\",\n\t\"timeslider.forwardRevision\": \"Ir á revisión posterior neste pad\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"xaneiro\",\n\t\"timeslider.month.february\": \"febreiro\",\n\t\"timeslider.month.march\": \"marzo\",\n\t\"timeslider.month.april\": \"abril\",\n\t\"timeslider.month.may\": \"maio\",\n\t\"timeslider.month.june\": \"xuño\",\n\t\"timeslider.month.july\": \"xullo\",\n\t\"timeslider.month.august\": \"agosto\",\n\t\"timeslider.month.september\": \"setembro\",\n\t\"timeslider.month.october\": \"outubro\",\n\t\"timeslider.month.november\": \"novembro\",\n\t\"timeslider.month.december\": \"decembro\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: editora anónima, other: editora anónima ]}\",\n\t\"pad.savedrevs.marked\": \"Esta revisión está agora marcada como revisión gardada\",\n\t\"pad.savedrevs.timeslider\": \"Pode consultar as revisións gardadas visitando a cronoloxía\",\n\t\"pad.userlist.entername\": \"Insira o seu nome\",\n\t\"pad.userlist.unnamed\": \"anónimo\",\n\t\"pad.editbar.clearcolors\": \"Eliminar as cores relativas ás participantes en todo o documento? Non se poderán recuperar\",\n\t\"pad.impexp.importbutton\": \"Importar agora\",\n\t\"pad.impexp.importing\": \"Importando...\",\n\t\"pad.impexp.confirmimport\": \"A importación dun ficheiro ha sobrescribir o texto actual do documento. Está seguro de querer continuar?\",\n\t\"pad.impexp.convertFailed\": \"Non somos capaces de importar o ficheiro. Utilice un formato de documento diferente ou copie e pegue manualmente\",\n\t\"pad.impexp.padHasData\": \"Non puidemos importar este ficheiro porque este documento xa sufriu cambios; importe a un novo documento.\",\n\t\"pad.impexp.uploadFailed\": \"Houbo un erro ao cargar o ficheiro; inténteo de novo\",\n\t\"pad.impexp.importfailed\": \"Fallou a importación\",\n\t\"pad.impexp.copypaste\": \"Copie e pegue\",\n\t\"pad.impexp.exportdisabled\": \"A exportación en formato {{type}} está desactivada. Póñase en contacto co administrador do sistema se quere máis detalles.\",\n\t\"pad.impexp.maxFileSize\": \"Ficheiro demasiado granda. Contacta coa administración para aumentar o tamaño permitido para importacións\"\n}\n"
  },
  {
    "path": "src/locales/got.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Gothicspeaker\"\n\t\t]\n\t},\n\t\"admin_plugins.name\": \"𐌽𐌰𐌼𐍉\",\n\t\"admin_plugins.version\": \"𐌿𐍃𐌼𐌴𐍂𐌹\",\n\t\"admin_plugins_info.version_latest\": \"𐌰𐍆𐍄𐌿𐌼𐌹𐍃𐍄 𐍅𐌹𐍃𐌰𐌽𐌳𐍉 𐌿𐍃𐌼𐌴𐍂𐌹\",\n\t\"admin_plugins_info.version_number\": \"𐍂𐌰𐌸𐌾𐍉 𐌿𐍃𐌼𐌴𐍂𐌾𐌹𐍃\",\n\t\"index.newPad\": \"𐍀𐌰𐌳 𐌽𐌹𐍅𐌹\",\n\t\"index.createOpenPad\": \"𐍀𐌰𐌳 𐌱𐌹 𐌽𐌰𐌼𐌹𐌽 𐌿𐍃𐌻𐌿𐌺𐌰𐌽\",\n\t\"index.openPad\": \"𐍅𐌹𐍃𐌰𐌽𐌳𐍉 𐍀𐌰𐌳 𐌿𐍃𐌻𐌿𐌺𐌰𐌽 𐌼𐌹𐌸 𐌽𐌰𐌼𐌹𐌽:\",\n\t\"index.recentPads\": \"𐌰𐍆𐍄𐌿𐌼𐌹𐍃𐍄𐌰 𐍀𐌰𐌳𐌰\",\n\t\"index.recentPadsEmpty\": \"𐌽𐌹 𐌱𐌹𐌲𐌰𐍄 𐌰𐍆𐍄𐌿𐌼𐌹𐍃𐍄𐌰 𐍀𐌰𐌳𐌰.\",\n\t\"index.labelPad\": \"𐌽𐌰𐌼𐍉 𐍀𐌰𐌳𐌹𐍃 (𐌼𐌰𐌷𐍄𐌴𐌹𐌲)\",\n\t\"index.placeholderPadEnter\": \"𐌱𐌹𐌳𐌾𐌰𐌼 𐌸𐌿𐌺 𐌼𐌴𐌻𐌴𐌹 𐌽𐌰𐌼𐍉 𐍀𐌰𐌳𐌹𐍃...\",\n\t\"pad.toolbar.bold.title\": \"𐌱𐌰𐌻𐌸 (𐌺𐍄𐍂𐌻+𐌱)\",\n\t\"pad.toolbar.italic.title\": \"𐌹𐍄𐌰𐌻𐌹𐍃𐌺 (𐌺𐍄𐍂𐌻+𐌹)\",\n\t\"pad.toolbar.underline.title\": \"𐌿𐍆𐍃𐍄𐍂𐌹𐌺𐍃 (𐌺𐍄𐍂𐌻+𐌿)\",\n\t\"pad.toolbar.strikethrough.title\": \"𐍃𐍄𐍂𐌹𐌺𐌰𐌸𐌰𐌹𐍂𐌷 (𐌺𐍄𐍂𐌻+5)\",\n\t\"pad.toolbar.undo.title\": \"𐌱𐌻𐌰𐌿𐌸𐌾𐌰𐌽 (𐌺𐍄𐍂𐌻+𐌶)\",\n\t\"pad.toolbar.redo.title\": \"𐌰𐍆𐍄𐍂𐌰𐌲𐌰𐍄𐌰𐌿𐌾𐌰𐌽 (𐌺𐍄𐍂𐌻+𐍅)\",\n\t\"pad.toolbar.timeslider.title\": \"𐌸𐌴𐌹𐌷𐍃𐌰𐍃𐌻𐌴𐌹𐌳𐌰𐌽𐌳𐍃\",\n\t\"pad.toolbar.savedRevision.title\": \"𐌰𐍆𐍄𐍂𐌰𐍃𐌹𐌿𐌽 𐌲𐌰𐍆𐌰𐍃𐍄𐌰𐌽\",\n\t\"pad.toolbar.home.title\": \"𐌲𐌰𐍅𐌰𐌽𐌳𐌾𐌰𐌽 𐌸𐌿𐌺 𐌳𐌿 𐌲𐌰𐍂𐌳𐌰\",\n\t\"pad.colorpicker.save\": \"𐌲𐌰𐍆𐌰𐍃𐍄𐌰𐌽\",\n\t\"pad.colorpicker.cancel\": \"𐌱𐌹𐍅𐌰𐌽𐌳𐌾𐌰𐌽\",\n\t\"pad.settings.myView\": \"𐍃𐌹𐌿𐌽𐍃 𐌼𐌴𐌹𐌽𐌰\",\n\t\"pad.settings.stickychat\": \"𐌲𐌰𐍅𐌰𐌿𐍂𐌳𐌹 𐌰𐌽𐌰 𐍃𐌺𐌰𐌹𐍂𐌼𐌰 𐍃𐌹𐌽𐍄𐌴𐌹𐌽𐍉\",\n\t\"pad.settings.chatandusers\": \"𐌲𐌰𐍅𐌰𐌿𐍂𐌳𐌹 𐌾𐌰𐌷 𐌱𐍂𐌿𐌺𐌾𐌰𐌽𐍃 𐌱𐌰𐌽𐌳𐍅𐌾𐌰𐌽\",\n\t\"pad.settings.language\": \"𐍂𐌰𐌶𐌳𐌰:\",\n\t\"pad.settings.deletePad\": \"𐍀𐌰𐌳𐌰 𐌿𐍃𐌵𐌹𐍃𐍄𐌾𐌰𐌽\",\n\t\"pad.delete.confirm\": \"𐌱𐌹 𐍃𐌿𐌽𐌾𐌰𐌹 𐍅𐌹𐌻𐌴𐌹𐌶𐌿 𐌸𐌰𐌼𐌼𐌰 𐍀𐌰𐌳𐌰 𐌿𐍃𐌵𐌹𐍃𐍄𐌾𐌰𐌽?\",\n\t\"pad.settings.about\": \"𐌱𐌹\",\n\t\"pad.importExport.exporthtml\": \"𐌷𐍄𐌼𐌻\",\n\t\"pad.importExport.exportpdf\": \"𐍀𐌳𐍆\",\n\t\"pad.importExport.exportopen\": \"𐍉𐌳𐍆 (𐍉𐍀𐌰𐌹𐌽 𐌳𐍉𐌺𐌿𐌼𐌰𐌹𐌽𐍄 𐍆𐌰𐌿𐍂𐌼𐌰𐍄)\",\n\t\"pad.modals.cancel\": \"𐌱𐌹𐍅𐌰𐌽𐌳𐌾𐌰𐌽\",\n\t\"pad.modals.userdup\": \"𐌿𐍃𐌻𐌿𐌺𐌰𐌽 𐌹𐌽 𐌰𐌿𐌲𐌰𐌳𐌰𐌿𐍂𐌹𐌽 𐌰𐌽𐌸𐌰𐍂𐌰𐌼𐌼𐌰\",\n\t\"pad.modals.deleted\": \"𐌿𐍃𐌵𐌹𐍃𐍄𐌹𐌸.\",\n\t\"pad.modals.deleted.explanation\": \"𐌸𐌰𐍄𐌰 𐍀𐌰𐌳 𐌿𐍃𐌽𐌿𐌼𐌰𐌽 𐌹𐍃𐍄.\",\n\t\"pad.share\": \"𐌸𐌰𐍄𐌰 𐍀𐌰𐌳 𐌳𐌰𐌹𐌻𐌾𐌰𐌽\",\n\t\"pad.share.readonly\": \"𐌸𐌰𐍄𐌰𐌹𐌽𐌴𐌹 𐌰𐌽𐌰𐌺𐌿𐌽𐌽𐌰𐌽\",\n\t\"pad.share.link\": \"𐌲𐌰𐍅𐌹𐍃𐍃\",\n\t\"pad.chat\": \"𐌲𐌰𐍅𐌰𐌿𐍂𐌳𐌹\",\n\t\"pad.chat.title\": \"𐌲𐌰𐍅𐌰𐌿𐍂𐌳𐌹 𐌸𐌰𐌼𐌼𐌰 𐍀𐌰𐌳𐌰 𐌿𐍃𐌻𐌿𐌺𐌰𐌽.\",\n\t\"pad.chat.stick.title\": \"𐌲𐌰𐍅𐌰𐌿𐍂𐌳𐌹 𐍃𐍄𐌹𐌺𐌰𐌽 𐌳𐌿 𐍃𐌺𐌰𐌹𐍂𐌼𐌰\",\n\t\"timeslider.toolbar.returnbutton\": \"𐌲𐌰𐍅𐌰𐌽𐌳𐌾𐌰𐌽 𐌸𐌿𐌺 𐌳𐌿 𐍀𐌰𐌳𐌰\",\n\t\"timeslider.toolbar.authors\": \"𐌱𐍉𐌺𐌰𐍂𐌾𐍉𐍃:\",\n\t\"timeslider.toolbar.authorsList\": \"𐌽𐌹 𐌱𐍉𐌺𐌰𐍂𐌾𐍉𐍃\",\n\t\"timeslider.version\": \"𐌿𐍃𐌼𐌴𐍂𐌹 {{version}}\",\n\t\"timeslider.month.january\": \"𐌾𐌰𐌽𐌿𐌰𐍂𐌴𐌹𐍃\",\n\t\"timeslider.month.february\": \"𐍆𐌰𐌹𐌱𐍂𐌿𐌰𐍂𐌴𐌹𐍃\",\n\t\"timeslider.month.march\": \"𐌼𐌰𐍂𐍄𐌹𐌿𐍃\",\n\t\"timeslider.month.april\": \"𐌰𐍀𐍂𐌴𐌹𐌻𐌹𐍃\",\n\t\"timeslider.month.may\": \"𐌼𐌰𐌾𐌿𐍃\",\n\t\"timeslider.month.june\": \"𐌾𐌿𐌽𐌹𐌿𐍃\",\n\t\"timeslider.month.july\": \"𐌾𐌿𐌻𐌹𐌿𐍃\",\n\t\"timeslider.month.august\": \"𐌰𐌲𐌿𐍃𐍄𐌿𐍃\",\n\t\"timeslider.month.september\": \"𐍃𐌰𐌹𐍀𐍄𐌰𐌹𐌼𐌱𐌰𐌹𐍂\",\n\t\"timeslider.month.october\": \"𐌰𐌿𐌺𐍄𐍉𐌱𐌰𐌹𐍂\",\n\t\"timeslider.month.november\": \"𐌽𐌰𐌿𐌱𐌰𐌹𐌼𐌱𐌰𐌹𐍂\",\n\t\"timeslider.month.december\": \"𐌾𐌹𐌿𐌻𐌴𐌹𐍃\",\n\t\"pad.userlist.entername\": \"𐌼𐌴𐌻𐌴𐌹 𐌽𐌰𐌼𐍉 𐌸𐌴𐌹𐌽\",\n\t\"pad.userlist.unnamed\": \"𐌿𐌽𐌽𐌰𐌼𐌽𐌹𐌸\"\n}\n"
  },
  {
    "path": "src/locales/gu.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Bhatakati aatma\",\n\t\t\t\"Dsvyas\",\n\t\t\t\"Harsh4101991\",\n\t\t\t\"KartikMistry\"\n\t\t]\n\t},\n\t\"index.newPad\": \"નવું પેડ\",\n\t\"pad.toolbar.bold.title\": \"બોલ્ડ\",\n\t\"pad.toolbar.settings.title\": \"ગોઠવણીઓ\",\n\t\"pad.colorpicker.save\": \"સાચવો\",\n\t\"pad.colorpicker.cancel\": \"રદ કરો\",\n\t\"pad.loading\": \"લાવે છે...\",\n\t\"pad.noCookie\": \"કુકી મળી નહી. આપના બ્રાઉઝર સેટિંગમાં જઇ કુકી સક્રિય કરો!\",\n\t\"pad.permissionDenied\": \"આ પેડના ઉપયોગની આપને પરવાનગી નથી\",\n\t\"pad.settings.padSettings\": \"પેડ ગોઠવણીઓ\",\n\t\"pad.settings.myView\": \"મારા મતે\",\n\t\"pad.settings.fontType.normal\": \"સામાન્ય\",\n\t\"pad.settings.language\": \"ભાષા:\",\n\t\"pad.importExport.import_export\": \"આયાત/નિકાસ\",\n\t\"pad.importExport.importSuccessful\": \"સફળ!\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"સાદું લખાણ\",\n\t\"pad.importExport.exportword\": \"માઇક્રોસોફ્ટ વર્ડ\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (ઓપન ડોક્યુમેન્ટ ફોરમેટ)\",\n\t\"pad.importExport.abiword.innerHTML\": \"આપ માત્ર સાદુ લખાણ અથવા HTML આયાત કરી શકો છો. વધુ અધ્યતન આયાત સુવિધા માટે  <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">abiword ઇન્સ્ટોલ કરો</a>.\",\n\t\"pad.modals.connected\": \"જોડાયેલું\",\n\t\"pad.chat\": \"વાતચીત\",\n\t\"pad.chat.title\": \"આ પેડ માટે વાતચીત ખોલો.\",\n\t\"pad.chat.loadmessages\": \"વધુ સંદેશાઓ લાવો\",\n\t\"timeslider.toolbar.authors\": \"લેખકો:\",\n\t\"timeslider.month.january\": \"જાન્યુઆરી\",\n\t\"timeslider.month.february\": \"ફેબ્રુઆરી\",\n\t\"timeslider.month.march\": \"માર્ચ\",\n\t\"timeslider.month.april\": \"એપ્રિલ\",\n\t\"timeslider.month.may\": \"મે\",\n\t\"timeslider.month.june\": \"જૂન\",\n\t\"timeslider.month.july\": \"જુલાઇ\",\n\t\"timeslider.month.august\": \"ઓગસ્ટ\",\n\t\"timeslider.month.september\": \"સપ્ટેમ્બર\",\n\t\"timeslider.month.october\": \"ઓક્ટોબર\",\n\t\"timeslider.month.november\": \"નવેમ્બર\",\n\t\"timeslider.month.december\": \"ડિસેમ્બર\",\n\t\"pad.userlist.entername\": \"તમારું નામ દાખલ કરો\",\n\t\"pad.userlist.unnamed\": \"અનામી\",\n\t\"pad.impexp.importbutton\": \"આયાત કરો\",\n\t\"pad.impexp.importing\": \"આયાત કરે છે...\"\n}\n"
  },
  {
    "path": "src/locales/he.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Amire80\",\n\t\t\t\"Ofrahod\",\n\t\t\t\"Steeve815\",\n\t\t\t\"YaronSh\",\n\t\t\t\"תומר ט\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"לוח ניהול - Etherpad\",\n\t\"admin_plugins\": \"מנהל תוספים\",\n\t\"admin_plugins.available\": \"תוספים זמינים\",\n\t\"admin_plugins.available_not-found\": \"לא נמצאו תוספים.\",\n\t\"admin_plugins.available_fetching\": \"מתקבל…\",\n\t\"admin_plugins.available_install.value\": \"התקנה\",\n\t\"admin_plugins.available_search.placeholder\": \"חיפוש תוספים להתקנה\",\n\t\"admin_plugins.description\": \"תיאור\",\n\t\"admin_plugins.installed\": \"תוספים מותקנים\",\n\t\"admin_plugins.installed_fetching\": \"התוספים המותקנים מתקבלים…\",\n\t\"admin_plugins.installed_nothing\": \"לא התקנת תוספים עדיין.\",\n\t\"admin_plugins.installed_uninstall.value\": \"הסרה\",\n\t\"admin_plugins.last-update\": \"עדכון אחרון\",\n\t\"admin_plugins.name\": \"שם\",\n\t\"admin_plugins.page-title\": \"מנהל תוספים - Etherpad\",\n\t\"admin_plugins.version\": \"גרסה\",\n\t\"admin_plugins_info\": \"מידע לפתרון תקלות\",\n\t\"admin_plugins_info.hooks\": \"התליות מותקנות\",\n\t\"admin_plugins_info.hooks_client\": \"התליות מצד הלקוח\",\n\t\"admin_plugins_info.hooks_server\": \"התליות מצד השרת\",\n\t\"admin_plugins_info.parts\": \"חלקים מותקנים\",\n\t\"admin_plugins_info.plugins\": \"תוספים מותקנים\",\n\t\"admin_plugins_info.page-title\": \"פרטי תוסף - Etherpad\",\n\t\"admin_plugins_info.version\": \"גרסת Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"הגרסה העדכנית ביותר הזמינה\",\n\t\"admin_plugins_info.version_number\": \"מספר גרסה\",\n\t\"admin_settings\": \"הגדרות\",\n\t\"admin_settings.current\": \"הגדרות נוכחיות\",\n\t\"admin_settings.current_example-devel\": \"תבנית הגדרות פיתוח לדוגמה\",\n\t\"admin_settings.current_example-prod\": \"תבנית הגדרות פעילות מבצעית לדוגמה\",\n\t\"admin_settings.current_restart.value\": \"הפעלת Etherpad מחדש\",\n\t\"admin_settings.current_save.value\": \"שמירת הגדרות\",\n\t\"admin_settings.page-title\": \"הגדרות - Etherpad\",\n\t\"index.newPad\": \"פנקס חדש\",\n\t\"index.settings\": \"הגדרות\",\n\t\"index.transferSessionTitle\": \"העברת הפעלה\",\n\t\"index.receiveSessionTitle\": \"קבלת הפעלה\",\n\t\"index.receiveSessionDescription\": \"כאן אפשר לקבל הפעלת Etherpad מדפדפן או מכשיר אחרים. נא לשים לב, שזה עלול למחוק את ההפעלה הנוכחית שלך, אם יש כזאת.\",\n\t\"index.transferSession\": \"1. העברת הפעלה\",\n\t\"index.transferSessionNow\": \"העברת הפעלה כעת\",\n\t\"index.copyLink\": \"2. להעתיק קישור\",\n\t\"index.copyLinkDescription\": \"לחיצה על הכפתור להלן תעתיק את הקישור ללוח הגזירים שלך.\",\n\t\"index.copyLinkButton\": \"העתקת קישור ללוח\",\n\t\"index.transferToSystem\": \"3. העתקת הפעלה למערכת חדשה\",\n\t\"index.transferToSystemDescription\": \"יש לפתוח את הקישור שהועתק בדפדפן או מכשיר היעד כדי להעביר את ההפעלה שלך.\",\n\t\"index.transferSessionDescription\": \"אפשר להעביר את ההתחברות הנוכחית שלך לדפדפן או למכשיר בלחיצה על הכפתור שלהלן. הפעולה הזאת תעתיק את הקישור לדף שיעביר את ההפעלה שלך כשייפתח בדפדפן או מכשיר היעד.\",\n\t\"index.createOpenPad\": \"פתיחת פנקס לפי שם\",\n\t\"index.openPad\": \"פתיחת פנקס קיים עם השם:\",\n\t\"index.recentPads\": \"פנקסים אחרונים\",\n\t\"index.recentPadsEmpty\": \"לא נמצאו פנקסים אחרונים.\",\n\t\"index.generateNewPad\": \"יצירת שם אקראי לפנקס\",\n\t\"index.labelPad\": \"שם הפנקס (רשות)\",\n\t\"index.placeholderPadEnter\": \"נא למלא שם לפנקס...\",\n\t\"index.createAndShareDocuments\": \"יצירה ושיתוף של מסמכים בזמן אמת\",\n\t\"index.createAndShareDocumentsDescription\": \"Etherpad מאפשר לך לערוך מסמכים באופן שיתופי בזמן אמת, כמו עורך רב־שחקנים בזמן אחר שרץ בדפדפן שלך.\",\n\t\"pad.toolbar.bold.title\": \"מודגש (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"נטוי (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"קו תחתי (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"קו חוצה (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"רשימה ממוספרת (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"רשימת תבליטים (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"הזחה (טאב)\",\n\t\"pad.toolbar.unindent.title\": \"צמצום הזחה (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"ביטול (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"ביצוע מחדש\",\n\t\"pad.toolbar.clearAuthorship.title\": \"ניקוי צבעי כותבים (Ctrl-Shift-C)\",\n\t\"pad.toolbar.import_export.title\": \"יבוא/יצוא בתסדירי קבצים שונים\",\n\t\"pad.toolbar.timeslider.title\": \"גולל זמן\",\n\t\"pad.toolbar.savedRevision.title\": \"שמירת גרסה\",\n\t\"pad.toolbar.settings.title\": \"הגדרות\",\n\t\"pad.toolbar.embed.title\": \"שיתוף והטמעה של הפנקס הזה\",\n\t\"pad.toolbar.home.title\": \"חזרה לדף הבית\",\n\t\"pad.toolbar.showusers.title\": \"הצגת המשתמשים בפנקס הזה\",\n\t\"pad.colorpicker.save\": \"שמירה\",\n\t\"pad.colorpicker.cancel\": \"ביטול\",\n\t\"pad.loading\": \"טעינה...\",\n\t\"pad.noCookie\": \"העוגייה לא נמצאה. נא לאפשר עוגיות בדפדפן שלך! ההפעלה וההגדרות שלך לא יישמרו בין ביקורים. זה יכול לקרות עם Etherpad נכלל בתוך חלונית פנימית (iframe) בחלק מהדפדפנים. נא לוודא ש־Etherpad הוא תחת אותו שם תחום/תת־שם תחום כמו החלונית הפנימית של ההורה\",\n\t\"pad.permissionDenied\": \"אין לך הרשאה לגשת לפנקס הזה\",\n\t\"pad.settings.padSettings\": \"הגדרות פנקס\",\n\t\"pad.settings.myView\": \"התצוגה שלי\",\n\t\"pad.settings.stickychat\": \"השיחה תמיד על המסך\",\n\t\"pad.settings.chatandusers\": \"הצגת צ׳אט ומשתמשים\",\n\t\"pad.settings.colorcheck\": \"צביעה לפי מחבר\",\n\t\"pad.settings.linenocheck\": \"מספרי שורות\",\n\t\"pad.settings.rtlcheck\": \"לקרוא את התוכן מימין לשמאל?\",\n\t\"pad.settings.fontType\": \"סוג גופן:\",\n\t\"pad.settings.fontType.normal\": \"רגיל\",\n\t\"pad.settings.language\": \"שפה:\",\n\t\"pad.settings.deletePad\": \"מחיקת פנקס\",\n\t\"pad.delete.confirm\": \"למחוק את הפנקס הזה?\",\n\t\"pad.settings.about\": \"על אודות\",\n\t\"pad.settings.poweredBy\": \"מופעל על גבי\",\n\t\"pad.importExport.import_export\": \"יבוא/יצוא\",\n\t\"pad.importExport.import\": \"העלאת כל קובץ טקסט או מסמך\",\n\t\"pad.importExport.importSuccessful\": \"זה עבד!\",\n\t\"pad.importExport.export\": \"יצוא הפנקס הנוכחי בתור:\",\n\t\"pad.importExport.exportetherpad\": \"את'רפד\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"טקסט רגיל\",\n\t\"pad.importExport.exportword\": \"מיקרוסופט וורד\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"באפשרותך לייבא מטקסט פשוט או מ־HTML. לאפשרויות יבוא מתקדמות יותר יש <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">להתקין AbiWord או LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"מחובר.\",\n\t\"pad.modals.reconnecting\": \"מתבצע חיבור מחדש למחברת שלך…\",\n\t\"pad.modals.forcereconnect\": \"לכפות חיבור מחדש\",\n\t\"pad.modals.reconnecttimer\": \"מנסה להתחבר מחדש בעוד\",\n\t\"pad.modals.cancel\": \"ביטול\",\n\t\"pad.modals.userdup\": \"פתוח בחלון אחר\",\n\t\"pad.modals.userdup.explanation\": \"נראה שהפנקס הזה פתוח ביותר מחלון דפדפן אחד במחשב הזה.\",\n\t\"pad.modals.userdup.advice\": \"להתחבר מחדש באמצעות החלון הזה.\",\n\t\"pad.modals.unauth\": \"אין הרשאה\",\n\t\"pad.modals.unauth.explanation\": \"ההרשאות שלך השתנו בזמן שניסית להתחבר. נא לנסות להתחבר מחדש.\",\n\t\"pad.modals.looping.explanation\": \"יש בעיות חיבור עם השרת המתאם.\",\n\t\"pad.modals.looping.cause\": \"ייתכן שהתחברת דרך חומת־אש או שרת מתווך בלתי־תואמים.\",\n\t\"pad.modals.initsocketfail\": \"אין תקשורות לשרת.\",\n\t\"pad.modals.initsocketfail.explanation\": \"התחברות לשרת המתאם לא הצליחה.\",\n\t\"pad.modals.initsocketfail.cause\": \"אולי זה בגלל הדפדפן שלך או חיבור האינטרנט שלך.\",\n\t\"pad.modals.slowcommit.explanation\": \"השרת אינו מגיב.\",\n\t\"pad.modals.slowcommit.cause\": \"אולי זה בגלל בעיות עם תקשורת לרשת.\",\n\t\"pad.modals.badChangeset.explanation\": \"עריכה שעשית סווגה כבלתי־תקינה על־ידי שרת הסנכרון.\",\n\t\"pad.modals.badChangeset.cause\": \"ייתכן שזה קרה בגלל הגדרות שרת שגויות או התנהגות בלתי־צפויה כלשהי. נא ליצור קשר עם המנהל של השירות אם נראה לך שזאת שגיאה. כדי להמשיך לערוך יש לנסות להתחבר מחדש.\",\n\t\"pad.modals.corruptPad.explanation\": \"הנתונים בפנקס שניסית לגשת אליו התקלקלו.\",\n\t\"pad.modals.corruptPad.cause\": \"ייתכן שזה קרה בגלל הגדרות שרת שגויות או התנהגות בלתי־צפויה כלשהי. נא ליצור קשר עם המנהל של השירות אם נראה לך שזאת שגיאה.\",\n\t\"pad.modals.deleted\": \"נמחק.\",\n\t\"pad.modals.deleted.explanation\": \"הפנקס הזה הוסר.\",\n\t\"pad.modals.rateLimited\": \"מוגבל קצב.\",\n\t\"pad.modals.rateLimited.explanation\": \"שלחת יותר מדי הודעות לפנקס הזה ולכן הוא ניתק אותך.\",\n\t\"pad.modals.rejected.explanation\": \"השרת דחה את ההודעה שנשלחה על ידי הדפדפן שלך.\",\n\t\"pad.modals.rejected.cause\": \"יכול להיות שהשרת עודכן בזמן שצפית בפנקס או שיש תקלה ב־Etherpad. מומלץ לנסות לרענן את העמוד.\",\n\t\"pad.modals.disconnected\": \"נותקת.\",\n\t\"pad.modals.disconnected.explanation\": \"התקשורת לשרת אבדה\",\n\t\"pad.modals.disconnected.cause\": \"ייתכן שהשרת אינו זמין. נא להודיע למנהל השירות אם זה ממשיך לקרות.\",\n\t\"pad.share\": \"שיתוף הפנקס הזה\",\n\t\"pad.share.readonly\": \"קישור\",\n\t\"pad.share.link\": \"קישור\",\n\t\"pad.share.emebdcode\": \"הטמעת קישור\",\n\t\"pad.chat\": \"שיחה\",\n\t\"pad.chat.title\": \"פתיחת השיחה של הפנקס הזה.\",\n\t\"pad.chat.loadmessages\": \"טעינת הודעות נוספות\",\n\t\"pad.chat.stick.title\": \"הצמדת צ׳אט למסך\",\n\t\"pad.chat.writeMessage.placeholder\": \"מקום לכתיבת ההודעה שלך\",\n\t\"timeslider.followContents\": \"לעקוב אחר עדכוני תוכן פנקס\",\n\t\"timeslider.pageTitle\": \"גולל זמן של {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"חזרה אל הפנקס\",\n\t\"timeslider.toolbar.authors\": \"כותבים:\",\n\t\"timeslider.toolbar.authorsList\": \"אין כותבים\",\n\t\"timeslider.toolbar.exportlink.title\": \"יצוא\",\n\t\"timeslider.exportCurrent\": \"יצוא הגרסה הנוכחית בתור:\",\n\t\"timeslider.version\": \"גרסה {{version}}\",\n\t\"timeslider.saved\": \"נשמרה ב־{{day}} ב{{month}} {{year}}\",\n\t\"timeslider.playPause\": \"לנגן / לעצור את תוכן הפנקס\",\n\t\"timeslider.backRevision\": \"לחזור לגרסה של הפנקס הזה\",\n\t\"timeslider.forwardRevision\": \"ללכת לגרסה חדשה יותר בפנקס הזה\",\n\t\"timeslider.dateformat\": \"{{year}}-{{month}}-{{day}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"ינואר\",\n\t\"timeslider.month.february\": \"פברואר\",\n\t\"timeslider.month.march\": \"מרץ\",\n\t\"timeslider.month.april\": \"אפריל\",\n\t\"timeslider.month.may\": \"מאי\",\n\t\"timeslider.month.june\": \"יוני\",\n\t\"timeslider.month.july\": \"יולי\",\n\t\"timeslider.month.august\": \"אוגוסט\",\n\t\"timeslider.month.september\": \"ספטמבר\",\n\t\"timeslider.month.october\": \"אוקטובר\",\n\t\"timeslider.month.november\": \"נובמבר\",\n\t\"timeslider.month.december\": \"דצמבר\",\n\t\"timeslider.unnamedauthors\": \"{[plural(num) one: יוצר אחד, other: {{num}} יוצרים ]} ללא שם\",\n\t\"pad.savedrevs.marked\": \"גרסה זו מסומנת כגרסה שמורה\",\n\t\"pad.savedrevs.timeslider\": \"אפשר להציג גרסאות שמורות באמצעות ביקור בגולל הזמן\",\n\t\"pad.userlist.entername\": \"נא להזין את שמך\",\n\t\"pad.userlist.unnamed\": \"ללא שם\",\n\t\"pad.editbar.clearcolors\": \"לנקות צבעים לסימון כותבים בכל המסמך? זו פעולה בלתי הפיכה\",\n\t\"pad.impexp.importbutton\": \"לייבא כעת\",\n\t\"pad.impexp.importing\": \"מתבצע יבוא...\",\n\t\"pad.impexp.confirmimport\": \"יבוא של קובץ יבטל את הטקסט הנוכחי בפנקס. להמשיך?\",\n\t\"pad.impexp.convertFailed\": \"לא הצלחנו לייבא את הקובץ הזה. נא להשתמש בתסדיר מסמך שונה או להעתיק ולהדביק ידנית\",\n\t\"pad.impexp.padHasData\": \"לא הצלחנו לייבא את הקובץ הזה, כי בפנקס הזה כבר יש שינויים. נא לייבא לפנקס חדש.\",\n\t\"pad.impexp.uploadFailed\": \"ההעלאה נכשלה, נא לנסות שוב\",\n\t\"pad.impexp.importfailed\": \"היבוא נכשל\",\n\t\"pad.impexp.copypaste\": \"נא להעתיק ולהדביק\",\n\t\"pad.impexp.exportdisabled\": \"יצוא בתסדיר {{type}} אינו פעיל. מנהל המערכת שלך יוכל לספר לך על זה עוד פרטים.\",\n\t\"pad.impexp.maxFileSize\": \"הקובץ גדול מדי. נא ליצור קשר עם הנהלת האתר כדי להגדיל את הגודל המרבי שמותר לייבא.\"\n}\n"
  },
  {
    "path": "src/locales/hi.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Sfic\"\n\t\t]\n\t},\n\t\"pad.toolbar.bold.title\": \"गहरा (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"तिरछा (Ctrl+I)\",\n\t\"pad.toolbar.strikethrough.title\": \"काटें (Ctrl+5)\",\n\t\"pad.colorpicker.save\": \"सहेजें\",\n\t\"pad.colorpicker.cancel\": \"रद्द करें\",\n\t\"pad.loading\": \"लोड हो रहा है...\",\n\t\"pad.settings.language\": \"भाषा:\",\n\t\"pad.importExport.import_export\": \"आयात/निर्यात\",\n\t\"pad.importExport.exportpdf\": \"पीडीएफ़\",\n\t\"pad.modals.cancel\": \"रद्द करें\",\n\t\"timeslider.toolbar.authors\": \"लेखक:\",\n\t\"timeslider.toolbar.exportlink.title\": \"निर्यात\",\n\t\"timeslider.version\": \"संस्करण {{version}}\",\n\t\"timeslider.saved\": \"{{day}} {{month}} {{year}} सहेजा गया\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"जनवरी\",\n\t\"timeslider.month.february\": \"फ़रवरी\",\n\t\"timeslider.month.march\": \"मार्च\",\n\t\"timeslider.month.april\": \"अप्रैल\",\n\t\"timeslider.month.may\": \"मई\",\n\t\"timeslider.month.june\": \"जून\",\n\t\"timeslider.month.july\": \"जुलाई\",\n\t\"timeslider.month.august\": \"अगस्त\",\n\t\"timeslider.month.september\": \"सितम्बर\",\n\t\"timeslider.month.october\": \"अक्टूबर\",\n\t\"timeslider.month.november\": \"नवम्बर\",\n\t\"timeslider.month.december\": \"दिसम्बर\",\n\t\"pad.impexp.importbutton\": \"अभी आयात करें\",\n\t\"pad.impexp.importing\": \"आयात कर रहा...\",\n\t\"pad.impexp.importfailed\": \"आयात विफल हुआ\",\n\t\"pad.impexp.copypaste\": \"कृपया कॉपी पेस्ट करें\"\n}\n"
  },
  {
    "path": "src/locales/hr.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Bugoslav\",\n\t\t\t\"Hmxhmx\",\n\t\t\t\"Ponor\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Novi blokić\",\n\t\"index.createOpenPad\": \"ili stvori/otvori blokić s imenom:\",\n\t\"index.openPad\": \"otvori postojeći blokić Etherpada s imenom:\",\n\t\"pad.toolbar.bold.title\": \"Masno (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Ukošeno (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Podcrtano (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Prekriženo (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Uređeni popis (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Neuređeni popis (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Uvlaka (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Izvlaka (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Poništi (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Ponovi (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Ukloni boje autorstva (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Uvezi/izvezi iz/na različite datotečne formate\",\n\t\"pad.toolbar.timeslider.title\": \"Pokazivač vremenske lente\",\n\t\"pad.toolbar.savedRevision.title\": \"Spremi inačicu\",\n\t\"pad.toolbar.settings.title\": \"Postavke\",\n\t\"pad.toolbar.embed.title\": \"Dijeli i umetni ovaj blokić\",\n\t\"pad.toolbar.showusers.title\": \"Pokaži suradnike ovoga blokića\",\n\t\"pad.colorpicker.save\": \"Spremi\",\n\t\"pad.colorpicker.cancel\": \"Otkaži\",\n\t\"pad.loading\": \"Učitavanje...\",\n\t\"pad.noCookie\": \"Kolačić nije pronađen. Molimo Vas, omogućite kolačiće u Vašem pregledniku! Sesija i postavke neće biti sačuvane između Vaših posjećivanja. Razlog može biti uključenost Etherpada u iFrame u nekim preglednicima. Molimo Vas, osigurajte da je Etherpad na istoj poddomeni/domeni kao i ''roditeljski'' iFrame.\",\n\t\"pad.permissionDenied\": \"Nemate dopuštenje za pristup ovome blokiću\",\n\t\"pad.settings.padSettings\": \"Postavke blokića\",\n\t\"pad.settings.myView\": \"Vaš prikaz\",\n\t\"pad.settings.stickychat\": \"Stavi čavrljanje uvijek na ekranu\",\n\t\"pad.settings.chatandusers\": \"Prikaži čavrljanje i suradnike\",\n\t\"pad.settings.colorcheck\": \"Boje autorstva\",\n\t\"pad.settings.linenocheck\": \"Brojevi redaka\",\n\t\"pad.settings.rtlcheck\": \"Želite li prikaz sadržaja s desna na lijevo?\",\n\t\"pad.settings.fontType\": \"Vrsta fonta:\",\n\t\"pad.settings.fontType.normal\": \"Normalna\",\n\t\"pad.settings.language\": \"Jezik:\",\n\t\"pad.settings.about\": \"O projektu\",\n\t\"pad.settings.poweredBy\": \"Proizvod Vam pruža\",\n\t\"pad.importExport.import_export\": \"Uvoz/Izvoz\",\n\t\"pad.importExport.import\": \"Postavite bilo koju tekstualnu datoteku ili dokument\",\n\t\"pad.importExport.importSuccessful\": \"Uspješno!\",\n\t\"pad.importExport.export\": \"Izvezi trenutačni blokić kao:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad (virtualni blokići)\",\n\t\"pad.importExport.exporthtml\": \"HTML (oblikovanje sadržaja)\",\n\t\"pad.importExport.exportplain\": \"Obični tekst (bez oblikovanja)\",\n\t\"pad.importExport.exportword\": \"Datoteku programa Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"Datoteku Acrobatova PDF formata\",\n\t\"pad.importExport.exportopen\": \"Datoteku formata Open Document (ODF)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Možete uvoziti samo datoteke formata za obični tekst (bez oblikovanja) te datoteke u formatima HTML-a. Za naprednije mogućnosti uvoza, molimo Vas, instalirajte <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">program AbiWord ili LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Povezano.\",\n\t\"pad.modals.reconnecting\": \"Ponovo Vas povezujemo s Vašim blokićem...\",\n\t\"pad.modals.forcereconnect\": \"Prisilno se ponovo poveži\",\n\t\"pad.modals.reconnecttimer\": \"Sustav Vas pokušava ponovo povezati\",\n\t\"pad.modals.cancel\": \"Odustani\",\n\t\"pad.modals.userdup\": \"Otvoreno u drugom prozoru\",\n\t\"pad.modals.userdup.explanation\": \"Čini se da je ovaj blokić otvoren u više od jednoga prozora Vašega preglednika na ovom računalu.\",\n\t\"pad.modals.userdup.advice\": \"Ponovo se povežite da biste rabili ovaj prozor.\",\n\t\"pad.modals.unauth\": \"Niste ovlašteni\",\n\t\"pad.modals.unauth.explanation\": \"Vaše su ovlasti promijenjene za vrijeme dok ste pregledavali stranicu.\\nPokušajte se ponovo spojiti.\",\n\t\"pad.modals.looping.explanation\": \"Postoje komunikacijski problemi sa sinkronizacijskim poslužiteljem.\",\n\t\"pad.modals.looping.cause\": \"Možda ste se spojili preko nekompatibilne sigurnosne stijene ili proxyja.\",\n\t\"pad.modals.initsocketfail\": \"Poslužitelj nije dostupan.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Ne mogu se povezati sa sinkronizacijskim poslužiteljem.\",\n\t\"pad.modals.initsocketfail.cause\": \"Najvjerojatnije je došlo do problema s Vašim preglednikom ili s Vašom internetskom vezom.\",\n\t\"pad.modals.slowcommit.explanation\": \"Poslužitelj ne šalje odziv.\",\n\t\"pad.modals.slowcommit.cause\": \"Najvjerojatnije je došlo do problema s dostupnošću mreže.\",\n\t\"pad.modals.badChangeset.explanation\": \"Sinkronizacijski poslužitelj označio je Vaše uređivanje kao nedopušteno.\",\n\t\"pad.modals.badChangeset.cause\": \"Moguće je da je došlo do pogrješke konfiguracije poslužitelja ili nekog drugog neočekivanog događaja odnosno postupka. Molimo Vas da kontaktirate s Vašim administratorom usluge, ukoliko držite da je ovo pogrješka. Molimo Vas, pokušajte se ponovo spojiti kako biste nastavili s uređivanjem.\",\n\t\"pad.modals.corruptPad.explanation\": \"Blokić kom pokušavate pristupiti je oštećen.\",\n\t\"pad.modals.corruptPad.cause\": \"Moguće je došlo do pogrješne konfiguracije poslužitelja ili nekog drugog neočekivanog događaja ili postupka. Molimo Vas, kontaktirajte administratora usluge.\",\n\t\"pad.modals.deleted\": \"Pobrisano.\",\n\t\"pad.modals.deleted.explanation\": \"Blokić je bio uklonjen.\",\n\t\"pad.modals.rateLimited\": \"Brzina slanja poruka na blokić je ograničena.\",\n\t\"pad.modals.rateLimited.explanation\": \"Poslali ste previše poruka na ovaj blokić, te ste stoga odspojeni.\",\n\t\"pad.modals.disconnected\": \"Vaša je veza prekinuta.\",\n\t\"pad.modals.disconnected.explanation\": \"Veza s poslužiteljem je izgubljena.\",\n\t\"pad.modals.disconnected.cause\": \"Moguće je da poslužitelj nije dostupan. Molimo Vas, obavijestite administratora usluge ukoliko se to nastavi događati.\",\n\t\"pad.share\": \"Dijeljenje ovoga blokića.\",\n\t\"pad.share.readonly\": \"Samo za čitanje\",\n\t\"pad.share.link\": \"Poveznica\",\n\t\"pad.share.emebdcode\": \"Umetni poveznicu (URL)\",\n\t\"pad.chat\": \"Čavrljanje\",\n\t\"pad.chat.title\": \"Otvori čavrljanje uz ovaj blokić.\",\n\t\"pad.chat.loadmessages\": \"Učitaj više poruka\",\n\t\"pad.chat.stick.title\": \"Prilijepi razgovor na zaslon\",\n\t\"pad.chat.writeMessage.placeholder\": \"Ovdje napišite svoju poruku\",\n\t\"timeslider.followContents\": \"Prati ažuriranja sadržaja blokića\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Vremenska lenta\",\n\t\"timeslider.toolbar.returnbutton\": \"Vrati se natrag na blokić\",\n\t\"timeslider.toolbar.authors\": \"Autori:\",\n\t\"timeslider.toolbar.authorsList\": \"Nema autora\",\n\t\"timeslider.toolbar.exportlink.title\": \"Izvoz\",\n\t\"timeslider.exportCurrent\": \"Izvezi trenutačnu inačicu kao:\",\n\t\"timeslider.version\": \"Inačica {{version}}\",\n\t\"timeslider.saved\": \"Spremljeno dana {{day}}. {{month}} {{year}}.\",\n\t\"timeslider.playPause\": \"Izvrti/pauziraj sadržaj blokića\",\n\t\"timeslider.backRevision\": \"Idi jednu inačicu ovog blokića natrag\",\n\t\"timeslider.forwardRevision\": \"Idi jednu inačicu ovog blokića naprijed\",\n\t\"timeslider.dateformat\": \"{{day}}. {{month}}. {{year}}. {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"siječnja\",\n\t\"timeslider.month.february\": \"veljače\",\n\t\"timeslider.month.march\": \"ožujka\",\n\t\"timeslider.month.april\": \"travnja\",\n\t\"timeslider.month.may\": \"svibnja\",\n\t\"timeslider.month.june\": \"lipnja\",\n\t\"timeslider.month.july\": \"srpnja\",\n\t\"timeslider.month.august\": \"kolovoza\",\n\t\"timeslider.month.september\": \"rujna\",\n\t\"timeslider.month.october\": \"listopada\",\n\t\"timeslider.month.november\": \"studenoga\",\n\t\"timeslider.month.december\": \"prosinca\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: neimenovani autor, plural(num) two: neimenovana autora, plural(num) other: neimenovanih autora ]}\",\n\t\"pad.savedrevs.marked\": \"Ova inačica označena je sada kao spremljena inačica\",\n\t\"pad.savedrevs.timeslider\": \"Možete vidjeti spremljene inačice rabeći vremensku lentu (timeslider)\",\n\t\"pad.userlist.entername\": \"Unesite Vaše suradničko ime\",\n\t\"pad.userlist.unnamed\": \"bez imena\",\n\t\"pad.editbar.clearcolors\": \"Ukloniti boje autorstva u cijelom blokiću? Radnju nije moguće poništiti jednom kad je izvršena.\",\n\t\"pad.impexp.importbutton\": \"Uvezi odmah\",\n\t\"pad.impexp.importing\": \"Uvoženje...\",\n\t\"pad.impexp.confirmimport\": \"Uvoženje datoteke presnimit će trenutačni sadržaj blokića.\\nJeste li sigurni da želite nastaviti?\",\n\t\"pad.impexp.convertFailed\": \"Nismo bili u mogućnosti uvesti tu datoteku. Molimo Vas, rabite neki drugi format dokumenta ili ručno preslikajte/zalijepite sadržaj\",\n\t\"pad.impexp.padHasData\": \"Nismo bili u mogućnosti uvesti navedenu datoteku, jer je blokić već bio mijenjan, molimo Vas uvezite u novi blokić\",\n\t\"pad.impexp.uploadFailed\": \"Postavljanje nije uspjelo. molimo Vas, pokušajte ponovo\",\n\t\"pad.impexp.importfailed\": \"Uvoz nije uspio\",\n\t\"pad.impexp.copypaste\": \"Molimo preslikajte/zalijepite\",\n\t\"pad.impexp.exportdisabled\": \"Izvoz u formatu {{type}} nije omogućen. Molimo Vas, kontaktirajte Vašega administratora sustava za više pojedinosti.\",\n\t\"pad.impexp.maxFileSize\": \"Datoteka je prevelika. Kontaktirajte administratora Vašega mrežnoga sjedišta kako biste zatražili povećanje dopuštene veličine datoteke za uvoz\"\n}\n"
  },
  {
    "path": "src/locales/hrx.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Paul Beppler\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Neies Pad\",\n\t\"index.createOpenPad\": \"Pad mit follichendem Noome uffmache:\",\n\t\"pad.toolbar.bold.title\": \"Fett (Strg-B)\",\n\t\"pad.toolbar.italic.title\": \"Kursiv (Strg-I)\",\n\t\"pad.toolbar.underline.title\": \"Unnerstrich (Strg-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Dorrichgestrich\",\n\t\"pad.toolbar.ol.title\": \"Nummerierte List\",\n\t\"pad.toolbar.ul.title\": \"Ungeordnete List\",\n\t\"pad.toolbar.indent.title\": \"Einrück (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Ausrück (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Rückgängich (Strg-Z)\",\n\t\"pad.toolbar.redo.title\": \"Wiederhole (Strg-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Autorefarbe zurücksetze\",\n\t\"pad.toolbar.import_export.title\": \"Import/Export von/zu verschiedne Dateiformate\",\n\t\"pad.toolbar.timeslider.title\": \"Pad-Versionsgeschicht oonzeiche\",\n\t\"pad.toolbar.savedRevision.title\": \"Version markiere\",\n\t\"pad.toolbar.settings.title\": \"Einstellunge\",\n\t\"pad.toolbar.embed.title\": \"Pad teile orrer inbette\",\n\t\"pad.toolbar.showusers.title\": \"Aktuell verbundne Benutzer oonzeiche\",\n\t\"pad.colorpicker.save\": \"Speichre\",\n\t\"pad.colorpicker.cancel\": \"Abbreche\",\n\t\"pad.loading\": \"Loode …\",\n\t\"pad.permissionDenied\": \"Du host ken Berechtichung, um uff das Pad zuzugreif\",\n\t\"pad.settings.padSettings\": \"Pad Einstellunge\",\n\t\"pad.settings.myView\": \"Eichne Oonsicht\",\n\t\"pad.settings.stickychat\": \"Chat immer oonzeiche\",\n\t\"pad.settings.colorcheck\": \"Autorenfarbe oonzeiche\",\n\t\"pad.settings.linenocheck\": \"Zeilenummer\",\n\t\"pad.settings.rtlcheck\": \"Inhalt von rechts bis links lese?\",\n\t\"pad.settings.fontType\": \"Schriftoort:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Sproch:\",\n\t\"pad.importExport.import_export\": \"Import/Export\",\n\t\"pad.importExport.import\": \"Text-Datei orrer Dokument hochloode\",\n\t\"pad.importExport.importSuccessful\": \"Erfollichreich!\",\n\t\"pad.importExport.export\": \"Aktuelles Pad exportiere wie:\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Textdatei\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Sie können nur aus Klartext oder HTML-Formaten importieren. Für mehr erweiterte Importfunktionen <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">installieren Sie bitte abiword</a>.\",\n\t\"pad.modals.connected\": \"Verbünd (konnektiert).\",\n\t\"pad.modals.reconnecting\": \"Wiederherstelle von der Verbinnung …\",\n\t\"pad.modals.forcereconnect\": \"Erneit Verbinne\",\n\t\"pad.modals.userdup\": \"In enem andren Fenster schon uff (geöffnet)\",\n\t\"pad.modals.userdup.explanation\": \"Das Pad scheint in meahr wie enem Browser-Fenster uff dem Komputadoar uff sin (geöffnet zu sein).\",\n\t\"pad.modals.userdup.advice\": \"Um das Fenster se benutze, verbinn bittschön wieder erneit.\",\n\t\"pad.modals.unauth\": \"Net authorisiert.\",\n\t\"pad.modals.unauth.explanation\": \"Dein Zugriffsberechtichung für das Pad hot sich zwischichzeitlich geännert. Du kannst versuche das Pad wieder erneit uffserufe.\",\n\t\"pad.modals.looping.explanation\": \"Es gebt Probleme bei der Kommunikation mit dem Pad-Server.\",\n\t\"pad.modals.looping.cause\": \"Möchlicherweis bist dorrich en inkompatible Firewall orrer üwer en inkompatible Proxy mit dem Pad-Server verbünd (konnektiert).\",\n\t\"pad.modals.initsocketfail\": \"Pad-Server net erreichbar.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Do konnt ken Verbinnung zum Pad-Server heargestellt sin.\",\n\t\"pad.modals.initsocketfail.cause\": \"Dies könnt an deinem Browser orrer dein Internet-Verbinnung leihe.\",\n\t\"pad.modals.slowcommit.explanation\": \"Der Pad-Server reagiert net.\",\n\t\"pad.modals.slowcommit.cause\": \"Das könnt en Netzwerkverbinnungsproblem sin orrer en momentane Üwerlaschtung von der Pad-Server.\",\n\t\"pad.modals.badChangeset.explanation\": \"En von dein gemachte Ännrung woard vom Pad-Server wie ungültich ingeschtuft (klassifiziert).\",\n\t\"pad.modals.badChangeset.cause\": \"Das könnt uffgrund von en falsche Serverkonfiguration orrer en annre unerwoortete Verhalt passiert sin. Bittschön kontaktier den Diensteadministratoar, falls du gloobst, dass das sich um en Fehler handelt. Versuch dich wieder erneit se verbinne, um mit dem Beoorbeite fortzufoohre.\",\n\t\"pad.modals.corruptPad.explanation\": \"Das Pad, uff das du zugreife willst, ist beschädicht.\",\n\t\"pad.modals.corruptPad.cause\": \"Das könnt an en falsche Serverkonfiguration orrer en annre unerwoortete Verhalten liehn. Bittschön kontaktier den Diensteadministratoar.\",\n\t\"pad.modals.deleted\": \"Abgewischt.\",\n\t\"pad.modals.deleted.explanation\": \"Das Pad woard entfernt.\",\n\t\"pad.modals.disconnected\": \"Verbinnung unnerbroch (du bist jetzt deskonnektiert).\",\n\t\"pad.modals.disconnected.explanation\": \"Die Verbinnung (konnektion) zu dem Pad-Server woard unnerbroch.\",\n\t\"pad.modals.disconnected.cause\": \"Möchlicherweis ist der Pad-Server net erreichbar. Bittschön benachrichtig den Dienstadministratoar, falls das weiterhin passiere tut.\",\n\t\"pad.share\": \"Das Pad teile\",\n\t\"pad.share.readonly\": \"Ingeschränkter Nur-Lese-Zugriff\",\n\t\"pad.share.link\": \"Link\",\n\t\"pad.share.emebdcode\": \"In Webseite einbette\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Den Chat von dem Pad uffmache\",\n\t\"pad.chat.loadmessages\": \"Weitre Nachrichte loode\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Pad-Versionsgeschicht\",\n\t\"timeslider.toolbar.returnbutton\": \"Zurück zum Pad\",\n\t\"timeslider.toolbar.authors\": \"Autore:\",\n\t\"timeslider.toolbar.authorsList\": \"ken Autore\",\n\t\"timeslider.toolbar.exportlink.title\": \"Die Version exportiere\",\n\t\"timeslider.exportCurrent\": \"Exportier die Version wie:\",\n\t\"timeslider.version\": \"Version {{version}}\",\n\t\"timeslider.saved\": \"Gespeichert am {{day}}.{{month}}.{{year}}\",\n\t\"timeslider.dateformat\": \"{{day}}.{{month}}.{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Januar\",\n\t\"timeslider.month.february\": \"Februar\",\n\t\"timeslider.month.march\": \"März\",\n\t\"timeslider.month.april\": \"April\",\n\t\"timeslider.month.may\": \"Mai\",\n\t\"timeslider.month.june\": \"Juni\",\n\t\"timeslider.month.july\": \"Juli\",\n\t\"timeslider.month.august\": \"August\",\n\t\"timeslider.month.september\": \"September\",\n\t\"timeslider.month.october\": \"Oktober\",\n\t\"timeslider.month.november\": \"November\",\n\t\"timeslider.month.december\": \"Dezember\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: unbenannter Autoar, other: unbenannte Autore ]}\",\n\t\"pad.savedrevs.marked\": \"Die Version woard jetzt wie gespeicherte Version gekennzeichnet\",\n\t\"pad.userlist.entername\": \"Tue en Noome ren gebe\",\n\t\"pad.userlist.unnamed\": \"unbenannt\",\n\t\"pad.editbar.clearcolors\": \"Autorefarbe im gesamte Dokument zurücksetze?\",\n\t\"pad.impexp.importbutton\": \"Jetzt importiere\",\n\t\"pad.impexp.importing\": \"Importiere …\",\n\t\"pad.impexp.confirmimport\": \"Das Importiere von en Datei üwerschreibt den aktuelle Text von dem Pad. Willst du weerklich fortfoohre?\",\n\t\"pad.impexp.convertFailed\": \"Mir könne die Datei net importiere. Bittschön verwenn en anner Dokumentformat orrer üwertrooh den Text manuell.\",\n\t\"pad.impexp.uploadFailed\": \"Der Upload ist fehlgeschloohn (das hot net funktioniert). Bittschön versuch  das wieder erneit.\",\n\t\"pad.impexp.importfailed\": \"Import fehlgeschloohn (das hot net funktioniert)\",\n\t\"pad.impexp.copypaste\": \"Bittschön tue kopiere und einfüche (oonklebe)\",\n\t\"pad.impexp.exportdisabled\": \"Der Export im {{type}}-Format ist deaktiviert. Für Einzelheite (Detalhes) tue bittschön dein Systemadministratoar kontaktiere.\"\n}\n"
  },
  {
    "path": "src/locales/hsb.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Michawiki\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Administratorowa deska – Etherpad\",\n\t\"admin_plugins\": \"Zrjadowak tykačow\",\n\t\"admin_plugins.available\": \"K dispoziciji stejace tykače\",\n\t\"admin_plugins.available_not-found\": \"Žane tykače namakane.\",\n\t\"admin_plugins.available_fetching\": \"Wobstaruje so …\",\n\t\"admin_plugins.available_install.value\": \"Instalować\",\n\t\"admin_plugins.available_search.placeholder\": \"Tykače za instalaciju pytać\",\n\t\"admin_plugins.description\": \"Wopisanje\",\n\t\"admin_plugins.installed\": \"Instalowane tykače\",\n\t\"admin_plugins.installed_fetching\": \"Instalowane tykače so wobstaruja …\",\n\t\"admin_plugins.installed_nothing\": \"Hišće njejsće tykače instalował.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Wotinstalować\",\n\t\"admin_plugins.last-update\": \"Poslednja aktualizacija\",\n\t\"admin_plugins.name\": \"Mjeno\",\n\t\"admin_plugins.page-title\": \"Zrjadowak tykačow – Etherpad\",\n\t\"admin_plugins.version\": \"Wersija\",\n\t\"admin_plugins_info\": \"Informacije wo rozrisanju problemow\",\n\t\"admin_plugins_info.hooks\": \"Instalowane hoki\",\n\t\"admin_plugins_info.hooks_client\": \"Hoki ze strony klienta\",\n\t\"admin_plugins_info.hooks_server\": \"Hoki ze strony serwera\",\n\t\"admin_plugins_info.parts\": \"Instalowane dźěle\",\n\t\"admin_plugins_info.plugins\": \"Instalowane tykače\",\n\t\"admin_plugins_info.page-title\": \"Tykačowe informacije – Ehterpad\",\n\t\"admin_plugins_info.version\": \"Wersija Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Najnowša wersija\",\n\t\"admin_plugins_info.version_number\": \"Wersijowe čisło\",\n\t\"admin_settings\": \"Nastajenja\",\n\t\"admin_settings.current\": \"Aktualna konfiguracija\",\n\t\"admin_settings.current_example-devel\": \"Přikładowa předłoha wuwiwanskich nastajenjow\",\n\t\"admin_settings.current_example-prod\": \"Přikładowa předłoha produkciskich nastajenjow\",\n\t\"admin_settings.current_restart.value\": \"Etherpad znowa startować\",\n\t\"admin_settings.current_save.value\": \"Nastajenja składować\",\n\t\"admin_settings.page-title\": \"Nastajenja – Etherpad\",\n\t\"index.newPad\": \"Nowy zapisnik\",\n\t\"index.settings\": \"Nastajenja\",\n\t\"index.transferSessionTitle\": \"Posedźenje přenošować\",\n\t\"index.receiveSessionTitle\": \"Posedźenje přijeć\",\n\t\"index.receiveSessionDescription\": \"Tu móžeš posedźenje Etherpad z druheho wobhladowaka abo grata přijeć. Prošu dźiwaj na to, zo to waše aktualne posedźenje zhaša, jeli tajke eksistuje.\",\n\t\"index.transferSession\": \"1. Posedźenje přenošować\",\n\t\"index.transferSessionNow\": \"Posedźenje nětko přenošować\",\n\t\"index.copyLink\": \"2. Wotkaz kopěrować\",\n\t\"index.copyLinkDescription\": \"Klikń na slědowace tłóčatko, zo by wotkaz do mjezyskłada kopěrował.\",\n\t\"index.copyLinkButton\": \"Wotkaz do mjezyskłada kopěrować\",\n\t\"index.transferToSystem\": \"3. Posedźenje do noweho systema kopěrować\",\n\t\"index.transferToSystemDescription\": \"Wočiń kopěrowany wotkaz w cilowym wobhladowaku abo graće, zo by swoje posedźenje přenošował.\",\n\t\"index.transferSessionDescription\": \"Klikń na slědowace tłóčatko, zo by swoje aktualne posedźenje do wobhladowaka abo grata přenošował. To budźe wotkaz do strony kopěrować, kotraž budźe waše posedźenje přenošować, hdyž so w cilowym wobhladowaku abo graće wočinja.\",\n\t\"index.createOpenPad\": \"Zapisnik po mjenje wočinić\",\n\t\"index.openPad\": \"wočińće eksistowacy Pad z mjenom:\",\n\t\"index.recentPads\": \"Najnowše zapisniki\",\n\t\"index.recentPadsEmpty\": \"Žane najnowše zapisniki namakane.\",\n\t\"index.generateNewPad\": \"Připadne mjeno zapisnika generować\",\n\t\"index.labelPad\": \"Mjeno zapisnika (po přeću)\",\n\t\"index.placeholderPadEnter\": \"Prošu zapodaj mjeno zapisnika…\",\n\t\"index.createAndShareDocuments\": \"Wutwor a dźěl dokumenty we woprawdźitym času\",\n\t\"index.createAndShareDocumentsDescription\": \"Etherpad wam zmóžnja, dokumenty zhromadnje we woprawdźitym času wobdźěłać, kaž editor live multi-player, kotryž we wašim wobhladowaku běži.\",\n\t\"pad.toolbar.bold.title\": \"Tučny (Strg-B)\",\n\t\"pad.toolbar.italic.title\": \"Kursiwny (Strg-I)\",\n\t\"pad.toolbar.underline.title\": \"Podšmórnyć (Strg-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Přešmórnyć (Strg+5)\",\n\t\"pad.toolbar.ol.title\": \"Čisłowana lisćina (Strg+Umsch+N)\",\n\t\"pad.toolbar.ul.title\": \"Naličenje (Strg+Umsch+L)\",\n\t\"pad.toolbar.indent.title\": \"Zasunyć (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Wusunyć (Umsch+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Cofnyć (Strg-Z)\",\n\t\"pad.toolbar.redo.title\": \"Wospjetować (Strg-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Awtorowe barby wotstronić (Strg+Umsch+C)\",\n\t\"pad.toolbar.import_export.title\": \"Import/Eksport z/do druhich datajowych formatow\",\n\t\"pad.toolbar.timeslider.title\": \"Historijowa strona\",\n\t\"pad.toolbar.savedRevision.title\": \"Wersiju składować\",\n\t\"pad.toolbar.settings.title\": \"Nastajenja\",\n\t\"pad.toolbar.embed.title\": \"Tutón zapisnik dźělić a zasadźić\",\n\t\"pad.toolbar.home.title\": \"Wróćo k startowej stronje\",\n\t\"pad.toolbar.showusers.title\": \"Wužiwarjow na tutym zapisniku pokazać\",\n\t\"pad.colorpicker.save\": \"Składować\",\n\t\"pad.colorpicker.cancel\": \"Přetorhnyć\",\n\t\"pad.loading\": \"Začituje so...\",\n\t\"pad.noCookie\": \"Plack njeje so namakał. Prošu dowolće placki w swojim wobhladowaku! Waše posedźenje a nastajenja so mjez dwěmaj wopytomaj njeskładuja. To móže so stać, hdyž Etherpad je w někotrych wobhladowakach w iFrame wobsahowany. Prošu zawěsćće, zo Etherpad je na samsnej poddomenje/domenje kaž nadrjadowany iFrame\",\n\t\"pad.permissionDenied\": \"Nimaće prawo za přistup na tutón zapisnik.\",\n\t\"pad.settings.padSettings\": \"Nastajenja zapisnika\",\n\t\"pad.settings.myView\": \"Mój napohlad\",\n\t\"pad.settings.stickychat\": \"Chat přeco na wobrazowce pokazać\",\n\t\"pad.settings.chatandusers\": \"Chat a wužiwarjow pokazać\",\n\t\"pad.settings.colorcheck\": \"Awtorowe barby\",\n\t\"pad.settings.linenocheck\": \"Linkowe čisła\",\n\t\"pad.settings.rtlcheck\": \"Wobsah wotprawa nalěwo čitać?\",\n\t\"pad.settings.fontType\": \"Pismowa družina:\",\n\t\"pad.settings.fontType.normal\": \"Normalny\",\n\t\"pad.settings.language\": \"Rěč:\",\n\t\"pad.settings.deletePad\": \"Zapisnik zhašeć\",\n\t\"pad.delete.confirm\": \"Chceće woprawdźe tutón zapisnik zhašeć?\",\n\t\"pad.settings.about\": \"Wo\",\n\t\"pad.settings.poweredBy\": \"Spěchowany wot\",\n\t\"pad.importExport.import_export\": \"Import/Eksport\",\n\t\"pad.importExport.import\": \"Tekstowu dataju abo dokument nahrać\",\n\t\"pad.importExport.importSuccessful\": \"Wuspěšny!\",\n\t\"pad.importExport.export\": \"Aktualny zapisnik eksportować jako:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Luty tekst\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Móžeš jenož z formatow luteho teksta abo z HTML-formata importować. Za bóle rozšěrjene importowe funkcije <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">instaluj prošu Abiword abo LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Zwjazany.\",\n\t\"pad.modals.reconnecting\": \"Zwjazuje so znowa z twojim zapisnikom...\",\n\t\"pad.modals.forcereconnect\": \"Znowa zwjazać\",\n\t\"pad.modals.reconnecttimer\": \"Spytaj so znowa zwjazać w\",\n\t\"pad.modals.cancel\": \"Přetorhnyć\",\n\t\"pad.modals.userdup\": \"W druhim woknje wočinjeny\",\n\t\"pad.modals.userdup.explanation\": \"Zda so, zo tutón zapisnik je so we wjace hač jednym woknje wobhladowaka na tutym ličaku wočinił.\",\n\t\"pad.modals.userdup.advice\": \"Zwjazaj znowa, zo by tute wokno město toho wužiwał.\",\n\t\"pad.modals.unauth\": \"Njeawtorizowany\",\n\t\"pad.modals.unauth.explanation\": \"Při wobhladowanju tuteje strony su so twoje prawa změnili. Spytaj so znowa zwjazać.\",\n\t\"pad.modals.looping.explanation\": \"Su komunikaciske problemy ze synchronizowanskim serwerom.\",\n\t\"pad.modals.looping.cause\": \"Snano sy přez njekompatibelnu wohnjowu murju abo proksy zwjazany.\",\n\t\"pad.modals.initsocketfail\": \"Serwer je njedocpějomny.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Zwisk ze synchronizowanskim serwerom móžno njeje.\",\n\t\"pad.modals.initsocketfail.cause\": \"To je najskerje problem z twojim wobhladowakom abo twojim internetnym zwiskom.\",\n\t\"pad.modals.slowcommit.explanation\": \"Serwer njewotmołwja.\",\n\t\"pad.modals.slowcommit.cause\": \"To móhło problem syćoweho zwiska być.\",\n\t\"pad.modals.badChangeset.explanation\": \"Změna, kotruž sy přewjedł, je so přez synchronizowanski serwer jako njedowolenu woznamjeniła.\",\n\t\"pad.modals.badChangeset.cause\": \"To je so snano wopačneje serweroweje konfiguracije dla abo druheho njewočakowaneho zadźerženja dla stało. Prošu staj so ze słužbowym administratorom do zwiska, jeli sej mysliš, zo to je zmylk. Spytaj hišće raz zwjazać, zo by z wobdźěłowanjom pokročował.\",\n\t\"pad.modals.corruptPad.explanation\": \"Zapisnik, na kotryž chceš přistup měć, je wobškodźeny.\",\n\t\"pad.modals.corruptPad.cause\": \"To je so snano wopačneje serweroweje konfiguracije dla abo druheho njewočakowaneho zadźerženja dla stało. Prošu staj so ze słužbowym administratorom do zwiska.\",\n\t\"pad.modals.deleted\": \"Zhašany.\",\n\t\"pad.modals.deleted.explanation\": \"Tutón zapisnik je so wotstronił.\",\n\t\"pad.modals.rateLimited\": \"Wobmjezowana rata.\",\n\t\"pad.modals.rateLimited.explanation\": \"Sće přewjele powěsćow na zapisnik pósłał, tohodla je so zwisk dźělił.\",\n\t\"pad.modals.rejected.explanation\": \"Serwer je powěsć wotpokazał, kotraž je so přez waš wobhladowak pósłał.\",\n\t\"pad.modals.rejected.cause\": \"Serwer je so snano zaktualizował, mjeztym zo sy sej zapisnik wobhladał, abo je snano zmylk w Etherpad. Spytaj stronu znowa začitać.\",\n\t\"pad.modals.disconnected\": \"Zwisk je přetorhnjeny.\",\n\t\"pad.modals.disconnected.explanation\": \"Zwisk ze serwerom je so zhubił\",\n\t\"pad.modals.disconnected.cause\": \"Serwer k dispoziciji njesteji. Prošu informuj słužboweho administratora, jeli to so dale stawa.\",\n\t\"pad.share\": \"Tutón zapisnik dźělić\",\n\t\"pad.share.readonly\": \"Jenož čitajomny\",\n\t\"pad.share.link\": \"Wotkaz\",\n\t\"pad.share.emebdcode\": \"URL zasadźić\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Chat za tutón zapisnik wočinić\",\n\t\"pad.chat.loadmessages\": \"Dalše powěsće začitać\",\n\t\"pad.chat.stick.title\": \"Chat k wobrazowce připjeć\",\n\t\"pad.chat.writeMessage.placeholder\": \"Pisajće swoju powěsć tu\",\n\t\"timeslider.followContents\": \"Aktualizacijam wobsaha zapisnika slědować\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} - wersijowa historija\",\n\t\"timeslider.toolbar.returnbutton\": \"Wróćo k zapisnikej\",\n\t\"timeslider.toolbar.authors\": \"Awtorojo:\",\n\t\"timeslider.toolbar.authorsList\": \"Žane awtorojo\",\n\t\"timeslider.toolbar.exportlink.title\": \"Eksportować\",\n\t\"timeslider.exportCurrent\": \"Aktualnu wersiju eksportować jako:\",\n\t\"timeslider.version\": \"Wersija {{version}}\",\n\t\"timeslider.saved\": \"Składowany {{day}}. {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Wobsah zapisnika wothrać/pawsěrować\",\n\t\"timeslider.backRevision\": \"Wo jednu wersiju w tutym dokumenće wróćo hić\",\n\t\"timeslider.forwardRevision\": \"Wo jednu wersiju w tutym dokumenće doprědka hić\",\n\t\"timeslider.dateformat\": \"{{day}}. {{month}} {{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"januara\",\n\t\"timeslider.month.february\": \"februara\",\n\t\"timeslider.month.march\": \"měrca\",\n\t\"timeslider.month.april\": \"apryla\",\n\t\"timeslider.month.may\": \"meje\",\n\t\"timeslider.month.june\": \"junija\",\n\t\"timeslider.month.july\": \"julija\",\n\t\"timeslider.month.august\": \"awgusta\",\n\t\"timeslider.month.september\": \"septembra\",\n\t\"timeslider.month.october\": \"oktobra\",\n\t\"timeslider.month.november\": \"nowembra\",\n\t\"timeslider.month.december\": \"decembra\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: awtor, two: awtoraj, few: awtorojo, other: awtorow ]} bjez mjena\",\n\t\"pad.savedrevs.marked\": \"Tuta wersija je so nětko jako składowana wersija woznamjeniła\",\n\t\"pad.savedrevs.timeslider\": \"Móžeš sej składowane wersije wobhladować, wopytujo historiju dokumenta.\",\n\t\"pad.userlist.entername\": \"Zapodaj swoje mjeno\",\n\t\"pad.userlist.unnamed\": \"bjez mjena\",\n\t\"pad.editbar.clearcolors\": \"Awtorowe barby w cyłym dokumenće zhašeć? To njeda so cofnyć\",\n\t\"pad.impexp.importbutton\": \"Nětko importować\",\n\t\"pad.impexp.importing\": \"Importuje so...\",\n\t\"pad.impexp.confirmimport\": \"Importowanje dataje přepisa aktualny tekst zapisnika. Chceš woprawdźe pokročować?\",\n\t\"pad.impexp.convertFailed\": \"Njemóžachmy tutu dataju importować. Prošu wužij druhi dokumentowy format abo kopěruj manuelnje\",\n\t\"pad.impexp.padHasData\": \"Njemóžachmy tutu dataju importować, dokelž tutón dokument hižo změny wobsahuje, prošu importuj nowy dokument.\",\n\t\"pad.impexp.uploadFailed\": \"Nahraće njeje so poradźiło, prošu spytaj hišće raz\",\n\t\"pad.impexp.importfailed\": \"Import njeje so poradźiło\",\n\t\"pad.impexp.copypaste\": \"Prošu kopěrować a zasadźić\",\n\t\"pad.impexp.exportdisabled\": \"Eksport jako format {{type}} je znjemóžnjeny. Prošu staj so ze swojim systemowym administratorom za podrobnosće do zwiska.\",\n\t\"pad.impexp.maxFileSize\": \"Dataja je přewulka. Stajće so ze swojim sydłowym administratorom do zwiska, zo by dowolenu datajowu wulkosć za import powyšił\"\n}\n"
  },
  {
    "path": "src/locales/hu.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"BanKris\",\n\t\t\t\"Bencemac\",\n\t\t\t\"Csega\",\n\t\t\t\"Dj\",\n\t\t\t\"Hanna Tardos\",\n\t\t\t\"Misibacsi\",\n\t\t\t\"Notramo\",\n\t\t\t\"Ovari\",\n\t\t\t\"R-Joe\",\n\t\t\t\"Tgr\",\n\t\t\t\"Urbalazs\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Admin irányítópult - Etherpad\",\n\t\"admin_plugins\": \"Bővítménykezelő\",\n\t\"admin_plugins.available\": \"Elérhető bővítmények\",\n\t\"admin_plugins.available_not-found\": \"Nem található bővítmény.\",\n\t\"admin_plugins.available_fetching\": \"Lehívás...\",\n\t\"admin_plugins.available_install.value\": \"Telepítés\",\n\t\"admin_plugins.available_search.placeholder\": \"Telepíthető bővítmények keresése\",\n\t\"admin_plugins.description\": \"Leírás\",\n\t\"admin_plugins.installed\": \"Telepített bővítmények\",\n\t\"admin_plugins.installed_fetching\": \"Telepített bővítmények lehívása...\",\n\t\"admin_plugins.installed_nothing\": \"Még nem telepítettél bővítményeket.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Eltávolítás\",\n\t\"admin_plugins.last-update\": \"Utolsó frissítés\",\n\t\"admin_plugins.name\": \"Név\",\n\t\"admin_plugins.page-title\": \"Bővítménykezelő - Etherpad\",\n\t\"admin_plugins.version\": \"Verzió\",\n\t\"admin_plugins_info\": \"Hibaelhárításra vonatkozó információ\",\n\t\"admin_plugins_info.hooks\": \"Telepített hookok\",\n\t\"admin_plugins_info.hooks_client\": \"Kliensoldali hookok\",\n\t\"admin_plugins_info.hooks_server\": \"Szerveroldali hookok\",\n\t\"admin_plugins_info.parts\": \"Telepített elemek\",\n\t\"admin_plugins_info.plugins\": \"Telepített bővítmények\",\n\t\"admin_plugins_info.page-title\": \"Információ bővítményről - Etherpad\",\n\t\"admin_plugins_info.version\": \"Etherpad verzió\",\n\t\"admin_plugins_info.version_latest\": \"Legfrissebb elérhető verzió\",\n\t\"admin_plugins_info.version_number\": \"Verziószám\",\n\t\"admin_settings\": \"Beállítások\",\n\t\"admin_settings.current\": \"Jelenlegi beállítások\",\n\t\"admin_settings.current_example-devel\": \"Fejlesztés beállítások sablon minta\",\n\t\"admin_settings.current_example-prod\": \"Gyártás beállítások sablon minta\",\n\t\"admin_settings.current_restart.value\": \"Etherpad újraindítása\",\n\t\"admin_settings.current_save.value\": \"Beállítások mentése\",\n\t\"admin_settings.page-title\": \"Beállítások - Etherpad\",\n\t\"index.newPad\": \"Új jegyzetfüzet\",\n\t\"index.createOpenPad\": \"vagy jegyzetfüzet létrehozása/megnyitása ezzel a névvel:\",\n\t\"index.openPad\": \"nyisson meg egy meglévő jegyzetfüzetet névvel:\",\n\t\"pad.toolbar.bold.title\": \"Félkövér (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Dőlt (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Aláhúzás (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Áthúzás (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Rendezett lista (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Rendezetlen lista (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Behúzás növelése (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Behúzás csökkentése (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Visszavonás (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Újra (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Szerzők színezésének kikapcsolása (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importálás/exportálás különböző fájlformátumokból/ba\",\n\t\"pad.toolbar.timeslider.title\": \"Időcsúszka\",\n\t\"pad.toolbar.savedRevision.title\": \"Revízió mentése\",\n\t\"pad.toolbar.settings.title\": \"Beállítások\",\n\t\"pad.toolbar.embed.title\": \"Jegyzetfüzet beágyazása és megosztása\",\n\t\"pad.toolbar.showusers.title\": \"Jegyzetfüzet felhasználóinak megmutatása\",\n\t\"pad.colorpicker.save\": \"Mentés\",\n\t\"pad.colorpicker.cancel\": \"Mégse\",\n\t\"pad.loading\": \"Betöltés…\",\n\t\"pad.noCookie\": \"Nem található a süti. Engedélyezd a böngésződben a sütik használatát! A munkamenet és a beállítások nem kerülnek mentésre a látogatások között. Ennek oka lehet az, hogy az Etherpad egyes böngészőkben szerepel az iFrame-ben. Ellenőrizze, hogy az Etherpad ugyanabban az altartomány / tartományban van-e, mint a szülő iFrame\",\n\t\"pad.permissionDenied\": \"Nincs engedélyed ezen jegyzetfüzet eléréséhez\",\n\t\"pad.settings.padSettings\": \"Jegyzetfüzet beállításai\",\n\t\"pad.settings.myView\": \"Az én nézetem\",\n\t\"pad.settings.stickychat\": \"Mindig mutasd a csevegés-dobozt\",\n\t\"pad.settings.chatandusers\": \"Csevegés és felhasználók mutatása\",\n\t\"pad.settings.colorcheck\": \"Szerzők színei\",\n\t\"pad.settings.linenocheck\": \"Sorok számozása\",\n\t\"pad.settings.rtlcheck\": \"Tartalom olvasása balról jobbra?\",\n\t\"pad.settings.fontType\": \"Betűtípus:\",\n\t\"pad.settings.fontType.normal\": \"Szokásos\",\n\t\"pad.settings.language\": \"Nyelv:\",\n\t\"pad.settings.about\": \"Névjegy\",\n\t\"pad.settings.poweredBy\": \"Működteti\",\n\t\"pad.importExport.import_export\": \"Import/export\",\n\t\"pad.importExport.import\": \"Tetszőleges szövegfájl vagy dokumentum feltöltése\",\n\t\"pad.importExport.importSuccessful\": \"Siker!\",\n\t\"pad.importExport.export\": \"Jelenlegi jegyzetfüzet exportálása így:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Sima szöveg\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document formátum)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Csak szöveges, vagy HTML formátumokból importálhat. A haladó importálási szolgáltatásért kérjük <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">telepítse a AbiWord vagy a LibreOffice alkalmazást</a>.\",\n\t\"pad.modals.connected\": \"Kapcsolódva.\",\n\t\"pad.modals.reconnecting\": \"Újrakapcsolódás a jegyzetfüzethez…\",\n\t\"pad.modals.forcereconnect\": \"Újrakapcsolódás kényszerítése\",\n\t\"pad.modals.reconnecttimer\": \"Megpróbálok újracsatlakozni ennyi múlva:\",\n\t\"pad.modals.cancel\": \"Mégse\",\n\t\"pad.modals.userdup\": \"Új ablakban megnyitva\",\n\t\"pad.modals.userdup.explanation\": \"Úgy tűnik, ez a jegyzetfüzet több különböző böngészőablakban is meg van nyitva a számítógépeden.\",\n\t\"pad.modals.userdup.advice\": \"Kapcsolódj újra, ha ezt az ablakot akarod használni.\",\n\t\"pad.modals.unauth\": \"Nincs rá jogosultságod\",\n\t\"pad.modals.unauth.explanation\": \"A jogosultságaid megváltoztak, miközben ezt az oldalt nézted. Próbálj meg újrakapcsolódni!\",\n\t\"pad.modals.looping.explanation\": \"Nem sikerült a kommunikáció a szinkronizációs szerverrel.\",\n\t\"pad.modals.looping.cause\": \"Talán egy túl szigorú tűzfalon vagy proxyn keresztül kapcsolódtál az internetre.\",\n\t\"pad.modals.initsocketfail\": \"A szerver nem érhető el.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Nem sikerült kapcsolódni a szinkronizációs szerverhez.\",\n\t\"pad.modals.initsocketfail.cause\": \"Valószínűleg a böngésződdel vagy az internetkapcsolatoddal van probléma.\",\n\t\"pad.modals.slowcommit.explanation\": \"A szerver nem válaszol.\",\n\t\"pad.modals.slowcommit.cause\": \"Valószínűleg az internetkapcsolattal van probléma.\",\n\t\"pad.modals.badChangeset.explanation\": \"Szerkesztéseded a szinkronizációs szerver illegálisnak sorolta be.\",\n\t\"pad.modals.badChangeset.cause\": \"Ennek oka lehet egy rossz szerver konfiguráció, vagy más váratlan viselkedés. Ha úgy érzi, ez hiba eredménye, lépjen kapcsolatba a szolgáltatás adminisztrátorával. Próbáljon meg újrakapcsolódni a szerkesztés folytatásához.\",\n\t\"pad.modals.corruptPad.explanation\": \"A jegyzetfüzet, amit megpróbálsz elérni, sérült.\",\n\t\"pad.modals.corruptPad.cause\": \"Ennek oka lehet egy rossz szerver konfiguráció, vagy más váratlan viselkedés. Kérjük, lépj kapcsolatba a szolgáltatás adminisztrátorával.\",\n\t\"pad.modals.deleted\": \"Törölve.\",\n\t\"pad.modals.deleted.explanation\": \"Ez a jegyzetfüzet el lett távolítva.\",\n\t\"pad.modals.rateLimited\": \"Korlátozott.\",\n\t\"pad.modals.rateLimited.explanation\": \"Túl sok üzenetet küldött erre a jegyzetfüzetre, így a kapcsolat bontva lett.\",\n\t\"pad.modals.rejected.explanation\": \"A szerver elutasított egy üzenetet, amit a keresőd küldött.\",\n\t\"pad.modals.rejected.cause\": \"Lehet, hogy a szerveren frissítés történt, miközben a padet nézted, vagy bugos az Etherpad. Próbáld meg frissíteni az oldalt.\",\n\t\"pad.modals.disconnected\": \"Kapcsolat bontva.\",\n\t\"pad.modals.disconnected.explanation\": \"A szerverrel való kapcsolat megszűnt\",\n\t\"pad.modals.disconnected.cause\": \"Lehet, hogy a szerver nem elérhető. Kérlek, értesítsd a szolgáltatás adminisztrátorát, ha a probléma tartósan fennáll.\",\n\t\"pad.share\": \"Jegyzetfüzet megosztása\",\n\t\"pad.share.readonly\": \"Csak olvasható\",\n\t\"pad.share.link\": \"Hivatkozás\",\n\t\"pad.share.emebdcode\": \"URL beágyazása\",\n\t\"pad.chat\": \"Csevegés\",\n\t\"pad.chat.title\": \"A jegyzetfüzethez tartozó csevegés megnyitása.\",\n\t\"pad.chat.loadmessages\": \"További üzenetek betöltése\",\n\t\"pad.chat.stick.title\": \"Csevegés a képernyőre\",\n\t\"pad.chat.writeMessage.placeholder\": \"Írja az üzenetét ide\",\n\t\"timeslider.followContents\": \"Kövesse a jegyzetfüzet tartalmának frissítéseit\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} időcsúszka\",\n\t\"timeslider.toolbar.returnbutton\": \"Vissza a jegyzetfüzethez\",\n\t\"timeslider.toolbar.authors\": \"Szerzők:\",\n\t\"timeslider.toolbar.authorsList\": \"Nincsenek szerzők\",\n\t\"timeslider.toolbar.exportlink.title\": \"Exportálás\",\n\t\"timeslider.exportCurrent\": \"Jelenlegi változat exportálása így:\",\n\t\"timeslider.version\": \"{{version}} verzió\",\n\t\"timeslider.saved\": \"{{year}}. {{month}} {{day}}-n elmentve\",\n\t\"timeslider.playPause\": \"Jegyzetfüzet tartalom visszajátszása/leállítása\",\n\t\"timeslider.backRevision\": \"Egy revízióval vissza a jegyzetfüzetben\",\n\t\"timeslider.forwardRevision\": \"Egy revízióval előre a jegyzetfüzetben\",\n\t\"timeslider.dateformat\": \"{{year}}.{{month}}.{{day}}. {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"január\",\n\t\"timeslider.month.february\": \"február\",\n\t\"timeslider.month.march\": \"március\",\n\t\"timeslider.month.april\": \"április\",\n\t\"timeslider.month.may\": \"május\",\n\t\"timeslider.month.june\": \"június\",\n\t\"timeslider.month.july\": \"július\",\n\t\"timeslider.month.august\": \"augusztus\",\n\t\"timeslider.month.september\": \"szeptember\",\n\t\"timeslider.month.october\": \"október\",\n\t\"timeslider.month.november\": \"november\",\n\t\"timeslider.month.december\": \"december\",\n\t\"timeslider.unnamedauthors\": \"{{num}} névtelen {[plural(num), one: szerző, other: szerző]}\",\n\t\"pad.savedrevs.marked\": \"Ez a revízió mostantól mentettként jelölve\",\n\t\"pad.savedrevs.timeslider\": \"A mentett revíziókat az időcsúszkán tudod megnézni\",\n\t\"pad.userlist.entername\": \"Add meg a nevedet\",\n\t\"pad.userlist.unnamed\": \"névtelen\",\n\t\"pad.editbar.clearcolors\": \"A szerzőséget jelző színeket törli a teljes dokumentumból? Ez nem vonható vissza.\",\n\t\"pad.impexp.importbutton\": \"Importálás most\",\n\t\"pad.impexp.importing\": \"Importálás…\",\n\t\"pad.impexp.confirmimport\": \"Egy fájl importálása felülírja a jelenlegi szöveget a jegyzetfüzetben. Biztosan folytatja?\",\n\t\"pad.impexp.convertFailed\": \"Nem tudtuk importálni ezt a fájlt. Kérjük, használj másik dokumentum formátumot, vagy kézzel másold és illeszd be a tartalmat\",\n\t\"pad.impexp.padHasData\": \"Nem tudjuk importálni ezt a fájlt, mert ez a jegyzetfüzet már megváltozott, kérjük, importálj egy új jegyzetfüzetbe.\",\n\t\"pad.impexp.uploadFailed\": \"A feltöltés sikertelen, próbáld meg újra\",\n\t\"pad.impexp.importfailed\": \"Az importálás nem sikerült\",\n\t\"pad.impexp.copypaste\": \"Kérjük másold be\",\n\t\"pad.impexp.exportdisabled\": \"{{type}} formátumba az exportálás nem engedélyezett. Kérjük, a részletekért fordulj a rendszeradminisztrátorhoz.\",\n\t\"pad.impexp.maxFileSize\": \"Túl nagy a fájl. Vegye fel a kapcsolatot a webhelygazdájával, hogy növelje az importálható fájl méretét\"\n}\n"
  },
  {
    "path": "src/locales/hy.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Armenoid\",\n\t\t\t\"Kareyac\"\n\t\t]\n\t},\n\t\"admin_plugins.available_install.value\": \"Տեղադրել\",\n\t\"admin_plugins.description\": \"Նկարագրություն\",\n\t\"admin_plugins.version\": \"Տարբերակ\",\n\t\"admin_settings\": \"Կարգավորումներ\",\n\t\"index.newPad\": \"Ստեղծել\",\n\t\"pad.toolbar.bold.title\": \"Թավատառ (Ctrl+B)\",\n\t\"pad.toolbar.underline.title\": \"ընդգծելով (Ctrl-U)\",\n\t\"pad.toolbar.undo.title\": \"Չեղարկել (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Վերադարձնել (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Մաքրել փաստաթղթի գույներ (Ctrl+Shift+C)\",\n\t\"pad.toolbar.savedRevision.title\": \"Պահպանել տարբերակը\",\n\t\"pad.toolbar.settings.title\": \"Կարգավորումներ\",\n\t\"pad.toolbar.embed.title\": \"Կիսվել և ներդնել այդ փաստաթուղթը\",\n\t\"pad.toolbar.showusers.title\": \"Ցույց տալ մասնակիցներին այս փաստաթղթում\",\n\t\"pad.colorpicker.save\": \"Պահպանել\",\n\t\"pad.colorpicker.cancel\": \"Չեղարկել\",\n\t\"pad.loading\": \"Բեռնվում է…\",\n\t\"pad.settings.myView\": \"Իմ տեսարան\",\n\t\"pad.settings.rtlcheck\": \"Կարդալ բովանդակությունը աջից ձախ\",\n\t\"pad.settings.fontType\": \"Տառատեսակի տեսակը\",\n\t\"pad.settings.language\": \"Լեզու\",\n\t\"pad.importExport.import_export\": \"Ներմուծում/արտահանում\",\n\t\"pad.importExport.import\": \"Վերբեռնել ցանկացած տեքստային նիշք կամ փաստաթուղթ\",\n\t\"pad.importExport.importSuccessful\": \"Հաջողություն\",\n\t\"pad.importExport.export\": \"Արտահանել ընթացիկ փաստաթուղթ է որպես\",\n\t\"pad.importExport.exportplain\": \"Պարզ տեքստ\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.modals.connected\": \"Կապված է\",\n\t\"pad.modals.forcereconnect\": \"Հարկադիր վերամիավորել\",\n\t\"pad.modals.cancel\": \"Չեղարկել\",\n\t\"pad.modals.userdup\": \"Բաց է մյուս պատուհանում\",\n\t\"pad.modals.initsocketfail\": \"Սերվերը անհասանելի է ։\",\n\t\"pad.modals.slowcommit.explanation\": \"Սերվերը չի պատասխանում։\",\n\t\"pad.modals.deleted\": \"Ջնջված է\",\n\t\"pad.share.readonly\": \"Միայն կարդալու\",\n\t\"pad.share.link\": \"Հղում\",\n\t\"timeslider.toolbar.authors\": \"Հեղինակներ\",\n\t\"timeslider.month.january\": \"Հունվար\",\n\t\"timeslider.month.february\": \"Փետրվար\",\n\t\"timeslider.month.march\": \"Մարտ\",\n\t\"timeslider.month.april\": \"Ապրիլ\",\n\t\"timeslider.month.may\": \"Մայիս\",\n\t\"timeslider.month.june\": \"Հունիս\",\n\t\"timeslider.month.july\": \"Հուլիս\",\n\t\"timeslider.month.august\": \"Օգոստոս\",\n\t\"timeslider.month.september\": \"Սեպտեմբեր\",\n\t\"timeslider.month.october\": \"Հոկտեմբեր\",\n\t\"timeslider.month.november\": \"Նոյեմբեր\",\n\t\"timeslider.month.december\": \"Դեկտեմբեր\",\n\t\"pad.userlist.entername\": \"Մուտքագրեք ձեր անունը\",\n\t\"pad.userlist.unnamed\": \"անանուն\",\n\t\"pad.impexp.importbutton\": \"Ներմուծել հիմա\",\n\t\"pad.impexp.copypaste\": \"Խնդրում ենք պատճենել\"\n}\n"
  },
  {
    "path": "src/locales/ia.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"McDutchie\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Pannello administrative – Etherpad\",\n\t\"admin_plugins\": \"Gestor de plug-ins\",\n\t\"admin_plugins.available\": \"Plug-ins disponibile\",\n\t\"admin_plugins.available_not-found\": \"Necun plug-in trovate.\",\n\t\"admin_plugins.available_fetching\": \"Obtention…\",\n\t\"admin_plugins.available_install.value\": \"Installar\",\n\t\"admin_plugins.available_search.placeholder\": \"Cercar plug-ins a installar\",\n\t\"admin_plugins.description\": \"Description\",\n\t\"admin_plugins.installed\": \"Plug-ins installate\",\n\t\"admin_plugins.installed_fetching\": \"Obtene plug-ins installate…\",\n\t\"admin_plugins.installed_nothing\": \"Tu non ha ancora installate alcun plug-in.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Disinstallar\",\n\t\"admin_plugins.last-update\": \"Ultime actualisation\",\n\t\"admin_plugins.name\": \"Nomine\",\n\t\"admin_plugins.page-title\": \"Gestor de plug-ins – Etherpad\",\n\t\"admin_plugins.version\": \"Version\",\n\t\"admin_plugins_info\": \"Resolution de problemas\",\n\t\"admin_plugins_info.hooks\": \"Uncinos installate\",\n\t\"admin_plugins_info.hooks_client\": \"Uncinos al latere del cliente\",\n\t\"admin_plugins_info.hooks_server\": \"Uncinos al latere del servitor\",\n\t\"admin_plugins_info.parts\": \"Partes installate\",\n\t\"admin_plugins_info.plugins\": \"Plug-ins installate\",\n\t\"admin_plugins_info.page-title\": \"Information sur le plug-in – Etherpad\",\n\t\"admin_plugins_info.version\": \"Version de Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Ultime version disponibile\",\n\t\"admin_plugins_info.version_number\": \"Numero de version\",\n\t\"admin_settings\": \"Parametros\",\n\t\"admin_settings.current\": \"Configuration actual\",\n\t\"admin_settings.current_example-devel\": \"Exemplo de patrono de parametros de disveloppamento\",\n\t\"admin_settings.current_example-prod\": \"Exemplo de patrono de parametros de production\",\n\t\"admin_settings.current_restart.value\": \"Reinitiar Etherpad\",\n\t\"admin_settings.current_save.value\": \"Salveguardar parametros\",\n\t\"admin_settings.page-title\": \"Parametros – Etherpad\",\n\t\"index.newPad\": \"Nove nota\",\n\t\"index.settings\": \"Parametros\",\n\t\"index.transferSessionTitle\": \"Transferer session\",\n\t\"index.receiveSessionTitle\": \"Reciper session\",\n\t\"index.receiveSessionDescription\": \"Hic tu pote reciper un session de Etherpad ab un altere navigator o apparato. Nota ben, totevia, que iste operation eliminara le session actual, si il ha un.\",\n\t\"index.transferSession\": \"1. Transferer session\",\n\t\"index.transferSessionNow\": \"Transferer session ora\",\n\t\"index.copyLink\": \"2. Copiar ligamine\",\n\t\"index.copyLinkDescription\": \"Clicca sur le button infra pro copiar le ligamine a tu area de transferentia.\",\n\t\"index.copyLinkButton\": \"Copiar ligamine al area de transferentia\",\n\t\"index.transferToSystem\": \"3. Copiar session al nove systema\",\n\t\"index.transferToSystemDescription\": \"Aperi le ligamine copiate in le navigator o apparato de destination pro transferer tu session.\",\n\t\"index.transferSessionDescription\": \"Clicca sur le button infra pro transferer tu session actual a un navigator o apparato. Isto copiara un ligamine a un pagina que, si es aperite in le navigator o apparato de destination, transferera tu session.\",\n\t\"index.createOpenPad\": \"Aperir le nota con le nomine\",\n\t\"index.openPad\": \"aperir un nota existente con le nomine:\",\n\t\"index.recentPads\": \"Notas recente\",\n\t\"index.recentPadsEmpty\": \"Necun nota recente trovate.\",\n\t\"index.generateNewPad\": \"Generar un nomine de nota aleatori\",\n\t\"index.labelPad\": \"Nomine del nota (optional)\",\n\t\"index.placeholderPadEnter\": \"Per favor insere un nomine de nota…\",\n\t\"index.createAndShareDocuments\": \"Crear e condivider documentos in tempore real\",\n\t\"index.createAndShareDocumentsDescription\": \"Etherpad permitte modificar documentos de maniera collaborative e in tempore real. Es un editor multi-usator in directo que se executa in tu navigator.\",\n\t\"pad.toolbar.bold.title\": \"Grasse (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Italic (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Sublineate (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Barrate (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Lista ordinate (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Lista non ordinate (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Indentar (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Disindentar (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Disfacer (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Refacer (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Rader colores de autor (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importar/exportar inter differente formatos de file\",\n\t\"pad.toolbar.timeslider.title\": \"Glissa-tempore\",\n\t\"pad.toolbar.savedRevision.title\": \"Salveguardar version\",\n\t\"pad.toolbar.settings.title\": \"Parametros\",\n\t\"pad.toolbar.embed.title\": \"Condivider e incorporar iste nota\",\n\t\"pad.toolbar.home.title\": \"Retro al initio\",\n\t\"pad.toolbar.showusers.title\": \"Monstrar le usatores de iste nota\",\n\t\"pad.colorpicker.save\": \"Salveguardar\",\n\t\"pad.colorpicker.cancel\": \"Cancellar\",\n\t\"pad.loading\": \"Cargamento…\",\n\t\"pad.noCookie\": \"Le cookie non pote esser trovate. Per favor permitte le cookies in tu navigator! Tu session e parametros non essera salveguardate inter visitas. In alcun navigatores, isto pote esser debite al facto que Etherpad ha essite includite in un iFrame. Assecura te que Etherpad es sur le mesme subdominio/dominio que su iFrame genitor.\",\n\t\"pad.permissionDenied\": \"Tu non ha le permission de acceder a iste nota\",\n\t\"pad.settings.padSettings\": \"Parametros del nota\",\n\t\"pad.settings.myView\": \"Mi vista\",\n\t\"pad.settings.stickychat\": \"Chat sempre visibile\",\n\t\"pad.settings.chatandusers\": \"Monstrar chat e usatores\",\n\t\"pad.settings.colorcheck\": \"Colores de autor\",\n\t\"pad.settings.linenocheck\": \"Numeros de linea\",\n\t\"pad.settings.rtlcheck\": \"Leger le contento de dextra a sinistra?\",\n\t\"pad.settings.fontType\": \"Typo de litteras:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Lingua:\",\n\t\"pad.settings.deletePad\": \"Deler nota\",\n\t\"pad.delete.confirm\": \"Es tu secur de voler deler iste nota?\",\n\t\"pad.settings.about\": \"A proposito\",\n\t\"pad.settings.poweredBy\": \"Actionate per\",\n\t\"pad.importExport.import_export\": \"Importar/Exportar\",\n\t\"pad.importExport.import\": \"Incargar qualcunque file de texto o documento\",\n\t\"pad.importExport.importSuccessful\": \"Succedite!\",\n\t\"pad.importExport.export\": \"Exportar le nota actual como:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Texto simple\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Es possibile importar solmente le files in formato de texto simple o HTML. Pro functionalitate de importation plus extense, <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">installa AbiWord o LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Connectite.\",\n\t\"pad.modals.reconnecting\": \"Reconnexion a tu nota…\",\n\t\"pad.modals.forcereconnect\": \"Fortiar reconnexion\",\n\t\"pad.modals.reconnecttimer\": \"Tentativa de reconnexion in\",\n\t\"pad.modals.cancel\": \"Cancellar\",\n\t\"pad.modals.userdup\": \"Aperte in un altere fenestra\",\n\t\"pad.modals.userdup.explanation\": \"Iste nota pare esser aperte in plus de un fenestra de navigator in iste computator.\",\n\t\"pad.modals.userdup.advice\": \"Reconnecte pro usar iste fenestra.\",\n\t\"pad.modals.unauth\": \"Non autorisate\",\n\t\"pad.modals.unauth.explanation\": \"Tu permissiones ha cambiate durante que tu legeva iste pagina. Tenta reconnecter.\",\n\t\"pad.modals.looping.explanation\": \"Il ha problemas de communication con le servitor de synchronisation.\",\n\t\"pad.modals.looping.cause\": \"Il es possibile que tu connexion passa per un firewall o proxy incompatibile.\",\n\t\"pad.modals.initsocketfail\": \"Le servitor es inattingibile.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Impossibile connecter al servitor de synchronisation.\",\n\t\"pad.modals.initsocketfail.cause\": \"Isto es probabilemente causate per un problema con tu navigator o connexion a internet.\",\n\t\"pad.modals.slowcommit.explanation\": \"Le servitor non responde.\",\n\t\"pad.modals.slowcommit.cause\": \"Isto pote esser causate per problemas con le connexion al rete.\",\n\t\"pad.modals.badChangeset.explanation\": \"Un modification que tu ha facite ha essite classificate como incorrecte per le servitor de synchronisation.\",\n\t\"pad.modals.badChangeset.cause\": \"Isto pote esser causate per un configuration incorrecte del servitor o per alcun altere comportamento impreviste. Per favor contacta le administrator del servicio si tu pensa que se tracta de un error. Tenta reconnecter te pro continuar a modificar.\",\n\t\"pad.modals.corruptPad.explanation\": \"Le nota al qual tu tenta acceder es corrumpite.\",\n\t\"pad.modals.corruptPad.cause\": \"Isto pote esser debite a un configuration incorrecte del servitor o a alcun altere comportamento impreviste. Per favor contacta le administrator del servicio.\",\n\t\"pad.modals.deleted\": \"Delite.\",\n\t\"pad.modals.deleted.explanation\": \"Iste nota ha essite removite.\",\n\t\"pad.modals.rateLimited\": \"Frequentia limitate.\",\n\t\"pad.modals.rateLimited.explanation\": \"Tu ha inviate troppo de messages a iste nota, dunque illo te ha disconnectite.\",\n\t\"pad.modals.rejected.explanation\": \"Le servitor ha rejectate un message que tu navigator ha inviate.\",\n\t\"pad.modals.rejected.cause\": \"Es possibile que le servitor ha essite actualisate durante que tu legeva le nota, o que il ha un falta in Etherpad. Tenta recargar le pagina.\",\n\t\"pad.modals.disconnected\": \"Tu ha essite disconnectite.\",\n\t\"pad.modals.disconnected.explanation\": \"Le connexion al servitor ha essite perdite.\",\n\t\"pad.modals.disconnected.cause\": \"Le servitor pote esser indisponibile. Per favor notifica le administrator del servicio si isto continua a producer se.\",\n\t\"pad.share\": \"Condivider iste nota\",\n\t\"pad.share.readonly\": \"Lectura solmente\",\n\t\"pad.share.link\": \"Ligamine\",\n\t\"pad.share.emebdcode\": \"Codice de incorporation\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Aperir le conversation pro iste nota.\",\n\t\"pad.chat.loadmessages\": \"Cargar plus messages\",\n\t\"pad.chat.stick.title\": \"Ancorar le chat sur le schermo\",\n\t\"pad.chat.writeMessage.placeholder\": \"Scribe tu message hic\",\n\t\"timeslider.followContents\": \"Sequer le nove contento del nota\",\n\t\"timeslider.pageTitle\": \"Glissa-tempore pro {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Retornar al nota\",\n\t\"timeslider.toolbar.authors\": \"Autores:\",\n\t\"timeslider.toolbar.authorsList\": \"Nulle autor\",\n\t\"timeslider.toolbar.exportlink.title\": \"Exportar\",\n\t\"timeslider.exportCurrent\": \"Exportar le version actual como:\",\n\t\"timeslider.version\": \"Version {{version}}\",\n\t\"timeslider.saved\": \"Salveguardate le {{day}} de {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Reproducer/pausar le contento del nota\",\n\t\"timeslider.backRevision\": \"Recular un version in iste nota\",\n\t\"timeslider.forwardRevision\": \"Avantiar un version in iste nota\",\n\t\"timeslider.dateformat\": \"{{year}}-{{month}}-{{day}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"januario\",\n\t\"timeslider.month.february\": \"februario\",\n\t\"timeslider.month.march\": \"martio\",\n\t\"timeslider.month.april\": \"april\",\n\t\"timeslider.month.may\": \"maio\",\n\t\"timeslider.month.june\": \"junio\",\n\t\"timeslider.month.july\": \"julio\",\n\t\"timeslider.month.august\": \"augusto\",\n\t\"timeslider.month.september\": \"septembre\",\n\t\"timeslider.month.october\": \"octobre\",\n\t\"timeslider.month.november\": \"novembre\",\n\t\"timeslider.month.december\": \"decembre\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: autor, other: autores ]} sin nomine\",\n\t\"pad.savedrevs.marked\": \"Iste version es ora marcate como version salveguardate\",\n\t\"pad.savedrevs.timeslider\": \"Tu pote vider versiones salveguardate con le chronologia glissante.\",\n\t\"pad.userlist.entername\": \"Entra tu nomine\",\n\t\"pad.userlist.unnamed\": \"sin nomine\",\n\t\"pad.editbar.clearcolors\": \"Rader le colores de autor in tote le documento? Isto non pote esser disfacite\",\n\t\"pad.impexp.importbutton\": \"Importar ora\",\n\t\"pad.impexp.importing\": \"Importation in curso…\",\n\t\"pad.impexp.confirmimport\": \"Le importation de un file superscribera le texto actual del nota. Es tu secur de voler continuar?\",\n\t\"pad.impexp.convertFailed\": \"Non esseva possibile importar iste file. Per favor usa un altere formato de documento o copia e colla le texto manualmente.\",\n\t\"pad.impexp.padHasData\": \"Non es possibile importar iste file perque iste nota ha ja essite modificate. Per favor importa lo in un nove nota.\",\n\t\"pad.impexp.uploadFailed\": \"Le incargamento ha fallite. Per favor reproba.\",\n\t\"pad.impexp.importfailed\": \"Importation fallite\",\n\t\"pad.impexp.copypaste\": \"Per favor copia e colla\",\n\t\"pad.impexp.exportdisabled\": \"Le exportation in formato {{type}} es disactivate. Per favor contacta le administrator del systema pro detalios.\",\n\t\"pad.impexp.maxFileSize\": \"Le file es troppo grande. Contacta le administrator de tu sito pro augmentar le grandor de file autorisate pro importation.\"\n}\n"
  },
  {
    "path": "src/locales/id.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Akmaie Ajam\",\n\t\t\t\"Atriwidada\",\n\t\t\t\"Bennylin\",\n\t\t\t\"IvanLanin\",\n\t\t\t\"Marwan Mohamad\",\n\t\t\t\"Penyuwangi\",\n\t\t\t\"Veracious\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Dasbor Pengurus - Etherpad\",\n\t\"admin_plugins\": \"Pengelola plugin\",\n\t\"admin_plugins.available\": \"Plugin yang tersedia\",\n\t\"admin_plugins.available_not-found\": \"Tidak ada plugin yang ditemukan.\",\n\t\"admin_plugins.available_fetching\": \"Mengambil…\",\n\t\"admin_plugins.available_install.value\": \"Instal\",\n\t\"admin_plugins.available_search.placeholder\": \"Cari plugin yang akan dipasang\",\n\t\"admin_plugins.description\": \"Deskripsi\",\n\t\"admin_plugins.installed\": \"Plugin terpasang\",\n\t\"admin_plugins.installed_fetching\": \"Mengambil plugin yang terpasang…\",\n\t\"admin_plugins.installed_nothing\": \"Anda belum memasang plugin apa pun.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Uninstal\",\n\t\"admin_plugins.last-update\": \"Pembaruan terakhir\",\n\t\"admin_plugins.name\": \"Nama\",\n\t\"admin_plugins.page-title\": \"Pengelola plugin - Etherpad\",\n\t\"admin_plugins.version\": \"Versi\",\n\t\"admin_plugins_info\": \"Informasi penelusuran masalah\",\n\t\"admin_plugins_info.hooks\": \"Kait terpasang\",\n\t\"admin_plugins_info.hooks_client\": \"Kait sisi klien\",\n\t\"admin_plugins_info.hooks_server\": \"Kait sisi peladen\",\n\t\"admin_plugins_info.parts\": \"Bagian terpasang\",\n\t\"admin_plugins_info.plugins\": \"Plugin terpasang\",\n\t\"admin_plugins_info.page-title\": \"Informasi plugin - Etherpad\",\n\t\"admin_plugins_info.version\": \"Versi Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Versi terakhir yang tersedia\",\n\t\"admin_plugins_info.version_number\": \"Nomor versi\",\n\t\"admin_settings\": \"Pengaturan\",\n\t\"admin_settings.current\": \"Konfigurasi kini\",\n\t\"admin_settings.current_example-devel\": \"Contoh templat pengaturan pengembangan\",\n\t\"admin_settings.current_example-prod\": \"Contoh templat pengaturan produksi\",\n\t\"admin_settings.current_restart.value\": \"Jalankan ulang Etherpad\",\n\t\"admin_settings.current_save.value\": \"Simpan pengaturan\",\n\t\"admin_settings.page-title\": \"Pengaturan - Etherpad\",\n\t\"index.newPad\": \"Pad baru\",\n\t\"index.createOpenPad\": \"atau buat/buka Pad dengan nama:\",\n\t\"index.openPad\": \"buka Pad yang ada dengan nama:\",\n\t\"pad.toolbar.bold.title\": \"Tebal (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Miring (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Garis bawah (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Tanda coret (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Daftar bernomor (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Daftar tak bernomor (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Indentasi (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Outdent (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Urungkan (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Ulangi (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Bersihkan Warna Penulis (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Impor/Ekspor dari/ke format-format berkas berbeda\",\n\t\"pad.toolbar.timeslider.title\": \"Timeslider\",\n\t\"pad.toolbar.savedRevision.title\": \"Simpan Perbaikan\",\n\t\"pad.toolbar.settings.title\": \"Pengaturan\",\n\t\"pad.toolbar.embed.title\": \"Bagi dan Cantumkan pad ini\",\n\t\"pad.toolbar.showusers.title\": \"Tampilkan pengguna di pad ini\",\n\t\"pad.colorpicker.save\": \"Simpan\",\n\t\"pad.colorpicker.cancel\": \"Batalkan\",\n\t\"pad.loading\": \"Memuat...\",\n\t\"pad.noCookie\": \"Kuki tidak dapat ditemukan. Izinkan kuki di peramban Anda! Sesi dan pengaturan Anda tidak akan disimpan antar kunjungan. Ini mungkin karena Etherpad disertakan dalam suatu iFrame dalam beberapa Peramban. Harap pastikan Etherpad ada pada sub domain/domain dengan iFrame induk\",\n\t\"pad.permissionDenied\": \"Anda tidak memiliki izin untuk mengakses pad ini\",\n\t\"pad.settings.padSettings\": \"Pengaturan Pad\",\n\t\"pad.settings.myView\": \"Tampilan Saya\",\n\t\"pad.settings.stickychat\": \"Chat selalu di layar\",\n\t\"pad.settings.chatandusers\": \"Tampilkan Chat dan Pengguna\",\n\t\"pad.settings.colorcheck\": \"Warna penulis\",\n\t\"pad.settings.linenocheck\": \"Nomor baris\",\n\t\"pad.settings.rtlcheck\": \"Membaca dari kanan ke kiri?\",\n\t\"pad.settings.fontType\": \"Jenis fonta:\",\n\t\"pad.settings.language\": \"Bahasa:\",\n\t\"pad.settings.deletePad\": \"Hapus Pad\",\n\t\"pad.delete.confirm\": \"Apakah Anda benar-benar ingin menghapus pad ini?\",\n\t\"pad.settings.about\": \"Tentang\",\n\t\"pad.settings.poweredBy\": \"Ditenagai oleh\",\n\t\"pad.importExport.import_export\": \"Impor/Ekspor\",\n\t\"pad.importExport.import\": \"Unggah setiap berkas teks atau dokumen\",\n\t\"pad.importExport.importSuccessful\": \"Berhasil!\",\n\t\"pad.importExport.export\": \"Ekspor pad sebagai:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Teks biasa\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Anda hanya dapat mengimpor dari format teks polos atau HTML. Untuk fitur impor yang lebih canggih, <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">pasanglah AbiWord atau LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Tersambung.\",\n\t\"pad.modals.reconnecting\": \"Menyambungkan kembali ke pad Anda…\",\n\t\"pad.modals.forcereconnect\": \"Sambung kembali secara paksa\",\n\t\"pad.modals.reconnecttimer\": \"Mencoba menghubungkan ulang\",\n\t\"pad.modals.cancel\": \"Batalkan\",\n\t\"pad.modals.userdup\": \"Dibuka di jendela lain\",\n\t\"pad.modals.userdup.explanation\": \"Pad ini tampaknya telah dibuka di lebih dari satu jendela peramban pada komputer ini.\",\n\t\"pad.modals.userdup.advice\": \"Sambung kembali di jendela ini.\",\n\t\"pad.modals.unauth\": \"Tidak terotoritas\",\n\t\"pad.modals.unauth.explanation\": \"Hak-hak Anda telah berubah ketika Anda sedang melihat halaman ini. Coba menyambungkan kembali.\",\n\t\"pad.modals.looping.explanation\": \"Ada masalah komunikasi dengan peladen sinkronisasi.\",\n\t\"pad.modals.looping.cause\": \"Mungkin Anda telah tersambung melalui firewall atau proksi yang tidak kompatibel.\",\n\t\"pad.modals.initsocketfail\": \"Peladen tidak dapat dihubungi.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Peladen sinkronisasi tidak dapat dihubungi.\",\n\t\"pad.modals.initsocketfail.cause\": \"Ini mungkin disebabkan oleh masalah dengan peramban atau sambungan internet Anda.\",\n\t\"pad.modals.slowcommit.explanation\": \"Peladen tidak menanggapi.\",\n\t\"pad.modals.slowcommit.cause\": \"Ini mungkin disebabkan oleh masalah dengan sambungan jaringan Anda.\",\n\t\"pad.modals.badChangeset.explanation\": \"Suntingan yang telah Anda buat digolongkan ilegal oleh peladen sinkronisasi.\",\n\t\"pad.modals.badChangeset.cause\": \"Ini mungkin disebabkan oleh konfigurasi peladen salah atau perilaku tak terduga lainnya. Harap hubungi pengurus layanan Anda jika Anda merasakan ini adalah satu kesalahan. Coba sambungkan kembali untuk terus menyunting.\",\n\t\"pad.modals.corruptPad.explanation\": \"Pad yang Anda coba akses telah korup.\",\n\t\"pad.modals.corruptPad.cause\": \"Ini mungkin disebabkan oleh konfigurasi peladen salah atau perilaku tak terduga lainnya. Harap hubungi pengurus layanan.\",\n\t\"pad.modals.deleted\": \"Dihapus\",\n\t\"pad.modals.deleted.explanation\": \"Pad ini telah dibuang.\",\n\t\"pad.modals.rateLimited\": \"Laju Dibatasi.\",\n\t\"pad.modals.rateLimited.explanation\": \"Anda mengirim terlalu banyak pesan ke pad ini sehingga itu memutus Anda.\",\n\t\"pad.modals.rejected.explanation\": \"Peladen menolak suatu pesan yang dikirim oleh peramban Anda.\",\n\t\"pad.modals.rejected.cause\": \"Peladen mungkin telah diperbarui ketika Anda sedang melihat pad, atau mungkin ada kekutu dalam Etherpad. Coba muat ulang halaman.\",\n\t\"pad.modals.disconnected\": \"Sambungan Anda telah diputuskan.\",\n\t\"pad.modals.disconnected.explanation\": \"Sambungan ke peladen terputus\",\n\t\"pad.modals.disconnected.cause\": \"Peladen ini mungkin tak tersedia. Silakan beritahukan pengurus jika masalah ini berlanjut.\",\n\t\"pad.share\": \"Bagikan pad ini\",\n\t\"pad.share.readonly\": \"Baca saja\",\n\t\"pad.share.link\": \"Pranala\",\n\t\"pad.share.emebdcode\": \"Embed URL\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Buka chat untuk pad ini.\",\n\t\"pad.chat.loadmessages\": \"Muatkan lebih banyak pesan\",\n\t\"pad.chat.stick.title\": \"Tempelkan chat ke layar\",\n\t\"pad.chat.writeMessage.placeholder\": \"Tuliskan pesan Anda di sini\",\n\t\"timeslider.followContents\": \"Ikuti pembaruan isi pad\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Timeslider\",\n\t\"timeslider.toolbar.returnbutton\": \"Kembali ke pad\",\n\t\"timeslider.toolbar.authors\": \"Pembuat:\",\n\t\"timeslider.toolbar.authorsList\": \"Tidak ada penulis\",\n\t\"timeslider.toolbar.exportlink.title\": \"Ekspor\",\n\t\"timeslider.exportCurrent\": \"Ekspor versi saat ini sebagai:\",\n\t\"timeslider.version\": \"Versi {{version}}\",\n\t\"timeslider.saved\": \"Disimpan pada {{day}} {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Mainkan / Pause Konten Pad\",\n\t\"timeslider.backRevision\": \"Mundur satu revisi di Pad ini\",\n\t\"timeslider.forwardRevision\": \"Maju satu revisi dalam Pad ini\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Januari\",\n\t\"timeslider.month.february\": \"Februari\",\n\t\"timeslider.month.march\": \"Maret\",\n\t\"timeslider.month.april\": \"April\",\n\t\"timeslider.month.may\": \"Mei\",\n\t\"timeslider.month.june\": \"Juni\",\n\t\"timeslider.month.july\": \"Juli\",\n\t\"timeslider.month.august\": \"Agustus\",\n\t\"timeslider.month.september\": \"September\",\n\t\"timeslider.month.october\": \"Oktober\",\n\t\"timeslider.month.november\": \"November\",\n\t\"timeslider.month.december\": \"Desember\",\n\t\"timeslider.unnamedauthors\": \"{{num}} orang {[plural(num) other: penulis]} awanama\",\n\t\"pad.savedrevs.marked\": \"Revisi ini telah ditandai sebagai revisi tersimpan\",\n\t\"pad.savedrevs.timeslider\": \"Anda bisa melihat revisi yang tersimpan dengan mengunjungi timeslider\",\n\t\"pad.userlist.entername\": \"Masukkan nama Anda\",\n\t\"pad.userlist.unnamed\": \"tanpa nama\",\n\t\"pad.editbar.clearcolors\": \"Bersihkan warna penulis pada seluruh dokumen? Ini tidak dapat dibatalkan\",\n\t\"pad.impexp.importbutton\": \"Impor Sekarang\",\n\t\"pad.impexp.importing\": \"Mengimpor...\",\n\t\"pad.impexp.confirmimport\": \"Mengimpor berkas akan menimpa teks saat ini di pad ini. Apakah Anda benar-benar ingin melakukannya?\",\n\t\"pad.impexp.convertFailed\": \"Berkas tidak dapat diimport. Silakan gunakan format dokumen yang lain atau salin tempel secara manual\",\n\t\"pad.impexp.padHasData\": \"Kami tidak dapat mengimpor berkas ini karena Pad ini sudah mengalami perubahan. Silakan impor ke pad yang baru\",\n\t\"pad.impexp.uploadFailed\": \"Penunggahan gagal, silakan mencoba lagi\",\n\t\"pad.impexp.importfailed\": \"Impor gagal\",\n\t\"pad.impexp.copypaste\": \"Silahkan salin tempel\",\n\t\"pad.impexp.exportdisabled\": \"Mengekspor dalam format {{type}} dimatikan. Silakan hubungi pengurus sistem Anda untuk rincian.\",\n\t\"pad.impexp.maxFileSize\": \"Berkas terlalu besar. Hubungi pengurus situs Anda untuk menaikkan ukuran berkas yang diizinkan untuk impor\"\n}\n"
  },
  {
    "path": "src/locales/is.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Sveinki\",\n\t\t\t\"Sveinn í Felli\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Stjórnborð fyrir stjórnendur - Etherpad\",\n\t\"admin_plugins\": \"Stýring viðbóta\",\n\t\"admin_plugins.available\": \"Tiltækar viðbætur\",\n\t\"admin_plugins.available_not-found\": \"Engar viðbætur fundust.\",\n\t\"admin_plugins.available_fetching\": \"Sæki…\",\n\t\"admin_plugins.available_install.value\": \"Setja upp\",\n\t\"admin_plugins.available_search.placeholder\": \"Leita að viðbótum til uppsetningar\",\n\t\"admin_plugins.description\": \"Lýsing\",\n\t\"admin_plugins.installed\": \"Uppsettar viðbætur\",\n\t\"admin_plugins.installed_fetching\": \"Sæki uppsettar viðbætur…\",\n\t\"admin_plugins.installed_nothing\": \"Þú hefur ekki ennþá sett upp neinarar viðbætur.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Taka út\",\n\t\"admin_plugins.last-update\": \"Síðast uppfært\",\n\t\"admin_plugins.name\": \"Heiti\",\n\t\"admin_plugins.page-title\": \"Stýring viðbóta - Etherpad\",\n\t\"admin_plugins.version\": \"Útgáfa\",\n\t\"admin_plugins_info\": \"Upplýsingar fyrir úrræðaleit\",\n\t\"admin_plugins_info.parts\": \"Uppsettir hlutar\",\n\t\"admin_plugins_info.plugins\": \"Uppsettar viðbætur\",\n\t\"admin_plugins_info.page-title\": \"Upplýsingar um viðbætur - Etherpad\",\n\t\"admin_plugins_info.version\": \"Útgáfa Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Nýjasta tiltæka útgáfa\",\n\t\"admin_plugins_info.version_number\": \"Útgáfunúmer\",\n\t\"admin_settings\": \"Stillingar\",\n\t\"admin_settings.current\": \"Fyrirliggjandi uppsetning\",\n\t\"admin_settings.current_example-devel\": \"Sniðmát með dæmigerðum þróunarstillingum\",\n\t\"admin_settings.current_example-prod\": \"Sniðmát með dæmigerðum keyrslustillingum\",\n\t\"admin_settings.current_restart.value\": \"Endurræsa Etherpad\",\n\t\"admin_settings.current_save.value\": \"Vista stillingar\",\n\t\"admin_settings.page-title\": \"Stillingar - Etherpad\",\n\t\"index.newPad\": \"Ný skrifblokk\",\n\t\"index.createOpenPad\": \"eða búa til/opna skrifblokk með heitinu:\",\n\t\"index.openPad\": \"opna skrifblokk með heitinu:\",\n\t\"pad.toolbar.bold.title\": \"Feitletrað (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Skáletrað (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Undirstrikað (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Yfirstrikun (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Raðaður listi (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Óraðaður listi (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Inndráttur (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Draga til baka (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Afturkalla (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Endurtaka (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Hreinsa liti höfunda (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Flytja inn/út frá/í önnur skráasnið\",\n\t\"pad.toolbar.timeslider.title\": \"Tímalína\",\n\t\"pad.toolbar.savedRevision.title\": \"Vista endurskoðaða útgáfu\",\n\t\"pad.toolbar.settings.title\": \"Stillingar\",\n\t\"pad.toolbar.embed.title\": \"Deila og ívefja þessari skrifblokk\",\n\t\"pad.toolbar.showusers.title\": \"Sýna notendur þessarar skrifblokkar\",\n\t\"pad.colorpicker.save\": \"Vista\",\n\t\"pad.colorpicker.cancel\": \"Hætta við\",\n\t\"pad.loading\": \"Hleð inn...\",\n\t\"pad.noCookie\": \"Vefkaka fannst ekki. Þú verður að leyfa vefkökur í vafranum þínum!  Setan þín og stillingar verða ekki vistaðar á milli heimsókna.  Þetta gæti stafað af því Etherpad sé innan í iFrame-ramma í sumum vöfrum.  Gakktu úr skugga um að Etherpad sé á sama undirléni/léni eins og yfir-iFrame-ramminn\",\n\t\"pad.permissionDenied\": \"Þú hefur ekki réttindi til að nota þessa skrifblokk\",\n\t\"pad.settings.padSettings\": \"Stillingar skrifblokkar\",\n\t\"pad.settings.myView\": \"Mitt yfirlit\",\n\t\"pad.settings.stickychat\": \"Spjall alltaf á skjánum\",\n\t\"pad.settings.chatandusers\": \"Sýna spjall og notendur\",\n\t\"pad.settings.colorcheck\": \"Litir höfunda\",\n\t\"pad.settings.linenocheck\": \"Línunúmer\",\n\t\"pad.settings.rtlcheck\": \"Lesa innihaldið frá hægri til vinstri?\",\n\t\"pad.settings.fontType\": \"Leturgerð:\",\n\t\"pad.settings.fontType.normal\": \"Venjulegt\",\n\t\"pad.settings.language\": \"Tungumál:\",\n\t\"pad.settings.about\": \"Um hugbúnaðinn\",\n\t\"pad.settings.poweredBy\": \"Keyrt með\",\n\t\"pad.importExport.import_export\": \"Flytja inn/út\",\n\t\"pad.importExport.import\": \"Settu inn hverskyns texta eða skjal\",\n\t\"pad.importExport.importSuccessful\": \"Heppnaðist!\",\n\t\"pad.importExport.export\": \"Flytja út núverandi skrifblokk sem:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad netskrifblokk\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Hreinn texti\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Þú getur aðeins flutt inn úr hreinum texta eða HTML sniðum. Til að geta nýtt \\nfleiri þróaðri innflutningssnið <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">settu þá upp AbiWord forritið eða LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Tengt.\",\n\t\"pad.modals.reconnecting\": \"Endurtengist skrifblokkinni þinni…\",\n\t\"pad.modals.forcereconnect\": \"Þvinga endurtengingu\",\n\t\"pad.modals.reconnecttimer\": \"Reyni aftur að tengjast eftir\",\n\t\"pad.modals.cancel\": \"Hætta við\",\n\t\"pad.modals.userdup\": \"Opnað í öðrum glugga\",\n\t\"pad.modals.userdup.explanation\": \"Þessi skrifblokk virðist vera opin í fleiri en einum vafraglugga á þessari tölvu.\",\n\t\"pad.modals.userdup.advice\": \"Endurtengdu til að nota þennan glugga í staðinn.\",\n\t\"pad.modals.unauth\": \"Ekki leyfilegt\",\n\t\"pad.modals.unauth.explanation\": \"Heimildir þínar hafa breyst á meðan þú skoðaðir þessa síðu. Reyndu að endurtengjast.\",\n\t\"pad.modals.looping.explanation\": \"Það eru samskiptavandamál við samstillingarmiðlarann.\",\n\t\"pad.modals.looping.cause\": \"Hugsanlega ertu tengdur í gegnum ósamhæfðan eldvegg eða milliþjón.\",\n\t\"pad.modals.initsocketfail\": \"Næ ekki sambandi við netþjón.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Gat ekki tengst samstillingarmiðlaranum.\",\n\t\"pad.modals.initsocketfail.cause\": \"Þetta er líklega vegna vandamáls varðandi vafrann þinn eða internettenginguna þína.\",\n\t\"pad.modals.slowcommit.explanation\": \"Þjónninn svarar ekki.\",\n\t\"pad.modals.slowcommit.cause\": \"Þetta gæti verið vegna vandamála varðandi nettengingar.\",\n\t\"pad.modals.badChangeset.explanation\": \"Breyting sem þú gerðir var flokkuð sem óleyfileg af samstillingarmiðlaranum.\",\n\t\"pad.modals.badChangeset.cause\": \"Þetta gæti verið vegna rangrar uppsetningar á þjóninum eða annarar óvæntrar hegðunar. Hafðu samband við stjórnanda þjónustunnar ef þér sýnist þetta vera villa. Reyndu að endurtengjast til að halda áfram með breytingar.\",\n\t\"pad.modals.corruptPad.explanation\": \"Skrifblokkin sem þú ert að reyna að tengjast er skemmd.\",\n\t\"pad.modals.corruptPad.cause\": \"Þetta gæti verið vegna rangrar uppsetningar á þjóninum eða annarar óvæntrar hegðunar. Hafðu samband við stjórnanda þjónustunnar.\",\n\t\"pad.modals.deleted\": \"Eytt.\",\n\t\"pad.modals.deleted.explanation\": \"Þessi skrifblokk hefur verið fjarlægð.\",\n\t\"pad.modals.rateLimited\": \"Með takmörkum.\",\n\t\"pad.modals.rateLimited.explanation\": \"Þú hefur sent of mörg skilaboð á þessa skrifblokk, þannig að hún aftengdi þig.\",\n\t\"pad.modals.rejected.explanation\": \"Þjónninn hafnaði skilaboðum sem vafrinn þinn sendi.\",\n\t\"pad.modals.disconnected\": \"Þú hefur verið aftengd(ur).\",\n\t\"pad.modals.disconnected.explanation\": \"Missti tengingu við miðlara\",\n\t\"pad.modals.disconnected.cause\": \"Miðlarinn gæti verið ekki tiltækur. Láttu kerfisstjóra vita ef þetta heldur áfram að gerast.\",\n\t\"pad.share\": \"Deila þessari skrifblokk\",\n\t\"pad.share.readonly\": \"Skrifvarið\",\n\t\"pad.share.link\": \"Tengill\",\n\t\"pad.share.emebdcode\": \"Ívefja slóð\",\n\t\"pad.chat\": \"Spjall\",\n\t\"pad.chat.title\": \"Opna spjallið fyrir þessa skrifblokk.\",\n\t\"pad.chat.loadmessages\": \"Hlaða inn fleiri skeytum\",\n\t\"pad.chat.stick.title\": \"Festa spjallið á skjáinn\",\n\t\"pad.chat.writeMessage.placeholder\": \"Skrifaðu skilaboðin þín hér\",\n\t\"timeslider.followContents\": \"Fylgja uppfærslum á efni skrifblokkar\",\n\t\"timeslider.pageTitle\": \"Tímalína {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Fara til baka í skrifblokk\",\n\t\"timeslider.toolbar.authors\": \"Höfundar:\",\n\t\"timeslider.toolbar.authorsList\": \"Engir höfundar\",\n\t\"timeslider.toolbar.exportlink.title\": \"Flytja út\",\n\t\"timeslider.exportCurrent\": \"Flytja út núverandi útgáfu sem:\",\n\t\"timeslider.version\": \"Útgáfa {{version}}\",\n\t\"timeslider.saved\": \"Vistað {{day}}. {{month}}, {{year}}\",\n\t\"timeslider.playPause\": \"Afspilun / Hlé á efni skrifblokkar\",\n\t\"timeslider.backRevision\": \"Fara til baka um eina útgáfu í þessari skrifblokk\",\n\t\"timeslider.forwardRevision\": \"Fara áfram um eina útgáfu í þessari skrifblokk\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Janúar\",\n\t\"timeslider.month.february\": \"febrúar\",\n\t\"timeslider.month.march\": \"mars\",\n\t\"timeslider.month.april\": \"apríl\",\n\t\"timeslider.month.may\": \"maí\",\n\t\"timeslider.month.june\": \"júní\",\n\t\"timeslider.month.july\": \"Júlí\",\n\t\"timeslider.month.august\": \"ágúst\",\n\t\"timeslider.month.september\": \"september\",\n\t\"timeslider.month.october\": \"október\",\n\t\"timeslider.month.november\": \"nóvember\",\n\t\"timeslider.month.december\": \"desember\",\n\t\"timeslider.unnamedauthors\": \"{{num}} ónefnt {[plural(num) one: höfundur, other: höfundar ]}\",\n\t\"pad.savedrevs.marked\": \"Þessi útgáfa er núna merkt sem vistuð útgáfa\",\n\t\"pad.savedrevs.timeslider\": \"Þú getur skoðað vistaðar útgáfur með því að fara á tímalínuna\",\n\t\"pad.userlist.entername\": \"Settu inn nafnið þitt\",\n\t\"pad.userlist.unnamed\": \"ónefnt\",\n\t\"pad.editbar.clearcolors\": \"Hreinsa liti höfunda á öllu skjalinu? Þetta er ekki hægt að afturkalla\",\n\t\"pad.impexp.importbutton\": \"Flytja inn núna\",\n\t\"pad.impexp.importing\": \"Flyt inn...\",\n\t\"pad.impexp.confirmimport\": \"Innflutningur á skrá mun skrifa yfir þann texta sem er á skrifblokkinni núna. \\nErtu viss um að þú viljir halda áfram?\",\n\t\"pad.impexp.convertFailed\": \"Við getum ekki flutt inn þessa skrá. Notaðu annað skráasnið eða afritaðu og \\nlímdu handvirkt\",\n\t\"pad.impexp.padHasData\": \"Við getum ekki flutt inn þessa skrá því þegar er búið að breyta þessari skrifblokk, flyttu inn í nýja skrifblokk\",\n\t\"pad.impexp.uploadFailed\": \"Sending mistókst, endilega reyndu aftur\",\n\t\"pad.impexp.importfailed\": \"Innflutningur mistókst\",\n\t\"pad.impexp.copypaste\": \"Afritaðu og límdu\",\n\t\"pad.impexp.exportdisabled\": \"Útflutningur á {{type}} sniði er óvirkur. Hafðu samband við kerfisstjóra til að fá frekari aðstoð.\",\n\t\"pad.impexp.maxFileSize\": \"Of stór skrá. Hafðu samband við kerfisstjóra til að láta auka leyfilega stærð skráa í innflutningi\"\n}\n"
  },
  {
    "path": "src/locales/it.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Ajeje Brazorf\",\n\t\t\t\"Albano\",\n\t\t\t\"Ayub Abdulla\",\n\t\t\t\"Beta16\",\n\t\t\t\"Gianfranco\",\n\t\t\t\"Jack\",\n\t\t\t\"Macofe\",\n\t\t\t\"Muxator\",\n\t\t\t\"Nivit\",\n\t\t\t\"VamosErik88\",\n\t\t\t\"Vituzzu\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Pannello amministrativo - Etherpad\",\n\t\"admin_plugins\": \"Gestione plugin\",\n\t\"admin_plugins.available\": \"Plugin disponibili\",\n\t\"admin_plugins.available_not-found\": \"Nessun plugin trovato.\",\n\t\"admin_plugins.available_fetching\": \"Recupero in corso…\",\n\t\"admin_plugins.available_install.value\": \"Installa\",\n\t\"admin_plugins.available_search.placeholder\": \"Cerca i plugin da installare\",\n\t\"admin_plugins.description\": \"Descrizione\",\n\t\"admin_plugins.installed\": \"Plugin installati\",\n\t\"admin_plugins.installed_fetching\": \"Recupero dei plugin installati…\",\n\t\"admin_plugins.installed_nothing\": \"Non hai ancora installato alcun plugin.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Disinstalla\",\n\t\"admin_plugins.last-update\": \"Ultimo aggiornamento\",\n\t\"admin_plugins.name\": \"Nome\",\n\t\"admin_plugins.page-title\": \"Gestore dei plugin - Etherpad\",\n\t\"admin_plugins.version\": \"Versione\",\n\t\"admin_plugins_info\": \"Informazioni sulla risoluzione dei problemi\",\n\t\"admin_plugins_info.hooks\": \"Hook installati\",\n\t\"admin_plugins_info.hooks_client\": \"Hook lato client\",\n\t\"admin_plugins_info.hooks_server\": \"Hook lato server\",\n\t\"admin_plugins_info.parts\": \"Parti installate\",\n\t\"admin_plugins_info.plugins\": \"Plugin installati\",\n\t\"admin_plugins_info.page-title\": \"Informazioni sul plugin - Etherpad\",\n\t\"admin_plugins_info.version\": \"Versione di Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Ultima versione disponibile\",\n\t\"admin_plugins_info.version_number\": \"Numero di versione\",\n\t\"admin_settings\": \"Impostazioni\",\n\t\"admin_settings.current\": \"Configurazione attuale\",\n\t\"admin_settings.current_example-devel\": \"Esempio di modello di impostazioni di sviluppo\",\n\t\"admin_settings.current_example-prod\": \"Esempio di modello di impostazioni di produzione\",\n\t\"admin_settings.current_restart.value\": \"Riavvia Etherpad\",\n\t\"admin_settings.current_save.value\": \"Salva impostazioni\",\n\t\"admin_settings.page-title\": \"Impostazioni - Etherpad\",\n\t\"index.newPad\": \"Nuovo pad\",\n\t\"index.settings\": \"Impostazioni\",\n\t\"index.transferSessionTitle\": \"Sessione di trasferimento\",\n\t\"index.receiveSessionTitle\": \"Ricevi sessione\",\n\t\"index.receiveSessionDescription\": \"Qui puoi ricevere una sessione Etherpad da un altro browser o dispositivo. Tieni presente, tuttavia, che questa operazione eliminerà la sessione corrente, se presente.\",\n\t\"index.transferSession\": \"1. Sessione di trasferimento\",\n\t\"index.transferSessionNow\": \"Trasferisci sessione ora\",\n\t\"index.copyLink\": \"2. Copia il collegamento\",\n\t\"index.copyLinkDescription\": \"Clicca sul pulsante qui sotto per copiare il link negli appunti.\",\n\t\"index.copyLinkButton\": \"Copia link negli appunti\",\n\t\"index.transferToSystem\": \"3. Copia la sessione sul nuovo sistema\",\n\t\"index.transferToSystemDescription\": \"Apri il collegamento copiato nel browser o nel dispositivo di destinazione per trasferire la sessione\",\n\t\"index.transferSessionDescription\": \"Trasferisci la tua sessione corrente al browser o al dispositivo cliccando sul pulsante qui sotto. Verrà copiato un link a una pagina che trasferirà la tua sessione quando verrà aperta nel browser o dispositivo di destinazione.\",\n\t\"index.createOpenPad\": \"Apri pad per nome\",\n\t\"index.openPad\": \"apri un Pad esistente col nome:\",\n\t\"index.recentPads\": \"Pad recenti\",\n\t\"index.recentPadsEmpty\": \"Nessun Pad recente trovato.\",\n\t\"index.generateNewPad\": \"Genera un nome casuale per il pad\",\n\t\"index.labelPad\": \"Nome pad (facoltativo)\",\n\t\"index.placeholderPadEnter\": \"Inserisci un nome per il pad...\",\n\t\"index.createAndShareDocuments\": \"Crea e condividi documenti in tempo reale\",\n\t\"index.createAndShareDocumentsDescription\": \"Etherpad consente di modificare documenti in modo collaborativo e in tempo reale, proprio come un editor multi-player in tempo reale eseguito nel browser.\",\n\t\"pad.toolbar.bold.title\": \"Grassetto (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Corsivo (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Sottolineato (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Barrato (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Elenco numerato (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Elenco puntato (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Indentazione (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Riduci indentazione (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Annulla (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Ripeti (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Elimina i colori degli autori (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importa/esporta da/a diversi formati di file\",\n\t\"pad.toolbar.timeslider.title\": \"Presentazione cronologia\",\n\t\"pad.toolbar.savedRevision.title\": \"Versione salvata\",\n\t\"pad.toolbar.settings.title\": \"Impostazioni\",\n\t\"pad.toolbar.embed.title\": \"Condividi ed incorpora questo Pad\",\n\t\"pad.toolbar.home.title\": \"Torna alla pagina principale\",\n\t\"pad.toolbar.showusers.title\": \"Visualizza gli utenti su questo Pad\",\n\t\"pad.colorpicker.save\": \"Salva\",\n\t\"pad.colorpicker.cancel\": \"Annulla\",\n\t\"pad.loading\": \"Caricamento in corso…\",\n\t\"pad.noCookie\": \"Il cookie non è stato trovato. Consenti i cookie nel tuo browser! La sessione e le impostazioni non verranno salvate tra le diverse visite. Ciò può essere dovuto al fatto che Etherpad è stato incluso in un iFrame in alcuni browser. Assicurati che Etherpad si trovi sullo stesso sottodominio/dominio dell'iFrame principale\",\n\t\"pad.permissionDenied\": \"Non si dispone dei permessi necessari per accedere a questo Pad\",\n\t\"pad.settings.padSettings\": \"Impostazioni del Pad\",\n\t\"pad.settings.myView\": \"Mia visualizzazione\",\n\t\"pad.settings.stickychat\": \"Chat sempre sullo schermo\",\n\t\"pad.settings.chatandusers\": \"Mostra chat e utenti\",\n\t\"pad.settings.colorcheck\": \"Colori che indicano gli autori\",\n\t\"pad.settings.linenocheck\": \"Numeri di riga\",\n\t\"pad.settings.rtlcheck\": \"Leggere il contenuto da destra a sinistra?\",\n\t\"pad.settings.fontType\": \"Tipo di carattere:\",\n\t\"pad.settings.fontType.normal\": \"Normale\",\n\t\"pad.settings.language\": \"Lingua:\",\n\t\"pad.settings.deletePad\": \"Elimina Pad\",\n\t\"pad.delete.confirm\": \"Vuoi veramente cancellare questo pad?\",\n\t\"pad.settings.about\": \"Informazioni\",\n\t\"pad.settings.poweredBy\": \"Realizzato con\",\n\t\"pad.importExport.import_export\": \"Importazione/esportazione\",\n\t\"pad.importExport.import\": \"Carica un file di testo o un documento\",\n\t\"pad.importExport.importSuccessful\": \"Riuscito!\",\n\t\"pad.importExport.export\": \"Esportare il Pad corrente come:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Testo normale\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"È possibile importare solo i formati di testo semplice o HTML. Per metodi più avanzati di importazione <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">installare AbiWord o LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Connesso.\",\n\t\"pad.modals.reconnecting\": \"Riconnessione al pad in corso…\",\n\t\"pad.modals.forcereconnect\": \"Forza la riconnessione\",\n\t\"pad.modals.reconnecttimer\": \"Tentativo di riconnessione\",\n\t\"pad.modals.cancel\": \"Annulla\",\n\t\"pad.modals.userdup\": \"Aperto in un'altra finestra\",\n\t\"pad.modals.userdup.explanation\": \"Questo Pad sembra essere aperto in più di una finestra del browser su questo computer.\",\n\t\"pad.modals.userdup.advice\": \"Riconnettiti per utilizzare invece questa finestra.\",\n\t\"pad.modals.unauth\": \"Non autorizzato\",\n\t\"pad.modals.unauth.explanation\": \"Le tue autorizzazioni sono state modificate durante la visualizzazione di questa pagina. Prova a riconnetterti.\",\n\t\"pad.modals.looping.explanation\": \"Ci sono problemi di comunicazione con il server di sincronizzazione.\",\n\t\"pad.modals.looping.cause\": \"Forse sei connesso attraverso un firewall o un server proxy non compatibili.\",\n\t\"pad.modals.initsocketfail\": \"Il server non è raggiungibile.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Impossibile connettersi al server di sincronizzazione.\",\n\t\"pad.modals.initsocketfail.cause\": \"Questo probabilmente è dovuto a un problema con il browser o con la tua connessione internet.\",\n\t\"pad.modals.slowcommit.explanation\": \"Il server non risponde.\",\n\t\"pad.modals.slowcommit.cause\": \"Questo potrebbe essere dovuto a problemi di connettività di rete.\",\n\t\"pad.modals.badChangeset.explanation\": \"Una modifica che hai fatto è stata considerata illegale dal server di sincronizzazione.\",\n\t\"pad.modals.badChangeset.cause\": \"Ciò potrebbe essere causato da una errata configurazione del server o qualche altro comportamento imprevisto. Si prega di contattare l'amministratore del servizio, se si ritiene che questo sia un errore. Prova a riconnetterti per tentare di continuare a modificare.\",\n\t\"pad.modals.corruptPad.explanation\": \"Il pad a cui stai tentando di accedere è danneggiato.\",\n\t\"pad.modals.corruptPad.cause\": \"Ciò potrebbe essere causato da una errata configurazione del server o qualche altro comportamento imprevisto. Si prega di contattare l'amministratore del servizio.\",\n\t\"pad.modals.deleted\": \"Cancellato.\",\n\t\"pad.modals.deleted.explanation\": \"Questo Pad è stato rimosso.\",\n\t\"pad.modals.rateLimited\": \"Limite di richieste.\",\n\t\"pad.modals.rateLimited.explanation\": \"Hai inviato troppi messaggi a questo pad e la connessione è stata interrotta.\",\n\t\"pad.modals.rejected.explanation\": \"Il server ha rifiutato un messaggio inviato dal tuo browser.\",\n\t\"pad.modals.rejected.cause\": \"Il server potrebbe essere stato aggiornato mentre stavi visualizzando il pad, oppure potrebbe esserci un bug in Etherpad. Prova a ricaricare la pagina.\",\n\t\"pad.modals.disconnected\": \"Sei stato disconnesso.\",\n\t\"pad.modals.disconnected.explanation\": \"La connessione al server è stata persa\",\n\t\"pad.modals.disconnected.cause\": \"Il server potrebbe essere non disponibile. Informa l'amministrazione del servizio se il problema persiste.\",\n\t\"pad.share\": \"Condividi questo Pad\",\n\t\"pad.share.readonly\": \"Sola lettura\",\n\t\"pad.share.link\": \"Collegamento\",\n\t\"pad.share.emebdcode\": \"Incorpora URL\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Apri la chat per questo Pad.\",\n\t\"pad.chat.loadmessages\": \"Carica altri messaggi\",\n\t\"pad.chat.stick.title\": \"Ancora chat nello schermo\",\n\t\"pad.chat.writeMessage.placeholder\": \"Scrivi il tuo messaggio qui\",\n\t\"timeslider.followContents\": \"Segui gli aggiornamenti sui contenuti del pad\",\n\t\"timeslider.pageTitle\": \"Cronologia {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Ritorna al Pad\",\n\t\"timeslider.toolbar.authors\": \"Autori:\",\n\t\"timeslider.toolbar.authorsList\": \"Nessun autore\",\n\t\"timeslider.toolbar.exportlink.title\": \"Esporta\",\n\t\"timeslider.exportCurrent\": \"Esporta la versione corrente come:\",\n\t\"timeslider.version\": \"Versione {{version}}\",\n\t\"timeslider.saved\": \"Salvato {{day}} {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Riproduzione / Pausa contenuti Pad\",\n\t\"timeslider.backRevision\": \"Vai indietro di una versione in questo Pad\",\n\t\"timeslider.forwardRevision\": \"Vai avanti di una versione in questo Pad\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"gennaio\",\n\t\"timeslider.month.february\": \"febbraio\",\n\t\"timeslider.month.march\": \"marzo\",\n\t\"timeslider.month.april\": \"aprile\",\n\t\"timeslider.month.may\": \"maggio\",\n\t\"timeslider.month.june\": \"giugno\",\n\t\"timeslider.month.july\": \"luglio\",\n\t\"timeslider.month.august\": \"agosto\",\n\t\"timeslider.month.september\": \"settembre\",\n\t\"timeslider.month.october\": \"ottobre\",\n\t\"timeslider.month.november\": \"novembre\",\n\t\"timeslider.month.december\": \"dicembre\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: autore, other: autori ]} senza nome\",\n\t\"pad.savedrevs.marked\": \"Questa revisione è ora contrassegnata come una versione salvata\",\n\t\"pad.savedrevs.timeslider\": \"Puoi vedere le versioni salvate visitando la cronologia\",\n\t\"pad.userlist.entername\": \"Inserisci il tuo nome\",\n\t\"pad.userlist.unnamed\": \"senza nome\",\n\t\"pad.editbar.clearcolors\": \"Eliminare i colori degli autori sull'intero documento? Questa azione non può essere annullata\",\n\t\"pad.impexp.importbutton\": \"Importa ora\",\n\t\"pad.impexp.importing\": \"Importazione in corso...\",\n\t\"pad.impexp.confirmimport\": \"L'importazione del file sovrascriverà il testo attuale del Pad. Sei sicuro di voler procedere?\",\n\t\"pad.impexp.convertFailed\": \"Non è stato possibile importare questo file. Utilizzare un formato differente o copiare ed incollare a mano\",\n\t\"pad.impexp.padHasData\": \"Non è possibile importare questo file poiché questo Pad ha già avuto modifiche; importalo in un nuovo Pad\",\n\t\"pad.impexp.uploadFailed\": \"Caricamento non riuscito, riprovare\",\n\t\"pad.impexp.importfailed\": \"Importazione fallita\",\n\t\"pad.impexp.copypaste\": \"Si prega di copiare e incollare\",\n\t\"pad.impexp.exportdisabled\": \"L'esportazione come {{type}} è disabilitata. Contattare l'amministratore per i dettagli.\",\n\t\"pad.impexp.maxFileSize\": \"File troppo grande. Contatta l'amministratore del sito per incrementare la dimensione consentita per l'importazione\"\n}\n"
  },
  {
    "path": "src/locales/ja.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Aefgh39622\",\n\t\t\t\"Afaz\",\n\t\t\t\"Chqaz\",\n\t\t\t\"Omotecho\",\n\t\t\t\"Shirayuki\",\n\t\t\t\"Torinky\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"管理者ダッシュボード - Etherpad\",\n\t\"admin_plugins\": \"プラグインマネージャー\",\n\t\"admin_plugins.available\": \"利用可能なプラグイン\",\n\t\"admin_plugins.available_not-found\": \"プラグインが見つかりません。\",\n\t\"admin_plugins.available_fetching\": \"読み込み中…\",\n\t\"admin_plugins.available_install.value\": \"インストール\",\n\t\"admin_plugins.available_search.placeholder\": \"インストールするプラグインを検索する\",\n\t\"admin_plugins.description\": \"説明\",\n\t\"admin_plugins.installed\": \"インストール済みプラグイン\",\n\t\"admin_plugins.installed_fetching\": \"インストール済みのプラグインを読み込んでいます…\",\n\t\"admin_plugins.installed_nothing\": \"まだプラグインをインストールしていません。\",\n\t\"admin_plugins.installed_uninstall.value\": \"アンインストール\",\n\t\"admin_plugins.last-update\": \"最終更新\",\n\t\"admin_plugins.name\": \"名前\",\n\t\"admin_plugins.page-title\": \"プラグインマネージャー - Etherpad\",\n\t\"admin_plugins.version\": \"バージョン\",\n\t\"admin_plugins_info\": \"トラブルシューティングの情報\",\n\t\"admin_plugins_info.hooks\": \"インストール済みフック\",\n\t\"admin_plugins_info.hooks_client\": \"クライアント側のフック\",\n\t\"admin_plugins_info.hooks_server\": \"サーバー側のフック\",\n\t\"index.newPad\": \"新規作成\",\n\t\"index.createOpenPad\": \"または作成/編集するパッド名を入力:\",\n\t\"index.openPad\": \"次の名称の既存の Pad を開く:\",\n\t\"pad.toolbar.bold.title\": \"太字 (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"斜体 (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"下線 (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"取り消し線 (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"番号付きリスト (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"番号なしリスト (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"インデント (Tab)\",\n\t\"pad.toolbar.unindent.title\": \"インデント解除 (Shift+Tab)\",\n\t\"pad.toolbar.undo.title\": \"元に戻す (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"やり直し (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"作者の色分けを消去(Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"他の形式のファイルのインポート/エクスポート\",\n\t\"pad.toolbar.timeslider.title\": \"タイムスライダー\",\n\t\"pad.toolbar.savedRevision.title\": \"版を保存\",\n\t\"pad.toolbar.settings.title\": \"設定\",\n\t\"pad.toolbar.embed.title\": \"このパッドを共有する/埋め込む\",\n\t\"pad.toolbar.showusers.title\": \"このパッドのユーザーを表示\",\n\t\"pad.colorpicker.save\": \"保存\",\n\t\"pad.colorpicker.cancel\": \"キャンセル\",\n\t\"pad.loading\": \"読み込み中...\",\n\t\"pad.noCookie\": \"Cookie could not be found. Please allow cookies in your browser!  Your session and settings will not be saved between visits.  \\n\\nクッキーが見つかりません。ブラウザの設定でクッキーの使用を許可するまで、アクセスの記録や設定は引き継がれません。原因はブラウザによって Etherpad が iFrame に組み込まれたからと考えられます。親ドメインの iFrame と同じドメイン/サブドメインに置かれているかどうか、Etherpad の設定を確認してください。\",\n\t\"pad.permissionDenied\": \"あなたにはこのパッドへのアクセス許可がありません\",\n\t\"pad.settings.padSettings\": \"パッドの設定\",\n\t\"pad.settings.myView\": \"個人設定\",\n\t\"pad.settings.stickychat\": \"画面にチャットを常に表示\",\n\t\"pad.settings.chatandusers\": \"チャットとユーザーを表示\",\n\t\"pad.settings.colorcheck\": \"作者の色分け\",\n\t\"pad.settings.linenocheck\": \"行番号\",\n\t\"pad.settings.rtlcheck\": \"右横書きにする\",\n\t\"pad.settings.fontType\": \"フォントの種類:\",\n\t\"pad.settings.fontType.normal\": \"通常\",\n\t\"pad.settings.language\": \"言語:\",\n\t\"pad.settings.about\": \"このアプリについて\",\n\t\"pad.settings.poweredBy\": \"提供\",\n\t\"pad.importExport.import_export\": \"インポート/エクスポート\",\n\t\"pad.importExport.import\": \"あらゆるテキストファイルや文書をアップロードできます\",\n\t\"pad.importExport.importSuccessful\": \"完了しました。\",\n\t\"pad.importExport.export\": \"現在のパッドをエクスポートする形式:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"プレーンテキスト\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"プレーンテキストまたは HTML ファイルからのみインポートできます。より高度なインポート機能を使用するには、<a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">AbiWord またはLibreOfficeをインストール</a>してください。\",\n\t\"pad.modals.connected\": \"接続されました。\",\n\t\"pad.modals.reconnecting\": \"パッドに再接続中...\",\n\t\"pad.modals.forcereconnect\": \"強制的に再接続\",\n\t\"pad.modals.reconnecttimer\": \"再接続を試行中\",\n\t\"pad.modals.cancel\": \"中止\",\n\t\"pad.modals.userdup\": \"別のウィンドウで開かれています\",\n\t\"pad.modals.userdup.explanation\": \"このコンピューターの複数のブラウザーウィンドウで、このパッドを開いているようです。\",\n\t\"pad.modals.userdup.advice\": \"代わりにこのウィンドウを再接続します。\",\n\t\"pad.modals.unauth\": \"権限がありません\",\n\t\"pad.modals.unauth.explanation\": \"このページの閲覧中にあなたの権限が変更されました。再接続をお試しください。\",\n\t\"pad.modals.looping.explanation\": \"同期サーバーとの通信に問題点があります。\",\n\t\"pad.modals.looping.cause\": \"ご使用中のファイアウォールまたはプロキシとは互換性がない可能性があります。\",\n\t\"pad.modals.initsocketfail\": \"サーバーに到達できません。\",\n\t\"pad.modals.initsocketfail.explanation\": \"同期サーバーに接続できませんでした。\",\n\t\"pad.modals.initsocketfail.cause\": \"これはご使用中のブラウザーやインターネット接続の問題が原因である可能性があります。\",\n\t\"pad.modals.slowcommit.explanation\": \"サーバーが応答しません。\",\n\t\"pad.modals.slowcommit.cause\": \"これはネットワーク接続の問題が原因である可能性があります。\",\n\t\"pad.modals.badChangeset.explanation\": \"投稿した編集は同期サーバーによって違法性のあるものとして秘匿されました。\",\n\t\"pad.modals.badChangeset.cause\": \"これはサーバーの構成不良か、予期せぬ挙動を見せたために発生した事象である可能性があります。これがエラーである疑いがあれば、当サービス管理者に問い合わせてください。編集を続行するには再接続してみてください。\",\n\t\"pad.modals.corruptPad.explanation\": \"アクセスしようとしているパッドは破損しています。\",\n\t\"pad.modals.corruptPad.cause\": \"これはサーバーの構成不良か、予期せぬ挙動を見せたために発生した事象である可能性があります。当サービス管理者にお問い合わせください。\",\n\t\"pad.modals.deleted\": \"削除されました。\",\n\t\"pad.modals.deleted.explanation\": \"このパッドは削除されました。\",\n\t\"pad.modals.rateLimited\": \"速度制限中です。\",\n\t\"pad.modals.rateLimited.explanation\": \"パッドに送ったメッセージ件数が多すぎたため接続が解除されました。\",\n\t\"pad.modals.disconnected\": \"切断されました。\",\n\t\"pad.modals.disconnected.explanation\": \"サーバーとの接続が失われました\",\n\t\"pad.modals.disconnected.cause\": \"サーバーを利用できない可能性があります。この問題が解決しない場合はサービスの管理者にお知らせください。\",\n\t\"pad.share\": \"このパッドを共有\",\n\t\"pad.share.readonly\": \"読み取り専用\",\n\t\"pad.share.link\": \"リンク\",\n\t\"pad.share.emebdcode\": \"埋め込み用 URL\",\n\t\"pad.chat\": \"チャット\",\n\t\"pad.chat.title\": \"このパッドのチャットを開きます。\",\n\t\"pad.chat.loadmessages\": \"その他のメッセージを読み込む\",\n\t\"pad.chat.stick.title\": \"チャットを画面に貼り付ける\",\n\t\"pad.chat.writeMessage.placeholder\": \"ここにメッセージを書き込んでください\",\n\t\"timeslider.followContents\": \"パッドの内容の更新をフォロー\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} タイムスライダー\",\n\t\"timeslider.toolbar.returnbutton\": \"パッドに戻る\",\n\t\"timeslider.toolbar.authors\": \"作者:\",\n\t\"timeslider.toolbar.authorsList\": \"作者なし\",\n\t\"timeslider.toolbar.exportlink.title\": \"エクスポート\",\n\t\"timeslider.exportCurrent\": \"現在の版をエクスポートする形式:\",\n\t\"timeslider.version\": \"バージョン {{version}}\",\n\t\"timeslider.saved\": \"{{year}}年{{month}}{{day}}日に保存\",\n\t\"timeslider.playPause\": \"パッドの過去の内容を再生／一時停止\",\n\t\"timeslider.backRevision\": \"前の版に戻る\",\n\t\"timeslider.forwardRevision\": \"次の版に進む\",\n\t\"timeslider.dateformat\": \"{{year}}年{{month}}月{{day}}日 {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"1月\",\n\t\"timeslider.month.february\": \"2月\",\n\t\"timeslider.month.march\": \"3月\",\n\t\"timeslider.month.april\": \"4月\",\n\t\"timeslider.month.may\": \"5月\",\n\t\"timeslider.month.june\": \"6月\",\n\t\"timeslider.month.july\": \"7月\",\n\t\"timeslider.month.august\": \"8月\",\n\t\"timeslider.month.september\": \"9月\",\n\t\"timeslider.month.october\": \"10月\",\n\t\"timeslider.month.november\": \"11月\",\n\t\"timeslider.month.december\": \"12月\",\n\t\"timeslider.unnamedauthors\": \"{{num}} 人の匿名の{[plural(num) other: 作者 ]}\",\n\t\"pad.savedrevs.marked\": \"この版を、保存済みの版としてマークしました。\",\n\t\"pad.savedrevs.timeslider\": \"タイムスライダーで保存された版を確認できます\",\n\t\"pad.userlist.entername\": \"名前を入力\",\n\t\"pad.userlist.unnamed\": \"名前なし\",\n\t\"pad.editbar.clearcolors\": \"文書全体の作者の色分けを消去しますか? 取り消しはできません。\",\n\t\"pad.impexp.importbutton\": \"インポートする\",\n\t\"pad.impexp.importing\": \"インポート中...\",\n\t\"pad.impexp.confirmimport\": \"ファイルをインポートすると、パッドの現在のテキストが上書きされます。本当に続行しますか?\",\n\t\"pad.impexp.convertFailed\": \"このファイルをインポートできませんでした。他の文書形式を使用するか、手作業でコピー & ペーストしてください\",\n\t\"pad.impexp.padHasData\": \"このパッドは変更されたため、ファイルからインポートできませんでした。新しいパッドにインポートしてください。\",\n\t\"pad.impexp.uploadFailed\": \"アップロードに失敗しました。もう一度お試しください\",\n\t\"pad.impexp.importfailed\": \"インポートに失敗しました\",\n\t\"pad.impexp.copypaste\": \"コピー & ペーストしてください\",\n\t\"pad.impexp.exportdisabled\": \"{{type}}形式でのエクスポートは無効になっています。詳細はシステム管理者にお問い合わせください。\",\n\t\"pad.impexp.maxFileSize\": \"ファイルが重すぎます。サイト管理者に連絡してインポート可能なファイルサイズの上限を引き上げてもらう必要があります\"\n}\n"
  },
  {
    "path": "src/locales/kab.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Belkacem77\",\n\t\t\t\"ButterflyOfFire\"\n\t\t]\n\t},\n\t\"admin_plugins.name\": \"Isem\",\n\t\"admin_plugins.version\": \"Lqem\",\n\t\"admin_settings\": \"Iɣewwaren\",\n\t\"admin_settings.current\": \"Tawila tamirant\",\n\t\"admin_settings.current_save.value\": \"Sekles iɣewwaren\",\n\t\"admin_settings.page-title\": \"Iɣewwaren - Etherpad\",\n\t\"index.newPad\": \"Apad amaynut\",\n\t\"index.createOpenPad\": \"neɣ rnu/ldi apad s yisem:\",\n\t\"pad.toolbar.bold.title\": \"Zur (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Uknan (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Ituderrer (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Ittujerreḍ (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Tabdart n usmizzwer (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Tabdart s war asmizzwer (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Rigel (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Kkes ariger (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Sefsex (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Err-d (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Sfeḍ initen yemmalen imeskaren (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Kter/Sifeḍ seg/ɣer umasal n ufaylu-nnḍen\",\n\t\"pad.toolbar.timeslider.title\": \"Amazray asmussan\",\n\t\"pad.toolbar.savedRevision.title\": \"Sekles aceggir\",\n\t\"pad.toolbar.settings.title\": \"Iɣewwaṛen\",\n\t\"pad.toolbar.embed.title\": \"Bḍu sakin seddu apad-agi\",\n\t\"pad.toolbar.showusers.title\": \"Sken iseqdacen ɣef upad-agi\",\n\t\"pad.colorpicker.save\": \"Sekles\",\n\t\"pad.colorpicker.cancel\": \"Semmet\",\n\t\"pad.loading\": \"Asali...\",\n\t\"pad.noCookie\": \"Anagi n tuqqna ulac-it. Sireg inagan n tuqqna deg iminig-ik!\",\n\t\"pad.permissionDenied\": \"Ur ɣur-k ara tasiregt akken ad tkecmeḍ ar upad-agi\",\n\t\"pad.settings.padSettings\": \"Iɣewwaṛen n upad\",\n\t\"pad.settings.myView\": \"Timeẓri-iw\",\n\t\"pad.settings.stickychat\": \"Asqerdec yezga deg ugdil\",\n\t\"pad.settings.chatandusers\": \"Sken asqerdec akken iseqdacen\",\n\t\"pad.settings.colorcheck\": \"Initen n usulu\",\n\t\"pad.settings.linenocheck\": \"Uṭṭunen n izirigen\",\n\t\"pad.settings.rtlcheck\": \"Ɣeṛ agbur seg uyeffus s azelmaḍ?\",\n\t\"pad.settings.fontType\": \"Anaw n tsefsit:\",\n\t\"pad.settings.language\": \"Tutlayt:\",\n\t\"pad.settings.about\": \"Ɣef\",\n\t\"pad.importExport.import_export\": \"Kter/Sifeḍ\",\n\t\"pad.importExport.import\": \"Sali aḍris neɣ isemli\",\n\t\"pad.importExport.importSuccessful\": \"Yedda!\",\n\t\"pad.importExport.export\": \"Sifeḍ apad amiran am:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Adris aččuran\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Tzemreḍ kan ad ketreḍ aḍris aččuran neɣ imasalen HTML. Ugar n tmahilin n ukter leqqayen, rzu ar <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">Sebded AbiWord</a>.\",\n\t\"pad.modals.connected\": \"Iqqen.\",\n\t\"pad.modals.reconnecting\": \"Tulsa n tuqqna ar upad-ik.\",\n\t\"pad.modals.forcereconnect\": \"Ḥettem tulsa n tuqqna\",\n\t\"pad.modals.reconnecttimer\": \"Ɛreḍ tikelt-nniḍen tuqqna\",\n\t\"pad.modals.cancel\": \"Semmet\",\n\t\"pad.modals.userdup\": \"Yeldi deg usfaylu-nniḍen\",\n\t\"pad.modals.userdup.explanation\": \"Apad-agi yettban yeldi deg isfuyla-nniḍen deg uselkim-agi.\",\n\t\"pad.modals.userdup.advice\": \"Ales tuqqna akken ad tesqedceḍ asfaylu-agi.\",\n\t\"pad.modals.unauth\": \"Ur uettwasireg ara\",\n\t\"pad.modals.unauth.explanation\": \"Tisirag-ik beddlent makken ad d-yettwaskan usebter. Ɛreḍ ad teqqneḍ.\",\n\t\"pad.modals.looping.explanation\": \"Nufa-d uguren n teywalt akked uqeddac n umtawi.\",\n\t\"pad.modals.looping.cause\": \"Ahat teqqneḍ s uɣrab n tmes neɣ apṛuksi ur yemṣadan ara\",\n\t\"pad.modals.initsocketfail\": \"Ulac aqeddac.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Ur izmir ara ad yeqqen ar uqeddac n umtawi.\",\n\t\"pad.modals.initsocketfail.cause\": \"Ahat d ugur i d-yekkan seg iminig-ik neɣ tuqqna ar Internet.\",\n\t\"pad.modals.slowcommit.explanation\": \"Aqeddac ur d-yettara ara awal.\",\n\t\"pad.modals.slowcommit.cause\": \"Ahat d ugur i d-yekkan seg tuqqna ar uẓeṭṭa.\",\n\t\"pad.modals.badChangeset.explanation\": \"Abeddel i tgiḍ yettwammel d ayen ur ilaqen ara deg uqeddac n umtawi.\",\n\t\"pad.modals.badChangeset.cause\": \"Ahat ayagi yekka-d si yir tawila n uqeddac neɣ kra n wayen ur nerǧi ara. Nermes anebdal n umeẓlu, ma yella tḥulfaḍ d tuccḍa. Ɛreḍ tuqqna akken ad tkemmleḍ taẓrigt.\",\n\t\"pad.modals.corruptPad.explanation\": \"Apad i tettaɣraḍeḍ ad tkecmeḍ yexseṛ.\",\n\t\"pad.modals.corruptPad.cause\": \"Ayagi ahat yekka-d seg yir tawila n uqeddac neɣ ayen ur yettwaṛǧan ara. Nermes anebdal n umeẓlu.\",\n\t\"pad.modals.deleted\": \"Yettwakkes.\",\n\t\"pad.modals.deleted.explanation\": \"Apad-agi yettwakkes.\",\n\t\"pad.modals.disconnected\": \"Suffren-k.\",\n\t\"pad.modals.disconnected.explanation\": \"Tuqqna ar uqeddac truḥ\",\n\t\"pad.modals.disconnected.cause\": \"Ahat aqeddac ulac-it. Nermes anebdal n umeẓlu ma yella yezga iḍeṛṛu\",\n\t\"pad.share\": \"Bḍu apad-agi\",\n\t\"pad.share.readonly\": \"Taɣuri kan\",\n\t\"pad.share.link\": \"Aseɣwen\",\n\t\"pad.share.emebdcode\": \"Seddu URL\",\n\t\"pad.chat\": \"Asqerdec\",\n\t\"pad.chat.title\": \"Ldi asqerdec deg upad-agi.\",\n\t\"pad.chat.loadmessages\": \"Sali-d ugar n yiznan\",\n\t\"pad.chat.stick.title\": \"Senṭeḍ adiwenni deg ugdil\",\n\t\"pad.chat.writeMessage.placeholder\": \"Aru izen dagi\",\n\t\"timeslider.pageTitle\": \"Amazray asmussan n {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Uqal ar upad\",\n\t\"timeslider.toolbar.authors\": \"Imeskaren:\",\n\t\"timeslider.toolbar.authorsList\": \"Ulac imeskaren\",\n\t\"timeslider.toolbar.exportlink.title\": \"Sifeḍ\",\n\t\"timeslider.exportCurrent\": \"Sifeḍ lqem-agi amiran am:\",\n\t\"timeslider.version\": \"Lqem {{version}}\",\n\t\"timeslider.saved\": \"Yettwasekles deg {{day}}-{{month}}-{{year}}\",\n\t\"timeslider.playPause\": \"Taɣuri/Aserǧu n igburen n upad\",\n\t\"timeslider.backRevision\": \"Uɣal s yiwen n uceggir ar deffir deg upad-agi\",\n\t\"timeslider.forwardRevision\": \"Ddu ar zdat s yiwen n uceggir deg upad-agi\",\n\t\"timeslider.dateformat\": \"{{day}}-{{month}}-{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Yennayer\",\n\t\"timeslider.month.february\": \"Fuṛaṛ\",\n\t\"timeslider.month.march\": \"Meɣres\",\n\t\"timeslider.month.april\": \"Yebrir\",\n\t\"timeslider.month.may\": \"Mayyu\",\n\t\"timeslider.month.june\": \"Yunyu\",\n\t\"timeslider.month.july\": \"Yulyu\",\n\t\"timeslider.month.august\": \"Ɣuct\",\n\t\"timeslider.month.september\": \"Ctamber\",\n\t\"timeslider.month.october\": \"Tuber\",\n\t\"timeslider.month.november\": \"Wamber\",\n\t\"timeslider.month.december\": \"Dujamber\",\n\t\"timeslider.unnamedauthors\": \"{{num}}{[plural(num) one: ameskar udrig, other: imeskaren udrigen]}\",\n\t\"pad.savedrevs.marked\": \"Aceggir-agi yettwacreḍ tura d aceggir yettwaskelsen\",\n\t\"pad.savedrevs.timeslider\": \"Tzemreḍ ad waliḍ iceggiren yettwaskelsen ticki teldiḍ amazray\",\n\t\"pad.userlist.entername\": \"Sekcem isem-ik\",\n\t\"pad.userlist.unnamed\": \"udrig\",\n\t\"pad.editbar.clearcolors\": \"Sfeḍ akk initen icudden ar imeskaren deg isemliyen meṛṛa?\",\n\t\"pad.impexp.importbutton\": \"Kter tura\",\n\t\"pad.impexp.importing\": \"Aktar iteddu...\",\n\t\"pad.impexp.confirmimport\": \"Akter n ufaylu ad yesfeɛj aḍris amiran deg upad. Tebɣiḍ ad tkemleḍ?\",\n\t\"pad.impexp.convertFailed\": \"Ur nezmir ara ad d-nekter afaylu-agi. Ma ulac aɣilif seqdec amasal n isemli-nniḍen neɣ nɣel/senteḍ s ufus.\",\n\t\"pad.impexp.padHasData\": \"Ur nezmir ara ad d-nekter afaylu-agi acku apad-agi ibeddel yakan, ma ulac aɣilif, kter ar upad amaynut\",\n\t\"pad.impexp.uploadFailed\": \"Asali yecceḍ, ma ulac aɣilif ɛreḍ tikelt-nniḍen\",\n\t\"pad.impexp.importfailed\": \"Akter ur yeddi ara\",\n\t\"pad.impexp.copypaste\": \"Ma ulac aɣilif nɣel/senteḍ\",\n\t\"pad.impexp.exportdisabled\": \"Aɣewwaṛ n usifeḍ s umasal{{type}} yensa. Nermes anebdal-ik n unagraw i ugar n telqayt.\"\n}\n"
  },
  {
    "path": "src/locales/km.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Pichnat Thong\",\n\t\t\t\"Sovichet\",\n\t\t\t\"វ័ណថារិទ្ធ\"\n\t\t]\n\t},\n\t\"index.newPad\": \"ផេតថ្មី\",\n\t\"index.createOpenPad\": \"ឬបង្កើត/បើកផេតដែលមានឈ្មោះ៖\",\n\t\"pad.toolbar.bold.title\": \"ដិត (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"ទ្រេត (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"គូសបន្ទាត់ (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"ឆូតចោល\",\n\t\"pad.toolbar.ol.title\": \"បញ្ជីតាមតម្រៀប\",\n\t\"pad.toolbar.ul.title\": \"បញ្ជីមិនតាមតម្រៀប\",\n\t\"pad.toolbar.indent.title\": \"ខិតចូលក្នុង (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"ខិតចេញក្រៅ (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"អាន់ឌូ (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"រីឌូ (Ctrl-Y)\",\n\t\"pad.toolbar.import_export.title\": \"នាំចូល/នាំចេញ ពី/ទៅប្រភេទឯកសារផ្សេងទៀត\",\n\t\"pad.toolbar.savedRevision.title\": \"រក្សាទុកកំណែ\",\n\t\"pad.toolbar.settings.title\": \"ការកំណត់​\",\n\t\"pad.toolbar.embed.title\": \"ចែក​រំលែក​និង​បង្កប់​ផេត​នេះ\",\n\t\"pad.toolbar.showusers.title\": \"បង្ហាញ​អ្នក​ប្រើ​លើ​ផេត​នេះ\",\n\t\"pad.colorpicker.save\": \"រក្សាទុក\",\n\t\"pad.colorpicker.cancel\": \"បោះបង់\",\n\t\"pad.loading\": \"កំពុងផ្ទុក…\",\n\t\"pad.permissionDenied\": \"អ្នក​មិន​មាន​សិទ្ធិ​ចូល​ផេត​នេះ​ទេ\",\n\t\"pad.settings.padSettings\": \"ការ​កំណត់​ផេត\",\n\t\"pad.settings.myView\": \"គំហើញរបស់ខ្ញុំ\",\n\t\"pad.settings.stickychat\": \"តែង​បង្ហាញ​ការ​ជជែក​លើ​អេក្រង់\",\n\t\"pad.settings.linenocheck\": \"លេខ​បន្ទាត់\",\n\t\"pad.settings.rtlcheck\": \"អាន​ពី​ស្ដាំ​ទៅ​ឆ្វេង?\",\n\t\"pad.settings.fontType\": \"ប្រភេទពុម្ពអក្សរ៖\",\n\t\"pad.settings.fontType.normal\": \"ធម្មតា\",\n\t\"pad.settings.language\": \"ភាសា៖\",\n\t\"pad.settings.about\": \"អំពី\",\n\t\"pad.settings.poweredBy\": \"ពលុបត្ថម្ភដោយ\",\n\t\"pad.importExport.import_export\": \"នាំចូល/នាំចេញ\",\n\t\"pad.importExport.import\": \"ផ្ទុក​ឡើង​ឯកសារ​អត្ថបទ​ណាមួយ\",\n\t\"pad.importExport.importSuccessful\": \"ដោយជោគជ័យ!\",\n\t\"pad.importExport.export\": \"នាំ​ចេញ​ផេត​បច្ចុប្បន្ន​ជា៖\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Plain text\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.modals.connected\": \"បាន​តភ្ជាប់​។\",\n\t\"pad.modals.reconnecting\": \"កំពុង​ភ្ជាប់​ទៅ​ផេត​របស់​អ្នក​ម្ដង​ទៀត..\",\n\t\"pad.modals.forcereconnect\": \"បង្ខំ​ឲ្យ​ភ្ជាប់​ឡើង​វិញ\",\n\t\"pad.modals.userdup\": \"បាន​បើក​ក្នុង​វីនដូ​មួយ​ទៀត\",\n\t\"pad.modals.unauth.explanation\": \"សិទ្ធិ​របស់​អ្នក​ត្រូវ​បាន​ប្ដូរ ខណៈ​ពេល​កំពុង​មើល​ទំព័រ​នេះ។ សូម​ព្យាយាម​ភ្ជាប់​ឡើង​វិញ។\",\n\t\"pad.modals.looping.cause\": \"ប្រហែល​ជា​អ្នក​បាន​ភ្ជាប់​តាម firewall ឬ ប្រុកស៊ី ដែល​មិន​ត្រូវ​គ្នា។\",\n\t\"pad.modals.initsocketfail\": \"មិន​អាច​ទៅ​ដល់​ម៉ាស៊ីន​បម្រើ។\",\n\t\"pad.modals.initsocketfail.cause\": \"នេះ​អាច​ជា​បញ្ហា​ជាមួយ​កម្មវិធី​អ៊ីនធឺណិត ឬ​ការ​តភ្ជាប់​អ៊ីនធឺណិត​របស់​អ្នក។\",\n\t\"pad.modals.slowcommit.explanation\": \"មិន​មាន​ចម្លើយ​តប​ពី​ម៉ាស៊ីន​បម្រើ​ទេ។\",\n\t\"pad.modals.deleted\": \"បាន​លុប។\",\n\t\"pad.modals.deleted.explanation\": \"បាន​លុប​ផេត​នេះ​ចេញ។\",\n\t\"pad.modals.disconnected.explanation\": \"បាន​បាត់​ការ​តភ្ជាប់​ទៅ​ម៉ាស៊ីន​បម្រើ\",\n\t\"pad.share\": \"ចែក​រំលែក​ផេត​នេះ\",\n\t\"pad.share.readonly\": \"អាន​តែ​ប៉ុណ្ណោះ\",\n\t\"pad.share.link\": \"តំណ​ភ្ជាប់\",\n\t\"pad.share.emebdcode\": \"URL បង្កប់\",\n\t\"pad.chat\": \"ជជែក\",\n\t\"pad.chat.title\": \"បើក​ការ​ជជែក​សម្រាប់​ផេត​នេះ។\",\n\t\"pad.chat.loadmessages\": \"ផ្ទុក​សារ​ថែម​ទៀត\",\n\t\"timeslider.toolbar.returnbutton\": \"ត្រឡប់​ទៅ​ផេត\",\n\t\"timeslider.toolbar.authors\": \"អ្នក​បង្កើត៖\",\n\t\"timeslider.toolbar.authorsList\": \"គ្មាន​អ្នក​បង្កើត\",\n\t\"timeslider.toolbar.exportlink.title\": \"នាំចេញ\",\n\t\"timeslider.exportCurrent\": \"នាំ​ចេញ​កំណែ​បច្ចុប្បន្ន​ជា៖\",\n\t\"timeslider.version\": \"កំណែ {{version}}\",\n\t\"timeslider.saved\": \"បាន​រក្សា​ទុក {{month}} {{day}}, {{year}}\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"មករា\",\n\t\"timeslider.month.february\": \"កុម្ភៈ\",\n\t\"timeslider.month.march\": \"មិនា\",\n\t\"timeslider.month.april\": \"មេសា\",\n\t\"timeslider.month.may\": \"ឧសភា\",\n\t\"timeslider.month.june\": \"មិថុនា​\",\n\t\"timeslider.month.july\": \"កក្ដដា​\",\n\t\"timeslider.month.august\": \"សីហា\",\n\t\"timeslider.month.september\": \"កញ្ញា\",\n\t\"timeslider.month.october\": \"តុលា\",\n\t\"timeslider.month.november\": \"វិច្ឆិកា\",\n\t\"timeslider.month.december\": \"ធ្នូ\",\n\t\"pad.userlist.entername\": \"បញ្ចូល​ឈ្មោះ​របស់​អ្នក\",\n\t\"pad.userlist.unnamed\": \"គ្មាន​ឈ្មោះ\",\n\t\"pad.impexp.importbutton\": \"នាំចូលឥឡូវនេះ\",\n\t\"pad.impexp.importing\": \"កំពុងនាំចូល​...\",\n\t\"pad.impexp.importfailed\": \"នាំចូល​មិន​បាន​សម្រេច\",\n\t\"pad.impexp.copypaste\": \"សូម​ចម្លង​ហើយ​បិទ​ភ្ជាប់\",\n\t\"pad.impexp.exportdisabled\": \"ការ​នាំចេញ​ជា {{type}} ត្រូវ​បាន​បិទ។ សូម​ទាក់ទង​អ្នក​គ្រប់​គ្រង​ប្រព័ន្ធ សម្រាប់​ព័ត៌មាន​បន្ថែម។\"\n}\n"
  },
  {
    "path": "src/locales/kn.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Nayvik\",\n\t\t\t\"ಮಲ್ನಾಡಾಚ್ ಕೊಂಕ್ಣೊ\"\n\t\t]\n\t},\n\t\"admin_plugins.available\": \"ಲಭ್ಯವಿರುವ ಪ್ಲಗಿನ್‌ಗಳು\",\n\t\"admin_plugins.available_not-found\": \"ಯಾವುದೇ ಪ್ಲಗಿನ್‌ಗಳು ಸಿಗಲಿಲ್ಲ\",\n\t\"admin_plugins.available_fetching\": \"ಪಡೆಯಲಾಗುತ್ತಿದೆ...\",\n\t\"admin_plugins.available_install.value\": \"ಅಳವಡಿಸು\",\n\t\"admin_plugins.available_search.placeholder\": \"ಅಳವಡಿಸಲು ಪ್ಲಗಿನ್‌ಗಳನ್ನು ಹುಡುಕಿ\",\n\t\"admin_plugins.description\": \"ವಿವರ\",\n\t\"admin_plugins.installed\": \"ಅಳವಡಿಸಿದ ಪ್ಲಗಿನ್‌ಗಳು\",\n\t\"admin_plugins.installed_fetching\": \"ಅಳವಡಿಸಿದ ಪ್ಲಗಿನ್‌ಗಳನ್ನು ಪಡೆಯಲಾಗುತ್ತಿದೆ...\",\n\t\"admin_plugins.installed_nothing\": \"ನೀವು ಇನ್ನೂ ಯಾವುದೇ ಪ್ಲಗಿನ್‌ಗಳನ್ನು ಅಳವಡಿಸಿಲ್ಲ.\",\n\t\"admin_plugins.name\": \"ಹೆಸರು\",\n\t\"admin_plugins.version\": \"ಆವೃತ್ತಿ\",\n\t\"admin_plugins_info.plugins\": \"ಅಳವಡಿಸಿದ ಪ್ಲಗಿನ್‌ಗಳು\",\n\t\"admin_plugins_info.page-title\": \"ಪ್ಲಗಿನ್ ಮಾಹಿತಿ - ಈಥರ್‌ಪ್ಯಾಡ್\",\n\t\"admin_plugins_info.version\": \"ಈಥರ್‌ಪ್ಯಾಡ್ ಆವೃತ್ತಿ\",\n\t\"admin_plugins_info.version_number\": \"ಆವೃತ್ತಿ ಸಂಖ್ಯೆ\",\n\t\"admin_settings\": \"ವ್ಯವಸ್ಥೆಗಳು\",\n\t\"admin_settings.page-title\": \"ವ್ಯವಸ್ಥೆಗಳು - ಈಥರ್‌ಪ್ಯಾಡ್\",\n\t\"index.newPad\": \"ಹೊಸ ಪ್ಯಾಡ್\",\n\t\"index.createOpenPad\": \"ಅಥವಾ ಈ ಹೆಸರಿನ ಪ್ಯಾಡನ್ನು ಸೃಷ್ಟಿಸು/ತೆರೆ:\",\n\t\"pad.toolbar.bold.title\": \"ದಟ್ಟ (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"ಓರೆ (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"ಕೆಳಗೆರೆ (Ctrl-U)\",\n\t\"pad.toolbar.settings.title\": \"ವ್ಯವಸ್ಥೆಗಳು\",\n\t\"pad.colorpicker.save\": \"ಉಳಿಸಿ\",\n\t\"pad.colorpicker.cancel\": \"ರದ್ದು ಮಾಡು\",\n\t\"pad.loading\": \"ತುಂಬಿಸಲಾಗುತ್ತಿದೆ....\",\n\t\"pad.settings.myView\": \"ನನ್ನ ನೋಟ\",\n\t\"pad.settings.linenocheck\": \"ಗೆರೆ ಸಂಖ್ಯೆಗಳು\",\n\t\"pad.settings.language\": \"ಭಾಷೆ:\",\n\t\"pad.settings.about\": \"ಕುರಿತು\",\n\t\"pad.importExport.import_export\": \"ಆಮದು/ರಫ್ತು\",\n\t\"pad.importExport.importSuccessful\": \"ಯಶಸ್ವಿ!\",\n\t\"pad.importExport.exportetherpad\": \"ಈಥರ್‌ಪ್ಯಾಡ್\",\n\t\"pad.importExport.exporthtml\": \"ಎಚ್‍ಟಿಎಂಎಲ್\",\n\t\"pad.importExport.exportplain\": \"ಸಾದಾ ಪಠ್ಯ\",\n\t\"pad.importExport.exportword\": \"ಮೈಕ್ರೋಸಾಫ್ಟ್ ವರ್ಡ್\",\n\t\"pad.importExport.exportpdf\": \"ಪಿಡಿಎಫ಼್\",\n\t\"pad.importExport.exportopen\": \"ಓಡಿಫ಼್ (ಓಪನ್ ಡಾಕ್ಯುಮೆಂಟ್ ಫ಼ಾರ್ಮ್ಯಾಟ್)\",\n\t\"pad.modals.cancel\": \"ರದ್ದು ಮಾಡು\",\n\t\"pad.share.link\": \"ಕೊಂಡಿ\",\n\t\"timeslider.toolbar.authors\": \"ಕರ್ತೃಗಳು:\",\n\t\"timeslider.toolbar.exportlink.title\": \"ರಫ್ತು ಮಾಡು\",\n\t\"timeslider.version\": \"ಆವೃತ್ತಿ {{version}}\",\n\t\"timeslider.saved\": \"ಉಳಿಸಲಾಗಿದೆ {{month}} {{day}}, {{year}}\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"ಜನವರಿ\",\n\t\"timeslider.month.february\": \"ಫೆಬ್ರವರಿ\",\n\t\"timeslider.month.march\": \"ಮಾರ್ಚ್\",\n\t\"timeslider.month.april\": \"ಏಪ್ರಿಲ್\",\n\t\"timeslider.month.may\": \"ಮೇ\",\n\t\"timeslider.month.june\": \"ಜೂನ್\",\n\t\"timeslider.month.july\": \"ಜುಲೈ\",\n\t\"timeslider.month.august\": \"ಆಗಸ್ಟ್\",\n\t\"timeslider.month.september\": \"ಸೆಪ್ಟೆಂಬರ್\",\n\t\"timeslider.month.october\": \"ಅಕ್ಟೋಬರ್\",\n\t\"timeslider.month.november\": \"ನವೆಂಬರ್\",\n\t\"timeslider.month.december\": \"ಡಿಸೆಂಬರ್\"\n}\n"
  },
  {
    "path": "src/locales/ko.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"CYAN\",\n\t\t\t\"Codenstory\",\n\t\t\t\"Ellif\",\n\t\t\t\"Hym411\",\n\t\t\t\"Kurousagi\",\n\t\t\t\"Revi\",\n\t\t\t\"SeoJeongHo\",\n\t\t\t\"Suleiman the Magnificent Television\",\n\t\t\t\"YeBoy371\",\n\t\t\t\"Ykhwong\",\n\t\t\t\"그냥기여자\",\n\t\t\t\"아라\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"관리 대시보드 - 이더패드\",\n\t\"admin_plugins\": \"플러그인 관리자\",\n\t\"admin_plugins.available\": \"사용 가능한 플러그인\",\n\t\"admin_plugins.available_not-found\": \"플러그인이 없습니다.\",\n\t\"admin_plugins.available_fetching\": \"검색 중...\",\n\t\"admin_plugins.available_install.value\": \"설치\",\n\t\"admin_plugins.available_search.placeholder\": \"설치할 플러그인을 검색\",\n\t\"admin_plugins.description\": \"설명\",\n\t\"admin_plugins.installed\": \"설치된 플러그인\",\n\t\"admin_plugins.installed_fetching\": \"설치된 플러그인을 검색하는 중...\",\n\t\"admin_plugins.installed_nothing\": \"아직 플러인을 설치하지 않으셨습니다.\",\n\t\"admin_plugins.installed_uninstall.value\": \"제거\",\n\t\"admin_plugins.last-update\": \"마지막 업데이트\",\n\t\"admin_plugins.name\": \"이름\",\n\t\"admin_plugins.page-title\": \"플러그인 관리자 - 이더패드\",\n\t\"admin_plugins.version\": \"버전\",\n\t\"admin_plugins_info\": \"문제 해결 정보\",\n\t\"admin_plugins_info.hooks\": \"설치된 훅\",\n\t\"admin_plugins_info.hooks_client\": \"클라이언트 사이드 훅\",\n\t\"admin_plugins_info.hooks_server\": \"서버사이드 훅\",\n\t\"admin_plugins_info.parts\": \"설치된 항목\",\n\t\"admin_plugins_info.plugins\": \"설치된 플러그인\",\n\t\"admin_plugins_info.page-title\": \"플러그인 정보 - 이더패드\",\n\t\"admin_plugins_info.version\": \"이더패드 버전\",\n\t\"admin_plugins_info.version_latest\": \"사용 가능한 최신 버전\",\n\t\"admin_plugins_info.version_number\": \"버전 번호\",\n\t\"admin_settings\": \"설정\",\n\t\"admin_settings.current\": \"현재 구성\",\n\t\"admin_settings.current_example-devel\": \"예시 개발용 설정 틀\",\n\t\"admin_settings.current_example-prod\": \"예시 운영용 설정 틀\",\n\t\"admin_settings.current_restart.value\": \"이더패드 다시 시작\",\n\t\"admin_settings.current_save.value\": \"설정 저장\",\n\t\"admin_settings.page-title\": \"설정 - 이더패드\",\n\t\"index.newPad\": \"새 패드\",\n\t\"index.createOpenPad\": \"또는 다음 이름으로 패드 만들기/열기:\",\n\t\"index.openPad\": \"이름으로 기존 패드 열기:\",\n\t\"pad.toolbar.bold.title\": \"굵게 (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"기울임꼴 (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"밑줄 (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"취소선 (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"순서 있는 목록 (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"순서 없는 목록 (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"들여쓰기 (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"내어쓰기 (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"실행 취소 (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"다시 실행 (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"작성자 표시 색상 지우기 (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"다른 파일 형식으로 가져오기/내보내기\",\n\t\"pad.toolbar.timeslider.title\": \"시간슬라이더\",\n\t\"pad.toolbar.savedRevision.title\": \"판 저장\",\n\t\"pad.toolbar.settings.title\": \"설정\",\n\t\"pad.toolbar.embed.title\": \"이 패드를 공유하고 포함하기\",\n\t\"pad.toolbar.home.title\": \"홈으로 돌아가기\",\n\t\"pad.toolbar.showusers.title\": \"이 패드의 사용자 보기\",\n\t\"pad.colorpicker.save\": \"저장\",\n\t\"pad.colorpicker.cancel\": \"취소\",\n\t\"pad.loading\": \"불러오는 중...\",\n\t\"pad.noCookie\": \"쿠키를 찾지 못했습니다. 브라우저에서 쿠키를 허용해 주십시오! 세션과 설정은 방문 간 저장되지 않습니다. 일부 브라우저의 iFrame에 이더패드가 포함된 것이 그 이유일 수 있습니다. 이더패드가 상위 iFrame과 동일한 서브도메인/도메인에 위치하는지 확인해 주십시오\",\n\t\"pad.permissionDenied\": \"이 패드에 접근할 권한이 없습니다\",\n\t\"pad.settings.padSettings\": \"패드 설정\",\n\t\"pad.settings.myView\": \"내 보기\",\n\t\"pad.settings.stickychat\": \"화면에 항상 대화 보기\",\n\t\"pad.settings.chatandusers\": \"대화와 사용자 보기\",\n\t\"pad.settings.colorcheck\": \"작성자 표시 색상\",\n\t\"pad.settings.linenocheck\": \"줄 번호\",\n\t\"pad.settings.rtlcheck\": \"우횡서(오른쪽에서 왼쪽으로)입니까?\",\n\t\"pad.settings.fontType\": \"글꼴 종류:\",\n\t\"pad.settings.fontType.normal\": \"보통\",\n\t\"pad.settings.language\": \"언어:\",\n\t\"pad.settings.deletePad\": \"패드 삭제\",\n\t\"pad.delete.confirm\": \"정말로 이 패드를 삭제하겠습니까?\",\n\t\"pad.settings.about\": \"정보\",\n\t\"pad.settings.poweredBy\": \"제공:\",\n\t\"pad.importExport.import_export\": \"가져오기/내보내기\",\n\t\"pad.importExport.import\": \"텍스트 파일이나 문서 올리기\",\n\t\"pad.importExport.importSuccessful\": \"성공!\",\n\t\"pad.importExport.export\": \"다음으로 현재 패드 내보내기:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"일반 텍스트\",\n\t\"pad.importExport.exportword\": \"Microsoft 워드\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format, 개방형 문서 형식)\",\n\t\"pad.importExport.abiword.innerHTML\": \"일반 텍스트나 HTML 형식으로만 가져올 수 있습니다. 고급 가져오기 기능에 대해서는 <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">AbiWord를 설치</a>하세요.\",\n\t\"pad.modals.connected\": \"연결함.\",\n\t\"pad.modals.reconnecting\": \"내 패드에 다시 연결하는 중...\",\n\t\"pad.modals.forcereconnect\": \"강제로 다시 연결\",\n\t\"pad.modals.reconnecttimer\": \"다시 접속 시도 중\",\n\t\"pad.modals.cancel\": \"취소\",\n\t\"pad.modals.userdup\": \"다른 창에서 열렸습니다\",\n\t\"pad.modals.userdup.explanation\": \"이 패드는 이 컴퓨터에 하나 이상의 브라우저 창에서 열린 것 같습니다.\",\n\t\"pad.modals.userdup.advice\": \"대신 이 창을 사용해 다시 연결합니다.\",\n\t\"pad.modals.unauth\": \"권한이 없음\",\n\t\"pad.modals.unauth.explanation\": \"이 문서를 보는 동안 권한이 바뀌었습니다. 연결을 다시 시도하세요.\",\n\t\"pad.modals.looping.explanation\": \"동기화 서버와 통신 문제가 있습니다.\",\n\t\"pad.modals.looping.cause\": \"아마 호환되지 않는 방화벽이나 프록시를 통해 연결되어 있습니다.\",\n\t\"pad.modals.initsocketfail\": \"서버에 연결할 수 없습니다.\",\n\t\"pad.modals.initsocketfail.explanation\": \"동기 서버에 연결할 수 없습니다.\",\n\t\"pad.modals.initsocketfail.cause\": \"아마도 브라우저나 인터넷 연결에 문제가 있기 때문일 수 있습니다.\",\n\t\"pad.modals.slowcommit.explanation\": \"서버가 응답하지 않습니다.\",\n\t\"pad.modals.slowcommit.cause\": \"네트워크 연결에 문제가 있기 때문일 수 있습니다.\",\n\t\"pad.modals.badChangeset.explanation\": \"당신의 편집은 동기화 서버에 의해 불법적인 것으로 분류되었습니다.\",\n\t\"pad.modals.badChangeset.cause\": \"잘못된 서버 구성이나 예기치 못한 행동 때문에 발생했을 수 있습니다. 서버 관리자와 연락하시기 바랍니다. 만약 이 메시지가 오류라고 생각된다면, 편집을 다시 시도해 보세요.\",\n\t\"pad.modals.corruptPad.explanation\": \"당신이 시도하려는 패드는 손상되었습니다.\",\n\t\"pad.modals.corruptPad.cause\": \"잘못된 서버 구성 또는 다른 예기치 않은 오류 때문에 발생했을 수 있습니다. 서버 관리자와 연락하세요.\",\n\t\"pad.modals.deleted\": \"삭제되었습니다.\",\n\t\"pad.modals.deleted.explanation\": \"이 패드를 제거했습니다.\",\n\t\"pad.modals.rateLimited\": \"속도 제한됨.\",\n\t\"pad.modals.rateLimited.explanation\": \"이 패드에 너무 많은 메시지를 송신하였으므로 연결을 해제했습니다.\",\n\t\"pad.modals.rejected.explanation\": \"브라우저가 보낸 메시지를 서버가 거부했습니다.\",\n\t\"pad.modals.rejected.cause\": \"패드를 보는 동안 서버가 업데이트되었거나 이더패드의 버그일 수 있습니다. 페이지를 다시 로드해 보십시오.\",\n\t\"pad.modals.disconnected\": \"연결이 끊어졌습니다.\",\n\t\"pad.modals.disconnected.explanation\": \"서버에서 연결을 잃었습니다\",\n\t\"pad.modals.disconnected.cause\": \"서버를 사용할 수 없습니다. 이 문제가 계속 발생하면 서비스 관리자에게 알려주시기 바랍니다.\",\n\t\"pad.share\": \"이 패드 공유하기\",\n\t\"pad.share.readonly\": \"읽기 전용\",\n\t\"pad.share.link\": \"링크\",\n\t\"pad.share.emebdcode\": \"URL 포함\",\n\t\"pad.chat\": \"대화\",\n\t\"pad.chat.title\": \"이 패드에 대화를 엽니다.\",\n\t\"pad.chat.loadmessages\": \"더 많은 메시지 불러오기\",\n\t\"pad.chat.stick.title\": \"채팅을 화면에 고정\",\n\t\"pad.chat.writeMessage.placeholder\": \"여기에 메시지를 적으십시오\",\n\t\"timeslider.followContents\": \"패드 콘텐츠의 갱신 주시하기\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} 시간슬라이더\",\n\t\"timeslider.toolbar.returnbutton\": \"패드로 돌아가기\",\n\t\"timeslider.toolbar.authors\": \"작성자:\",\n\t\"timeslider.toolbar.authorsList\": \"저자 없음\",\n\t\"timeslider.toolbar.exportlink.title\": \"내보내기\",\n\t\"timeslider.exportCurrent\": \"현재 버전으로 내보내기:\",\n\t\"timeslider.version\": \"버전 {{version}}\",\n\t\"timeslider.saved\": \"{{year}}년 {{month}} {{day}}일에 저장함\",\n\t\"timeslider.playPause\": \"시작 / 패드 내용을 일시 중지\",\n\t\"timeslider.backRevision\": \"패드의 수정판으로 다시 가기\",\n\t\"timeslider.forwardRevision\": \"패드의 수정판을 앞으로 이동 시키기\",\n\t\"timeslider.dateformat\": \"{{year}}년/{{month}}/{{day}}일 {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"1월\",\n\t\"timeslider.month.february\": \"2월\",\n\t\"timeslider.month.march\": \"3월\",\n\t\"timeslider.month.april\": \"4월\",\n\t\"timeslider.month.may\": \"5월\",\n\t\"timeslider.month.june\": \"6월\",\n\t\"timeslider.month.july\": \"7월\",\n\t\"timeslider.month.august\": \"8월\",\n\t\"timeslider.month.september\": \"9월\",\n\t\"timeslider.month.october\": \"10월\",\n\t\"timeslider.month.november\": \"11월\",\n\t\"timeslider.month.december\": \"12월\",\n\t\"timeslider.unnamedauthors\": \"이름 없는 {[plural(num) one: 작성자, other: 작성자]} {{num}}명\",\n\t\"pad.savedrevs.marked\": \"이 판은 이제 저장한 판으로 표시합니다.\",\n\t\"pad.savedrevs.timeslider\": \"당신은 타임슬라이더를 통해 저장된 버전을 볼 수 있습니다\",\n\t\"pad.userlist.entername\": \"이름을 입력하세요\",\n\t\"pad.userlist.unnamed\": \"이름없음\",\n\t\"pad.editbar.clearcolors\": \"전체 문서의 작성자 표시 색상을 지우시겠습니까? 이 작업은 취소할 수 없습니다\",\n\t\"pad.impexp.importbutton\": \"지금 가져오기\",\n\t\"pad.impexp.importing\": \"가져오는 중...\",\n\t\"pad.impexp.confirmimport\": \"파일을 가져오면 패드의 현재 텍스트를 덮어쓰게 됩니다. 진행하시겠습니까?\",\n\t\"pad.impexp.convertFailed\": \"이 파일을 가져올 수 없습니다. 다른 문서 형식을 사용하거나 수동으로 복사하여 붙여넣으세요\",\n\t\"pad.impexp.padHasData\": \"이 파일을 가져올 수 없습니다. 이 패드는 이미 수정되었으니, 새 패드를 가져와 주십시오\",\n\t\"pad.impexp.uploadFailed\": \"올리기를 실패했습니다. 다시 시도하세요\",\n\t\"pad.impexp.importfailed\": \"가져오기를 실패했습니다\",\n\t\"pad.impexp.copypaste\": \"복사하여 붙여넣으세요\",\n\t\"pad.impexp.exportdisabled\": \"{{type}} 형식으로 내보내기가 비활성화되어 있습니다. 자세한 내용은 시스템 관리자에게 문의하시기 바랍니다.\",\n\t\"pad.impexp.maxFileSize\": \"파일의 용량이 너무 큽니다. 가져올 파일의 허용 크기를 늘리려면 사이트 관리자에게 문의하십시오\"\n}\n"
  },
  {
    "path": "src/locales/krc.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Ernác\",\n\t\t\t\"Къарачайлы\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Администраторну панели — Etherpad\",\n\t\"admin_plugins\": \"Плагин менеджер\",\n\t\"admin_plugins.available\": \"Киришли плагинле\",\n\t\"admin_plugins.available_not-found\": \"Плагинле табылмадыла.\",\n\t\"admin_plugins.available_fetching\": \"Келтириле турады...\",\n\t\"admin_plugins.available_install.value\": \"Къур\",\n\t\"admin_plugins.available_search.placeholder\": \"Къурур ючюн плагинлени изле\",\n\t\"admin_plugins.description\": \"Ачыкълау\",\n\t\"admin_plugins.installed\": \"Къурулгъан плагинле\",\n\t\"admin_plugins.installed_fetching\": \"Къурулгъан плагинле алына турадыла...\",\n\t\"admin_plugins.installed_nothing\": \"Алкъын бир плагин да къурмагъансыз.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Къорат\",\n\t\"admin_plugins.last-update\": \"Ахыр джангыртыу\",\n\t\"admin_plugins.name\": \"Ат\",\n\t\"admin_plugins.page-title\": \"Плагин менеджер - Etherpad\",\n\t\"admin_plugins.version\": \"Версия\",\n\t\"admin_plugins_info\": \"Бузукъланы кетериулени юсюнден информация\",\n\t\"admin_plugins_info.hooks\": \"Къурулгъан ыргъакъла\",\n\t\"admin_plugins_info.hooks_client\": \"Клиентни джанындагъы ыргъакъла\",\n\t\"admin_plugins_info.hooks_server\": \"Сервер джанындагъы ыргъакъла\",\n\t\"admin_plugins_info.parts\": \"Къурулгъан юлюшле\",\n\t\"admin_plugins_info.plugins\": \"Къурулгъан плагинле\",\n\t\"admin_plugins_info.page-title\": \"Плагин информация — Etherpad\",\n\t\"admin_plugins_info.version\": \"Etherpad версия\",\n\t\"admin_plugins_info.version_latest\": \"Ахыр киришли версия\",\n\t\"admin_plugins_info.version_number\": \"Версияны номери\",\n\t\"admin_settings\": \"Джарашдырыўла\",\n\t\"admin_settings.current\": \"Баргъан конфигурация\",\n\t\"admin_settings.current_example-devel\": \"Юлгю хазырлау джарашдырыуланы шаблону\",\n\t\"admin_settings.current_example-prod\": \"Юлгю чыгъарыу джарашдырыуланы шаблону\",\n\t\"admin_settings.current_restart.value\": \"Etherpad-ны джангыдан башлат\",\n\t\"admin_settings.current_save.value\": \"Джарашдырыуланы Сакъландыр\",\n\t\"admin_settings.page-title\": \"Джарашдырыула — Etherpad\",\n\t\"index.newPad\": \"Джангы Блокнот\",\n\t\"index.createOpenPad\": \"неда бу ат бла Блокнот болдур/ач:\",\n\t\"index.openPad\": \"бу ат бла бар болгъан Блокнотну ачыгъыз:\",\n\t\"pad.toolbar.bold.title\": \"Къалын (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Курсив (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Тюбю чертилген (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Юсю сызылгъан (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Кёзюулю тизме (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Кёзюуге этилмеген тизме (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Абзац (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Чыгъыш (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Кери ал (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Къайтар (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Авторлукъну боялурын тазала (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Файлланы башха форматларын (а/дан) импорт/экспорт\",\n\t\"pad.toolbar.timeslider.title\": \"Заман шкала\",\n\t\"pad.toolbar.savedRevision.title\": \"Версияны сакъла\",\n\t\"pad.toolbar.settings.title\": \"Джарашдырыўла\",\n\t\"pad.toolbar.embed.title\": \"Бу блокнотну Джай эмда Ичине сал\",\n\t\"pad.toolbar.showusers.title\": \"Хайырланыучуланы бу блокнотда кёргюзт\",\n\t\"pad.colorpicker.save\": \"Сакъландыр\",\n\t\"pad.colorpicker.cancel\": \"Ызына ал\",\n\t\"pad.loading\": \"Джюклениу...\",\n\t\"pad.noCookie\": \"Куки табылмадыла. Бразуеригизде кукилени бир джандырсагъыз! Сизни кириулеригизни арасында сессиягъыз эмда джарашдырыуларыгъыз сакъланныкъ тюлдюле. Буну чуруму, бир къауум браузерледе Etherpad iFrame ичинде болгъаны болургъа болур. Тилейбиз, Etherpad эмда аны башындагъы iFrame бир тюбдоменде/доменде болгъанындан ишексиз болугъуз.\",\n\t\"pad.permissionDenied\": \"Бу блокнотха кириш  эркинлигигиз джокъду\",\n\t\"pad.settings.padSettings\": \"Блокнотну джарашдырыулары\",\n\t\"pad.settings.myView\": \"Кёрюнюмюм\",\n\t\"pad.settings.stickychat\": \"Ушакъны хар заман да экранда кёргюзт\",\n\t\"pad.settings.chatandusers\": \"Ушакъ бла къошулуучуланы кёргюзт\",\n\t\"pad.settings.colorcheck\": \"Авторлукъ бояула\",\n\t\"pad.settings.linenocheck\": \"Сатырланы номерлери\",\n\t\"pad.settings.rtlcheck\": \"Ичиндеги онгдан солгъа окъулсунму?\",\n\t\"pad.settings.fontType\": \"Шрифтни типи:\",\n\t\"pad.settings.fontType.normal\": \"Нормал\",\n\t\"pad.settings.language\": \"Тил:\",\n\t\"pad.settings.about\": \"Юсюнден\",\n\t\"pad.settings.poweredBy\": \"Этген:\",\n\t\"pad.importExport.import_export\": \"Импорт/экспорт\",\n\t\"pad.importExport.import\": \"Къаллай болса да текст файл неда документ джюкле\",\n\t\"pad.importExport.importSuccessful\": \"Тыйыншлы!\",\n\t\"pad.importExport.export\": \"Баргъан блокнотну бу шекилде экспорт эт:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Тюз текст\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (OpenOffice'ни документи)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Сиз къуру тюз текстни неда HTML импорт этерге боллукъсуз. Импортну андан кенг функциялары ючюн <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">AbiWord не LibreOffice къуругъуз</a>.\",\n\t\"pad.modals.connected\": \"Байланды.\",\n\t\"pad.modals.reconnecting\": \"Блокнотугъузгъа джангыдан байлана турады...\",\n\t\"pad.modals.forcereconnect\": \"Джангыдан зор бла байланыу\",\n\t\"pad.modals.reconnecttimer\": \"Джангыдан байланыргъа кюрешеди\",\n\t\"pad.modals.cancel\": \"Ызына ал\",\n\t\"pad.modals.userdup\": \"Башха терезеде ачыкъды\",\n\t\"pad.modals.userdup.explanation\": \"Бу блокнот, бу компьютерде бирден аслам бразуре терезеде ачылгъаннга ушайды.\",\n\t\"pad.modals.userdup.advice\": \"Бу терезени хайырланыб джангыдан байлан\",\n\t\"pad.modals.unauth\": \"Авторизацияны ётмегенди\",\n\t\"pad.modals.unauth.explanation\": \"Бу бетни къарагъан заманда, эркинликлеригиз тюрленнгедиле. Джангыдан байланыб кёрюгюз.\",\n\t\"pad.modals.looping.explanation\": \"Синхронизация сервер бла байлам проблемала боладыла.\",\n\t\"pad.modals.looping.cause\": \"Келишмеген фаерволл неда прокси бла байланнган болурсуз.\",\n\t\"pad.modals.initsocketfail\": \"Серверге джетишилимейди.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Синхронизация серверге байланылалмады.\",\n\t\"pad.modals.initsocketfail.cause\": \"Бу проблема браузеригиз бла, неда интернет байламыгъыз бла чурумланады.\",\n\t\"pad.modals.slowcommit.explanation\": \"Сервер джууаб бермейди.\",\n\t\"pad.modals.slowcommit.cause\": \"Бу болум ау байлам бла болгъан проблемала ючюн чыгъаргъа боллукъду.\",\n\t\"pad.modals.badChangeset.explanation\": \"Этген тюзетиуюгюзню, синхронизация сервер джараусузча класслагъанды.\",\n\t\"pad.modals.badChangeset.cause\": \"Буну чуруму джангылыч сервер конфигурация неда башха сакъланмагъан этиу болургъа боллукъду. Тилейбиз, буну халатха санай эсегиз, къуллукъну администратору бла байланыгъыз. Тюзетиуню андан ары бардырыр ючюн, джангыдан байланыб кёрюгюз.\",\n\t\"pad.modals.corruptPad.explanation\": \"Кириш алыргъа  излеген блокнот бузукъду.\",\n\t\"pad.modals.corruptPad.cause\": \"Буну чуруму джангылыч сервер конфигурация неда башха сакъланмагъан этиу болургъа боллукъду. Тилейбиз, къуллукъну администратору бла байланыгъыз.\",\n\t\"pad.modals.deleted\": \"Кетерилди.\",\n\t\"pad.modals.deleted.explanation\": \"Бу блокнот къоратылгъанды.\",\n\t\"pad.modals.rateLimited\": \"Терклик чеклендирилгенди.\",\n\t\"pad.modals.rateLimited.explanation\": \"Бу блокнотха асыры кёб билдириу джибергенигиз ючюн, байлам кесилди.\",\n\t\"pad.modals.rejected.explanation\": \"Браузеригиз джибергени билдириуюгюзню алыргъа унамады.\",\n\t\"pad.modals.rejected.cause\": \"Блоконтха къарай тургъанлайыгъызгъа, сервер джангыртылыргъа болур неда Etherpad халатлы болургъа болур. Бетни джангыдан джюклеб кёрюгюз.\",\n\t\"pad.modals.disconnected\": \"Байламыгъыз кесилди.\",\n\t\"pad.modals.disconnected.explanation\": \"Серверге байлам кесилди.\",\n\t\"pad.modals.disconnected.cause\": \"Сервер хайырланалмаз халда болургъа болур. Тилейбиз, былай андан ары барса, къуллукъну администраторуна билдиригизю.\",\n\t\"pad.share\": \"Бу блокнотну ортагъа сал\",\n\t\"pad.share.readonly\": \"Къуру окъу\",\n\t\"pad.share.link\": \"Джибериу\",\n\t\"pad.share.emebdcode\": \"URL сал\",\n\t\"pad.chat\": \"Чат\",\n\t\"pad.chat.title\": \"Бу блокнот ючюн ушакъны ач\",\n\t\"pad.chat.loadmessages\": \"Мындан аслам билдириу джюкле\",\n\t\"pad.chat.stick.title\": \"Ушакъны экраннга джабышдыр\",\n\t\"pad.chat.writeMessage.placeholder\": \"Билдириуюгюзню былайда джазыгъыз\",\n\t\"timeslider.followContents\": \"Блокнот ичин джангыртыуун марагъыз\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Заман Шкала\",\n\t\"timeslider.toolbar.returnbutton\": \"Документге\",\n\t\"timeslider.toolbar.authors\": \"Авторла:\",\n\t\"timeslider.toolbar.authorsList\": \"Автор джокъду\",\n\t\"timeslider.toolbar.exportlink.title\": \"Эспорт эт\",\n\t\"timeslider.exportCurrent\": \"Баргъан версияны бу шекилде экспорт эт:\",\n\t\"timeslider.version\": \"{{version}} версия\",\n\t\"timeslider.saved\": \"{{day}} {{month}} {{year}} датада сакъланнганды\",\n\t\"timeslider.playPause\": \"Блокнотну ичин Ойнат / Пауза\",\n\t\"timeslider.backRevision\": \"Бу блокнотдагъы версиягъа кери къайт\",\n\t\"timeslider.forwardRevision\": \"Блокнотда эндиги версиягъа бар\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}.{{minutes}}.{{seconds}}\",\n\t\"timeslider.month.january\": \"январь\",\n\t\"timeslider.month.february\": \"февраль\",\n\t\"timeslider.month.march\": \"март\",\n\t\"timeslider.month.april\": \"апрель\",\n\t\"timeslider.month.may\": \"май\",\n\t\"timeslider.month.june\": \"июнь\",\n\t\"timeslider.month.july\": \"июль\",\n\t\"timeslider.month.august\": \"август\",\n\t\"timeslider.month.september\": \"сентябрь\",\n\t\"timeslider.month.october\": \"октябрь\",\n\t\"timeslider.month.november\": \"ноябрь\",\n\t\"timeslider.month.december\": \"декабрь\",\n\t\"timeslider.unnamedauthors\": \"{{num}} атсыз {[plural(num) one: автор, other: автор ]}\",\n\t\"pad.savedrevs.marked\": \"Бу версия, артыкъ сакъланнган версия болуб белгиленнгенди\",\n\t\"pad.savedrevs.timeslider\": \"Заман шкалагъа кириб, сакъланнган версияланы кёрюрге боллукъсуз\",\n\t\"pad.userlist.entername\": \"Атынгы киргизт\",\n\t\"pad.userlist.unnamed\": \"атсыз\",\n\t\"pad.editbar.clearcolors\": \"Буютеу документдеги автор бояула сюртюлсюнмю? Бу этиу кери алынамаз\",\n\t\"pad.impexp.importbutton\": \"Энди импорт эт\",\n\t\"pad.impexp.importing\": \"Импорт этиу…\",\n\t\"pad.impexp.confirmimport\": \"Файлны импорту баргъан текстни джангыдан джазарыкъды. Андан ары бардырыргъа излегенигизден ишексизмисиз?\",\n\t\"pad.impexp.convertFailed\": \"Бу файлны импорт эталмадыкъ. Тилейбиз, башха форматны хайырланыгъыз, неда къол бла копия этиб джабышдырыгъыз\",\n\t\"pad.impexp.padHasData\": \"Бу блокнотда алайсыз да тюрлениуле болгъаны ючюн бу файлны импорт эталмадыкъ, тилейбиз джангы блокнотха импорт этигиз\",\n\t\"pad.impexp.uploadFailed\": \"Джюклеу джетишимсиз болду, тилейбизщ джангыдан сынагъыз\",\n\t\"pad.impexp.importfailed\": \"Импорт этилалмады\",\n\t\"pad.impexp.copypaste\": \"Тилейбиз, копия этиб джабышдыргъыз\",\n\t\"pad.impexp.exportdisabled\": \"{{type}} форматда экспорт джукъланыбды. Ачыкълаула ючюн система администраторлагъа байланыгъыз.\",\n\t\"pad.impexp.maxFileSize\": \"Файл асыры уллуду. Импорт ючюн эркинлик берилген файл ёлчемини уллу этер ючюн сайтны администратору бла байланыгъыз\"\n}\n"
  },
  {
    "path": "src/locales/ksh.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Purodha\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Neu Pädd\",\n\t\"index.createOpenPad\": \"udder maach e Pädd op med däm Nahme:\",\n\t\"pad.toolbar.bold.title\": \"Fättschreff (Strg-B)\",\n\t\"pad.toolbar.italic.title\": \"Scheive Schreff (Strg-I)\",\n\t\"pad.toolbar.underline.title\": \"Ongerstresche (Strg-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Dorschjeschtresche (Strg+5)\",\n\t\"pad.toolbar.ol.title\": \"Leß met Nommere (Strg+Jruhß+N)\",\n\t\"pad.toolbar.ul.title\": \"Leß met Pongkte (Strg+Jruhß+L)\",\n\t\"pad.toolbar.indent.title\": \"Enjerök (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Ußjerök (Jruhßschreff+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Retuhr nämme (Strg+Z)\",\n\t\"pad.toolbar.redo.title\": \"Norrens (Strg-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Donn dä Schriiver ier Färve fottnämme (Strg+Jruhß+C)\",\n\t\"pad.toolbar.import_export.title\": \"Ongerscheidlijje Dattei_Fommaate empotteere udder äxpotteere\",\n\t\"pad.toolbar.timeslider.title\": \"Verjangeheid afschpelle\",\n\t\"pad.toolbar.savedRevision.title\": \"Di Väsjohn faßhallde\",\n\t\"pad.toolbar.settings.title\": \"Enschtällonge\",\n\t\"pad.toolbar.embed.title\": \"Donn dat Pädd öffentlesch maache un enbenge\",\n\t\"pad.toolbar.showusers.title\": \"Verbonge Metschriiver aanzeije\",\n\t\"pad.colorpicker.save\": \"Faßhallde\",\n\t\"pad.colorpicker.cancel\": \"Ophüre\",\n\t\"pad.loading\": \"Ben aam Lahde&nbsp;…\",\n\t\"pad.noCookie\": \"Dat Pläzje wood nit jevonge. Don dat en Dingem Brauser zohlohße!\",\n\t\"pad.permissionDenied\": \"Do häs nit dat Rääsch, op heh dat Pädd zohzejriife.\",\n\t\"pad.settings.padSettings\": \"Däm Pädd sing Enschtällonge\",\n\t\"pad.settings.myView\": \"Aanseesch\",\n\t\"pad.settings.stickychat\": \"Donn der Klaaf emmer aanzeije\",\n\t\"pad.settings.chatandusers\": \"Dunn de Metmaacher un der Klaaf aanzeije\",\n\t\"pad.settings.colorcheck\": \"Färve för de Schrihver\",\n\t\"pad.settings.linenocheck\": \"Nommere för de Reije\",\n\t\"pad.settings.rtlcheck\": \"Schreff vun Rääschß noh Lenks?\",\n\t\"pad.settings.fontType\": \"Zoot Schreff\",\n\t\"pad.settings.fontType.normal\": \"Nommahl\",\n\t\"pad.settings.language\": \"Schprohch:\",\n\t\"pad.importExport.import_export\": \"Empoot/Äxpoot\",\n\t\"pad.importExport.import\": \"Donn jeede Täx udder jeede Zoot Dokemänt huhlaade\",\n\t\"pad.importExport.importSuccessful\": \"Jeschaff!\",\n\t\"pad.importExport.export\": \"Don dat Pädd äxpoteere alß:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Eijfach Täx\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF (Poteerbaa Dokemänte Fommaat)\",\n\t\"pad.importExport.exportopen\": \"ODF (Offe Dokemänte-Fommaat)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Mer künne bloß eijfaache Täxte udder HTML_Fommaate empoteere. Opwändejere Müjjeleschkeite fö der Empoot jon och, doför bruch mer en <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">Enschtallazjuhn met <i lang=\\\"en\\\" xml:lang=\\\"en\\\">Abiword</i></a>.\",\n\t\"pad.modals.connected\": \"Verbonge.\",\n\t\"pad.modals.reconnecting\": \"Ben wider aam Verbenge&nbsp;…\",\n\t\"pad.modals.forcereconnect\": \"Wider verbenge\",\n\t\"pad.modals.userdup\": \"En enem andere Finster en Ärbeid\",\n\t\"pad.modals.userdup.explanation\": \"Heh dat Padd schingk en mieh wi einem Finster vun enem Brauser op heh däm Rääschner op ze sin.\",\n\t\"pad.modals.userdup.advice\": \"En heh däm Finster wider verbenge.\",\n\t\"pad.modals.unauth\": \"Nit berääschtesch\",\n\t\"pad.modals.unauth.explanation\": \"Ding Berääschtejong hät sesch jeändert, derwiehl De di Sigg aam beloore wohrß. Versöhk en neu Verbendong ze maache.\",\n\t\"pad.modals.looping.explanation\": \"Et jitt Probleeme met dä Verbendong mem ẞööver för de Schriiver ier Aandeile zesamme_ze_bränge.\",\n\t\"pad.modals.looping.cause\": \"Künnt sin, Ding Verbendong jeiht dorj_ene onzopaß proxy-ẞööver udder firewall.\",\n\t\"pad.modals.initsocketfail\": \"Dä ẞööver es nit ze äreische.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Kein Verbendong met däm ẞööver ze krijje.\",\n\t\"pad.modals.initsocketfail.cause\": \"Dat künnt aam Brauser udder aan däm singer Verbendong övver et Internet lijje.\",\n\t\"pad.modals.slowcommit.explanation\": \"Dä ẞööver antwoot nit.\",\n\t\"pad.modals.slowcommit.cause\": \"Dat künnt aan Probleeme met Verbendonge em Näzwärrek lijje.\",\n\t\"pad.modals.badChangeset.explanation\": \"En Änderong, di De jemaat häs, wood vum ẞööver nit aanjenumme.\",\n\t\"pad.modals.badChangeset.cause\": \"Dat künnt sin wääje ener verkehte Enschtällong vum ẞööver udder ohnjät, wat mer nit äwaadt hät. Donn Desch aan däm ßööver singe Bedriever wände, wann De meins, dat dat ene Fähler wör. Donn desch neu verbende, öm mem Schriive wigger ze maache.\",\n\t\"pad.modals.corruptPad.explanation\": \"Dat Pädd, wo De desch met verbenge wells, es kappott.\",\n\t\"pad.modals.corruptPad.cause\": \"Dat künnt sin wääje ener verkehte Enschtällong vum ẞööver udder öhnßjät, wat mer nit äwaadt hät. Donn Desch aan däm ßööver singe Bedriever wände.\",\n\t\"pad.modals.deleted\": \"Fottjeschmeße.\",\n\t\"pad.modals.deleted.explanation\": \"Dat Pädd es fottjeschmeße woode.\",\n\t\"pad.modals.disconnected\": \"Do bes nit mih verbonge.\",\n\t\"pad.modals.disconnected.explanation\": \"De Verbendong mem ẞööver es fott.\",\n\t\"pad.modals.disconnected.cause\": \"Dä ẞööver künnt nit mih loufe.\\nSidd_esu jood und saad ons Bescheid, wann dadd esu bliiv.\",\n\t\"pad.share\": \"Maach heh dat Pädd öffentlesch\",\n\t\"pad.share.readonly\": \"Nor för ze Lässe\",\n\t\"pad.share.link\": \"Lengk\",\n\t\"pad.share.emebdcode\": \"Donn en <i lang=\\\"en\\\" xmk:lang=\\\"en\\\">URL</i> enboue\",\n\t\"pad.chat\": \"Klaaf\",\n\t\"pad.chat.title\": \"Maach dä Klaaf för heh dat Pädd op\",\n\t\"pad.chat.loadmessages\": \"Mih Nohreeschte lahde&nbsp;…\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} - Verjangeheid affschpelle\",\n\t\"timeslider.toolbar.returnbutton\": \"Jangk retuhr nohm Pädd\",\n\t\"timeslider.toolbar.authors\": \"De Schrihver:\",\n\t\"timeslider.toolbar.authorsList\": \"Kein Schriivere\",\n\t\"timeslider.toolbar.exportlink.title\": \"Äxpotteere\",\n\t\"timeslider.exportCurrent\": \"Donn de neußte Väsjohn äxpotteere alß:\",\n\t\"timeslider.version\": \"Väsjohn {{version}}\",\n\t\"timeslider.saved\": \"Faßjehallde aam {{day}}. {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Donn der Enhalld vum Päd afschpelle udder aanhallde\",\n\t\"timeslider.backRevision\": \"Jangk ein Väsjohn retuhr en dämm Pahdt\",\n\t\"timeslider.forwardRevision\": \"Jangg en Väsjohn vörwääz en heh däm Pahdt.\",\n\t\"timeslider.dateformat\": \"aam {{day}}. {{month}} {{year}} öm {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Jannewaa\",\n\t\"timeslider.month.february\": \"Fääbrowaa\",\n\t\"timeslider.month.march\": \"Määz\",\n\t\"timeslider.month.april\": \"Apprell\",\n\t\"timeslider.month.may\": \"Mai\",\n\t\"timeslider.month.june\": \"Juuni\",\n\t\"timeslider.month.july\": \"Juuli\",\n\t\"timeslider.month.august\": \"Oujoß\",\n\t\"timeslider.month.september\": \"Säptämber\",\n\t\"timeslider.month.october\": \"Oktoober\",\n\t\"timeslider.month.november\": \"Novämber\",\n\t\"timeslider.month.december\": \"Dezämber\",\n\t\"timeslider.unnamedauthors\": \"{[plural(num) zero: keine, one: eine, other: {{num}} ]} nahmeloose Schriiver\",\n\t\"pad.savedrevs.marked\": \"Heh di Väsjohn es jäz faßjehallde.\",\n\t\"pad.savedrevs.timeslider\": \"Mer kann de faßjehallde Väsjohne belohre beim Verjangeheid afschpelle\",\n\t\"pad.userlist.entername\": \"Jif Dinge Nahme en\",\n\t\"pad.userlist.unnamed\": \"nahmeloßß\",\n\t\"pad.editbar.clearcolors\": \"Sulle mer de Färve för de Schriiver uss_em janze Täx fott maache?\",\n\t\"pad.impexp.importbutton\": \"Jäz empoteere\",\n\t\"pad.impexp.importing\": \"Ben aam Empottehre&nbsp;…\",\n\t\"pad.impexp.confirmimport\": \"En Dattei ze empotteere määt der janze Täx em Pädd fott. Wells De dat verfaftesch hann?\",\n\t\"pad.impexp.convertFailed\": \"Mer kunnte di Dattei nit empoteere. Nemm en ander Dattei-Fommaat udder donn dä Täx vun Hand kopeere un ennföhje.\",\n\t\"pad.impexp.padHasData\": \"Mer kunnte di Dattei nit empottehre weil et Pädd alt Veränderonge metjemaht hät. Donn se en e neu Pädd empottehre.\",\n\t\"pad.impexp.uploadFailed\": \"Dat Huhlaade es donävve jejange. Bes esu johd un probeer et norr_ens.\",\n\t\"pad.impexp.importfailed\": \"Et Empoteere es donävve jejange.\",\n\t\"pad.impexp.copypaste\": \"Bes esu johd un donn et koppeere un enfööje\",\n\t\"pad.impexp.exportdisabled\": \"Et Äxpotteere em {{type}}-Formmaat es affjeschalldt. De Verwallder vun heh dä Sigge künne doh velleisch wiggerhällefe.\"\n}\n"
  },
  {
    "path": "src/locales/ku-latn.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Balyozxane\",\n\t\t\t\"Bikarhêner\",\n\t\t\t\"Dilyaramude\",\n\t\t\t\"George Animal\",\n\t\t\t\"Ghybu\",\n\t\t\t\"Gomada\",\n\t\t\t\"Mehk63\",\n\t\t\t\"MikaelF\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Bloknota nû\",\n\t\"index.createOpenPad\": \"Yan Padekê li gel navê biafirîne/veke:\",\n\t\"pad.toolbar.bold.title\": \"Stûr (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Xwar (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Binxet (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Serxêzkirî (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Lîsteya rêzkirî (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Lîsteya tevlîhev (Ctrl+Shift+L)\",\n\t\"pad.toolbar.undo.title\": \"Vegerîne (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Dîsa bike (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Rengên nivîskariyê jê bibe (Ctrl+Shift+C)\",\n\t\"pad.toolbar.savedRevision.title\": \"Sererastkirinê tomar bike\",\n\t\"pad.toolbar.settings.title\": \"Eyar\",\n\t\"pad.colorpicker.save\": \"Tomar bike\",\n\t\"pad.colorpicker.cancel\": \"Betal bike\",\n\t\"pad.loading\": \"Tê barkirin...\",\n\t\"pad.settings.padSettings\": \"Eyarên bloknotê\",\n\t\"pad.settings.myView\": \"Dîmena min\",\n\t\"pad.settings.stickychat\": \"Di ekranê de hertim çet bike\",\n\t\"pad.settings.chatandusers\": \"Çeta û Bikarhênera Nîşan bide\",\n\t\"pad.settings.colorcheck\": \"Rengên nivîskarîye\",\n\t\"pad.settings.linenocheck\": \"Hejmarên rêzê\",\n\t\"pad.settings.rtlcheck\": \"Bila naverok ji raste ber bi çepe be xwendin?\",\n\t\"pad.settings.fontType\": \"Tîpa nivîsê:\",\n\t\"pad.settings.language\": \"Ziman:\",\n\t\"pad.settings.about\": \"Derbar\",\n\t\"pad.importExport.importSuccessful\": \"Biserketî!\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.modals.connected\": \"Hate girêdan.\",\n\t\"pad.modals.reconnecting\": \"Ji bloknota te re dîsa tê girêdan...\",\n\t\"pad.modals.cancel\": \"Betal bike\",\n\t\"pad.modals.userdup\": \"Di pencereyek din de vebû\",\n\t\"pad.modals.userdup.advice\": \"Ji bo di vê pencereye de bikarbînîy dîsa giredanek çeke.\",\n\t\"pad.modals.unauth\": \"Desthilatdar nîne\",\n\t\"pad.modals.deleted\": \"Hate jêbirin.\",\n\t\"pad.modals.deleted.explanation\": \"Ev bloknot hatiye rakirin.\",\n\t\"pad.modals.disconnected\": \"Pêwendîya te qut bû.\",\n\t\"pad.modals.disconnected.explanation\": \"Pêwendîya rajeker qut bû\",\n\t\"pad.share\": \"Vê bloknotê parve bike\",\n\t\"pad.share.link\": \"Girêdan\",\n\t\"pad.chat\": \"Çet\",\n\t\"pad.chat.title\": \"Ji bo vê bloknotê çet veke.\",\n\t\"pad.chat.loadmessages\": \"Peyamên bêhtir barbike\",\n\t\"timeslider.toolbar.returnbutton\": \"Vegere bloknotê\",\n\t\"timeslider.toolbar.authors\": \"Nivîser:\",\n\t\"timeslider.toolbar.authorsList\": \"Nivîser Tine\",\n\t\"timeslider.version\": \"Guhartoya {{version}}\",\n\t\"timeslider.saved\": \"Di dîroka {{day}} {{month}} {{year}} de hate tomarkirin\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}.{{minutes}}.{{seconds}}\",\n\t\"timeslider.month.january\": \"kanûna paşîn\",\n\t\"timeslider.month.february\": \"sibat\",\n\t\"timeslider.month.march\": \"adar\",\n\t\"timeslider.month.april\": \"nîsan\",\n\t\"timeslider.month.may\": \"gulan\",\n\t\"timeslider.month.june\": \"hezîran\",\n\t\"timeslider.month.july\": \"tîrmeh\",\n\t\"timeslider.month.august\": \"tebax\",\n\t\"timeslider.month.september\": \"îlon\",\n\t\"timeslider.month.october\": \"çiriya pêşîn\",\n\t\"timeslider.month.november\": \"Çiriya paşîn\",\n\t\"timeslider.month.december\": \"kanûna pêşîn\",\n\t\"pad.userlist.entername\": \"Navê xwe têkeve\",\n\t\"pad.userlist.unnamed\": \"nenavkirî\",\n\t\"pad.impexp.copypaste\": \"Ji kerema xwe re jê bigre û pê ve deyne (Ctrl+c, Ctrl+v)\"\n}\n"
  },
  {
    "path": "src/locales/lb.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Gromper\",\n\t\t\t\"Robby\",\n\t\t\t\"Soued031\",\n\t\t\t\"Volvox\"\n\t\t]\n\t},\n\t\"admin_plugins.available_install.value\": \"Installéieren\",\n\t\"admin_plugins.description\": \"Beschreiwung\",\n\t\"admin_plugins.installed_uninstall.value\": \"Desinstalléieren\",\n\t\"admin_plugins.last-update\": \"Lescht Aktualiséierung\",\n\t\"admin_plugins.name\": \"Numm\",\n\t\"admin_plugins.version\": \"Versioun\",\n\t\"admin_plugins_info.version\": \"Etherpad-Versioun\",\n\t\"admin_plugins_info.version_latest\": \"Lescht disponibel Versioun\",\n\t\"admin_plugins_info.version_number\": \"Versiounsnummer\",\n\t\"admin_settings\": \"Astellungen\",\n\t\"admin_settings.current_save.value\": \"Astellunge späicheren\",\n\t\"admin_settings.page-title\": \"Astellungen - Etherpad\",\n\t\"index.newPad\": \"Neie Pad\",\n\t\"index.settings\": \"Astellungen\",\n\t\"index.copyLink\": \"2. Link kopéieren\",\n\t\"index.copyLinkButton\": \"Link an den Tëschespäicher kopéieren\",\n\t\"index.createOpenPad\": \"oder maacht ee Pad mat dësem Numm op:\",\n\t\"pad.toolbar.bold.title\": \"Fett (Strg-B)\",\n\t\"pad.toolbar.italic.title\": \"Schréi (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Ënnerstrach (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Duerchgestrach (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Nummeréiert Lëscht (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Net-nummeréiert Lëscht (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Aréckelen (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Erausréckelen (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Réckgängeg (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Widderhuelen (Ctrl-Y)\",\n\t\"pad.toolbar.savedRevision.title\": \"Versioun späicheren\",\n\t\"pad.toolbar.settings.title\": \"Astellungen\",\n\t\"pad.toolbar.home.title\": \"Zeréck op d'Haaptsäit\",\n\t\"pad.toolbar.showusers.title\": \"Aktuell Benotzer vun dësem Pad uweisen\",\n\t\"pad.colorpicker.save\": \"Späicheren\",\n\t\"pad.colorpicker.cancel\": \"Ofbriechen\",\n\t\"pad.loading\": \"Lueden...\",\n\t\"pad.noCookie\": \"Cookie gouf net fonnt. Erlaabt wgl. Cookien an Ärem Browser! Är Sessioun an Är Astellungen ginn net tëscht de Visitte gespäichert. Dëst kann doduerch bedéngt sinn datt Etherpad a verschiddene Browser an iFrameën agebaut ass. Vergewëssert Iech wgl. datt Etherpad am selwechten Subdomain/Domain ass wéi den iwwergeuerdneten iFrame\",\n\t\"pad.permissionDenied\": \"Dir hutt net déi néideg Rechter fir dëse Pad opzemaachen\",\n\t\"pad.settings.padSettings\": \"Pad-Astellungen\",\n\t\"pad.settings.myView\": \"Meng Usiicht\",\n\t\"pad.settings.linenocheck\": \"Zeilennummeren\",\n\t\"pad.settings.rtlcheck\": \"Inhalt vu riets no lénks liesen?\",\n\t\"pad.settings.fontType\": \"Schrëftart:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Sprooch:\",\n\t\"pad.settings.about\": \"Iwwer\",\n\t\"pad.settings.poweredBy\": \"Zur Verfügung gestallt vu(n)\",\n\t\"pad.importExport.import_export\": \"Import/Export\",\n\t\"pad.importExport.import\": \"Text-Fichier oder Dokument eroplueden\",\n\t\"pad.importExport.importSuccessful\": \"Erfollegräich\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Kloertext\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.modals.connected\": \"Verbonnen.\",\n\t\"pad.modals.cancel\": \"Ofbriechen\",\n\t\"pad.modals.userdup\": \"An enger anerer Fënster opgemaach\",\n\t\"pad.modals.userdup.explanation\": \"Et schéngt, datt dëse Pad a méi wéi enger Browserfënster op dësem Computer opgemaach ginn ass.\",\n\t\"pad.modals.unauth\": \"Net autoriséiert\",\n\t\"pad.modals.unauth.explanation\": \"Är Rechter hu geännert während deem Dir dës säit gekuckt hutt. Probéiert fir Iech nei ze connectéieren.\",\n\t\"pad.modals.looping.explanation\": \"Et gëtt Kommunikatiounsproblemer mam Synchronisatiouns-Server.\",\n\t\"pad.modals.initsocketfail\": \"De Server kann net erreecht ginn.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Et konnt keng Verbindung mam Synchronisatiounsserver opgeholl ginn.\",\n\t\"pad.modals.slowcommit.explanation\": \"De Server äntwert net.\",\n\t\"pad.modals.deleted\": \"Geläscht.\",\n\t\"pad.modals.deleted.explanation\": \"Dëse Pad gouf geläscht.\",\n\t\"pad.modals.disconnected\": \"Äre Verbindung ass ofgebrach.\",\n\t\"pad.modals.disconnected.explanation\": \"D'Verbindung mam Server ass verluergaang.\",\n\t\"pad.share\": \"Dëse Pad deelen\",\n\t\"pad.share.readonly\": \"Nëmme liesen\",\n\t\"pad.share.link\": \"Link\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Den Chat fir dëse Pad opmaachen.\",\n\t\"pad.chat.loadmessages\": \"Méi Message lueden\",\n\t\"pad.chat.writeMessage.placeholder\": \"Schreift Äre Message hei\",\n\t\"timeslider.toolbar.authors\": \"Auteuren:\",\n\t\"timeslider.toolbar.authorsList\": \"Keng Auteuren\",\n\t\"timeslider.toolbar.exportlink.title\": \"Exportéieren\",\n\t\"timeslider.exportCurrent\": \"Exportéiert déi aktuell Versioun als:\",\n\t\"timeslider.version\": \"Versioun {{version}}\",\n\t\"timeslider.saved\": \"Gespäichert de(n) {{day}}. {{month}} {{year}}\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Januar\",\n\t\"timeslider.month.february\": \"Februar\",\n\t\"timeslider.month.march\": \"Mäerz\",\n\t\"timeslider.month.april\": \"Abrëll\",\n\t\"timeslider.month.may\": \"Mee\",\n\t\"timeslider.month.june\": \"Juni\",\n\t\"timeslider.month.july\": \"Juli\",\n\t\"timeslider.month.august\": \"August\",\n\t\"timeslider.month.september\": \"September\",\n\t\"timeslider.month.october\": \"Oktober\",\n\t\"timeslider.month.november\": \"November\",\n\t\"timeslider.month.december\": \"Dezember\",\n\t\"pad.savedrevs.marked\": \"Dës Versioun ass elo als gespäichert Versioun markéiert\",\n\t\"pad.userlist.entername\": \"Gitt Ären Numm an\",\n\t\"pad.userlist.unnamed\": \"anonym\",\n\t\"pad.impexp.importbutton\": \"Elo importéieren\",\n\t\"pad.impexp.importing\": \"Importéieren...\",\n\t\"pad.impexp.uploadFailed\": \"D'Eroplueden huet net funktionéiert, probéiert wgl. nach eng Kéier\",\n\t\"pad.impexp.importfailed\": \"Den Import huet net funktionéiert\"\n}\n"
  },
  {
    "path": "src/locales/lki.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Arash71\",\n\t\t\t\"Hosseinblue\",\n\t\t\t\"Lakzon\"\n\t\t]\n\t},\n\t\"index.newPad\": \"تازۀpad\",\n\t\"index.createOpenPad\": \":وە نۆم Pad یا سازین/واز کردن یإگلە\",\n\t\"pad.toolbar.bold.title\": \"پررنگ-تئژ(Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"ایتالیک (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"ژئر خطی(Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"(Ctrl+5)خط هوواردێ\",\n\t\"pad.toolbar.ol.title\": \" (Ctrl+5)لیست مرتب بی\",\n\t\"pad.toolbar.ul.title\": \"(Ctrl+5)لیست شؤیا/نامرتب\",\n\t\"pad.toolbar.indent.title\": \"تورفتگی(إنۆم چێن)(TAB)\",\n\t\"pad.toolbar.unindent.title\": \"(Shift+TAB)بیرون آمدگی-درچئن\",\n\t\"pad.toolbar.undo.title\": \"گِلّا دائن-ئآهۀتن(Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"وە نووآ هەتن (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"(Ctrl+Shift+C)پاک‌کردن رنگةل نویسندگی\",\n\t\"pad.toolbar.import_export.title\": \"دەربردن/إنۆم آووِردن  إژ/وە قاڵبەل گوناگون\",\n\t\"pad.toolbar.timeslider.title\": \"اسلایدر وختی-زمانی\",\n\t\"pad.toolbar.savedRevision.title\": \"نسخە بِیل(ذخیره کە)\",\n\t\"pad.toolbar.settings.title\": \"تنظیمۀل\",\n\t\"pad.toolbar.embed.title\": \"بەشآکردن ؤ نیائن(اشتراک ونشاندن)وە نۆم سایت\",\n\t\"pad.toolbar.showusers.title\": \"نیشان دائن کاربەر أنۆم اێ\\nیادداشتە\",\n\t\"pad.colorpicker.save\": \"هیشتن(ذخیره)\",\n\t\"pad.colorpicker.cancel\": \"ئآهووسانن/لغو\",\n\t\"pad.loading\": \"...(loading)بارنیائن\",\n\t\"pad.noCookie\": \"کوکی یافت نشد. لطفاً اجازهٔ اجرای کوکی در مروگرتان را بدهید!\",\n\t\"pad.permissionDenied\": \"شما اجازه‌ی دسترسی به این دفترچه یادداشت را ندارید\",\n\t\"pad.settings.padSettings\": \"pad تنظیمۀل\",\n\t\"pad.settings.myView\": \"نمایش-سئرکردن مه\",\n\t\"pad.settings.stickychat\": \"گەپ(قسە)هەمۆیشە وە وەڵگە نمایش بوو\",\n\t\"pad.settings.chatandusers\": \"نمایش چت و کاربران\",\n\t\"pad.settings.colorcheck\": \"رنگۀل تالیفی\",\n\t\"pad.settings.linenocheck\": \"شؤمارۀل خطی\",\n\t\"pad.settings.rtlcheck\": \"خواندن نۆم جِک(محتوا)أژ لآ ڕاس بە چەپ؟\",\n\t\"pad.settings.fontType\": \":شئؤۀ فؤنت\",\n\t\"pad.settings.fontType.normal\": \"عادی\",\n\t\"pad.settings.language\": \":زوون\",\n\t\"pad.importExport.import_export\": \"دەر بردن/إنۆم آووِردن\",\n\t\"pad.importExport.import\": \"بارنیائن هر جور نوشته یا سندئ\",\n\t\"pad.importExport.importSuccessful\": \"! موفق بی-پیرووز بی\",\n\t\"pad.importExport.export\": \":برون‌ریزی این دفترچه یادداشت با قالب\",\n\t\"pad.importExport.exportetherpad\": \"اترپد\",\n\t\"pad.importExport.exporthtml\": \"html\",\n\t\"pad.importExport.exportplain\": \"متن پئن-درئژ\",\n\t\"pad.importExport.exportword\": \"مایکروسافت وورد\",\n\t\"pad.importExport.exportpdf\": \"پی دی اف\",\n\t\"pad.importExport.exportopen\": \" (قالب سند باز)ODF\",\n\t\"pad.importExport.abiword.innerHTML\": \"شما تنها می‌توانید از قالب متن ساده یا اچ‌تی‌ام‌ال درون‌ریزی کنید. برای بیشتر شدن ویژگی‌های درون‌ریزی پیشرفته <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">AbiWord</a> را نصب کنید.\",\n\t\"pad.modals.connected\": \"وصل بیۀ\",\n\t\"pad.modals.reconnecting\": \"..در حال اتصال دوباره به دفترچه یادداشت شما\",\n\t\"pad.modals.forcereconnect\": \"واداشتن به اتصال دوباره\",\n\t\"pad.modals.userdup\": \"ئة پنجرإ تر واز بو\",\n\t\"pad.modals.userdup.explanation\": \"گمان می‌رود این دفترچه یادداشت در بیش از یک پنجره‌ی مرورگر باز شده‌است.\",\n\t\"pad.modals.userdup.advice\": \"برای استفاده از این پنجره دوباره وصل شوید.\",\n\t\"pad.modals.unauth\": \"مجاز نیة\",\n\t\"pad.modals.unauth.explanation\": \"دسترسی شما در حین مشاهده‌ی این برگه تغییر یافته‌است. دوباره متصل شوید.\",\n\t\"pad.modals.looping.explanation\": \"مشکلاتی ارتباطی با سرور همگام‌سازی وجود دارد.\",\n\t\"pad.modals.looping.cause\": \"شاید شما از طریق یک فایروال یا پروکسی ناسازگار متصل شده‌اید.\",\n\t\"pad.modals.initsocketfail\": \"سرور در دسترس نیست.\",\n\t\"pad.modals.initsocketfail.explanation\": \".نمی‌توان به سرور همگام سازی وصل شد\",\n\t\"pad.modals.initsocketfail.cause\": \".شاید این به خاطر مشکلی در مرورگر یا اتصال اینترنتی شما باشد\",\n\t\"pad.modals.slowcommit.explanation\": \".سرور پاسخ نمی‌دهد\",\n\t\"pad.modals.slowcommit.cause\": \".این می‌تواند به خاطر مشکلاتی در اتصال به شبکه باشد\",\n\t\"pad.modals.badChangeset.explanation\": \"ویرایشی که شما انجام داده‌اید توسط سرور همگام‌سازی نادرست طیقه‌بندی شده است.\",\n\t\"pad.modals.badChangeset.cause\": \"این می‌تواند به دلیل پیکربندی اشتباه یا سایر رفتارهای غیرمنتظره باشد. اگر فکر می‌کنید این یک خطا است لطفاً با مدیر خدمت تماس بگیرید. برای ادامهٔ ویرایش سعی کنید که دوباره متصل شوید.\",\n\t\"pad.modals.corruptPad.explanation\": \".پدی که شما سعی دارید دسترسی پیدا کنید خراب است\",\n\t\"pad.modals.corruptPad.cause\": \"این احتمالاً به دلیل تنظیمات اشتباه کارساز یا سایر رفتارهای غیرمنتظره است. لطفاً با مدیر خدمت تماس حاصل کنید.\",\n\t\"pad.modals.deleted\": \"پاک بیا-حذف بی\",\n\t\"pad.modals.deleted.explanation\": \"این دفترچه یادداشت پاک شده‌است.\",\n\t\"pad.modals.disconnected\": \"اتصال شما قطع شده‌است.\",\n\t\"pad.modals.disconnected.explanation\": \".اتصال وة سرور قطع بیة\",\n\t\"pad.modals.disconnected.cause\": \"ممکن است سرور در دسترس نباشد. اگر این مشکل باز هم رخ داد مدیر حدمت را .آگاه کنید\",\n\t\"pad.share\": \"به اشتراک‌گذاری این دفترچه یادداشت\",\n\t\"pad.share.readonly\": \"تەنیا(فقط)خووەنن\",\n\t\"pad.share.link\": \"لینک\",\n\t\"pad.share.emebdcode\": \"جاسازی نشانی\",\n\t\"pad.chat\": \"گپ\",\n\t\"pad.chat.title\": \"بازکردن گفتگو برای این دفترچه یادداشت\",\n\t\"pad.chat.loadmessages\": \"پئامۀلئ تر باره سۀر\",\n\t\"timeslider.pageTitle\": \"لغزندهٔ زمان {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"بازگشت به دفترچه یادداشت\",\n\t\"timeslider.toolbar.authors\": \"نویسندگان:\",\n\t\"timeslider.toolbar.authorsList\": \"بدون نویسنده\",\n\t\"timeslider.toolbar.exportlink.title\": \"در بِردن\",\n\t\"timeslider.exportCurrent\": \"برون‌ریزی نگارش کنونی به عنوان:\",\n\t\"timeslider.version\": \"نگارش {{version}}\",\n\t\"timeslider.saved\": \"{{month}} {{day}}، {{year}} ذخیره شد\",\n\t\"timeslider.playPause\": \"اجرای مجدد/متوقف کردن پخش\",\n\t\"timeslider.backRevision\": \"رفتن به نسخهٔ پیشین در این دفترچه\",\n\t\"timeslider.forwardRevision\": \"رفتن به نسخهٔ بعدی در این دفترچه\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"دی)نورووژ)\",\n\t\"timeslider.month.february\": \"(خاکه لێه(بهمن\",\n\t\"timeslider.month.march\": \"(مانگ ئێد(اسفندگان\",\n\t\"timeslider.month.april\": \"(په نجه(فروردین\",\n\t\"timeslider.month.may\": \"(مێرێان(اردیبهشت\",\n\t\"timeslider.month.june\": \"(گاکوور(خرداد\",\n\t\"timeslider.month.july\": \"تیر)ئاگرانی)\",\n\t\"timeslider.month.august\": \"مرداد)مردار)\",\n\t\"timeslider.month.september\": \"(ماڵه ژێر(شهریور\",\n\t\"timeslider.month.october\": \"(ماڵه ژێر دوماێنه(مهر\",\n\t\"timeslider.month.november\": \"آبان)تویل ته کن)\",\n\t\"timeslider.month.december\": \"(مانگه سێه(آذر\",\n\t\"timeslider.unnamedauthors\": \" نویسندة بی‌ نام{{num}}\",\n\t\"pad.savedrevs.marked\": \"این بازنویسی هم اکنون به عنوان ذخیره شده علامت‌گذاری شد\",\n\t\"pad.savedrevs.timeslider\": \"شما می‌توانید نسخه‌های ذخیره شده را با دیدن نوار زمان ببنید\",\n\t\"pad.userlist.entername\": \"نۆم ووژت وارد کە\",\n\t\"pad.userlist.unnamed\": \"بێ نۆم\",\n\t\"pad.editbar.clearcolors\": \"رنگ نویسندگی از همه‌ی سند پاک شود؟\",\n\t\"pad.impexp.importbutton\": \"ایسگە وارد کە\",\n\t\"pad.impexp.importing\": \"...وارد مۀهه\",\n\t\"pad.impexp.confirmimport\": \"با درون‌ریزی یک پرونده نوشتهٔ کنونی دفترچه پاک می‌شود. آیا می‌خواهید ادامه دهید؟\",\n\t\"pad.impexp.convertFailed\": \"ما نمی‌توانیم این پرونده را درون‌ریزی کنیم. خواهشمندیم قالب دیگری برای سندتان انتخاب کرده یا بصورت دستی آنرا کپی کنید\",\n\t\"pad.impexp.padHasData\": \"امکان درون‌ریز این پرونده نیست زیرا این پد تغییر کرده‌است. لطفاً در پد جدید درون‌ریزی کنید.\",\n\t\"pad.impexp.uploadFailed\": \"آپلود انجام نشد، دوباره تلاش کنید\",\n\t\"pad.impexp.importfailed\": \"وارد نؤنئ-خطای واردکردن\",\n\t\"pad.impexp.copypaste\": \"کپی پیست کنید\",\n\t\"pad.impexp.exportdisabled\": \"برون‌ریزی با قالب {{type}} از کار افتاده است. برای جزئیات بیشتر با مدیر سیستمتان تماس بگیرید.\"\n}\n"
  },
  {
    "path": "src/locales/lrc.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Lorestani\",\n\t\t\t\"Mogoeilor\"\n\t\t]\n\t},\n\t\"index.newPad\": \"دٱفتٱرچٱ تازٱ\",\n\t\"pad.toolbar.bold.title\": \"تۊپور\",\n\t\"pad.toolbar.italic.title\": \"هٱلٛ هار(ctrl-l)\",\n\t\"pad.toolbar.underline.title\": \"زؽر خٱت (Ctrl-U)\",\n\t\"pad.toolbar.ol.title\": \"نومگٱ مورٱتٱب بیٱ\",\n\t\"pad.toolbar.ul.title\": \"نومگٱ مورٱتٱب ناٛیٱ\",\n\t\"pad.toolbar.indent.title\": \"قوپساٛیی(TAB)\",\n\t\"pad.toolbar.unindent.title\": \"ڤ دٱر رٱتاٛیی (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"رٱد ٱنجوم داٛئن (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"د نۊ ٱنجوم داٛئن(Ctrl-Y)\",\n\t\"pad.toolbar.savedRevision.title\": \"ڤانری بٱلگٱ\",\n\t\"pad.toolbar.settings.title\": \"میزوکاری\",\n\t\"pad.colorpicker.save\": \"زٱخیرٱ كردن\",\n\t\"pad.colorpicker.cancel\": \"ٱنجوم شؽڤ كردن\",\n\t\"pad.loading\": \"د هالٱت سڤار كرد...\",\n\t\"pad.settings.padSettings\": \"میزوکاری دٱفتٱرچٱ\",\n\t\"pad.settings.myView\": \"نٱزٱرگٱ ماْ\",\n\t\"pad.settings.stickychat\": \"همیشٱ د بٱلگٱ چٱک چنٱ بٱکؽت\",\n\t\"pad.settings.linenocheck\": \"شمارٱ خٱتؽا\",\n\t\"pad.settings.fontType\": \"نوع فونت:\",\n\t\"pad.settings.fontType.normal\": \"عادی\",\n\t\"pad.settings.language\": \"زڤون:\",\n\t\"pad.importExport.import_export\": \"ڤامین آوئردن/ڤ دٱر داٛئن\",\n\t\"pad.importExport.importSuccessful\": \"موئٱفٱق بی!\",\n\t\"pad.importExport.export\": \"دٱفتٱرچٱ تازٱ چی ڤ دٱر بیٱ:\",\n\t\"pad.importExport.exporthtml\": \"اْچ تی اْم اْل\",\n\t\"pad.importExport.exportplain\": \"نیسسٱ سادٱ\",\n\t\"pad.importExport.exportword\": \"ڤاژٱ پالایشگٱر  مایکروسافت\",\n\t\"pad.importExport.exportpdf\": \"پی دی اْف\",\n\t\"pad.importExport.exportopen\": \"او دی اْف(قالب سٱنٱد ڤاز)\",\n\t\"pad.modals.connected\": \"ڤٱسل بیٱ\",\n\t\"pad.modals.forcereconnect\": \"سی ڤٱسل بیئن دوئارٱ مٱجبۊر کو\",\n\t\"pad.modals.userdup\": \"د نیمدری هنی ڤاز بیٱ\",\n\t\"pad.modals.initsocketfail\": \"سرور د دٱسرسی نؽ.\",\n\t\"pad.modals.deleted\": \"پاک بیٱ\",\n\t\"pad.modals.deleted.explanation\": \"اؽ دٱفتٱرچٱ جا ڤ جا بیٱ\",\n\t\"pad.modals.disconnected\": \"اْرتبات تو قٱت بیٱ.\",\n\t\"pad.share\": \"اؽ دٱفتٱرچٱ ناْ بٱئر کو\",\n\t\"pad.share.readonly\": \"فقٱت ڤٱننی\",\n\t\"pad.share.link\": \"هوم پاٛڤٱن\",\n\t\"pad.chat\": \"سالفٱ\",\n\t\"pad.chat.title\": \"سالفٱ ناْ سی دٱفتٱرچٱ ڤاز کو.\",\n\t\"pad.chat.loadmessages\": \"پاٛغومؽا ؽشتر ناْ سڤار کو\",\n\t\"timeslider.toolbar.returnbutton\": \"ڤرگٱشتن ڤ دٱفتٱرچٱ\",\n\t\"timeslider.toolbar.authors\": \"نیسٱنٱ یا:\",\n\t\"timeslider.toolbar.authorsList\": \"بؽ نیسٱنٱ\",\n\t\"timeslider.toolbar.exportlink.title\": \"ڤ دٱر داٛئن\",\n\t\"timeslider.version\": \"نۏسخٱ{{نۏسخٱ}}\",\n\t\"timeslider.month.january\": \"ژانڤیٱ\",\n\t\"timeslider.month.february\": \"فڤریٱ\",\n\t\"timeslider.month.march\": \"مارس\",\n\t\"timeslider.month.april\": \"آڤريل\",\n\t\"timeslider.month.may\": \"ماٛی\",\n\t\"timeslider.month.june\": \"ژوئٱن\",\n\t\"timeslider.month.july\": \"جۊلای\",\n\t\"timeslider.month.august\": \"آگوست\",\n\t\"timeslider.month.september\": \"سپتامر\",\n\t\"timeslider.month.october\": \"اوكتوبر\",\n\t\"timeslider.month.november\": \"نوڤامر\",\n\t\"timeslider.month.december\": \"دسامر\",\n\t\"pad.userlist.entername\": \"نوم توناْ ڤارد بٱکؽت\",\n\t\"pad.userlist.unnamed\": \"بؽ نوم\",\n\t\"pad.impexp.importbutton\": \"ایساْ ڤارد کو\",\n\t\"pad.impexp.importing\": \"د هالٱت ڤارد کردن...\",\n\t\"pad.impexp.importfailed\": \"ڤامؽن آوئردن شکٱست هٱرد\",\n\t\"pad.impexp.copypaste\": \"خاهشٱن ڤردار بٱدیسن\"\n}\n"
  },
  {
    "path": "src/locales/lt.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Eitvys200\",\n\t\t\t\"I-svetaines\",\n\t\t\t\"Mantak111\",\n\t\t\t\"Naktis\",\n\t\t\t\"Nokeoo\",\n\t\t\t\"Vogone\",\n\t\t\t\"Zygimantus\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Administratoriaus prietaisų skydelis – Etherpad\",\n\t\"admin_plugins\": \"Įskiepių tvarkyklė\",\n\t\"admin_plugins.available\": \"Galimi įskiepiai\",\n\t\"admin_plugins.available_not-found\": \"Įskiepių nerasta.\",\n\t\"admin_plugins.available_fetching\": \"Gaunama…\",\n\t\"admin_plugins.available_install.value\": \"Įdiegti\",\n\t\"admin_plugins.available_search.placeholder\": \"Ieškokite įskiepių įdiegimui\",\n\t\"admin_plugins.description\": \"Aprašymas\",\n\t\"admin_plugins.installed\": \"Įdiegti įskiepiai\",\n\t\"admin_plugins.installed_fetching\": \"Gaunami įdiegti papildiniai…\",\n\t\"admin_plugins.installed_nothing\": \"Dar neįdiegėte jokių papildinių.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Išinstaluoti\",\n\t\"admin_plugins.last-update\": \"Paskutinis atnaujinimas\",\n\t\"admin_plugins.name\": \"Pavadinimas\",\n\t\"admin_plugins.page-title\": \"Papildinių tvarkyklė – Etherpad\",\n\t\"admin_plugins.version\": \"Versija\",\n\t\"admin_plugins_info\": \"Trikčių šalinimo informacija\",\n\t\"admin_plugins_info.parts\": \"Įdiegtos dalys\",\n\t\"admin_plugins_info.plugins\": \"Įdiegti papildiniai\",\n\t\"admin_plugins_info.page-title\": \"Papildinio informacija – Etherpad\",\n\t\"admin_plugins_info.version\": \"Etherpad versija\",\n\t\"admin_plugins_info.version_latest\": \"Naujausia prieinama versija\",\n\t\"admin_plugins_info.version_number\": \"Versijos numeris\",\n\t\"admin_settings\": \"Nustatymai\",\n\t\"admin_settings.current\": \"Dabartinė konfigūracija\",\n\t\"admin_settings.current_example-devel\": \"Kūrimo nustatymų šablono pavyzdys\",\n\t\"admin_settings.current_example-prod\": \"Gamybos nustatymų šablono pavyzdys\",\n\t\"admin_settings.current_restart.value\": \"Iš naujo paleisti Etherpad\",\n\t\"admin_settings.current_save.value\": \"Išsaugoti nustatymus\",\n\t\"admin_settings.page-title\": \"Nustatymai – Etherpad\",\n\t\"index.newPad\": \"Naujas bloknotas\",\n\t\"index.createOpenPad\": \"arba sukurkite/atidarykite Bloknotą su pavadinimu:\",\n\t\"index.openPad\": \"atidaryti egzistuojantį bloknotą su pavadinimu:\",\n\t\"pad.toolbar.bold.title\": \"Paryškintasis (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Pasvirasis (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Pabraukimas (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Perbrauktasis (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Numeruotas sąrašas (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Nenumeruotas Sąrašas (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Įtrauka\",\n\t\"pad.toolbar.unindent.title\": \"Atvirkštinė įtrauka (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Anuliuoti (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Perdaryti (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Valyti Autorystės Spalvas (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importuoti/Eksportuoti iš/į įvairius failų formatus\",\n\t\"pad.toolbar.timeslider.title\": \"Laiko slankiklis\",\n\t\"pad.toolbar.savedRevision.title\": \"Išsaugoti peržiūrą\",\n\t\"pad.toolbar.settings.title\": \"Nustatymai\",\n\t\"pad.toolbar.embed.title\": \"Dalintis ir įterpti šį bloknotą\",\n\t\"pad.toolbar.showusers.title\": \"Rodyti naudotojus šiame bloknote\",\n\t\"pad.colorpicker.save\": \"Išsaugoti\",\n\t\"pad.colorpicker.cancel\": \"Atšaukti\",\n\t\"pad.loading\": \"Įkraunama...\",\n\t\"pad.noCookie\": \"Slapuko rasti nepavyko. Prašome leisti slapukus savo naršyklėje! Jūsų sesija ir nustatymai nebus išsaugoti tarp apsilankymų. Taip gali būti dėl to, kad kai kuriose naršyklėse Etherpad yra įtrauktas į iFrame. Įsitikinkite, kad Etherpad yra tame pačiame padomenyje / domene kaip ir pirminis iFrame.\",\n\t\"pad.permissionDenied\": \"Jūs neturite leidimo patekti į šį bloknotą\",\n\t\"pad.settings.padSettings\": \"Bloknoto nustatymai\",\n\t\"pad.settings.myView\": \"Mano Vaizdas\",\n\t\"pad.settings.stickychat\": \"Pokalbiai visada viršuje\",\n\t\"pad.settings.chatandusers\": \"Rodyti Pokalbius ir Vartotojus\",\n\t\"pad.settings.colorcheck\": \"Autorystės spalvos\",\n\t\"pad.settings.linenocheck\": \"Eilučių numeriai\",\n\t\"pad.settings.rtlcheck\": \"Skaityti turinį iš dešinės į kairę?\",\n\t\"pad.settings.fontType\": \"Šrifto tipas:\",\n\t\"pad.settings.fontType.normal\": \"Normalus\",\n\t\"pad.settings.language\": \"Kalba:\",\n\t\"pad.settings.deletePad\": \"Ištrinti bloką\",\n\t\"pad.delete.confirm\": \"Ar tikrai norite ištrinti šį bloką?\",\n\t\"pad.settings.about\": \"Apie\",\n\t\"pad.settings.poweredBy\": \"Palaiko\",\n\t\"pad.importExport.import_export\": \"Importuoti/Eksportuoti\",\n\t\"pad.importExport.import\": \"Įkelkite bet kokį tekstinį failą arba dokumentą\",\n\t\"pad.importExport.importSuccessful\": \"Pavyko!\",\n\t\"pad.importExport.export\": \"Eksportuoti dabartinį bloknotą kaip:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Paprastasis tekstas\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Atvirasis dokumento formatas)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Galite importuoti tik iš paprasto teksto ar HTML formatų. Dėl išplėstinių importavimo funkcijų prašome <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">įdiegti AbiWord ar LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Prisijungta.\",\n\t\"pad.modals.reconnecting\": \"Iš naujo prisijungiama prie jūsų bloknoto…\",\n\t\"pad.modals.forcereconnect\": \"Priversti prisijungti iš naujo\",\n\t\"pad.modals.reconnecttimer\": \"Bandoma vėl prisijungti\",\n\t\"pad.modals.cancel\": \"Atšaukti\",\n\t\"pad.modals.userdup\": \"Atidaryta kitame lange\",\n\t\"pad.modals.userdup.explanation\": \"Šis bloknotas, atrodo yra atidarytas daugiau nei viename šio kompiuterio naršyklės lange.\",\n\t\"pad.modals.userdup.advice\": \"Prisijunkite iš naujo, kad vietoj to naudotumėte šį langą.\",\n\t\"pad.modals.unauth\": \"Neleidžiama\",\n\t\"pad.modals.unauth.explanation\": \"Jūsų teiisės pasikeitė kol žiūrėjote šį puslapį. Bandykite prisijungti iš naujo.\",\n\t\"pad.modals.looping.explanation\": \"Yra komunikacijos problemų su sinchronizacijos serveriu.\",\n\t\"pad.modals.looping.cause\": \"Galbūt prisijungėte per nesuderinamą ugniasienę ar proxy.\",\n\t\"pad.modals.initsocketfail\": \"Serveris yra nepasiekiamas.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Nepavyko prisijungti prie sinchronizacijos serverio.\",\n\t\"pad.modals.initsocketfail.cause\": \"Tai tikriausiai nutiko dėl problemų su jūsų naršykle ar jūsų interneto ryšiu.\",\n\t\"pad.modals.slowcommit.explanation\": \"Serveris neatsako.\",\n\t\"pad.modals.slowcommit.cause\": \"Tai gali būti dėl problemų su tinklo ryšiu.\",\n\t\"pad.modals.badChangeset.explanation\": \"Pakeitimas, kurį atlikote buvo klasifikuotas sinchorizacijos serverio kaip neteisėtas.\",\n\t\"pad.modals.badChangeset.cause\": \"Tai galėjo nutikti dėl neteisingos serverio konfigūracijos ar kitos netikėtos elgsenos. Prašome susisiekti su paslaugos administratoriumi jei manote, kad tai klaida. Pabandykite prisijungti iš naujo, kad tęstumėte redagavimą.\",\n\t\"pad.modals.corruptPad.explanation\": \"Bloknotas, kurį bandote pasiekti yra sugadintas.\",\n\t\"pad.modals.corruptPad.cause\": \"Tai gali nutikti dėl neteisingos serverio konfigūracijos ar kitos netikėtos elgsenos. Prašome susisiekti su paslaugos administratoriumi.\",\n\t\"pad.modals.deleted\": \"Ištrintas.\",\n\t\"pad.modals.deleted.explanation\": \"Bloknotas buvo pašalintas.\",\n\t\"pad.modals.rateLimited.explanation\": \"Išsiuntėte per daug pranešimų į šį bloknotą, todėl jis atjungė jus.\",\n\t\"pad.modals.rejected.explanation\": \"Serveris atmetė jūsų naršyklės išsiųstą pranešimą.\",\n\t\"pad.modals.rejected.cause\": \"Gali būti, kad serveris buvo atnaujintas jums peržiūrint bloknotą, o gal tai Etherpad klaida. Pabandykite iš naujo įkelti puslapį.\",\n\t\"pad.modals.disconnected\": \"Jūs atsijungėte.\",\n\t\"pad.modals.disconnected.explanation\": \"Ryšys su serveriu nutrūko\",\n\t\"pad.modals.disconnected.cause\": \"Gali būti, kad serveris yra nepasiekiamas. Prašome informuoti paslaugos administratorių jei tai tęsiasi.\",\n\t\"pad.share\": \"Dalintis šiuo bloknotu\",\n\t\"pad.share.readonly\": \"Tik skaityti\",\n\t\"pad.share.link\": \"Nuoroda\",\n\t\"pad.share.emebdcode\": \"Įterptasis URL\",\n\t\"pad.chat\": \"Pokalbiai\",\n\t\"pad.chat.title\": \"Atverti šio bloknoto pokalbį.\",\n\t\"pad.chat.loadmessages\": \"Įkrauti daugiau pranešimų\",\n\t\"pad.chat.stick.title\": \"Priklijuoti pokalbį\",\n\t\"pad.chat.writeMessage.placeholder\": \"Rašykite savo žinutę čia\",\n\t\"timeslider.followContents\": \"Sekite bloknoto turinio atnaujinimus\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Laiko slinkiklis\",\n\t\"timeslider.toolbar.returnbutton\": \"Grįžti į bloknotą\",\n\t\"timeslider.toolbar.authors\": \"Autoriai:\",\n\t\"timeslider.toolbar.authorsList\": \"Nėra autorių\",\n\t\"timeslider.toolbar.exportlink.title\": \"Eksportuoti\",\n\t\"timeslider.exportCurrent\": \"Eksportuoti dabartinę versiją kaip:\",\n\t\"timeslider.version\": \"Versija {{version}}\",\n\t\"timeslider.saved\": \"Išsaugota {{year}},{{month}} {{day}}\",\n\t\"timeslider.playPause\": \"Atkurti / Pristabdyti Bloknoto Turinį\",\n\t\"timeslider.backRevision\": \"Grįžti viena Bloknoto peržiūra atgal\",\n\t\"timeslider.forwardRevision\": \"Eiti viena Bloknoto peržiūra į priekį\",\n\t\"timeslider.dateformat\": \"{{year}}-{{month}}-{{day}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Sausis\",\n\t\"timeslider.month.february\": \"Vasaris\",\n\t\"timeslider.month.march\": \"Kovas\",\n\t\"timeslider.month.april\": \"Balandis\",\n\t\"timeslider.month.may\": \"Gegužė\",\n\t\"timeslider.month.june\": \"Birželis\",\n\t\"timeslider.month.july\": \"Liepa\",\n\t\"timeslider.month.august\": \"Rugpjūtis\",\n\t\"timeslider.month.september\": \"Rugsėjis\",\n\t\"timeslider.month.october\": \"Spalis\",\n\t\"timeslider.month.november\": \"Lapkritis\",\n\t\"timeslider.month.december\": \"Gruodis\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: bevardis autorius, other: bevardžiai autoriai ]}\",\n\t\"pad.savedrevs.marked\": \"Peržiūrą dabar pažymėta kaip išsaugota peržiūra\",\n\t\"pad.savedrevs.timeslider\": \"Galite peržiūrėti išsaugotas peržiūras apsilankydami laiko slinkiklyje\",\n\t\"pad.userlist.entername\": \"Įveskite savo vardą\",\n\t\"pad.userlist.unnamed\": \"bevardis\",\n\t\"pad.editbar.clearcolors\": \"Išvalyti autorystės spalvas visame dokumente? To negalima atšaukti\",\n\t\"pad.impexp.importbutton\": \"Importuoti dabar\",\n\t\"pad.impexp.importing\": \"Importuojama...\",\n\t\"pad.impexp.confirmimport\": \"Failo importavimas pakeis dabartinį bloknoto tekstą. Ar tikrai norite tęsti?\",\n\t\"pad.impexp.convertFailed\": \"Mums nepavyko importuoti šio failo. Prašome naudoti kitokį dokumento formatą arba nukopijuoti ir įklijuoti rankiniu būdu\",\n\t\"pad.impexp.padHasData\": \"Mums nepavyko importuoti šio failo, nes šis Bloknotas jau turėjo pakeitimų, prašome importuoti į naują bloknotą\",\n\t\"pad.impexp.uploadFailed\": \"Įkėlimas nepavyko, bandykite dar kartą\",\n\t\"pad.impexp.importfailed\": \"Importuoti nepavyko\",\n\t\"pad.impexp.copypaste\": \"Prašome nukopijuoti ir įklijuoti\",\n\t\"pad.impexp.exportdisabled\": \"Eksportavimas {{type}} formatu yra išjungtas. Prašome susisiekti su savo sistemos administratoriumi dėl informacijos.\",\n\t\"pad.impexp.maxFileSize\": \"Failas per didelis. Susisiekite su svetainės administratoriumi, kad padidintų leistiną importuoti failo dydį\"\n}\n"
  },
  {
    "path": "src/locales/lv.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Admresdeserv.\",\n\t\t\t\"Jmg.cmdi\",\n\t\t\t\"Oskars\",\n\t\t\t\"Papuass\",\n\t\t\t\"Silraks\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Tiek izmantots kā pogas teksts. Blociņš, Etherpad kontekstā, ir piezīmju blociņš, uz kura rakstīt.\",\n\t\"index.createOpenPad\": \"vai izveidojiet/atveriet Blociņu ar nosaukumu:\",\n\t\"pad.toolbar.bold.title\": \"Treknrakstā (CTRL + B)\",\n\t\"pad.toolbar.italic.title\": \"Slīpraksta (Ctrl-es)\",\n\t\"pad.toolbar.underline.title\": \"Pasvītrojuma (CTRL + U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Pārsvītrojums (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Sakārtots saraksts (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Nesakārtots saraksts (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Atkāpe (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Izkāpe (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Atsaukt (CTRL + Z)\",\n\t\"pad.toolbar.redo.title\": \"Atcelt atsaukšanu (CTRL + Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Notīrit autoru krāsas (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importēšanas/eksportēšanas no un uz citu failu formātiem\",\n\t\"pad.toolbar.timeslider.title\": \"Laika-slidinātājs\",\n\t\"pad.toolbar.savedRevision.title\": \"Saglabāt pārskatīšanu\",\n\t\"pad.toolbar.settings.title\": \"Iestatījumi\",\n\t\"pad.toolbar.embed.title\": \"Koplietot un iegut šo pad\",\n\t\"pad.toolbar.showusers.title\": \"Parādīt šo padu lietotājus\",\n\t\"pad.colorpicker.save\": \"Saglabāt\",\n\t\"pad.colorpicker.cancel\": \"Atcelt\",\n\t\"pad.loading\": \"Ielādē…\",\n\t\"pad.permissionDenied\": \"Atvaino, bet tev nav pieejas šim pad.\",\n\t\"pad.settings.padSettings\": \"Pad Iestatijumi\",\n\t\"pad.settings.myView\": \"Mans viedoklis\",\n\t\"pad.settings.stickychat\": \"Čats vienmēr ekrānā\",\n\t\"pad.settings.colorcheck\": \"Autorības krāsas\",\n\t\"pad.settings.linenocheck\": \"Rindiņu numurus\",\n\t\"pad.settings.rtlcheck\": \"Lasīt saturu no labās puses uz kreiso?\",\n\t\"pad.settings.fontType\": \"Fonta tips:\",\n\t\"pad.settings.fontType.normal\": \"Normāls\",\n\t\"pad.settings.language\": \"Valoda:\",\n\t\"pad.settings.about\": \"Par\",\n\t\"pad.importExport.import_export\": \"Importet/Eksportet\",\n\t\"pad.importExport.import\": \"Augšupielādēt jebkuru teksta failu vai dokumentu\",\n\t\"pad.importExport.importSuccessful\": \"Veiksmīgi!\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Vienkārša teksta\",\n\t\"pad.importExport.exportword\": \"Programma Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open dokumenta formāts)\",\n\t\"pad.modals.connected\": \"Pievienojies.\",\n\t\"pad.modals.cancel\": \"Atcelt\",\n\t\"pad.modals.userdup\": \"Atvērts citā logā\",\n\t\"pad.modals.unauth\": \"Nav atļauts\",\n\t\"pad.modals.looping.explanation\": \"Pastāv sakaru problēmas ar sinhronizācijas servera.\",\n\t\"pad.modals.initsocketfail\": \"Serveris nav sasniedzams.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Nevarēja izveidot savienojumu ar sinhronizācijas serveri.\",\n\t\"pad.modals.slowcommit.explanation\": \"Serveris nereaģē.\",\n\t\"pad.modals.deleted\": \"Dzēsts\",\n\t\"pad.modals.disconnected\": \"Jūs esat atvienots.\",\n\t\"pad.modals.disconnected.explanation\": \"Tika zaudēts savienojums ar serveri\",\n\t\"pad.modals.disconnected.cause\": \"Iespējams, ka serveris nav pieejams. Lūgums paziņot pakalpojuma administratoram, ja tas turpina notikt.\",\n\t\"pad.share\": \"Koplietot šo pad\",\n\t\"pad.share.readonly\": \"Tikai lasāms\",\n\t\"pad.share.link\": \"Saite\",\n\t\"pad.chat\": \"Čats\",\n\t\"timeslider.toolbar.authors\": \"Autori:\",\n\t\"timeslider.toolbar.authorsList\": \"Nav autoru\",\n\t\"timeslider.toolbar.exportlink.title\": \"Eksportēt\",\n\t\"timeslider.month.january\": \"Janvāris\",\n\t\"timeslider.month.february\": \"Februāris\",\n\t\"timeslider.month.march\": \"Marts\",\n\t\"timeslider.month.april\": \"Aprīlis\",\n\t\"timeslider.month.may\": \"Maijs\",\n\t\"timeslider.month.june\": \"Jūnijs\",\n\t\"timeslider.month.july\": \"Jūlijs\",\n\t\"timeslider.month.august\": \"Augusts\",\n\t\"timeslider.month.september\": \"Septembris\",\n\t\"timeslider.month.october\": \"Oktobris\",\n\t\"timeslider.month.november\": \"Novembris\",\n\t\"timeslider.month.december\": \"Decembris\",\n\t\"pad.userlist.entername\": \"Ievadiet savu vārdu\",\n\t\"pad.userlist.unnamed\": \"nenosaukts\",\n\t\"pad.impexp.importbutton\": \"Importēt tūlīt\",\n\t\"pad.impexp.importing\": \"Importē...\",\n\t\"pad.impexp.uploadFailed\": \"Augšupielāde neizdevās, lūdzu, mēģiniet vēlreiz\",\n\t\"pad.impexp.importfailed\": \"Imports neizdevās\"\n}\n"
  },
  {
    "path": "src/locales/map-bms.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Empu\",\n\t\t\t\"StefanusRA\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Pad Anyar\",\n\t\"index.createOpenPad\": \"utawa gawe/bukak Pad nganggo jeneng:\",\n\t\"pad.toolbar.bold.title\": \"Kandhel (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Miring (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Garisngisor (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Corettengaeh\",\n\t\"pad.toolbar.ol.title\": \"Daftar nganggo nomer\",\n\t\"pad.toolbar.ul.title\": \"Daftar ora nganggo nomer\",\n\t\"pad.toolbar.indent.title\": \"Nggantung\",\n\t\"pad.toolbar.unindent.title\": \"nggantung njaba\",\n\t\"pad.toolbar.undo.title\": \"Batalna (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Baleni (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Busek ''Authorship Colors''\",\n\t\"pad.toolbar.import_export.title\": \"Impor/Ekspor sekang/maring format berkas sejen\",\n\t\"pad.toolbar.timeslider.title\": \"Timeslider\",\n\t\"pad.toolbar.savedRevision.title\": \"Simpen revisi\",\n\t\"pad.toolbar.settings.title\": \"Pangaturan\",\n\t\"pad.toolbar.embed.title\": \"Sebarna lan ''embed'' pad kiye\",\n\t\"pad.toolbar.showusers.title\": \"Tidokna panganggo-panganggo nang pad kiye\",\n\t\"pad.colorpicker.save\": \"Simpen\",\n\t\"pad.colorpicker.cancel\": \"Batalna\",\n\t\"pad.loading\": \"Muatna...\",\n\t\"pad.permissionDenied\": \"Rika ora duwe idin kanggo ngakses pad kiye\",\n\t\"pad.settings.padSettings\": \"Pangaturan Pad\",\n\t\"pad.settings.myView\": \"Delengané Inyong\",\n\t\"pad.settings.stickychat\": \"Dopokan mesti nang layar\",\n\t\"pad.settings.colorcheck\": \"Authorship colors\",\n\t\"pad.settings.linenocheck\": \"Nomer baris\",\n\t\"pad.settings.rtlcheck\": \"Waca isi sekang tengen maring kiwe?\",\n\t\"pad.settings.fontType\": \"Tipe Font:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Basa:\",\n\t\"pad.importExport.import_export\": \"Impor/Ekspor\",\n\t\"pad.importExport.import\": \"Unggahna berkas teks utawa dokumen\",\n\t\"pad.importExport.importSuccessful\": \"Sukses!\",\n\t\"pad.importExport.export\": \"Ekspor pad kiye dadi:\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"t\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Rika mung teyeng impor sekang format plain text utawa HTML. Kanggo fitur impor sing lewih maju monggo <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">masang abiword</a>.\",\n\t\"pad.modals.connected\": \"Nyambung.\",\n\t\"pad.modals.reconnecting\": \"Mbaleli nyambung ming pad Rika...\",\n\t\"pad.modals.forcereconnect\": \"Maksa nyambung maning\",\n\t\"pad.modals.userdup\": \"Bukak nang jendela sejen\",\n\t\"pad.modals.userdup.explanation\": \"Pad kiye kayane dibukak nang lewih sekang siji browser nang komputer kiye.\",\n\t\"pad.modals.userdup.advice\": \"Nyambung maning nganggo jendela kiye baen.\",\n\t\"pad.modals.unauth\": \"Not authorized\",\n\t\"pad.modals.unauth.explanation\": \"Idin-e Rika wis diowahi dong lagi ndeleng kaca kiye. Jajal nyambung maning.\",\n\t\"pad.modals.looping.explanation\": \"Pra ana masalah komunikasi karo server sinkronisasi.\",\n\t\"pad.modals.looping.cause\": \"Ndeyan Rika gole nyambung nganggo firewall utawa proksi sing ora pas.\",\n\t\"pad.modals.initsocketfail\": \"Server ora teyeng dihubungi.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Ora teyeng nyambung maring sinkronisasi server.\",\n\t\"pad.modals.initsocketfail.cause\": \"Kiye ndeyan ana masalah karo perambanne Rika utawa sambungan internete Rika.\",\n\t\"pad.modals.slowcommit.explanation\": \"Server ora respon.\",\n\t\"pad.modals.slowcommit.cause\": \"Kiye ndeyan ana masalah karo sambungan jaringan.\",\n\t\"pad.modals.deleted\": \"Dibusek.\",\n\t\"pad.modals.deleted.explanation\": \"Pad kiye wis dibusek.\",\n\t\"pad.modals.disconnected\": \"Rika wis dipedot sambungane.\",\n\t\"pad.modals.disconnected.explanation\": \"Sambungan maring server wis ilang\",\n\t\"pad.modals.disconnected.cause\": \"Servere ndeyan ora ana. Monggo tidokna inyong angger kahanan kiye terus kedaden maning.\",\n\t\"pad.share\": \"Sebarna pad kiye\",\n\t\"pad.share.readonly\": \"Waca thok\",\n\t\"pad.share.link\": \"Pranala\",\n\t\"pad.share.emebdcode\": \"Embed URL\",\n\t\"pad.chat\": \"Dopokan\",\n\t\"pad.chat.title\": \"Buka dopokan kanggo pad kiye.\",\n\t\"pad.chat.loadmessages\": \"Muatna pesen lewih akeh\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Timeslider\",\n\t\"timeslider.toolbar.returnbutton\": \"Mbalik ming pad\",\n\t\"timeslider.toolbar.authors\": \"Penulise:\",\n\t\"timeslider.toolbar.authorsList\": \"Ora ana penulise\",\n\t\"timeslider.toolbar.exportlink.title\": \"Ekspor\",\n\t\"timeslider.exportCurrent\": \"Ekspor versi sekiye dadi:\",\n\t\"timeslider.version\": \"Versi {{version}}\",\n\t\"timeslider.saved\": \"Simpen {{day}} {{month}} {{year}}\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Januari\",\n\t\"timeslider.month.february\": \"Februari\",\n\t\"timeslider.month.march\": \"Maret\",\n\t\"timeslider.month.april\": \"April\",\n\t\"timeslider.month.may\": \"Mei\",\n\t\"timeslider.month.june\": \"Juni\",\n\t\"timeslider.month.july\": \"Juli\",\n\t\"timeslider.month.august\": \"Agustus\",\n\t\"timeslider.month.september\": \"September\",\n\t\"timeslider.month.october\": \"Oktober\",\n\t\"timeslider.month.november\": \"November\",\n\t\"timeslider.month.december\": \"Desember\",\n\t\"timeslider.unnamedauthors\": \"{{num}} durung dijenengi {[plural(num) one: author, other: authors ]}\",\n\t\"pad.savedrevs.marked\": \"Revisi kiye sekiye ditandani dadi revisi sing wis disimpen\",\n\t\"pad.userlist.entername\": \"Lebokna jenenge Rika\",\n\t\"pad.userlist.unnamed\": \"durungdijenengi\",\n\t\"pad.editbar.clearcolors\": \"Busek ''authorship colors'' nang kabeh dokumen?\",\n\t\"pad.impexp.importbutton\": \"Impor Sekiye\",\n\t\"pad.impexp.importing\": \"Lagi ngimpor...\",\n\t\"pad.impexp.confirmimport\": \"Ngimpor berkas bakal dadi nindih teks sekiye nang pad. Apa Rika wis mantep arep mroses kiye?\",\n\t\"pad.impexp.convertFailed\": \"Inyong ora teyeng ngimpor berkas kiye. Jajal nganggo format dokumen sejen utawa salin-tempel manual.\",\n\t\"pad.impexp.uploadFailed\": \"Gole ngunggah gagal, monggo dijajal maning\",\n\t\"pad.impexp.importfailed\": \"Gole ngimpor gagal\",\n\t\"pad.impexp.copypaste\": \"Monggo salin-tempel\",\n\t\"pad.impexp.exportdisabled\": \"Ngekspor maring format {{type}} ora olih. Monggo takon maring administrator sisteme Rika kanggo detile.\"\n}\n"
  },
  {
    "path": "src/locales/mg.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Jagwar\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Pad vaovao\",\n\t\"index.createOpenPad\": \"na hamorona/hanokatra Pad manana anarana:\",\n\t\"pad.toolbar.bold.title\": \"Matevina (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Mandry (Ctrl-L)\",\n\t\"pad.toolbar.underline.title\": \"Tsipihana (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Voatsipika\",\n\t\"pad.toolbar.ol.title\": \"Lisitra nalamina\",\n\t\"pad.toolbar.ul.title\": \"Lisitra tsy voalamina\",\n\t\"pad.toolbar.undo.title\": \"Averina (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Averina (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Hanala ny loko famantarana mpanorona\",\n\t\"pad.toolbar.import_export.title\": \"Hampiditra/Hamoaka amin'ny karazan-drakitra hafa\",\n\t\"pad.toolbar.settings.title\": \"Fanafahana\",\n\t\"pad.colorpicker.save\": \"Tehirizina\",\n\t\"pad.colorpicker.cancel\": \"Aoka ihany\",\n\t\"pad.loading\": \"Am-pakàna…\",\n\t\"pad.permissionDenied\": \"Tsy manana lalalana mijery ity pad ity ianao\",\n\t\"pad.settings.padSettings\": \"Safidin'ny ped\",\n\t\"pad.settings.myView\": \"Ny jeriko\",\n\t\"pad.settings.linenocheck\": \"Laharan'ny andalana\",\n\t\"pad.settings.rtlcheck\": \"Hamaky ny votoatiny miankavia?\",\n\t\"pad.settings.fontType\": \"Karazan-tarehintsoratra:\",\n\t\"pad.settings.language\": \"Fiteny:\",\n\t\"pad.importExport.import_export\": \"Hampiditra/Hamoaka\",\n\t\"pad.importExport.import\": \"Hampiditra raki-tsoratra na rakitra\",\n\t\"pad.importExport.importSuccessful\": \"Vita soa aman-tsara!\",\n\t\"pad.importExport.export\": \"Hamoaka ny pad ankehitriny ho:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Soratra tsotra\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.modals.connected\": \"Tafaray.\",\n\t\"pad.modals.forcereconnect\": \"Hanery ny famerenam-pifandraisana\",\n\t\"pad.modals.reconnecttimer\": \"Manandrana mamerim-pifandraisana\",\n\t\"pad.modals.cancel\": \"Aoka ihany\",\n\t\"pad.modals.userdup\": \"Nosokafana tanaty varavarankely hafa\",\n\t\"pad.modals.unauth\": \"Tsy nahazo alalana\",\n\t\"pad.modals.initsocketfail\": \"Tsy hita ny lohamilina.\",\n\t\"pad.modals.slowcommit.explanation\": \"Tsy mamaly ny lohamilina\",\n\t\"pad.modals.slowcommit.cause\": \"Izany zavatra izany dia mety nohon'ny fifandraisana ratsy amin'ny lohamilina.\",\n\t\"pad.modals.badChangeset.explanation\": \"Voasokajin'ny lohamilim-pirindrana ho tsy azo atao ny fiovana nataonao.\",\n\t\"pad.modals.badChangeset.cause\": \"Izany zavatra izany dia mety nohon'ny configuration lohamilina diso na hetsika tsy nampoizina hafa. Mifandraisa amin'ny mpandrindran'ny serivisy, raha heverinao fa hadisoana io. Mifandraisa indray ahafahanao manohy ny fanovana.\",\n\t\"pad.modals.deleted\": \"Voafafa.\",\n\t\"pad.modals.deleted.explanation\": \"Nesorina ity pad ity.\",\n\t\"pad.modals.disconnected\": \"Tapaka ny fifandraisanao.\",\n\t\"pad.modals.disconnected.explanation\": \"Very ny fifandraisana tamin'ny lohamilina\",\n\t\"pad.share\": \"Hizara ity pad ity\",\n\t\"pad.share.readonly\": \"Vakiana ihany\",\n\t\"pad.share.link\": \"Rohy\",\n\t\"pad.share.emebdcode\": \"Hampiditra URL\",\n\t\"pad.chat\": \"Resaka mivantana\",\n\t\"pad.chat.title\": \"Hampiditra ny karajia ho an'ity pad ity.\",\n\t\"pad.chat.loadmessages\": \"Haka hafatra be kokoa\",\n\t\"timeslider.pageTitle\": \"Tantara dinamikan'i {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Hiverina amin'ny pad\",\n\t\"timeslider.toolbar.authors\": \"Mpamorona:\",\n\t\"timeslider.toolbar.authorsList\": \"Tsy misy mpamorona\",\n\t\"timeslider.toolbar.exportlink.title\": \"Avoaka\",\n\t\"timeslider.exportCurrent\": \"Hamoaka ny versiona ankehitriny ho:\",\n\t\"timeslider.version\": \"Versiona {{version}}\",\n\t\"timeslider.saved\": \"Notahirizina ny {{day}} {{month}} {{year}}\",\n\t\"timeslider.month.january\": \"Janoary\",\n\t\"timeslider.month.february\": \"Febroary\",\n\t\"timeslider.month.march\": \"Martsa\",\n\t\"timeslider.month.april\": \"Aprily\",\n\t\"timeslider.month.may\": \"Mey\",\n\t\"timeslider.month.june\": \"Jiona\",\n\t\"timeslider.month.july\": \"Jolay\",\n\t\"timeslider.month.august\": \"Aogositra\",\n\t\"timeslider.month.september\": \"Septambra\",\n\t\"timeslider.month.october\": \"Oktobra\",\n\t\"timeslider.month.november\": \"Novambra\",\n\t\"timeslider.month.december\": \"Desambra\",\n\t\"pad.userlist.unnamed\": \"tsy manana naarana\",\n\t\"pad.impexp.importbutton\": \"Ampidirina izao\",\n\t\"pad.impexp.importing\": \"Mampiditra...\"\n}\n"
  },
  {
    "path": "src/locales/mk.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Bjankuloski06\",\n\t\t\t\"Brest\",\n\t\t\t\"Vlad5250\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Администраторска управувачница — Etherpad\",\n\t\"admin_plugins\": \"Раководител со приклучоци\",\n\t\"admin_plugins.available\": \"Приклучоци на располагање\",\n\t\"admin_plugins.available_not-found\": \"Не пронајдов ниеден приклучок.\",\n\t\"admin_plugins.available_fetching\": \"Земам...\",\n\t\"admin_plugins.available_install.value\": \"Воспостави\",\n\t\"admin_plugins.available_search.placeholder\": \"Пребарај приклучоци за воспоставка\",\n\t\"admin_plugins.description\": \"Опис\",\n\t\"admin_plugins.installed\": \"Воспоставени приклучоци\",\n\t\"admin_plugins.installed_fetching\": \"Ги земам воспоставените приклучоци…\",\n\t\"admin_plugins.installed_nothing\": \"Засега немате воспоставено ниеден приклучок.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Отстрани\",\n\t\"admin_plugins.last-update\": \"Последна поднова\",\n\t\"admin_plugins.name\": \"Назив\",\n\t\"admin_plugins.page-title\": \"Раководител со приклучоци — Etherpad\",\n\t\"admin_plugins.version\": \"Верзија\",\n\t\"admin_plugins_info\": \"Инфрмации за решавање проблеми\",\n\t\"admin_plugins_info.hooks\": \"Воспоставени пресретници\",\n\t\"admin_plugins_info.hooks_client\": \"Пресретници од клиентска страна\",\n\t\"admin_plugins_info.hooks_server\": \"Пресретници од опслужувачка страна\",\n\t\"admin_plugins_info.parts\": \"Воспоставени делови\",\n\t\"admin_plugins_info.plugins\": \"Воспоставени приклучоци\",\n\t\"admin_plugins_info.page-title\": \"Информации за приклучоци — Etherpad\",\n\t\"admin_plugins_info.version\": \"Верзија на Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Најнова достапна верзија\",\n\t\"admin_plugins_info.version_number\": \"Број на верзијата\",\n\t\"admin_settings\": \"Нагодувања\",\n\t\"admin_settings.current\": \"Тековна поставеност\",\n\t\"admin_settings.current_example-devel\": \"Предлошка за примерни разработни нагодувања\",\n\t\"admin_settings.current_example-prod\": \"Предлошка за примерни производни нагодувања\",\n\t\"admin_settings.current_restart.value\": \"Пушти го Etherpad одново\",\n\t\"admin_settings.current_save.value\": \"Зачувај нагодувања\",\n\t\"admin_settings.page-title\": \"Нагодувања — Etherpad\",\n\t\"index.newPad\": \"Нова тетратка\",\n\t\"index.settings\": \"Нагодувања\",\n\t\"index.transferSessionTitle\": \"Префрли седница\",\n\t\"index.receiveSessionTitle\": \"Прими седница\",\n\t\"index.receiveSessionDescription\": \"Тука можете да примите седница на Etherpad од друг прелистувач или уред. Но имајте на ум дека ова ќе ја избрише вашата тековна седница, ако ја има.\",\n\t\"index.transferSession\": \"1. Префрли седница\",\n\t\"index.transferSessionNow\": \"Префрли седница сега\",\n\t\"index.copyLink\": \"2. Копирај врска\",\n\t\"index.copyLinkDescription\": \"Стиснете на копчето подолу за да ја прекопирајте врската во вашиот меѓусклад\",\n\t\"index.copyLinkButton\": \"Копирај врска во меѓускладот\",\n\t\"index.transferToSystem\": \"3. Копирај седница во нов систем\",\n\t\"index.transferToSystemDescription\": \"Отворете ја ископираната врска во целниот прелистувач или уред за да ја префрлите вашата седница.\",\n\t\"index.transferSessionDescription\": \"Префрлете ја вашата тековна седница на прелистувач или уред стискајќи на копчето подолу. Ова ќе ја прекопира врската во страница која ќе ви ја префрли седницата кога ќе се отвори во целниот прелистувач или уред.\",\n\t\"index.createOpenPad\": \"Отвори тетратка по име\",\n\t\"index.openPad\": \"отвори постоечка тетратка наречена:\",\n\t\"index.recentPads\": \"Скорешни тетратки\",\n\t\"index.recentPadsEmpty\": \"Не најдов скорешни тетратки.\",\n\t\"index.generateNewPad\": \"Создај случајно име на тетратка\",\n\t\"index.labelPad\": \"Име на тетратка (незадолжително)\",\n\t\"index.placeholderPadEnter\": \"Внесете име на тетратката...\",\n\t\"index.createAndShareDocuments\": \"Создавајте и споделувајте документи во живо\",\n\t\"index.createAndShareDocumentsDescription\": \"Etherpad ви овозможува соработно уредување на документи во живо, слично како уредувачот за повеќе играчи во живо што работи во вашиот пречистувач.\",\n\t\"pad.toolbar.bold.title\": \"Задебелено (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Косо (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Подвлечено (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Прецртано (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Подреден список (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Неподреден список (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Отстап (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Истап (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Врати (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Повтори (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Тргни ги авторските бои (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Увоз/Извоз од/во разни податотечни формати\",\n\t\"pad.toolbar.timeslider.title\": \"Историски преглед\",\n\t\"pad.toolbar.savedRevision.title\": \"Зачувај преработка\",\n\t\"pad.toolbar.settings.title\": \"Поставки\",\n\t\"pad.toolbar.embed.title\": \"Споделете и вметнете ја тетраткава\",\n\t\"pad.toolbar.home.title\": \"Назад на Почетна\",\n\t\"pad.toolbar.showusers.title\": \"Прикажи корисниците на тетраткава\",\n\t\"pad.colorpicker.save\": \"Зачувај\",\n\t\"pad.colorpicker.cancel\": \"Откажи\",\n\t\"pad.loading\": \"Вчитувам...\",\n\t\"pad.noCookie\": \"Не можев да го најдам колачето. Овозможете колачиња во вашиот прелистувач! Вашата седница и нагодувања нема да бидат зачувани за следната посета. Ова можеби се должи на тоа што Etherpad е вклучен во iFrame во некои прелистувачи. Проверете дали Etherpad е на истиот поддомен/домен како матичниот iFrame\",\n\t\"pad.permissionDenied\": \"За овде не е потребна дозвола за пристап\",\n\t\"pad.settings.padSettings\": \"Поставки на тетратката\",\n\t\"pad.settings.myView\": \"Мој поглед\",\n\t\"pad.settings.stickychat\": \"Разговорите секогаш на екранот\",\n\t\"pad.settings.chatandusers\": \"Прикажи разговор и корисници\",\n\t\"pad.settings.colorcheck\": \"Авторски бои\",\n\t\"pad.settings.linenocheck\": \"Броеви на редовите\",\n\t\"pad.settings.rtlcheck\": \"Содржините да се читаат од десно на лево?\",\n\t\"pad.settings.fontType\": \"Тип на фонт:\",\n\t\"pad.settings.fontType.normal\": \"Нормален\",\n\t\"pad.settings.language\": \"Јазик:\",\n\t\"pad.settings.deletePad\": \"Избриши тетратка\",\n\t\"pad.delete.confirm\": \"Дали навистина сакате да ја избришете тетраткава?\",\n\t\"pad.settings.about\": \"За додатоков\",\n\t\"pad.settings.poweredBy\": \"Овозможено од\",\n\t\"pad.importExport.import_export\": \"Увоз/Извоз\",\n\t\"pad.importExport.import\": \"Подигање на било каква било текстуална податотека или документ\",\n\t\"pad.importExport.importSuccessful\": \"Успешно!\",\n\t\"pad.importExport.export\": \"Извези ја тековната тетратка како\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Прост текст\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Можете да увезувате само од прост текст и HTML-формат. Понапредни можности за увоз ќе добиете ако <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">воспоставите AbiWord или LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Поврзано.\",\n\t\"pad.modals.reconnecting\": \"Ве преповрзувам со тетратката...\",\n\t\"pad.modals.forcereconnect\": \"Наметни преповрзување\",\n\t\"pad.modals.reconnecttimer\": \"Се преповрзувам за\",\n\t\"pad.modals.cancel\": \"Откажи\",\n\t\"pad.modals.userdup\": \"Отворено во друг прозорец\",\n\t\"pad.modals.userdup.explanation\": \"Оваа тетратка е отворена на повеќе од еден прозорец (во прелистувач) на сметачот.\",\n\t\"pad.modals.userdup.advice\": \"Преповрзете се за да го користите овој прозорец.\",\n\t\"pad.modals.unauth\": \"Неовластено\",\n\t\"pad.modals.unauth.explanation\": \"Вашите дозволи се имаат изменето додека ја гледавте страницава. Обидете се да се преповрзете.\",\n\t\"pad.modals.looping.explanation\": \"Се јавија проблеми со врската со усогласителниот опслужувач.\",\n\t\"pad.modals.looping.cause\": \"Можеби сте поврзани преку нескладен огнен ѕид или застапник.\",\n\t\"pad.modals.initsocketfail\": \"Опслужувачот е недостапен.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Не можев да се поврзам со усогласителниот опслужувач.\",\n\t\"pad.modals.initsocketfail.cause\": \"Ова веројатно се должи на проблем со вашиот прелистувач или семрежната врска.\",\n\t\"pad.modals.slowcommit.explanation\": \"Опслужувачот не се одѕива.\",\n\t\"pad.modals.slowcommit.cause\": \"Ова може да се должи на проблеми со мрежното поврзување.\",\n\t\"pad.modals.badChangeset.explanation\": \"Опслужувачот за усогласување го смета уредувањето што го направивте за недопуштено.\",\n\t\"pad.modals.badChangeset.cause\": \"Ова може да се должи на погрешна поставеност на опслужувачот или некое друго неочекувано поведение. Обратете се кај администраторот доколку сметате дека ова е грешка. Обидете се да се превклучите за да продолжите со уредување.\",\n\t\"pad.modals.corruptPad.explanation\": \"Тетратката што сакате да ја отворите е расипана.\",\n\t\"pad.modals.corruptPad.cause\": \"Ова може да се должи на погрешна поставеност на опслужувачот или некое друго неочекувано поведение. Обратете се кај администраторот.\",\n\t\"pad.modals.deleted\": \"Избришано.\",\n\t\"pad.modals.deleted.explanation\": \"Оваа тетратка е отстранета.\",\n\t\"pad.modals.rateLimited\": \"Ограничено по стапка.\",\n\t\"pad.modals.rateLimited.explanation\": \"Испративте премногу пораки на тетраткава, па затоа таа ве исклучи.\",\n\t\"pad.modals.rejected.explanation\": \"Опслужувачот ја отфрли пораката што му беше испратена од вашиот прелистувач.\",\n\t\"pad.modals.rejected.cause\": \"Опслужувачот може да бил подновен додека ја гледавте тетратката, или пак Etherpad има некоја грешка. Пробајте со превчитување на страницата.\",\n\t\"pad.modals.disconnected\": \"Врската е прекината.\",\n\t\"pad.modals.disconnected.explanation\": \"Врската со опслужувачот е прекината\",\n\t\"pad.modals.disconnected.cause\": \"Опслужувачот може да е недостапен. Известете го администраторот ако ова продолжи да ви се случува.\",\n\t\"pad.share\": \"Сподели ја тетраткава\",\n\t\"pad.share.readonly\": \"Само читање\",\n\t\"pad.share.link\": \"Врска\",\n\t\"pad.share.emebdcode\": \"Вметни URL\",\n\t\"pad.chat\": \"Разговор\",\n\t\"pad.chat.title\": \"Отвори го разговорот за оваа тетратка.\",\n\t\"pad.chat.loadmessages\": \"Вчитај уште пораки\",\n\t\"pad.chat.stick.title\": \"Залепи го разговорот на екранот\",\n\t\"pad.chat.writeMessage.placeholder\": \"Тука напишете порака\",\n\t\"timeslider.followContents\": \"Следи ги подновите во содржината на тетратката\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Историски преглед\",\n\t\"timeslider.toolbar.returnbutton\": \"Назад на тетратката\",\n\t\"timeslider.toolbar.authors\": \"Автори:\",\n\t\"timeslider.toolbar.authorsList\": \"Нема автори\",\n\t\"timeslider.toolbar.exportlink.title\": \"Извоз\",\n\t\"timeslider.exportCurrent\": \"Извези ја тековната верзија како:\",\n\t\"timeslider.version\": \"Верзија {{version}}\",\n\t\"timeslider.saved\": \"Зачувано на {{day}} {{month}} {{year}} г.\",\n\t\"timeslider.playPause\": \"Пушти/запри содржина на тетратката\",\n\t\"timeslider.backRevision\": \"Назад за една преработка на тетратката\",\n\t\"timeslider.forwardRevision\": \"Напред за една преработка на тетратката\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"јануари\",\n\t\"timeslider.month.february\": \"февруари\",\n\t\"timeslider.month.march\": \"март\",\n\t\"timeslider.month.april\": \"април\",\n\t\"timeslider.month.may\": \"мај\",\n\t\"timeslider.month.june\": \"јуни\",\n\t\"timeslider.month.july\": \"јули\",\n\t\"timeslider.month.august\": \"август\",\n\t\"timeslider.month.september\": \"септември\",\n\t\"timeslider.month.october\": \"октомври\",\n\t\"timeslider.month.november\": \"ноември\",\n\t\"timeslider.month.december\": \"декември\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: неименуван автор, other: неименувани автори ]}\",\n\t\"pad.savedrevs.marked\": \"Оваа преработка сега е означена како зачувана\",\n\t\"pad.savedrevs.timeslider\": \"Можете да ги погледате зачуваните преработки посетувајќи го времеследниот лизгач\",\n\t\"pad.userlist.entername\": \"Внесете го вашето име\",\n\t\"pad.userlist.unnamed\": \"без име\",\n\t\"pad.editbar.clearcolors\": \"Да ги отстранам авторските бои од целиот документ? Ова е неповратно\",\n\t\"pad.impexp.importbutton\": \"Увези сега\",\n\t\"pad.impexp.importing\": \"Увезувам...\",\n\t\"pad.impexp.confirmimport\": \"Увезувањето на податотека ќе го презапише тековниот текст на тетратката. Дали сте сигурни дека сакате да продолжите?\",\n\t\"pad.impexp.convertFailed\": \"Не можев да ја увезам податотеката. Послужете се со поинаков формат или прекопирајте го текстот рачно.\",\n\t\"pad.impexp.padHasData\": \"Не можевме да ја увеземе оваа податотека бидејќи оваа тетратка веќе има промени. Увезете ја во нова тетратка.\",\n\t\"pad.impexp.uploadFailed\": \"Подигањето не успеа. Обидете се повторно.\",\n\t\"pad.impexp.importfailed\": \"Увозот не успеа\",\n\t\"pad.impexp.copypaste\": \"Прекопирајте\",\n\t\"pad.impexp.exportdisabled\": \"Извозот во форматот {{type}} е оневозможен. Ако сакате да дознаете повеќе за ова, обратете се кај системскиот администратор.\",\n\t\"pad.impexp.maxFileSize\": \"Податотеката е преголема. Обратете се кај администраторот за да ви ја зголеми допуштената големина за увоз на податотеки\"\n}\n"
  },
  {
    "path": "src/locales/ml.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Adithyak1997\",\n\t\t\t\"Akhilan\",\n\t\t\t\"Ambadyanands\",\n\t\t\t\"Clockery\",\n\t\t\t\"Hrishikesh.kb\",\n\t\t\t\"Jinoytommanjaly\",\n\t\t\t\"Nesi\",\n\t\t\t\"Praveenp\",\n\t\t\t\"Santhosh.thottingal\"\n\t\t]\n\t},\n\t\"index.newPad\": \"പുതിയ പാഡ്\",\n\t\"index.createOpenPad\": \"അല്ലെങ്കിൽ പേരുപയോഗിച്ച് പാഡ് സൃഷ്ടിക്കുക/തുറക്കുക:\",\n\t\"pad.toolbar.bold.title\": \"കടുപ്പത്തിലെഴുതുക (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"ചെരിച്ചെഴുതുക (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"അടിവരയിടുക (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"വെട്ടുക (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"ക്രമത്തിലുള്ള പട്ടിക (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"ക്രമരഹിത പട്ടിക (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"വലത്തേക്ക് തള്ളുക (ടാബ്)\",\n\t\"pad.toolbar.unindent.title\": \"ഇടത്തേക്ക് തള്ളുക (ഷിഫ്റ്റ്+ടാബ്)\",\n\t\"pad.toolbar.undo.title\": \"തിരസ്കരിക്കുക (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"വീണ്ടും ചെയ്യുക (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"രചയിതാക്കൾക്കുള്ള നിറങ്ങൾ കളയുക (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"വ്യത്യസ്ത ഫയൽ തരങ്ങളിലേക്ക്/തരങ്ങളിൽ നിന്ന് ഇറക്കുമതി/കയറ്റുമതി ചെയ്യുക\",\n\t\"pad.toolbar.timeslider.title\": \"സമയരേഖ\",\n\t\"pad.toolbar.savedRevision.title\": \"നാൾപ്പതിപ്പ് സേവ് ചെയ്യുക\",\n\t\"pad.toolbar.settings.title\": \"സജ്ജീകരണങ്ങൾ\",\n\t\"pad.toolbar.embed.title\": \"ഈ പാഡ് പങ്ക് വെയ്ക്കുക, എംബെഡ് ചെയ്യുക\",\n\t\"pad.toolbar.showusers.title\": \"ഈ പാഡിലുള്ള ഉപയോക്താക്കളെ പ്രദർശിപ്പിക്കുക\",\n\t\"pad.colorpicker.save\": \"സേവ് ചെയ്യുക\",\n\t\"pad.colorpicker.cancel\": \"റദ്ദാക്കുക\",\n\t\"pad.loading\": \"ശേഖരിക്കുന്നു...\",\n\t\"pad.noCookie\": \"കുക്കി കണ്ടെത്താനായില്ല. ദയവായി താങ്കളുടെ ബ്രൗസറിൽ കുക്കികൾ അനുവദിക്കുക! തിരുത്തലുകൾക്കിടയിൽ താങ്കളുടെ സെഷനോ സജ്ജീകരണങ്ങളോ സേവ് ചെയ്യപ്പെടുകയില്ല. ചില ബ്രൗസറുകളിൽ ഈതർപാഡ് ഐഫ്രെയിമിന്റെ കൂടെ ഉൾപ്പെടുത്തിയത്കൊണ്ടാവാം ഈ പ്രശ്നം. പാരന്റ് ഐഫ്രെയിമിന്റെ അതെ ഡൊമെയ്‌നിൽ/ഉപഡൊമെയ്‌നിൽ തന്നെയാണ് ഈതർപാഡ് ഉള്ളത് എന്ന കാര്യം ഉറപ്പ് വരുത്തുക.\",\n\t\"pad.permissionDenied\": \"ഈ പാഡ് കാണുവാൻ താങ്കൾക്ക് അനുമതിയില്ല\",\n\t\"pad.settings.padSettings\": \"പാഡ് സജ്ജീകരണങ്ങൾ\",\n\t\"pad.settings.myView\": \"എന്റെ കാഴ്ച\",\n\t\"pad.settings.stickychat\": \"തത്സമയസംവാദം എപ്പോഴും സ്ക്രീനിൽ കാണിക്കുക\",\n\t\"pad.settings.chatandusers\": \"ഉപയോക്താക്കളേയും ചാറ്റും കാണിക്കുക\",\n\t\"pad.settings.colorcheck\": \"എഴുത്തുകാർക്കുള്ള നിറങ്ങൾ\",\n\t\"pad.settings.linenocheck\": \"വരികളുടെ ക്രമസംഖ്യ\",\n\t\"pad.settings.rtlcheck\": \"ഉള്ളടക്കം വലത്തുനിന്ന് ഇടത്തോട്ടാണോ വായിക്കേണ്ടത്?\",\n\t\"pad.settings.fontType\": \"ഫോണ്ട് തരം:\",\n\t\"pad.settings.fontType.normal\": \"സാധാരണം\",\n\t\"pad.settings.language\": \"ഭാഷ:\",\n\t\"pad.importExport.import_export\": \"ഇറക്കുമതി/കയറ്റുമതി ചെയ്യുക\",\n\t\"pad.importExport.import\": \"എന്തെങ്കിലും എഴുത്തു പ്രമാണമോ രേഖയോ അപ്‌ലോഡ് ചെയ്യുക\",\n\t\"pad.importExport.importSuccessful\": \"വിജയകരം!\",\n\t\"pad.importExport.export\": \"ഇപ്പോഴത്തെ പാഡ് ഇങ്ങനെ കയറ്റുമതി ചെയ്യുക:\",\n\t\"pad.importExport.exportetherpad\": \"ഈതർപാഡ്\",\n\t\"pad.importExport.exporthtml\": \"എച്ച്.റ്റി.എം.എൽ.\",\n\t\"pad.importExport.exportplain\": \"വെറും എഴുത്ത്\",\n\t\"pad.importExport.exportword\": \"മൈക്രോസോഫ്റ്റ് വേഡ്\",\n\t\"pad.importExport.exportpdf\": \"പി.ഡി.എഫ്.\",\n\t\"pad.importExport.exportopen\": \"ഒ.ഡി.എഫ്. (ഓപ്പൺ ഡോക്യുമെന്റ് ഫോർമാറ്റ്)\",\n\t\"pad.importExport.abiword.innerHTML\": \"പ്ലെയിൻ ടെക്സ്റ്റോ എച്ച്.റ്റി.എം.എൽ. തരമോ മാത്രമേ താങ്കൾക്ക് ഇറക്കുമതി ചെയ്യാനാവൂ. കൂടുതൽ വിപുലീകൃത ഇറക്കുമതി സൗകര്യങ്ങൾക്കായി ദയവായി <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">അബിവേഡ് അല്ലെങ്കിൽ ലിബർഓഫീസ് ഇൻസ്റ്റോൾ ചെയ്യുക</a>.\",\n\t\"pad.modals.connected\": \"ബന്ധിപ്പിച്ചിരിക്കുന്നു.\",\n\t\"pad.modals.reconnecting\": \"താങ്കളുടെ പാഡിലേയ്ക്ക് വീണ്ടും ബന്ധിപ്പിക്കുന്നു...\",\n\t\"pad.modals.forcereconnect\": \"എന്തായാലും ബന്ധിപ്പിക്കുക\",\n\t\"pad.modals.reconnecttimer\": \"വീണ്ടും ബന്ധപ്പെടുവാൻ ശ്രമിക്കുന്നു\",\n\t\"pad.modals.cancel\": \"റദ്ദാക്കുക\",\n\t\"pad.modals.userdup\": \"മറ്റൊരു ജാലകത്തിൽ തുറന്നിരിക്കുന്നു\",\n\t\"pad.modals.userdup.explanation\": \"ഈ കമ്പ്യൂട്ടറിൽ ഈ പാഡ് ഒന്നിലധികം ബ്രൗസർ ജാലകങ്ങളിൽ തുറന്നതായി കാണുന്നു.\",\n\t\"pad.modals.userdup.advice\": \"ഈ ജാലകം തന്നെ ഉപയോഗിക്കാനായി ബന്ധിപ്പിക്കുക\",\n\t\"pad.modals.unauth\": \"അനുവാദമില്ല\",\n\t\"pad.modals.unauth.explanation\": \"ഈ താൾ കണ്ടുകൊണ്ടിരിക്കെ താങ്കൾക്കുള്ള അനുമതികളിൽ മാറ്റമുണ്ടായി. വീണ്ടും ബന്ധപ്പെടാൻ ശ്രമിക്കുക.\",\n\t\"pad.modals.looping.explanation\": \"സിംക്രണൈസേഷൻ സെർവറുമായുള്ള ആശയവിനിമയത്തിൽ പ്രശ്നങ്ങളുണ്ട്.\",\n\t\"pad.modals.looping.cause\": \"ഒരുപക്ഷേ പൊരുത്തപ്പെടാത്ത ഫയർവാളിലൂടെയോ പ്രോക്സിയിലൂടെയോ ആകാം താങ്കൾ ബന്ധിച്ചിരുന്നത്.\",\n\t\"pad.modals.initsocketfail\": \"സെർവറിലെത്താൻ പറ്റുന്നില്ല.\",\n\t\"pad.modals.initsocketfail.explanation\": \"സിംക്രണൈസേഷൻ സെർവറുമായി ബന്ധപ്പെടാൻ കഴിഞ്ഞില്ല.\",\n\t\"pad.modals.initsocketfail.cause\": \"താങ്കളുടെ ഇന്റർനെറ്റ് കണക്ഷന്റെയോ ബ്രൗസറിന്റെയോ പ്രശ്നമാകാം\",\n\t\"pad.modals.slowcommit.explanation\": \"സെർവർ പ്രതികരിക്കുന്നില്ല.\",\n\t\"pad.modals.slowcommit.cause\": \"നെറ്റ്‌വർക്ക് പ്രശ്നം കാരണമാകാം.\",\n\t\"pad.modals.badChangeset.explanation\": \"താങ്കൾ ചെയ്ത ഒരു തിരുത്ത് സമീകരണ സെർവർ നയവിരുദ്ധമെന്ന് പെടുത്തിയിരിക്കുന്നു.\",\n\t\"pad.modals.badChangeset.cause\": \"ഇത്, തെറ്റായ സെർവർ ക്രമീകരണം മൂലമോ മറ്റെന്തെങ്കിലും അപ്രതീക്ഷിത കാരണം കൊണ്ടോ ഉണ്ടായതായേക്കാം. ഇത് തെറ്റാണെന്ന് താങ്കൾക്ക് തോന്നുന്നുണ്ടെങ്കിൽ സേവന കാര്യനിർവാഹകയെ(നെ) താങ്കൾക്ക് സമീപിക്കാവുന്നതാണ്. തിരുത്തൽ തുടരാൻ വീണ്ടും ബദ്ധപ്പെടുക.\",\n\t\"pad.modals.corruptPad.explanation\": \"താങ്കൾ എടുക്കാൻ ശ്രമിക്കുന്ന പാഡ് കേടാണ്.\",\n\t\"pad.modals.corruptPad.cause\": \"ഇത്, തെറ്റായ സെർവർ ക്രമീകരണം മൂലമോ മറ്റെന്തെങ്കിലും അപ്രതീക്ഷിത കാരണം കൊണ്ടോ ഉണ്ടായതായേക്കാം. ദയവായി സേവന കാര്യനിർവാഹകയെ(നെ) സമീപിക്കുക.\",\n\t\"pad.modals.deleted\": \"മായ്ച്ചു\",\n\t\"pad.modals.deleted.explanation\": \"ഈ പാഡ് നീക്കം ചെയ്തു.\",\n\t\"pad.modals.disconnected\": \"താങ്കൾ വേർപെട്ടിരിക്കുന്നു.\",\n\t\"pad.modals.disconnected.explanation\": \"സെർവറുമായുള്ള ബന്ധം നഷ്ടപ്പെട്ടു\",\n\t\"pad.modals.disconnected.cause\": \"സെർവർ ലഭ്യമല്ലായിരിക്കാം. ഇത് തുടർച്ചയായി സംഭവിക്കുന്നുണ്ടെങ്കിൽ ദയവായി സേവന കാര്യനിർവാഹകയെ(നെ) അറിയിക്കുക.\",\n\t\"pad.share\": \"ഈ പാഡ് പങ്കിടുക\",\n\t\"pad.share.readonly\": \"വായിക്കൽ മാത്രം\",\n\t\"pad.share.link\": \"കണ്ണി\",\n\t\"pad.share.emebdcode\": \"എംബെഡ് യു.ആർ.എൽ.\",\n\t\"pad.chat\": \"തത്സമയസംവാദം\",\n\t\"pad.chat.title\": \"ഈ പാഡിന്റെ തത്സമയസംവാദം തുറക്കുക.\",\n\t\"pad.chat.loadmessages\": \"കൂടുതൽ സന്ദേശങ്ങൾ എടുക്കുക\",\n\t\"pad.chat.writeMessage.placeholder\": \"താങ്കളുടെ സന്ദേശങ്ങൾ ഇവിടെ കുറിക്കുക\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} സമയരേഖ\",\n\t\"timeslider.toolbar.returnbutton\": \"പാഡിലേക്ക് മടങ്ങുക\",\n\t\"timeslider.toolbar.authors\": \"രചയിതാക്കൾ:\",\n\t\"timeslider.toolbar.authorsList\": \"ആരും എഴുതിയിട്ടില്ല\",\n\t\"timeslider.toolbar.exportlink.title\": \"കയറ്റുമതി\",\n\t\"timeslider.exportCurrent\": \"ഈ പതിപ്പ് ഇങ്ങനെ എടുക്കുക:\",\n\t\"timeslider.version\": \"പതിപ്പ് {{version}}\",\n\t\"timeslider.saved\": \"സേവ് ചെയ്തത് {{month}} {{day}}, {{year}}\",\n\t\"timeslider.playPause\": \"പാ‍ഡിലെ ഉള്ളടക്കങ്ങൾ പ്ലേ / പോസ് ചെയ്യുക\",\n\t\"timeslider.backRevision\": \"ഈ പാഡിലെ ഒരു നാൾപ്പതിപ്പിലേക്ക് മടങ്ങുക\",\n\t\"timeslider.forwardRevision\": \"ഈ പാഡിലെ അടുത്ത മാറ്റത്തിലേക്ക് പോവുക\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"ജനുവരി\",\n\t\"timeslider.month.february\": \"ഫെബ്രുവരി\",\n\t\"timeslider.month.march\": \"മാർച്ച്\",\n\t\"timeslider.month.april\": \"ഏപ്രിൽ\",\n\t\"timeslider.month.may\": \"മേയ്\",\n\t\"timeslider.month.june\": \"ജൂൺ\",\n\t\"timeslider.month.july\": \"ജൂലൈ\",\n\t\"timeslider.month.august\": \"ഓഗസ്റ്റ്\",\n\t\"timeslider.month.september\": \"സെപ്റ്റംബർ\",\n\t\"timeslider.month.october\": \"ഒക്ടോബർ\",\n\t\"timeslider.month.november\": \"നവംബർ\",\n\t\"timeslider.month.december\": \"ഡിസംബർ\",\n\t\"timeslider.unnamedauthors\": \"{{num}} പേരില്ലാത്ത {[plural(num) one: രചയിതാവ്, other: രചയിതാക്കൾ }}\",\n\t\"pad.savedrevs.marked\": \"ഈ നാൾപ്പതിപ്പ് സേവ് ചെയ്തിട്ടുള്ള നാൾപ്പതിപ്പായി അടയാളപ്പെടുത്തിയിരിക്കുന്നു\",\n\t\"pad.savedrevs.timeslider\": \"സേവ് ചെയ്ത മറ്റു നാൾപ്പതിപ്പുകൾ സമയസൂചികയിൽ കാണാവുന്നതാണ്\",\n\t\"pad.userlist.entername\": \"താങ്കളുടെ പേര് നൽകുക\",\n\t\"pad.userlist.unnamed\": \"പേരില്ലാത്തവ\",\n\t\"pad.editbar.clearcolors\": \"ഡോക്യുമെന്റിൽ രചയിതാക്കളെ സൂചിപ്പിക്കാനായി നൽകിയിട്ടുള്ള നിറങ്ങൾ ഒഴിവാക്കട്ടെ? ഈ തിരുത്തൽ പഴയപടി ആക്കുവാൻ സാധിക്കില്ല\",\n\t\"pad.impexp.importbutton\": \"ഇറക്കുമതി ചെയ്യുക\",\n\t\"pad.impexp.importing\": \"ഇറക്കുമതി ചെയ്യുന്നു...\",\n\t\"pad.impexp.confirmimport\": \"ഒരു പ്രമാണം ഇറക്കുമതി ചെയ്യുന്നത് നിലവിലുള്ള എഴുത്തുകൾ നഷ്ടപ്പെടാനിടയാക്കും, തുടരണമെന്ന് ഉറപ്പാണോ?\",\n\t\"pad.impexp.convertFailed\": \"ഈ പ്രമാണം  ഇറക്കുമതി ചെയ്യാൻ സാധിച്ചില്ല. ദയവായി മറ്റൊരു ഡോക്യുമെന്റ് ഫോർമാറ്റ് ഉപയോഗിക്കുകയോ, സ്വന്തമായി പകർത്തി ചേർക്കുകയോ ചെയ്യുക\",\n\t\"pad.impexp.padHasData\": \"ഈ പാഡിൽ ഇതിനകം തന്നെ മാറ്റങ്ങൾ നടന്നിട്ടുള്ളതിനാൽ, നൽകിയ പ്രമാണം ഇതിലേക്ക് ചേർക്കാൻ സാധിച്ചില്ല. ദയവായി പുതിയ ഒരു പാഡിലേക്ക് ചേർക്കുക\",\n\t\"pad.impexp.uploadFailed\": \"അപ്‌‌ലോഡ് പരാജയപ്പെട്ടു. ദയവായി വീണ്ടും ശ്രമിക്കുക\",\n\t\"pad.impexp.importfailed\": \"ഇറക്കുമതി പരാജയപ്പെട്ടു\",\n\t\"pad.impexp.copypaste\": \"ദയവായി പകർത്തി ചേർക്കുക\",\n\t\"pad.impexp.exportdisabled\": \"{{type}} ഫോർമാറ്റിൽ കയറ്റുമതി ചെയ്യുന്നത് തടഞ്ഞിരിക്കുന്നു. കൂടുതൽ വിവരങ്ങൾക്ക് താങ്കളുടെ സിസ്റ്റം അഡ്മിനിസ്ട്രേറ്ററുമായി ബന്ധപ്പെടുക.\"\n}\n"
  },
  {
    "path": "src/locales/mn.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"MongolWiki\",\n\t\t\t\"Munkhzaya.E\",\n\t\t\t\"Wisdom\"\n\t\t]\n\t},\n\t\"pad.toolbar.bold.title\": \"Болд тескт (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Налуу тескт (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Доогуур зураас (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Дундуураа зураастай\",\n\t\"pad.toolbar.ol.title\": \"Эрэмбэлэгдсэн жагсаалт\",\n\t\"pad.toolbar.ul.title\": \"Эрэмбэлээгүй жагсаалт\",\n\t\"pad.toolbar.indent.title\": \"Догол мөр (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Догол мөрийг буцаах (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Буцаах (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Давтах (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Зохиогчийн өнгийг буцаах (Ctrl+Shift+C)\",\n\t\"pad.toolbar.timeslider.title\": \"Засварласан түүх\",\n\t\"pad.toolbar.savedRevision.title\": \"Хувилбарыг хадгалах\",\n\t\"pad.toolbar.settings.title\": \"Тохиргоо\",\n\t\"pad.colorpicker.save\": \"Хадгалах\",\n\t\"pad.colorpicker.cancel\": \"Цуцлах\",\n\t\"pad.loading\": \"Уншиж байна...\",\n\t\"pad.settings.padSettings\": \"Падын тохиргоо\",\n\t\"pad.settings.myView\": \"Өөрийн харагдац\",\n\t\"pad.settings.linenocheck\": \"Мөрийн дугаар\",\n\t\"pad.settings.fontType\": \"Фонтын төрөл:\",\n\t\"pad.settings.fontType.normal\": \"Ердийн\",\n\t\"pad.settings.language\": \"Хэл:\",\n\t\"pad.importExport.import_export\": \"Импорт/Экспорт\",\n\t\"pad.importExport.import\": \"Бичвэр, текст файл оруулах\",\n\t\"pad.importExport.importSuccessful\": \"Амжилттай!\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Цулгаа бичвэр\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF файл\",\n\t\"pad.importExport.exportopen\": \"ODF файл\",\n\t\"pad.modals.connected\": \"Холбогдсон.\",\n\t\"pad.modals.unauth\": \"Үл зөвшөөрөгдсөн\",\n\t\"pad.modals.initsocketfail\": \"Сервер холбогдох боломжгүй.\",\n\t\"pad.modals.slowcommit.explanation\": \"Сервер хариу өгөхгүй байна.\",\n\t\"pad.modals.deleted\": \"Устгагдсан\",\n\t\"pad.modals.deleted.explanation\": \"Энэ паб устсан байна.\",\n\t\"pad.modals.disconnected\": \"Таны холболт салсан байна.\",\n\t\"pad.modals.disconnected.explanation\": \"Серверын холболт салсан байна\",\n\t\"pad.share\": \"Энэ падыг тараах\",\n\t\"pad.share.readonly\": \"Зөвхөн унших\",\n\t\"pad.share.link\": \"Холбоос\",\n\t\"pad.share.emebdcode\": \"URL хавсаргах\",\n\t\"pad.chat\": \"Чат\",\n\t\"pad.chat.loadmessages\": \"Нэмэж мессеж оруулах\",\n\t\"timeslider.toolbar.returnbutton\": \"Падруу буцах\",\n\t\"timeslider.toolbar.authors\": \"Зохиогч:\",\n\t\"timeslider.toolbar.authorsList\": \"Зохиогчгүй\",\n\t\"timeslider.toolbar.exportlink.title\": \"Экспорт\",\n\t\"timeslider.exportCurrent\": \"Энэ хувилбарыг экспортлохдоо:\",\n\t\"timeslider.version\": \"Хувилбар {{version}}\",\n\t\"timeslider.saved\": \"{{year}}-ы {{month}}-н {{day}}-нд да;галсан.\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Нэгдүгээр сар\",\n\t\"timeslider.month.february\": \"Хоёрдугаар сар\",\n\t\"timeslider.month.march\": \"Гуравдугаар сар\",\n\t\"timeslider.month.april\": \"Дөрөвдүгээр сар\",\n\t\"timeslider.month.may\": \"Тавдугаар сар\",\n\t\"timeslider.month.june\": \"Зургаадугаар сар\",\n\t\"timeslider.month.july\": \"Долоодугаар сар\",\n\t\"timeslider.month.august\": \"Наймдугаар сар\",\n\t\"timeslider.month.september\": \"Есдүгээр сар\",\n\t\"timeslider.month.october\": \"Аравдугаар сар\",\n\t\"timeslider.month.november\": \"Арваннэгдүгээр сар\",\n\t\"timeslider.month.december\": \"Арванхоёрдугаар сар\",\n\t\"pad.savedrevs.marked\": \"Энэ хувилбар хадгалагдаагүй байна\",\n\t\"pad.userlist.entername\": \"Нэрээ бичнэ үү\",\n\t\"pad.userlist.unnamed\": \"нэргүй\",\n\t\"pad.impexp.importbutton\": \"Одоо импорт хий\",\n\t\"pad.impexp.importing\": \"Импортлож байна...\",\n\t\"pad.impexp.importfailed\": \"Импортлоход алдаа\",\n\t\"pad.impexp.copypaste\": \"Хуулаад тавина уу\"\n}\n"
  },
  {
    "path": "src/locales/mnw.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Aue Nai\",\n\t\t\t\"咽頭べさ\"\n\t\t]\n\t},\n\t\"admin_plugins.description\": \"တၚ်ထမံက်ထ္ၜး\",\n\t\"index.newPad\": \"တၞးတၟိ\",\n\t\"pad.toolbar.bold.title\": \"လ္စံက် (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"ဒစေၚ် (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"သက်ပၞောန်သၟဝ် (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"ခရက်ပၞောန်လဒေါဝ် (Ctrl+5)\",\n\t\"pad.colorpicker.save\": \"ဂိုင်သိပ်\",\n\t\"pad.colorpicker.cancel\": \"တးပဲါ\",\n\t\"pad.loading\": \"ပတိုန်ဒၟံၚ်\",\n\t\"pad.settings.language\": \"အရေဝ်ဘာသာ\",\n\t\"pad.importExport.import_export\": \"ပလုပ်/ပတိတ်\",\n\t\"pad.importExport.importSuccessful\": \"ဍိုက်ပေၚ်စိုပ်ဒတုဲ\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"လိက်ပလး\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.modals.reconnecttimer\": \"ဂစာန်မံၚ်သွက်ကလေၚ်ဆက်လုပ်\",\n\t\"pad.modals.cancel\": \"တးပဲါ\",\n\t\"pad.modals.unauth\": \"အခေါၚ်အဝဵုဟွံမွဲ\",\n\t\"pad.modals.deleted\": \"ပလီု\",\n\t\"pad.modals.deleted.explanation\": \"တၞးဏအ်ဒးဒုၚ်တးပဲါထောံယျ။\",\n\t\"pad.modals.disconnected\": \"မၞးဆက်စၠောံဟွံမွဲမံၚ်ယျ\",\n\t\"pad.share\": \"ပါ်ပရအ်တၞးဏအ်ညိ\",\n\t\"pad.share.readonly\": \"ဆ အယာံမာတ်ဗှ်ဟေၚ်\",\n\t\"pad.share.link\": \"လေန်\",\n\t\"pad.share.emebdcode\": \"Embed URL\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Timeslider\",\n\t\"timeslider.toolbar.returnbutton\": \"ကလၚ်တၞးတေံ\",\n\t\"timeslider.toolbar.authors\": \"ကဝိ\",\n\t\"timeslider.toolbar.authorsList\": \"ကဝိ ဟွံမွဲ\",\n\t\"timeslider.toolbar.exportlink.title\": \"ပတိတ်\",\n\t\"timeslider.version\": \"ဗာရှောန်{{version}}\",\n\t\"timeslider.saved\": \"သီဂိုၚ်လဝ် {{month}} {{day}}, {{year}}\",\n\t\"timeslider.month.january\": \"ဇာန်နဝါရဳ\",\n\t\"timeslider.month.february\": \"ဖေဖဝ်ဝါရဳ\",\n\t\"timeslider.month.march\": \"မာတ်\",\n\t\"timeslider.month.april\": \"ဨပြဳ\",\n\t\"timeslider.month.may\": \"မေ\",\n\t\"timeslider.month.june\": \"ဂျောန်\",\n\t\"timeslider.month.july\": \"ဂျူလာၚ်\",\n\t\"timeslider.month.august\": \"အဝ်ဂေတ်\",\n\t\"timeslider.month.september\": \"\\nသေပ်တေမ်ပါ\",\n\t\"timeslider.month.october\": \"\\nအံက်တဝ်ပါ\",\n\t\"timeslider.month.november\": \"\\nနဝ်ဝေမ်ပါ\",\n\t\"timeslider.month.december\": \"ဒဳဇြေန်ပါ\",\n\t\"timeslider.unnamedauthors\": \"{{num}} ဟွံကဵုယၟု {[ဂမၠိုၚ်(num) မွဲ: ကဝိ, တၞဟ်: ကဝိဂမၠိုၚ် ]}\",\n\t\"pad.userlist.entername\": \"စုတ် ယၟုညးလွပ်\",\n\t\"pad.userlist.unnamed\": \"ဟွံကဵုလဝ်ယၟု\",\n\t\"pad.impexp.importbutton\": \" မပၠောပ်စုတ် လၟုဟ်\",\n\t\"pad.impexp.importing\": \"မပၠောပ်စုတ်ဒၟံၚ်...\",\n\t\"pad.impexp.importfailed\": \"မပၠောပ်စုတ်တအ်လီုအာ\"\n}\n"
  },
  {
    "path": "src/locales/mr.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Ganeshgiram\",\n\t\t\t\"V.narsikar\",\n\t\t\t\"Ydyashad\"\n\t\t]\n\t},\n\t\"index.newPad\": \"नव पान\",\n\t\"pad.toolbar.bold.title\": \"ठळक (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"तिरपी मुद्राक्षरे (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"अधोरेखन (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"अक्षरांवर काट\",\n\t\"pad.toolbar.savedRevision.title\": \"आवृत्ती जतन करा\",\n\t\"pad.toolbar.settings.title\": \"संरचना\",\n\t\"pad.colorpicker.save\": \"जतन करा\",\n\t\"pad.colorpicker.cancel\": \"रद्द करा\",\n\t\"pad.loading\": \"प्रभारण करीत आहे\",\n\t\"pad.settings.myView\": \"माझे दृश्य\",\n\t\"pad.settings.linenocheck\": \"रेषांचे क्रमांक\",\n\t\"pad.settings.language\": \"भाषा\",\n\t\"pad.importExport.import_export\": \"आयात/निर्यात\",\n\t\"pad.importExport.importSuccessful\": \"यशस्वी!\",\n\t\"pad.importExport.exportplain\": \"साधा मजकूर\",\n\t\"pad.importExport.exportword\": \"मायक्रोसॉफ्ट वर्ड\",\n\t\"pad.importExport.exportpdf\": \"पीडीएफ\",\n\t\"pad.importExport.exportopen\": \"ओडीएफ(ओपन डॉक्यूमेंट फॉरमॅट)\",\n\t\"pad.modals.connected\": \"अनुबंधित\",\n\t\"pad.modals.initsocketfail\": \"विदागारास पोच नाही.\",\n\t\"pad.modals.deleted\": \"वगळले.\",\n\t\"pad.modals.disconnected.cause\": \"बहुतेक सरवर उपलब्ध होणार नाही। अस वारंवार झाल्यास कृपया आम्हाला कळवा।\",\n\t\"pad.share.link\": \"दुवा\",\n\t\"pad.chat\": \"गप्पा\",\n\t\"timeslider.toolbar.authorsList\": \"लेखक नाही\",\n\t\"timeslider.month.january\": \"जानेवारी\",\n\t\"timeslider.month.february\": \"फेब्रुवारी\",\n\t\"timeslider.month.march\": \"मार्च\",\n\t\"timeslider.month.april\": \"एप्रिल\",\n\t\"timeslider.month.may\": \"मे\",\n\t\"timeslider.month.june\": \"जून\",\n\t\"timeslider.month.july\": \"जुलै\",\n\t\"timeslider.month.august\": \"ऑगस्ट\",\n\t\"timeslider.month.september\": \"सप्टेंबर\",\n\t\"timeslider.month.october\": \"ऑक्टोबर\",\n\t\"timeslider.month.november\": \"नोव्हेंबर\",\n\t\"timeslider.month.december\": \"डिसेंबर\",\n\t\"pad.userlist.entername\": \"आपले नाव टाका\",\n\t\"pad.userlist.unnamed\": \"निनावी\",\n\t\"pad.impexp.importbutton\": \"आता आयात करा\",\n\t\"pad.impexp.importing\": \"आयात करीत आहे...\",\n\t\"pad.impexp.importfailed\": \"आयात अयशस्वी\",\n\t\"pad.impexp.copypaste\": \"कृपया नकल-डकवा\"\n}\n"
  },
  {
    "path": "src/locales/ms.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Anakmalaysia\",\n\t\t\t\"Hakimi97\",\n\t\t\t\"Jeluang Terluang\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Papan muka Penyelia - Etherpad\",\n\t\"index.newPad\": \"Pad baru\",\n\t\"index.createOpenPad\": \"atau cipta/buka Pad yang bernama:\",\n\t\"pad.toolbar.bold.title\": \"Tebal (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Miring (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Garis bawah (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Garis lorek (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Senarai tertib (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Senarai tak tertib (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Engsot ke dalam (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Engsot ke luar (Shift + TAB)\",\n\t\"pad.toolbar.undo.title\": \"Buat asal (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Buat semula (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Padamkan Warna Pengarang (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Import/Eksport dari/ke format-format fail berbeza\",\n\t\"pad.toolbar.timeslider.title\": \"Gelangsar masa\",\n\t\"pad.toolbar.savedRevision.title\": \"Simpan Semakan\",\n\t\"pad.toolbar.settings.title\": \"Tetapan\",\n\t\"pad.toolbar.embed.title\": \"Kongsikan dan Terapkan pad ini\",\n\t\"pad.toolbar.showusers.title\": \"Tunjukkan pengguna pada pad ini\",\n\t\"pad.colorpicker.save\": \"Simpan\",\n\t\"pad.colorpicker.cancel\": \"Batalkan\",\n\t\"pad.loading\": \"Sedang dimuatkan...\",\n\t\"pad.noCookie\": \"Cookie tidak dapat dijumpai. Tolong benarkan cookie dalam pelayar anda!\",\n\t\"pad.permissionDenied\": \"Anda tiada kebenaran untuk mengakses pad ini\",\n\t\"pad.settings.padSettings\": \"Tetapan Pad\",\n\t\"pad.settings.myView\": \"Paparan Saya\",\n\t\"pad.settings.stickychat\": \"Sentiasa bersembang pada skrin\",\n\t\"pad.settings.chatandusers\": \"Paparkan Ruang Sembang dan Pengguna-Pengguna\",\n\t\"pad.settings.colorcheck\": \"Warna pengarang\",\n\t\"pad.settings.linenocheck\": \"Nombor baris\",\n\t\"pad.settings.rtlcheck\": \"Membaca dari kanan ke kiri?\",\n\t\"pad.settings.fontType\": \"Jenis fon:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Bahasa:\",\n\t\"pad.settings.poweredBy\": \"Dikuasakan oleh\",\n\t\"pad.importExport.import_export\": \"Import/Eksport\",\n\t\"pad.importExport.import\": \"Muat naik sebarang fail teks atau dokumen\",\n\t\"pad.importExport.importSuccessful\": \"Berjaya!\",\n\t\"pad.importExport.export\": \"Eksport pad semasa sebagai:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Teks biasa\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Anda hanya boleh mengimport dari format teks biasa atau html. Untuk ciri-ciri import yang lebih maju, sila <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">memasang abiword</a>.\",\n\t\"pad.modals.connected\": \"Bersambung.\",\n\t\"pad.modals.reconnecting\": \"Bersambung semula dengan pad anda...\",\n\t\"pad.modals.forcereconnect\": \"Sambung semula secara paksa\",\n\t\"pad.modals.userdup\": \"Dibuka di tetingkap lain\",\n\t\"pad.modals.userdup.explanation\": \"Pad ini nampaknya telah dibuka di lebih daripada satu tetingkap pelayar pada komputer ini.\",\n\t\"pad.modals.userdup.advice\": \"Sambung semula untuk menggunakan tetingkap ini pula.\",\n\t\"pad.modals.unauth\": \"Tidak dibenarkan\",\n\t\"pad.modals.unauth.explanation\": \"Kebenaran anda telah berubah sewaktu memaparkan halaman ini. Cuba bersambung semula.\",\n\t\"pad.modals.looping.explanation\": \"Terdapat masalah komunikasi dengan pelayan penyegerakan.\",\n\t\"pad.modals.looping.cause\": \"Mungkin anda telah bersambung melalui tembok api atau proksi yang tidak serasi.\",\n\t\"pad.modals.initsocketfail\": \"Pelayan tidak boleh dicapai.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Tidak dapat bersambung dengan pelayar penyegerakan.\",\n\t\"pad.modals.initsocketfail.cause\": \"Ini mungkin disebabkan oleh masalah dengan pelayar atau sambungan internet anda.\",\n\t\"pad.modals.slowcommit.explanation\": \"Pelayan tidak membalas.\",\n\t\"pad.modals.slowcommit.cause\": \"Ini mungkin disebabkan oleh masalah dengan kesambungan rangkaian anda.\",\n\t\"pad.modals.badChangeset.explanation\": \"Suntingan yang telah anda lakukan telah dikira sebagai terlarang oleh pelayan penyegerakan.\",\n\t\"pad.modals.badChangeset.cause\": \"Hal ini mungkin disebabkan oleh konfigurasi pelayan salah atau sesuatu kelakuan yang tidak dijangka. Sila hubungi penyelia servis anda jika anda merasakan ini ialah satu kesilapan. Cuba sambungkan semula talian untuk terus menyunting.\",\n\t\"pad.modals.corruptPad.explanation\": \"Pad yang anda cuba akses itu telah tercemar.\",\n\t\"pad.modals.corruptPad.cause\": \"Ini mungkin disebabkan oleh konfigurasi pelayan salah atau sesuatu kelakuan yang tidak dijangka. Sila hubungi penyelia servis anda.\",\n\t\"pad.modals.deleted\": \"Dihapuskan.\",\n\t\"pad.modals.deleted.explanation\": \"Pad ini telah dibuang.\",\n\t\"pad.modals.disconnected\": \"Sambungan anda telah diputuskan.\",\n\t\"pad.modals.disconnected.explanation\": \"Sambungan ke pelayan terputus\",\n\t\"pad.modals.disconnected.cause\": \"Pelayan mungkin tidak dapat dicapai. Sila beritahu penyelia servis jika masalah ini berterusan.\",\n\t\"pad.share\": \"Kongsikan pad ini\",\n\t\"pad.share.readonly\": \"Baca sahaja\",\n\t\"pad.share.link\": \"Pautan\",\n\t\"pad.share.emebdcode\": \"Benamkan URL\",\n\t\"pad.chat\": \"Sembang\",\n\t\"pad.chat.title\": \"Buka ruang sembang untuk pad ini.\",\n\t\"pad.chat.loadmessages\": \"Muatkan banyak lagi pesanan\",\n\t\"timeslider.pageTitle\": \"Gelangsar Masa {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Kembali ke pad\",\n\t\"timeslider.toolbar.authors\": \"Pengarang:\",\n\t\"timeslider.toolbar.authorsList\": \"Tiada Pengarang\",\n\t\"timeslider.toolbar.exportlink.title\": \"Eksport\",\n\t\"timeslider.exportCurrent\": \"Eksport versi semasa sebagai:\",\n\t\"timeslider.version\": \"Versi {{version}}\",\n\t\"timeslider.saved\": \"Disimpan pada {{day}} {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Mainkan / Pausekan Kandungan Pad\",\n\t\"timeslider.backRevision\": \"Undur satu semakan di Pad ini\",\n\t\"timeslider.forwardRevision\": \"Maju satu semakan dalam Pad ini\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Januari\",\n\t\"timeslider.month.february\": \"Februari\",\n\t\"timeslider.month.march\": \"Mac\",\n\t\"timeslider.month.april\": \"April\",\n\t\"timeslider.month.may\": \"Mei\",\n\t\"timeslider.month.june\": \"Jun\",\n\t\"timeslider.month.july\": \"Julai\",\n\t\"timeslider.month.august\": \"Ogos\",\n\t\"timeslider.month.september\": \"September\",\n\t\"timeslider.month.october\": \"Oktober\",\n\t\"timeslider.month.november\": \"November\",\n\t\"timeslider.month.december\": \"Disember\",\n\t\"timeslider.unnamedauthors\": \"{{num}} orang {[plural(num) other: pengarang]} awanama\",\n\t\"pad.savedrevs.marked\": \"Semakan ini telah ditandai sebagai semakan tersimpan\",\n\t\"pad.savedrevs.timeslider\": \"Anda boleh melihat semakan yang tersimpan dengan melawat gelangsar masa\",\n\t\"pad.userlist.entername\": \"Taipkan nama anda\",\n\t\"pad.userlist.unnamed\": \"tidak bernama\",\n\t\"pad.editbar.clearcolors\": \"Padamkan warna pengarang pada seluruh dokumen?\",\n\t\"pad.impexp.importbutton\": \"Import Sekarang\",\n\t\"pad.impexp.importing\": \"Sedang mengimport...\",\n\t\"pad.impexp.confirmimport\": \"Mengimport fail akan menulis ganti teks semasa pada pad ini. Adakah anda benar-benar ingin teruskan?\",\n\t\"pad.impexp.convertFailed\": \"Fail tidak dapat diimport. Sila gunakan format dokumen yang lain atau salin tampal secara manual\",\n\t\"pad.impexp.padHasData\": \"Kami tidak dapat mengimport fail ini kerana Pad ini sudah mengalami perubahan. Sila import ke pad yang baru\",\n\t\"pad.impexp.uploadFailed\": \"Muat naik gagal, sila cuba lagi\",\n\t\"pad.impexp.importfailed\": \"Import gagal\",\n\t\"pad.impexp.copypaste\": \"Sila salin tampal\",\n\t\"pad.impexp.exportdisabled\": \"Mengeksport dalam format {{type}} dilarang. Sila hubungi pentadbir sistem anda untuk keterangan lanjut.\"\n}\n"
  },
  {
    "path": "src/locales/my.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Andibecker\",\n\t\t\t\"Dr Lotus Black\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"စီမံခန့်ခွဲသူဒိုင်ခွက် - Etherpad\",\n\t\"admin_plugins\": \"ပလပ်အင်မန်နေဂျာ\",\n\t\"admin_plugins.available\": \"ရနိုင်သော plugins များ\",\n\t\"admin_plugins.available_not-found\": \"ပလပ်အင်များမတွေ့ပါ။\",\n\t\"admin_plugins.available_fetching\": \"ရယူနေသည်…\",\n\t\"admin_plugins.available_install.value\": \"အင်စတော လုပ်ပါ\",\n\t\"admin_plugins.available_search.placeholder\": \"အင်စတောလုပ်ဖို့ plugins များကိုရှာပါ\",\n\t\"admin_plugins.description\": \"ဖော်ပြချက်\",\n\t\"admin_plugins.installed\": \"plugins များထည့်သွင်းထားသည်\",\n\t\"admin_plugins.installed_fetching\": \"ထည့်သွင်းထားသောပလပ်အင်များကိုရယူနေသည်…\",\n\t\"admin_plugins.installed_nothing\": \"သင်မည်သည့် plugins ကိုမျှမထည့်သွင်းရသေးပါ။\",\n\t\"admin_plugins.installed_uninstall.value\": \"ဖြုတ်ပါ\",\n\t\"admin_plugins.last-update\": \"နောက်ဆုံးအပ်ဒိတ်\",\n\t\"admin_plugins.name\": \"နာမည်\",\n\t\"admin_plugins.page-title\": \"ပလပ်အင်မန်နေဂျာ - Etherpad\",\n\t\"admin_plugins.version\": \"ဗားရှင်း\",\n\t\"admin_plugins_info\": \"သတင်းအချက်အလက်ပြဿနာဖြေရှင်းခြင်း\",\n\t\"admin_plugins_info.hooks\": \"ချိတ်များတပ်ဆင်ထားသည်\",\n\t\"admin_plugins_info.hooks_client\": \"Client-side ချိတ်\",\n\t\"admin_plugins_info.hooks_server\": \"Server-side ချိတ်\",\n\t\"admin_plugins_info.parts\": \"တပ်ဆင်ထားသော အစိတ်အပိုင်းများ\",\n\t\"admin_plugins_info.plugins\": \"plugins များထည့်သွင်းထားသည်\",\n\t\"admin_plugins_info.page-title\": \"ပလပ်အင်အချက်အလက် - Etherpad\",\n\t\"admin_plugins_info.version\": \"Etherpad ဗားရှင်း\",\n\t\"admin_plugins_info.version_latest\": \"နောက်ဆုံးရနိုင်သောဗားရှင်း\",\n\t\"admin_plugins_info.version_number\": \"ဗားရှင်းနံပါတ်\",\n\t\"admin_settings\": \"အပြင်အဆင်များ\",\n\t\"admin_settings.current\": \"လက်ရှိဖွဲ့စည်းမှု\",\n\t\"admin_settings.current_example-devel\": \"နမူနာဖွံ့ဖြိုးတိုးတက်မှုဆက်တင်နမူနာ\",\n\t\"admin_settings.current_example-prod\": \"နမူနာထုတ်လုပ်မှုဆက်တင်ပုံစံ\",\n\t\"admin_settings.current_restart.value\": \"Etherpad ကိုပြန်လည်စတင်ပါ\",\n\t\"admin_settings.current_save.value\": \"ဆက်တင်များကိုသိမ်းပါ\",\n\t\"admin_settings.page-title\": \"ဆက်တင်များ - Etherpad\",\n\t\"index.newPad\": \"Pad အသစ်\",\n\t\"index.createOpenPad\": \"သို့မဟုတ် Pad နှင့်နာမည်ဖွင့်ပါ။\",\n\t\"index.openPad\": \"ရှိပြီးသား Pad ကိုနာမည်နှင့်ဖွင့်ပါ။\",\n\t\"pad.toolbar.bold.title\": \"စာလုံးအကြီး (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"စာလုံးစောင်း (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"မျဉ်းသားရန် (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"ဖြတ်တောက်ခြင်း (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"အမှာစာစာရင်း (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Unordered စာရင်း (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"အင်တင်း (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"အပြင် (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"ပြန်လုပ်ရန် (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"ပြန်လုပ်ရန် (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"စာရေးသူအရောင်များကိုရှင်းလင်းပါ (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"ကွဲပြားခြားနားသောဖိုင်အမျိုးအစားများမှ/သွင်းကုန်/တင်ပို့ပါ\",\n\t\"pad.toolbar.timeslider.title\": \"Timeslider\",\n\t\"pad.toolbar.savedRevision.title\": \"ပြန်လည်တည်းဖြတ်ပါ\",\n\t\"pad.toolbar.settings.title\": \"အပြင်အဆင်များ\",\n\t\"pad.toolbar.embed.title\": \"ဒီ pad ကို Share လုပ်ပြီးမြှုပ်လိုက်ပါ\",\n\t\"pad.toolbar.showusers.title\": \"ဤ pad ပေါ်တွင်အသုံးပြုသူများကိုပြပါ\",\n\t\"pad.colorpicker.save\": \"သိမ်းရန်\",\n\t\"pad.colorpicker.cancel\": \"မလုပ်တော့ပါ\",\n\t\"pad.loading\": \"ဝန်ဆွဲတင်နေသည်...\",\n\t\"pad.noCookie\": \"ကွတ်ကီးကိုရှာမတွေ့ပါ။ ကျေးဇူးပြု၍ သင်၏ browser တွင် cookies များကိုခွင့်ပြုပါ။ လည်ပတ်မှုများအကြားသင်၏အစည်းအဝေးနှင့်ဆက်တင်များကိုသိမ်းဆည်းမည်မဟုတ်ပါ။ ၎င်းသည်အချို့သောဘရောင်ဇာများတွင် iFrame တွင် iFrame တွင်ထည့်သွင်းခံရခြင်းကြောင့်ဖြစ်နိုင်သည်။ Etherpad သည် parent iFrame ကဲ့သို့တူညီသော subdomain/domain ပေါ်တွင်သေချာပါစေ\",\n\t\"pad.permissionDenied\": \"သင်ဤ pad ကိုသုံးခွင့်မရှိပါ\",\n\t\"pad.settings.padSettings\": \"Pad ဆက်တင်များ\",\n\t\"pad.settings.myView\": \"ငါ့အမြင်\",\n\t\"pad.settings.stickychat\": \"ဖန်သားပြင်ပေါ်တွင်အမြဲစကားပြောပါ\",\n\t\"pad.settings.chatandusers\": \"ချတ်နှင့်အသုံးပြုသူများကိုပြပါ\",\n\t\"pad.settings.colorcheck\": \"စာရေးသူအရောင်များ\",\n\t\"pad.settings.linenocheck\": \"လိုင်းနံပါတ်များ\",\n\t\"pad.settings.rtlcheck\": \"အကြောင်းအရာကိုညာမှဘယ်သို့ဖတ်ပါ။\",\n\t\"pad.settings.fontType\": \"ဖောင့်အမျိုးအစား\",\n\t\"pad.settings.fontType.normal\": \"သာမန်\",\n\t\"pad.settings.language\": \"ဘာသာစကား:\",\n\t\"pad.settings.about\": \"အကြောင်း\",\n\t\"pad.settings.poweredBy\": \"မှပံ့ပိုးသည်\",\n\t\"pad.importExport.import_export\": \"သွင်းကုန်/ပို့ကုန်\",\n\t\"pad.importExport.import\": \"မည်သည့်စာသားဖိုင်သို့မဆိုစာရွက်စာတမ်းတင်ပါ\",\n\t\"pad.importExport.importSuccessful\": \"အောင်မြင်သည်။\",\n\t\"pad.importExport.export\": \"လက်ရှိ pad ကိုအောက်ပါအတိုင်းတင်ပို့ပါ။\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad ပါ\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"ရိုးရိုးစာသား\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"ပီဒီအက်ဖ်\",\n\t\"pad.importExport.exportopen\": \"ODF (စာရွက်စာတမ်းဖွင့်ပုံစံ)\",\n\t\"pad.importExport.abiword.innerHTML\": \"သင်ရိုးရိုးစာသားများ (သို့) HTML ပုံစံများဖြင့်သာတင်သွင်းနိုင်သည်။ ပိုမိုအဆင့်မြင့်သောသွင်းကုန်အင်္ဂါရပ်များအတွက် ကျေးဇူးပြု၍ ကျေးဇူးပြု၍ <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\"> AbiWord သို့မဟုတ် LibreOffice </a> ကို install လုပ်ပါ။\",\n\t\"pad.modals.connected\": \"ချိတ်ဆက်ထားသည်။\",\n\t\"pad.modals.reconnecting\": \"သင်၏ pad သို့ပြန်လည်ချိတ်ဆက်နေသည်…\",\n\t\"pad.modals.forcereconnect\": \"ပြန်လည်ချိတ်ဆက်ခိုင်းပါ\",\n\t\"pad.modals.reconnecttimer\": \"ပြန်လည်ချိတ်ဆက်ရန်ကြိုးစားနေသည်\",\n\t\"pad.modals.cancel\": \"မလုပ်တော့ပါ\",\n\t\"pad.modals.userdup\": \"ပယ်ဖျက်\",\n\t\"pad.modals.userdup.explanation\": \"ဤ pad ကိုဤကွန်ပျူတာရှိ browser window တစ်ခုထက်ပိုဖွင့်ထားပုံရသည်။\",\n\t\"pad.modals.userdup.advice\": \"၎င်းအစားဤဝင်းဒိုးကိုသုံးရန်ပြန်လည်ချိတ်ဆက်ပါ။\",\n\t\"pad.modals.unauth\": \"လုပ်ပိုင်ခွင့်မရှိပါ\",\n\t\"pad.modals.unauth.explanation\": \"ဤစာမျက်နှာကိုကြည့်နေစဉ်သင်၏ခွင့်ပြုချက်များပြောင်းသွားသည်။ ပြန်လည်ချိတ်ဆက်ရန်ကြိုးစားပါ။\",\n\t\"pad.modals.looping.explanation\": \"synchronization server နှင့်ဆက်သွယ်မှုပြဿနာများရှိသည်။\",\n\t\"pad.modals.looping.cause\": \"သဟဇာတမဖြစ်သည့် firewall (သို့) proxy မှတဆင့်သင်ဆက်သွယ်နိုင်သည်။\",\n\t\"pad.modals.initsocketfail\": \"ဆာဗာကို ဆက်သွယ်၍ မရပါ။\",\n\t\"pad.modals.initsocketfail.explanation\": \"ထပ်တူပြုခြင်းဆာဗာသို့မချိတ်ဆက်နိုင်ခဲ့ပါ။\",\n\t\"pad.modals.initsocketfail.cause\": \"၎င်းသည်သင်၏ browser (သို့) သင်၏အင်တာနက်ဆက်သွယ်မှုပြဿနာကြောင့်ဖြစ်နိုင်သည်။\",\n\t\"pad.modals.slowcommit.explanation\": \"ဆာဗာကမတုံ့ပြန်ပါ။\",\n\t\"pad.modals.slowcommit.cause\": \"၎င်းသည်ကွန်ယက်ချိတ်ဆက်မှုဆိုင်ရာပြဿနာများကြောင့်ဖြစ်နိုင်သည်။\",\n\t\"pad.modals.badChangeset.explanation\": \"သင်ပြုလုပ်သောတည်းဖြတ်မှုကို synchronization server မှတရားမ ၀ င်ခွဲခြားခဲ့သည်။\",\n\t\"pad.modals.badChangeset.cause\": \"၎င်းသည်မှားယွင်းသော server ဖွဲ့စည်းမှုပုံစံ (သို့) အခြားမမျှော်လင့်သောအပြုအမူများကြောင့်ဖြစ်နိုင်သည်။ ဤအရာသည်မှားယွင်းမှုတစ်ခုဟုသင်ခံစားရပါက ၀ န်ဆောင်မှုစီမံခန့်ခွဲသူအားဆက်သွယ်ပါ။ တည်းဖြတ်မှုကိုဆက်လက်လုပ်ဆောင်နိုင်ရန်ပြန်လည်ချိတ်ဆက်ကြည့်ပါ။\",\n\t\"pad.modals.corruptPad.explanation\": \"သင်ရယူရန်ကြိုးစားနေသော pad သည်ယိုယွင်းနေသည်။\",\n\t\"pad.modals.corruptPad.cause\": \"၎င်းသည်မှားယွင်းသော server ဖွဲ့စည်းမှုပုံစံ (သို့) အခြားမမျှော်လင့်သောအပြုအမူများကြောင့်ဖြစ်နိုင်သည်။ ကျေးဇူးပြု၍ ၀ န်ဆောင်မှုစီမံခန့်ခွဲသူကိုဆက်သွယ်ပါ။\",\n\t\"pad.modals.deleted\": \"ဖျက်လိုက်သည်။\",\n\t\"pad.modals.deleted.explanation\": \"ဒီအကွက်ကိုဖယ်ရှားပြီးပါပြီ။\",\n\t\"pad.modals.rateLimited\": \"နှုန်းကန့်သတ်။\",\n\t\"pad.modals.rateLimited.explanation\": \"မင်းဒီအဆက်အသွယ်ကိုဒီ pad မှာအရမ်းများတဲ့မက်ဆေ့ဂျ်တွေပို့ခဲ့တယ်။\",\n\t\"pad.modals.rejected.explanation\": \"ဆာဗာသည်သင်၏ဘရောင်ဇာမှပေးပို့သောစာကိုငြင်းပယ်ခဲ့သည်။\",\n\t\"pad.modals.rejected.cause\": \"သင် pad ကိုကြည့်နေစဉ်ဆာဗာကိုမွမ်းမံခဲ့ပေမည်၊ သို့မဟုတ် Etherpad တွင်ချို့ယွင်းချက်တစ်ခုရှိနေနိုင်သည်။ စာမျက်နှာကိုပြန်တင်ကြည့်ပါ။\",\n\t\"pad.modals.disconnected\": \"မင်းအဆက်အသွယ်ဖြတ်လိုက်ပြီ။\",\n\t\"pad.modals.disconnected.explanation\": \"ဆာဗာနှင့်ချိတ်ဆက်မှုပြတ်တောက်သွားသည်\",\n\t\"pad.modals.disconnected.cause\": \"ဆာဗာမရနိုင်ပါ။ ဤသို့ဆက်ဖြစ်နေပါက ၀န်ဆောင်မှုစီမံခန့်ခွဲသူအား အကြောင်းကြားပါ။\",\n\t\"pad.share\": \"ဒီစာရွက်ကိုမျှဝေပါ\",\n\t\"pad.share.readonly\": \"ဖတ်သာကြည့်ပါ\",\n\t\"pad.share.link\": \"လင့်\",\n\t\"pad.share.emebdcode\": \"URL ထည့်ပါ\",\n\t\"pad.chat\": \"စကားပြောမယ်\",\n\t\"pad.chat.title\": \"ဒီ pad အတွက်စကားပြောခန်းကိုဖွင့်ပါ။\",\n\t\"pad.chat.loadmessages\": \"နောက်ထပ်မက်ဆေ့ခ်ျများတင်ပါ\",\n\t\"pad.chat.stick.title\": \"ချတ်ကိုမျက်နှာပြင်သို့ကပ်ပါ\",\n\t\"pad.chat.writeMessage.placeholder\": \"မင်းရဲ့စာကိုဒီမှာရေးပါ\",\n\t\"timeslider.followContents\": \"pad အကြောင်းအရာနောက်ဆုံးသတင်းများကိုလိုက်နာပါ\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Timeslider\",\n\t\"timeslider.toolbar.returnbutton\": \"ပလက်ဖောင်းသို့ပြန်သွားရန်\",\n\t\"timeslider.toolbar.authors\": \"ရေးသားသူ -\",\n\t\"timeslider.toolbar.authorsList\": \"စာရေးသူမရှိပါ\",\n\t\"timeslider.toolbar.exportlink.title\": \"တင်ပို့သည်\",\n\t\"timeslider.exportCurrent\": \"လက်ရှိဗားရှင်းအဖြစ်\",\n\t\"timeslider.version\": \"ဗားရှင်း {{version}}\",\n\t\"timeslider.saved\": \"သိမ်းထားသည် {{month}} {{day}}, {{year}}\",\n\t\"timeslider.playPause\": \"Pad အကြောင်းအရာများပြန်ဖွင့်ခြင်း / ခဏရပ်ခြင်း\",\n\t\"timeslider.backRevision\": \"ဤ Pad ရှိပြန်လည်သုံးသပ်ခြင်းကိုပြန်သွားပါ\",\n\t\"timeslider.forwardRevision\": \"ဤ Pad ၌တည်းဖြတ်မှုတစ်ခုကိုရှေ့ဆက်ပါ\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}: {{minutes}}: {{seconds}}\",\n\t\"timeslider.month.january\": \"ဇန်နဝါရီ\",\n\t\"timeslider.month.february\": \"ဖေဖော်ဝါရီ\",\n\t\"timeslider.month.march\": \"မတ်\",\n\t\"timeslider.month.april\": \"ဧပြီ\",\n\t\"timeslider.month.may\": \"မေ\",\n\t\"timeslider.month.june\": \"ဇွန်\",\n\t\"timeslider.month.july\": \"ဇူလိုင်\",\n\t\"timeslider.month.august\": \"ဩဂုတ်\",\n\t\"timeslider.month.september\": \"စက်တင်ဘာ\",\n\t\"timeslider.month.october\": \"အောက်တိုဘာ\",\n\t\"timeslider.month.november\": \"နို​ဝင်​ဘာ​\",\n\t\"timeslider.month.december\": \"ဒီဇင်ဘာ\",\n\t\"timeslider.unnamedauthors\": \"{{num}} အမည်မဖော်လိုသူ {[အများကိန်း (num) one: author၊ အခြား: author]}\",\n\t\"pad.savedrevs.marked\": \"ယခုပြန်လည်တည်းဖြတ်မှုအားသိမ်းဆည်းထားသောတည်းဖြတ်မှုတစ်ခုအဖြစ်အမှတ်အသားပြုထားသည်\",\n\t\"pad.savedrevs.timeslider\": \"timeslider ကိုသွားခြင်းဖြင့်သိမ်းဆည်းထားသောပြန်လည်တည်းဖြတ်ချက်များကိုသင်မြင်နိုင်သည်\",\n\t\"pad.userlist.entername\": \"မင်းနာမည်ထည့်ပါ\",\n\t\"pad.userlist.unnamed\": \"အမည်မဲ့\",\n\t\"pad.editbar.clearcolors\": \"စာရွက်စာတမ်းတစ်ခုလုံးတွင်စာရေးသူအရောင်များကိုရှင်းလိုပါသလား။ ဒါကိုပြန် ပြင်၍ မရပါ\",\n\t\"pad.impexp.importbutton\": \"ယခုတင်သွင်းပါ\",\n\t\"pad.impexp.importing\": \"တင်သွင်းနေသည် ...\",\n\t\"pad.impexp.confirmimport\": \"ဖိုင်တစ်ခုတင်သွင်းခြင်းသည် pad ၏လက်ရှိစာသားကိုထပ်ရေးလိမ့်မည်။ သင်ရှေ့ဆက်လိုသည်မှာသေချာသလား။\",\n\t\"pad.impexp.convertFailed\": \"ဤဖိုင်ကိုကျွန်ုပ်တို့မတင်သွင်းနိုင်ခဲ့ပါ။ ကျေးဇူးပြု၍ အခြားစာရွက်စာတမ်းပုံစံတစ်ခုကိုသုံးပါသို့မဟုတ်ကိုယ်တိုင်ကူးယူပါ\",\n\t\"pad.impexp.padHasData\": \"ဤ Pad သည်အပြောင်းအလဲများရှိနေပြီးဖြစ်သောကြောင့် ကျေးဇူးပြု၍ ဤဖိုင်ကိုတင်သွင်းနိုင်ခဲ့ခြင်းမရှိပါ၊ ကျေးဇူးပြု၍ pad အသစ်သို့တင်သွင်းပါ\",\n\t\"pad.impexp.uploadFailed\": \"အပ်လုဒ်တင်ခြင်းမအောင်မြင်ပါ၊ ကျေးဇူးပြု၍ ထပ်ကြိုးစားပါ\",\n\t\"pad.impexp.importfailed\": \"တင်သွင်းမှုမအောင်မြင်ပါ\",\n\t\"pad.impexp.copypaste\": \"ကျေးဇူးပြု၍ ကူးထည့်ပါ\",\n\t\"pad.impexp.exportdisabled\": \"{{type}} ပုံစံအဖြစ်ထုတ်ယူခြင်းကိုပိတ်ထားသည်။ အသေးစိတ်အတွက် ကျေးဇူးပြု၍ သင်၏စနစ်စီမံခန့်ခွဲသူကိုဆက်သွယ်ပါ။\",\n\t\"pad.impexp.maxFileSize\": \"ဖိုင်ဆိုဒ်အရမ်းကြီးတယ်။ သွင်းကုန်အတွက်ခွင့်ပြုထားသောဖိုင်အရွယ်အစားကိုမြှင့်ရန်သင်၏ site စီမံခန့်ခွဲသူနှင့်ဆက်သွယ်ပါ\"\n}\n"
  },
  {
    "path": "src/locales/nah.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Akapochtli\",\n\t\t\t\"Languaeditor\",\n\t\t\t\"Taresi\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Yancuic Pad\",\n\t\"index.createOpenPad\": \"auh xicchīhua/xictlapo cē Pad in ītōcā:\",\n\t\"pad.toolbar.bold.title\": \"Tilāhuac (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Coltic (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Tlahuahuantli (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Tlīlhuahuantli (Ctrl+5)\",\n\t\"pad.toolbar.undo.title\": \"Xicmācuepa (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Occeppa (Ctrl+Y)\",\n\t\"pad.toolbar.settings.title\": \"Tlatlālīliztli\",\n\t\"pad.colorpicker.save\": \"Xicpiya\",\n\t\"pad.colorpicker.cancel\": \"Xikxolewa\",\n\t\"pad.settings.padSettings\": \"Pad Ītlatlālīliz\",\n\t\"pad.settings.myView\": \"Notlachiyaliz\",\n\t\"pad.settings.language\": \"Tlahtolli:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.modals.cancel\": \"Xikxolewa\",\n\t\"pad.modals.deleted\": \"Omopohpoloh.\",\n\t\"pad.modals.deleted.explanation\": \"Ōmopoloh inīn Pad.\",\n\t\"timeslider.version\": \"Inīc {{version}} Cuepaliztli\",\n\t\"timeslider.month.january\": \"Eneroh\",\n\t\"timeslider.month.february\": \"Febreroh\",\n\t\"timeslider.month.march\": \"Marsoh\",\n\t\"timeslider.month.april\": \"April\",\n\t\"timeslider.month.may\": \"Mayoh\",\n\t\"timeslider.month.june\": \"Honioh\",\n\t\"timeslider.month.july\": \"Holioh\",\n\t\"timeslider.month.august\": \"Ahostoh\",\n\t\"timeslider.month.september\": \"Septiempreh\",\n\t\"timeslider.month.october\": \"Oktopreh\",\n\t\"timeslider.month.november\": \"Noviempreh\",\n\t\"timeslider.month.december\": \"Tisiempreh\"\n}\n"
  },
  {
    "path": "src/locales/nap.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"C.R.\",\n\t\t\t\"Chelin\",\n\t\t\t\"Finizio\",\n\t\t\t\"Ruthven\"\n\t\t]\n\t},\n\t\"admin_plugins.name\": \"Nomme\",\n\t\"index.newPad\": \"Nuovo Pad\",\n\t\"index.createOpenPad\": \"o crià o arape nu Pad cu 'o nomme:\",\n\t\"pad.toolbar.bold.title\": \"Grassetto (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Cursivo (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Sottolineato (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Barrato (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Ennece nummerato (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Ennece puntato (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Rientro (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Riduce rientro (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Sfàjere (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Ripete (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Elimina 'e culure ca 'ndicanno 'e auture (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"'Mporta/esporta 'e/a diverse furmate 'e file\",\n\t\"pad.toolbar.timeslider.title\": \"Presentazzione cronologgia\",\n\t\"pad.toolbar.savedRevision.title\": \"Sarva revisione\",\n\t\"pad.toolbar.settings.title\": \"Mpustaziune\",\n\t\"pad.toolbar.embed.title\": \"Sparte e nzerta stu Pad\",\n\t\"pad.toolbar.showusers.title\": \"Mmusta ll'utente ncopp'a stu Pad\",\n\t\"pad.colorpicker.save\": \"Sarva\",\n\t\"pad.colorpicker.cancel\": \"Canciella\",\n\t\"pad.loading\": \"Carecamiento 'n curso…\",\n\t\"pad.noCookie\": \"Cookie nun truvata. Pe' piacere premmettete 'e cookies dint' 'o navigatóre vuosto!\",\n\t\"pad.permissionDenied\": \"Nun se dispunne d\\\"e permisse necessare pe' accede a chisto Pad\",\n\t\"pad.settings.padSettings\": \"Mpostazzione d\\\"o pad\",\n\t\"pad.settings.myView\": \"Mia Veruta\",\n\t\"pad.settings.stickychat\": \"Chat sempe ncopp' 'o schermo\",\n\t\"pad.settings.colorcheck\": \"Auturevolezza pe' culure\",\n\t\"pad.settings.linenocheck\": \"Nummere 'e riga\",\n\t\"pad.settings.rtlcheck\": \"Lieggere 'e cuntenute 'a destra a smerza?\",\n\t\"pad.settings.fontType\": \"Tipo 'e funte:\",\n\t\"pad.settings.fontType.normal\": \"Nurmale\",\n\t\"pad.settings.language\": \"Llengua:\",\n\t\"pad.importExport.import_export\": \"Mpurtaziune/sportaziune\",\n\t\"pad.importExport.import\": \"Carreca coccherunto testo o documento\",\n\t\"pad.importExport.importSuccessful\": \"Ngarrata!\",\n\t\"pad.importExport.export\": \"Sportà stu Pad comme:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Testo nurmale\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Putite surtanto mpurtà testo chiano o furmatte HTML. Pe n'avé sisteme cchiù annanze 'e mpurtazione pe' piacere <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">installate Abiword</a>.\",\n\t\"pad.modals.connected\": \"Cunnesso.\",\n\t\"pad.modals.reconnecting\": \"Ricunnessione ô pad 'n curso...\",\n\t\"pad.modals.forcereconnect\": \"Forza 'a ricunnessione\",\n\t\"pad.modals.userdup\": \"Aprito 'n n'ata fenesta\",\n\t\"pad.modals.userdup.explanation\": \"Stu Pad pare fosse araputo dint'a cchiù 'e na fenesta 'e navigatore dint'a stu computer.\",\n\t\"pad.modals.userdup.advice\": \"Riconnettateve pe' putè ausà mmece sta fenesta.\",\n\t\"pad.modals.unauth\": \"Nun autorizzato\",\n\t\"pad.modals.unauth.explanation\": \"'E premmesse vuoste so' cagnate pe' tramente ca se vereva sta paggena. Tentate 'e ve riconnettà.\",\n\t\"pad.modals.looping.explanation\": \"Ce stanno probbleme 'e comunicazione c' 'o server 'e sincronizzaziona.\",\n\t\"pad.modals.looping.cause\": \"Può darse ca ve site cullegato pe' mmiez' 'e nu firewall incompatibbele o proxy.\",\n\t\"pad.modals.initsocketfail\": \"Nun se può arrevà 'o server.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Nun se può cunnettà 'o server e sincronizzaziona.\",\n\t\"pad.modals.initsocketfail.cause\": \"Stu fatto è succiesso, probabbilmente pe' bbìa 'e nu probblema c' 'o navigatóre 'o ll'internet.\",\n\t\"pad.modals.slowcommit.explanation\": \"'O server nun risponne.\",\n\t\"pad.modals.slowcommit.cause\": \"'Sto fatto putiss' essere causato 'a prubblemi 'e connettività 'e rezza.\",\n\t\"pad.modals.badChangeset.explanation\": \"Nu cagnamento ca stavate facenno è stato classeficato comme illegale p' 'o server 'e sincronizzaziona.\",\n\t\"pad.modals.badChangeset.cause\": \"Chistu fatto può darse ca è causato pe' bbìa 'e na mpustazione errata d' 'o server o cocch'atu comportamento nun preveduto. Pe' piacere cuntattate l'ammenistratore d' 'o servizio, si se pienza ca chist'è n'errore. Tentate a ve riconnettà pe' cuntinuà 'a edità.\",\n\t\"pad.modals.corruptPad.explanation\": \"'O pad addò vulevate trasì è scassato.\",\n\t\"pad.modals.deleted\": \"Canciellato.\",\n\t\"pad.share.link\": \"Jonta\",\n\t\"pad.chat\": \"Chiàcchiera\",\n\t\"timeslider.pageTitle\": \"Cronologgia {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Ritorna ô Pad\",\n\t\"timeslider.toolbar.authors\": \"Auture:\",\n\t\"timeslider.toolbar.authorsList\": \"Nisciun autore\",\n\t\"timeslider.toolbar.exportlink.title\": \"Espurta\",\n\t\"timeslider.exportCurrent\": \"Espurta 'a verzione corrente comme:\",\n\t\"timeslider.version\": \"Verzione {{version}}\",\n\t\"timeslider.saved\": \"Sarvato {{day}} {{month}} {{year}}\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Jennaro\",\n\t\"timeslider.month.february\": \"Frevaro\",\n\t\"timeslider.month.march\": \"Màrzo\",\n\t\"timeslider.month.april\": \"Abbrile\",\n\t\"timeslider.month.may\": \"Màjo\",\n\t\"timeslider.month.june\": \"Giùgno\",\n\t\"timeslider.month.july\": \"Luglio\",\n\t\"timeslider.month.august\": \"Aùsto\",\n\t\"timeslider.month.september\": \"Settembre\",\n\t\"timeslider.month.october\": \"Ottovre\",\n\t\"timeslider.month.november\": \"Nuvembre\",\n\t\"timeslider.month.december\": \"Dicembre\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: autore, other: auture ]} senza nomme\",\n\t\"pad.userlist.entername\": \"'Nserisce 'o tujo nomme\",\n\t\"pad.userlist.unnamed\": \"senza nomme\",\n\t\"pad.impexp.importbutton\": \"'Mpurta mmo\",\n\t\"pad.impexp.importing\": \"'Mpurtazzione 'n curso...\"\n}\n"
  },
  {
    "path": "src/locales/nb.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Chameleon222\",\n\t\t\t\"Cocu\",\n\t\t\t\"EdoAug\",\n\t\t\t\"Jon Harald Søby\",\n\t\t\t\"Laaknor\",\n\t\t\t\"Orjanmen\",\n\t\t\t\"SuperPotato\"\n\t\t]\n\t},\n\t\"admin_plugins.available_install.value\": \"Installer\",\n\t\"admin_plugins.version\": \"Versjon\",\n\t\"admin_settings\": \"Innstillinger\",\n\t\"admin_settings.current_save.value\": \"Lagre innstillinger\",\n\t\"index.newPad\": \"Ny pad\",\n\t\"index.createOpenPad\": \"eller opprett/åpne en pad med dette navnet:\",\n\t\"index.openPad\": \"åpne en eksisterende Pad med følgende navn:\",\n\t\"pad.toolbar.bold.title\": \"Fet (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Kursiv (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Understreking (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Gjennomstreking (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Nummerert liste (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Punktliste (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Innrykk (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Rykk ut (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Angre (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Gjør om igjen (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Fjern forfatterfarger (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importer/eksporter fra/til ulike filformat\",\n\t\"pad.toolbar.timeslider.title\": \"Tidslinje\",\n\t\"pad.toolbar.savedRevision.title\": \"Lagre revisjon\",\n\t\"pad.toolbar.settings.title\": \"Innstillinger\",\n\t\"pad.toolbar.embed.title\": \"Del og sett inn denne pad-en\",\n\t\"pad.toolbar.showusers.title\": \"Vis brukerne av denne pad-en\",\n\t\"pad.colorpicker.save\": \"Lagre\",\n\t\"pad.colorpicker.cancel\": \"Avbryt\",\n\t\"pad.loading\": \"Laster …\",\n\t\"pad.noCookie\": \"Kunne ikke finne informasjonskapselen. Vennligst tillat informasjonskapsler (cookies) i din nettleser! Informasjonskapsler brukes til å lagre innstillinger o.l. Om feilen gjentar seg, kan det skyldes feil i nettsidens bruk av iFrame.\",\n\t\"pad.permissionDenied\": \"Du har ikke tilgang til denne pad-en\",\n\t\"pad.settings.padSettings\": \"Padinnstillinger\",\n\t\"pad.settings.myView\": \"Min visning\",\n\t\"pad.settings.stickychat\": \"Chat alltid synlig\",\n\t\"pad.settings.chatandusers\": \"Vis chat og brukere\",\n\t\"pad.settings.colorcheck\": \"Forfatterfarger\",\n\t\"pad.settings.linenocheck\": \"Linjenummer\",\n\t\"pad.settings.rtlcheck\": \"Les innhold fra høyre til venstre\",\n\t\"pad.settings.fontType\": \"Skrifttype:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Språk:\",\n\t\"pad.settings.about\": \"Om\",\n\t\"pad.settings.poweredBy\": \"Drives av\",\n\t\"pad.importExport.import_export\": \"Importer/eksporter\",\n\t\"pad.importExport.import\": \"Last opp tekstfil eller dokument\",\n\t\"pad.importExport.importSuccessful\": \"Vellykket!\",\n\t\"pad.importExport.export\": \"Eksporter som:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Ren tekst\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Du kan bare importere fra ren tekst eller HTML-formater. For mer avanserte importfunksjoner, <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">installer AbiWord eller LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Tilkoblet.\",\n\t\"pad.modals.reconnecting\": \"Kobler til pad-en på nytt …\",\n\t\"pad.modals.forcereconnect\": \"Tving gjenoppkobling\",\n\t\"pad.modals.reconnecttimer\": \"Prøver å koble til på nytt\",\n\t\"pad.modals.cancel\": \"Avbryt\",\n\t\"pad.modals.userdup\": \"Åpnet i nytt vindu\",\n\t\"pad.modals.userdup.explanation\": \"Denne pad-en ser ut til å være åpnet i flere vindu/faner i nettleseren din.\",\n\t\"pad.modals.userdup.advice\": \"Koble til igjen for å redigere fra dette vinduet.\",\n\t\"pad.modals.unauth\": \"Ikke tillatt\",\n\t\"pad.modals.unauth.explanation\": \"Dine rettigheter har blitt endret mens du så på denne siden. Prøv å koble til på nytt\",\n\t\"pad.modals.looping.explanation\": \"Det er problemer med synkroniseringen av pad-en.\",\n\t\"pad.modals.looping.cause\": \"Kanskje du koblet til en inkompatibel brannmur eller mellomtjener\",\n\t\"pad.modals.initsocketfail\": \"Serveren er utilgjengelig\",\n\t\"pad.modals.initsocketfail.explanation\": \"Kunne ikke koble til synkroniseringsserveren.\",\n\t\"pad.modals.initsocketfail.cause\": \"Dette skyldes sannsynligvis et problem med internettoppkoblingen, eller nettleseren din.\",\n\t\"pad.modals.slowcommit.explanation\": \"Serveren svarer ikke.\",\n\t\"pad.modals.slowcommit.cause\": \"Dette skyldes nettverksoppkoblingen din.\",\n\t\"pad.modals.badChangeset.explanation\": \"En redigering som du gjorde ble klassifisert som ugyldig av synkroniseringsserveren.\",\n\t\"pad.modals.badChangeset.cause\": \"Dette kan komme av feil serverkonfigurasjon eller en annen uventet adferd. Vennligst kontakt serviceadministratoren hvis du anser dette å være feil. Prøv å gjenopprette forbindelsen for å fortsette med redigeringen.\",\n\t\"pad.modals.corruptPad.explanation\": \"Pad-en du forsøker å få tilgang til, fungerer ikke.\",\n\t\"pad.modals.corruptPad.cause\": \"Dette kan komme av feil serverkonfigurasjon eller en annen uventet adferd. Vennligst kontakt serviceadministratoren hvis du anser dette å være feil.\",\n\t\"pad.modals.deleted\": \"Slettet.\",\n\t\"pad.modals.deleted.explanation\": \"Denne pad-en har blitt fjernet.\",\n\t\"pad.modals.disconnected\": \"Du har blitt frakoblet.\",\n\t\"pad.modals.disconnected.explanation\": \"Ingen tilkobling til serveren.\",\n\t\"pad.modals.disconnected.cause\": \"Serveren kan være utilgjengelig. Vennligst gi beskjed dersom dette gjentar seg.\",\n\t\"pad.share\": \"Del denne pad-en\",\n\t\"pad.share.readonly\": \"Skrivebeskyttet\",\n\t\"pad.share.link\": \"Lenke\",\n\t\"pad.share.emebdcode\": \"URL for innbygging\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Åpne chatten for denne blokken.\",\n\t\"pad.chat.loadmessages\": \"Last flere beskjeder\",\n\t\"pad.chat.stick.title\": \"Fest chatten til skjermen\",\n\t\"pad.chat.writeMessage.placeholder\": \"Skriv beskjeden din her\",\n\t\"timeslider.pageTitle\": \"{{appTitle}}-tidslinje\",\n\t\"timeslider.toolbar.returnbutton\": \"Gå tilbake til pad-en\",\n\t\"timeslider.toolbar.authors\": \"Forfattere:\",\n\t\"timeslider.toolbar.authorsList\": \"Ingen forfattere\",\n\t\"timeslider.toolbar.exportlink.title\": \"Eksporter\",\n\t\"timeslider.exportCurrent\": \"Eksporter nåværende versjon som:\",\n\t\"timeslider.version\": \"Versjon {{version}}\",\n\t\"timeslider.saved\": \"Lagret {{day}}. {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Spill av/Pause pad-innholdet\",\n\t\"timeslider.backRevision\": \"Gå tilbake en revisjon i denne pad-en\",\n\t\"timeslider.forwardRevision\": \"Gå fremover en revisjon i denne pad-en\",\n\t\"timeslider.dateformat\": \"{{day}}. {{month}} {{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"januar\",\n\t\"timeslider.month.february\": \"februar\",\n\t\"timeslider.month.march\": \"mars\",\n\t\"timeslider.month.april\": \"april\",\n\t\"timeslider.month.may\": \"mai\",\n\t\"timeslider.month.june\": \"juni\",\n\t\"timeslider.month.july\": \"juli\",\n\t\"timeslider.month.august\": \"august\",\n\t\"timeslider.month.september\": \"september\",\n\t\"timeslider.month.october\": \"oktober\",\n\t\"timeslider.month.november\": \"november\",\n\t\"timeslider.month.december\": \"desember\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: navnløs forfatter, other: navnløse forfattere ]}\",\n\t\"pad.savedrevs.marked\": \"Denne revisjonen er nå markert som en lagret revisjon\",\n\t\"pad.savedrevs.timeslider\": \"Du kan se lagrede revisjoner med tidslinjen\",\n\t\"pad.userlist.entername\": \"Skriv inn ditt navn\",\n\t\"pad.userlist.unnamed\": \"navnløs\",\n\t\"pad.editbar.clearcolors\": \"Fjern forfatterfarger på hele dokumentet? Dette kan ikke angres\",\n\t\"pad.impexp.importbutton\": \"Importer nå\",\n\t\"pad.impexp.importing\": \"Importerer …\",\n\t\"pad.impexp.confirmimport\": \"Importering av en fil vil overskrive den nåværende teksten på blokken. Er du sikker på at du vil fortsette?\",\n\t\"pad.impexp.convertFailed\": \"Vi greide ikke å importere denne filen. Bruk et annet dokumentformat eller kopier og lim inn teksten manuelt\",\n\t\"pad.impexp.padHasData\": \"Vi kunne ikke importere denne filen fordi blokken allerede hadde endringer. Importer til en ny blokk.\",\n\t\"pad.impexp.uploadFailed\": \"Opplastning feilet. Prøv igjen\",\n\t\"pad.impexp.importfailed\": \"Import feilet\",\n\t\"pad.impexp.copypaste\": \"Vennligst kopier og lim inn\",\n\t\"pad.impexp.exportdisabled\": \"Eksportering som {{type}} er deaktivert. Vennligst kontakt systemadministratoren din for detaljer.\",\n\t\"pad.impexp.maxFileSize\": \"Filen er for stor. Kontakt systemansvarlig for å øke filstørrelse for import\"\n}\n"
  },
  {
    "path": "src/locales/nds.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Gthoele\",\n\t\t\t\"Joachim Mos\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Nee'et Pad\",\n\t\"index.createOpenPad\": \"oder Pad mit düssen Naam apen maken:\",\n\t\"pad.toolbar.bold.title\": \"Fett (Strg-B)\",\n\t\"pad.toolbar.italic.title\": \"Kursiv (Strg-I)\",\n\t\"pad.toolbar.underline.title\": \"Mit Streek dor ünner (Strg-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Mit Streek dor dör\",\n\t\"pad.toolbar.ol.title\": \"List na Nummern\",\n\t\"pad.toolbar.ul.title\": \"List ahn Nummern\",\n\t\"pad.toolbar.indent.title\": \"Text na rechts\",\n\t\"pad.toolbar.unindent.title\": \"Text na links\",\n\t\"pad.toolbar.undo.title\": \"Een Stapp retuur (Strg-Z)\",\n\t\"pad.toolbar.redo.title\": \"Noch mal (Strg-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Klöör vun den Schriever wegnehmen\",\n\t\"pad.toolbar.import_export.title\": \"Rinhalen/Rutgeven in verscheden Dateiformate\",\n\t\"pad.toolbar.timeslider.title\": \"Geschicht vun de Pad-Faten wiesen\",\n\t\"pad.toolbar.savedRevision.title\": \"Faten sekern\",\n\t\"pad.toolbar.settings.title\": \"Instellungen\",\n\t\"pad.toolbar.embed.title\": \"Düt Pad verdelen oder annerswo ringeven\",\n\t\"pad.toolbar.showusers.title\": \"Wokeen is online?\",\n\t\"pad.colorpicker.save\": \"Spiekern\",\n\t\"pad.colorpicker.cancel\": \"Afbreken\",\n\t\"pad.loading\": \"Läädt…\",\n\t\"pad.permissionDenied\": \"In düt Pad dröffst du nich rin\",\n\t\"pad.settings.padSettings\": \"So is dat Pad instellt\",\n\t\"pad.settings.myView\": \"So heff ik dat instellt\",\n\t\"pad.settings.stickychat\": \"Chat jümmers wiesen\",\n\t\"pad.settings.colorcheck\": \"Klören vun de Schrievers wiesen\",\n\t\"pad.settings.linenocheck\": \"Nummer vun de Reeg\",\n\t\"pad.settings.rtlcheck\": \"Lees Pad vun rechts nach links\",\n\t\"pad.settings.fontType\": \"Schriftoort:\",\n\t\"pad.settings.fontType.normal\": \"Normaal\",\n\t\"pad.settings.language\": \"Spraak:\",\n\t\"pad.importExport.import_export\": \"Rinhalen/Rutgeven\",\n\t\"pad.importExport.import\": \"Datei oder Dokument hoochladen\",\n\t\"pad.importExport.importSuccessful\": \"Hett slumpt!\",\n\t\"pad.importExport.export\": \"Düt Pad rutgeven as:\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Textdatei\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Se köönt blots wat vun Kloortext oder HTML-Stücken röverhalen. Mit <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\"> köönt Se ok anner Saken röverhalen. Dorför mööt Se bidde abiword inrichten</a>.\",\n\t\"pad.modals.connected\": \"Verbindung steiht.\",\n\t\"pad.modals.reconnecting\": \"En Verbindung wedder opboen ...\",\n\t\"pad.modals.forcereconnect\": \"Noch mal verbinnen\",\n\t\"pad.modals.userdup\": \"In en anner Fenster op\",\n\t\"pad.modals.userdup.explanation\": \"Dat lett, düt Pat is op düssen Reekner in mehr as een Browser-Fenster op.\",\n\t\"pad.modals.userdup.advice\": \"Wullt du düt Fenster bruken, bidde noch mal de Verbindung opboen.\",\n\t\"pad.modals.unauth\": \"Nich freegeven.\",\n\t\"pad.modals.unauth.explanation\": \"Du hest nu anner Rechten för düt Pad. Maak dat bidde noch mal wedder nee op.\",\n\t\"pad.modals.looping.explanation\": \"Dat gifft Kummer bi de Verbindung mit den Pad-Server.\",\n\t\"pad.modals.looping.cause\": \"Mag ween un du hest Verbindung mit den Padserver över en Firewall, de nich passt, oder en Proxy, de nich passt.\",\n\t\"pad.modals.initsocketfail\": \"Wi köönt den Pad-Server nich faat kriegen.\",\n\t\"pad.modals.initsocketfail.explanation\": \"De Verbindung mit den Pad-Server hett nich klappt.\",\n\t\"pad.modals.initsocketfail.cause\": \"Mag ween un dat liggt an dien Browser oder an dien Internet-Verbindung.\",\n\t\"pad.modals.slowcommit.explanation\": \"De Pad-Server gifft keen Antwoort.\",\n\t\"pad.modals.slowcommit.cause\": \"Kunn wenn un dat liggt an dat Nettwark, oder dor arbeidt jüst to veel Lüüd op den Pad-Server.\",\n\t\"pad.modals.deleted\": \"Weg is dat!\",\n\t\"pad.modals.deleted.explanation\": \"Düt Pad is nu weg.\",\n\t\"pad.modals.disconnected\": \"De Kuntakt is afreten.\",\n\t\"pad.modals.disconnected.explanation\": \"De Kuntakt mit den Pad-Server is afreten.\",\n\t\"pad.modals.disconnected.cause\": \"Mag ween un wi köönt den Pad-Server jüst nich faat kriegen. Schull dat so wiedergahn, segg man Bescheed.\",\n\t\"pad.share\": \"Düt Pad ok anner Lüüd wiesen\",\n\t\"pad.share.readonly\": \"Se köönt hier jüst blots lesen\",\n\t\"pad.share.link\": \"Link\",\n\t\"pad.share.emebdcode\": \"In Websiet ringeven\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Den Chat vun düt Pad apen maken\",\n\t\"pad.chat.loadmessages\": \"Mehr Narichten laden\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Öllere Faten vun dat Pad\",\n\t\"timeslider.toolbar.returnbutton\": \"Retuur na dat Pad\",\n\t\"timeslider.toolbar.authors\": \"Schrievers:\",\n\t\"timeslider.toolbar.authorsList\": \"keen Schrievers\",\n\t\"timeslider.toolbar.exportlink.title\": \"Rutschicken\",\n\t\"timeslider.exportCurrent\": \"Schick düsse Faten rut as:\",\n\t\"timeslider.version\": \"Faten {{version}}\",\n\t\"timeslider.saved\": \"Sekert an den {{day}}.{{month}}.{{year}}\",\n\t\"timeslider.dateformat\": \"{{day}}.{{month}}.{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Januar\",\n\t\"timeslider.month.february\": \"Februar\",\n\t\"timeslider.month.march\": \"März\",\n\t\"timeslider.month.april\": \"April\",\n\t\"timeslider.month.may\": \"Mai\",\n\t\"timeslider.month.june\": \"Juni\",\n\t\"timeslider.month.july\": \"Juli\",\n\t\"timeslider.month.august\": \"August\",\n\t\"timeslider.month.september\": \"September\",\n\t\"timeslider.month.october\": \"Oktober\",\n\t\"timeslider.month.november\": \"November\",\n\t\"timeslider.month.december\": \"Dezember\",\n\t\"timeslider.unnamedauthors\": \"{{num}} Schriever ohn Naam {[plural(num) one: Schriever, other: Schrievers ]}\",\n\t\"pad.savedrevs.marked\": \"Düsse Faten hett nu dat Teken: sekerte Faten\",\n\t\"pad.userlist.entername\": \"Schriev dien Naam\",\n\t\"pad.userlist.unnamed\": \"hett keen Naam\",\n\t\"pad.editbar.clearcolors\": \"Klören vun de Schrievers in dat ganze Dokument torüchsetten?\",\n\t\"pad.impexp.importbutton\": \"Nu rinhalen\",\n\t\"pad.impexp.importing\": \"Haal dat rin …\",\n\t\"pad.impexp.confirmimport\": \"Wenn du nu en Datei rinhaalst, warrt de Text in dat Pad överschreven. Wullt du würklich wieder maken?\",\n\t\"pad.impexp.convertFailed\": \"Wi köönt düsse Datei nich rinhalen. Ännert Se bidde dat Format vun dat Dokument oder haalt Se den Text mit de Hand rin.\",\n\t\"pad.impexp.uploadFailed\": \"Dat Hoochladen hett nich slumpt. Versöök dat man noch mal.\",\n\t\"pad.impexp.importfailed\": \"Dat Rinhalen hett nich slumpt\",\n\t\"pad.impexp.copypaste\": \"Bidde koperen un rinsetten\",\n\t\"pad.impexp.exportdisabled\": \"Du kannst dat nich in dat {{type}}-Format rutschicken. Wenn du mehr weten wullt, fraag man den Systemadministrator.\"\n}\n"
  },
  {
    "path": "src/locales/ne.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Bada Kaji\",\n\t\t\t\"Nirajan pant\",\n\t\t\t\"Nirjal stha\",\n\t\t\t\"पर्वत सुबेदी\",\n\t\t\t\"बडा काजी\",\n\t\t\t\"राम प्रसाद जोशी\",\n\t\t\t\"सरोज कुमार ढकाल\",\n\t\t\t\"हिमाल सुबेदी\"\n\t\t]\n\t},\n\t\"admin_plugins.description\": \"विवरण\",\n\t\"admin_plugins.name\": \"नाम\",\n\t\"admin_plugins.version\": \"संस्करण\",\n\t\"index.newPad\": \"नयाँ प्याड\",\n\t\"index.settings\": \"अभिरुचिहरू\",\n\t\"index.createOpenPad\": \"नाम सहितको नयाँ प्याड सिर्जना गर्ने / खोल्ने :\",\n\t\"pad.toolbar.bold.title\": \"मोटो (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"ढल्के (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"निम्न रेखाङ्कन (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"बीचको धर्को (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"क्रमवद्ध सूची (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"अक्रमाङ्कित सूची (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"इन्डेन्ट (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"आउटडेन्ट (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"रद्द (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"पुन:लागु (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"लेखकत्व रङहरू खाली गर्नुहोस् (Ctrl + Shift + C)\",\n\t\"pad.toolbar.timeslider.title\": \"टाइमस्लाइडर\",\n\t\"pad.toolbar.savedRevision.title\": \"पुनरावलोकन संग्रहगर्ने\",\n\t\"pad.toolbar.settings.title\": \"अभिरुचिहरू\",\n\t\"pad.toolbar.embed.title\": \"यस प्याडलाई बाड्ने या इम्बेड गर्ने\",\n\t\"pad.toolbar.showusers.title\": \"यस प्याडमा रहेका प्रयोगकर्ता देखाउने\",\n\t\"pad.colorpicker.save\": \"सङ्ग्रह गर्नुहोस्\",\n\t\"pad.colorpicker.cancel\": \"रद्द गर्नुहोस्\",\n\t\"pad.loading\": \"खुल्दै छ…\",\n\t\"pad.permissionDenied\": \"तपाईंलाई यो प्याड खोल्न अनुमति छैन\",\n\t\"pad.settings.padSettings\": \"प्याड अभिरुचिहरू\",\n\t\"pad.settings.myView\": \"मेरो दृष्य\",\n\t\"pad.settings.stickychat\": \"पर्दामा सधै च्याट गर्ने\",\n\t\"pad.settings.chatandusers\": \"वार्ता तथा प्रयोगकर्ताहरू देखाउने\",\n\t\"pad.settings.colorcheck\": \"लेखकका रङहरू\",\n\t\"pad.settings.linenocheck\": \"हरफ संख्या\",\n\t\"pad.settings.rtlcheck\": \"के सामग्री दाहिने देखि देब्रे पढ्ने हो ?\",\n\t\"pad.settings.fontType\": \"लिपि प्रकार:\",\n\t\"pad.settings.fontType.normal\": \"सामान्य\",\n\t\"pad.settings.language\": \"भाषा:\",\n\t\"pad.settings.about\": \"बारेमा\",\n\t\"pad.settings.poweredBy\": \"प्रवर्धक\",\n\t\"pad.importExport.import_export\": \"आयात/निर्यात\",\n\t\"pad.importExport.import\": \"कुनै पनि पाठ रहेको फाइल या कागजात अपलोड गर्नुहोस्\",\n\t\"pad.importExport.importSuccessful\": \"सफल भयो!\",\n\t\"pad.importExport.export\": \"निम्न रुपमा प्याड निर्यात गर्ने :\",\n\t\"pad.importExport.exportetherpad\": \"इथरप्याड\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"साधारण पाठ\",\n\t\"pad.importExport.exportword\": \"माइक्रोसफ्ट वर्ड\",\n\t\"pad.importExport.exportpdf\": \"पिडिएफ\",\n\t\"pad.importExport.exportopen\": \"ओडिएफ(खुल्ला कागजात ढाँचा)\",\n\t\"pad.modals.connected\": \"जोडीएको।\",\n\t\"pad.modals.reconnecting\": \"तपाईंको प्याडमा पुन: जडान गर्दै\",\n\t\"pad.modals.forcereconnect\": \"जडानको लागि जोडगर्ने\",\n\t\"pad.modals.cancel\": \"रद्द गर्नुहोस्\",\n\t\"pad.modals.userdup\": \"अर्को सन्झ्यालमा खोल्ने\",\n\t\"pad.modals.unauth\": \"अनुमती नदिइएको\",\n\t\"pad.modals.initsocketfail\": \"सर्भरमा पहुँच पुर्‍याउन सकिएन ।\",\n\t\"pad.modals.slowcommit.explanation\": \"सर्भरसँग सम्पर्क हुने सकेन ।\",\n\t\"pad.modals.deleted\": \"मेटिएको ।\",\n\t\"pad.modals.deleted.explanation\": \"यो प्याड हटाइसकेको छ ।\",\n\t\"pad.modals.disconnected\": \"तपाईंको जडान अवरुद्ध भयो ।\",\n\t\"pad.modals.disconnected.explanation\": \"तपाईंको सर्भरसँगको जडान अवरुद्ध भयो\",\n\t\"pad.share\": \"यस प्यडलाई बाड्ने\",\n\t\"pad.share.readonly\": \"पढ्ने मात्र\",\n\t\"pad.share.link\": \"कडी\",\n\t\"pad.share.emebdcode\": \"URL थप्ने\",\n\t\"pad.chat\": \"कुराकानी\",\n\t\"pad.chat.title\": \"यस प्याडको लागि कुराकानी खोल्ने\",\n\t\"pad.chat.loadmessages\": \"थप सन्देशहरू खोल्ने\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} समय रेखा\",\n\t\"timeslider.toolbar.returnbutton\": \"प्याडमा फर्कनुहोस्\",\n\t\"timeslider.toolbar.authors\": \"लेखकहरु:\",\n\t\"timeslider.toolbar.authorsList\": \"कुनै पनि लेखकहरू छैनन्\",\n\t\"timeslider.toolbar.exportlink.title\": \"निर्यात\",\n\t\"timeslider.exportCurrent\": \"हालको संस्करण निम्म रुपमा निर्यात गर्ने :\",\n\t\"timeslider.version\": \"संस्करण {{version}}\",\n\t\"timeslider.saved\": \"सङ्ग्रह गरिएको {{month}} {{day}}, {{year}}\",\n\t\"timeslider.playPause\": \"प्याडको सामग्रीहरूलाई चालु / बन्द गर्नुहोस्\",\n\t\"timeslider.backRevision\": \"यो प्याडको एक संस्करण पहिले जानुहोस्\",\n\t\"timeslider.forwardRevision\": \"यो प्याडको एक संस्करण पछि जानुहोस्\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"जनवरी\",\n\t\"timeslider.month.february\": \"फेब्रुअरी\",\n\t\"timeslider.month.march\": \"मार्च\",\n\t\"timeslider.month.april\": \"एप्रील\",\n\t\"timeslider.month.may\": \"मे\",\n\t\"timeslider.month.june\": \"जुन\",\n\t\"timeslider.month.july\": \"जुलाई\",\n\t\"timeslider.month.august\": \"अगस्ट\",\n\t\"timeslider.month.september\": \"सेप्टेम्बर\",\n\t\"timeslider.month.october\": \"अक्टोबर\",\n\t\"timeslider.month.november\": \"नोभेम्बर\",\n\t\"timeslider.month.december\": \"डिसेम्बर\",\n\t\"timeslider.unnamedauthors\": \"{{num}} unnamed {[plural(num) one: author, other: authors ]}\",\n\t\"pad.savedrevs.marked\": \"यस संस्करणलाई संग्रहितको रुपमा चिनो लगाइएको छैन\",\n\t\"pad.userlist.entername\": \"तपाईंको नाम लेख्नुहोस्\",\n\t\"pad.userlist.unnamed\": \"नाम नखुलाइएको\",\n\t\"pad.impexp.importbutton\": \"अहिले आयात गर्ने\",\n\t\"pad.impexp.importing\": \"आयात गर्ने...\",\n\t\"pad.impexp.uploadFailed\": \"अपलोड असफल भयो , कृपया पुन: प्रयास गर्नुहोस् ।\",\n\t\"pad.impexp.importfailed\": \"आयात असफल भयो\",\n\t\"pad.impexp.copypaste\": \"कृपया कपी पेस्ट गर्नुहोस\"\n}\n"
  },
  {
    "path": "src/locales/nl.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"ABPMAB\",\n\t\t\t\"Dutchy45\",\n\t\t\t\"Klaas van Buiten\",\n\t\t\t\"KlaasZ4usV\",\n\t\t\t\"Macofe\",\n\t\t\t\"Mainframe98\",\n\t\t\t\"Marcelhospers\",\n\t\t\t\"McDutchie\",\n\t\t\t\"PonkoSasuke\",\n\t\t\t\"Rickvl\",\n\t\t\t\"Robin van der Vliet\",\n\t\t\t\"Robin0van0der0vliet\",\n\t\t\t\"Siebrand\",\n\t\t\t\"Spinster\",\n\t\t\t\"woeterman94\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Beheerdashboard – Etherpad\",\n\t\"admin_plugins\": \"Beheer plug-ins\",\n\t\"admin_plugins.available\": \"Beschikbare plug-ins\",\n\t\"admin_plugins.available_not-found\": \"Geen plug-ins gevonden.\",\n\t\"admin_plugins.available_fetching\": \"Ophalen…\",\n\t\"admin_plugins.available_install.value\": \"Installeren\",\n\t\"admin_plugins.available_search.placeholder\": \"Zoeken naar plug-ins om te installeren\",\n\t\"admin_plugins.description\": \"Beschrijving\",\n\t\"admin_plugins.installed\": \"Geïnstalleerde plug-ins\",\n\t\"admin_plugins.installed_fetching\": \"Geïnstalleerd plug-ins worden opgehaald…\",\n\t\"admin_plugins.installed_nothing\": \"U hebt nog geen plug-ins geïnstalleerd.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Verwijderen\",\n\t\"admin_plugins.last-update\": \"Laatst bijgewerkt\",\n\t\"admin_plugins.name\": \"Naam\",\n\t\"admin_plugins.page-title\": \"Beheer plug-ins – Etherpad\",\n\t\"admin_plugins.version\": \"Versie\",\n\t\"admin_plugins_info\": \"Probleemoplossingsinformatie\",\n\t\"admin_plugins_info.hooks\": \"Geïnstalleerde hooks\",\n\t\"admin_plugins_info.hooks_client\": \"Client-side hooks\",\n\t\"admin_plugins_info.hooks_server\": \"Server-side hooks\",\n\t\"admin_plugins_info.parts\": \"Geïnstalleerde onderdelen\",\n\t\"admin_plugins_info.plugins\": \"Geïnstalleerde plug-ins\",\n\t\"admin_plugins_info.page-title\": \"Plug-in-informatie – Etherpad\",\n\t\"admin_plugins_info.version\": \"Versie van Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Meest recente versie\",\n\t\"admin_plugins_info.version_number\": \"Versienummer\",\n\t\"admin_settings\": \"Instellingen\",\n\t\"admin_settings.current\": \"Huidige configuratie\",\n\t\"admin_settings.current_example-devel\": \"Voorbeeldsjabloon voor ontwikkelingsinstellingen\",\n\t\"admin_settings.current_example-prod\": \"Voorbeeldsjabloon voor productie-instellingen\",\n\t\"admin_settings.current_restart.value\": \"Herstart Etherpad\",\n\t\"admin_settings.current_save.value\": \"Bewaar instellingen\",\n\t\"admin_settings.page-title\": \"Instellingen - Etherpad\",\n\t\"index.newPad\": \"Nieuwe notitie\",\n\t\"index.settings\": \"Instellingen\",\n\t\"index.transferSessionTitle\": \"Sessie overzetten\",\n\t\"index.receiveSessionTitle\": \"Sessie ontvangen\",\n\t\"index.receiveSessionDescription\": \"Hier kunt u een Etherpad-sessie ontvangen vanaf een andere browser of een ander apparaat. Houd er echter rekening mee dat hiermee uw huidige sessie, indien aanwezig, wordt verwijderd.\",\n\t\"index.transferSession\": \"1. Sessie overzetten\",\n\t\"index.transferSessionNow\": \"Sessie nu overzetten\",\n\t\"index.copyLink\": \"2. Koppeling kopiëren\",\n\t\"index.copyLinkDescription\": \"Klik op de onderstaande knop om de koppeling naar uw klembord te kopiëren.\",\n\t\"index.copyLinkButton\": \"Koppeling naar klembord kopiëren\",\n\t\"index.transferToSystem\": \"3. Kopieer de sessie naar het nieuwe systeem\",\n\t\"index.transferToSystemDescription\": \"Open de gekopieerde koppeling in de doelbrowser of het doelapparaat om uw sessie over te zetten.\",\n\t\"index.transferSessionDescription\": \"Zet uw huidige sessie over naar uw browser of apparaat door op de onderstaande knop te klikken. Hiermee wordt een koppeling naar een pagina gekopieerd die uw sessie overzet wanneer deze in de gewenste browser of op het gewenste apparaat wordt geopend.\",\n\t\"index.createOpenPad\": \"Open een notitie met de naam\",\n\t\"index.openPad\": \"open een bestaande notitie met de naam:\",\n\t\"index.recentPads\": \"Recente notities\",\n\t\"index.recentPadsEmpty\": \"Geen recente notities gevonden.\",\n\t\"index.generateNewPad\": \"Genereer willekeurige notitienaam\",\n\t\"index.labelPad\": \"Notitienaam (optioneel)\",\n\t\"index.placeholderPadEnter\": \"Voer de naam van een notitie in…\",\n\t\"index.createAndShareDocuments\": \"Maak en deel documenten in realtime\",\n\t\"index.createAndShareDocumentsDescription\": \"Met Etherpad kunt u in samenwerking met anderen tegelijkertijd hetzelfde document bewerken. Het is zoals een live multiplayer-editor die in uw browser draait.\",\n\t\"pad.toolbar.bold.title\": \"Vet (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Cursief (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Onderstrepen (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Doorhalen (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Geordende lijst (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Ongeordende lijst (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Inspringen (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Uitspringen (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Ongedaan maken (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Opnieuw uitvoeren (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Kleuren auteurs wissen (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Naar/van andere opmaak exporteren/importeren\",\n\t\"pad.toolbar.timeslider.title\": \"Tijdlijn\",\n\t\"pad.toolbar.savedRevision.title\": \"Versie opslaan\",\n\t\"pad.toolbar.settings.title\": \"Instellingen\",\n\t\"pad.toolbar.embed.title\": \"Deze notitie delen en insluiten\",\n\t\"pad.toolbar.home.title\": \"Terug naar de startpagina\",\n\t\"pad.toolbar.showusers.title\": \"Gebruikers van deze notitie weergeven\",\n\t\"pad.colorpicker.save\": \"Opslaan\",\n\t\"pad.colorpicker.cancel\": \"Annuleren\",\n\t\"pad.loading\": \"Bezig met laden…\",\n\t\"pad.noCookie\": \"Er kon geen cookie gevonden worden. Zorg ervoor dat uw browser cookies accepteert. Uw sessie en instellingen worden tussen bezoeken niet opgeslagen. Dit kan te wijten zijn aan het feit dat Etherpad in sommige browsers wordt opgenomen in een iFrame. Zorg ervoor dat Etherpad zich op hetzelfde subdomein/domein bevindt als het bovenliggende iFrame.\",\n\t\"pad.permissionDenied\": \"U hebt geen toestemming om deze notitie te openen\",\n\t\"pad.settings.padSettings\": \"Notitie-instellingen\",\n\t\"pad.settings.myView\": \"Mijn overzicht\",\n\t\"pad.settings.stickychat\": \"Chat altijd zichtbaar\",\n\t\"pad.settings.chatandusers\": \"Chat en gebruikers weergeven\",\n\t\"pad.settings.colorcheck\": \"Kleuren auteurs\",\n\t\"pad.settings.linenocheck\": \"Regelnummers\",\n\t\"pad.settings.rtlcheck\": \"Inhoud van rechts naar links lezen?\",\n\t\"pad.settings.fontType\": \"Lettertype:\",\n\t\"pad.settings.fontType.normal\": \"Normaal\",\n\t\"pad.settings.language\": \"Taal:\",\n\t\"pad.settings.deletePad\": \"Notitie verwijderen\",\n\t\"pad.delete.confirm\": \"Wilt u deze notitie echt verwijderen?\",\n\t\"pad.settings.about\": \"Over\",\n\t\"pad.settings.poweredBy\": \"Mogelijk gemaakt door\",\n\t\"pad.importExport.import_export\": \"Importeren/exporteren\",\n\t\"pad.importExport.import\": \"Tekstbestand of document uploaden\",\n\t\"pad.importExport.importSuccessful\": \"Gelukt!\",\n\t\"pad.importExport.export\": \"Huidige notitie exporteren als:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Tekst zonder opmaak\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"U kunt alleen importeren vanuit tekst zonder opmaak of met HTML-opmaak. <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">Installeer AbiWord of LibreOffice</a> om meer geavanceerde importmogelijkheden te krijgen.\",\n\t\"pad.modals.connected\": \"Verbonden.\",\n\t\"pad.modals.reconnecting\": \"De verbinding met uw notitie wordt hersteld…\",\n\t\"pad.modals.forcereconnect\": \"Opnieuw verbinden\",\n\t\"pad.modals.reconnecttimer\": \"Nieuwe verbindingspoging over\",\n\t\"pad.modals.cancel\": \"Annuleren\",\n\t\"pad.modals.userdup\": \"In een ander venster geopend\",\n\t\"pad.modals.userdup.explanation\": \"Deze notitie is blijkbaar in meer dan één browservenster op deze computer geopend.\",\n\t\"pad.modals.userdup.advice\": \"Maak opnieuw verbinding als u dit venster wilt gebruiken.\",\n\t\"pad.modals.unauth\": \"Niet toegestaan\",\n\t\"pad.modals.unauth.explanation\": \"Uw rechten zijn gewijzigd terwijl u de pagina aan het bekijken was. Probeer opnieuw te verbinden.\",\n\t\"pad.modals.looping.explanation\": \"Er is een probleem opgetreden tijdens de communicatie met de synchronisatieserver.\",\n\t\"pad.modals.looping.cause\": \"Mogelijk hebt u verbinding gemaakt via een niet-compatibele firewall of proxy.\",\n\t\"pad.modals.initsocketfail\": \"De server is niet bereikbaar.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Er kon geen verbinding worden gemaakt met de synchronisatieserver.\",\n\t\"pad.modals.initsocketfail.cause\": \"Dit komt waarschijnlijk door een probleem met uw browser of uw internetverbinding.\",\n\t\"pad.modals.slowcommit.explanation\": \"De server reageert niet.\",\n\t\"pad.modals.slowcommit.cause\": \"Dit komt mogelijk door netwerkproblemen.\",\n\t\"pad.modals.badChangeset.explanation\": \"Een door u gemaakte bewerking is door de synchronisatieserver als incorrect aangemerkt.\",\n\t\"pad.modals.badChangeset.cause\": \"Dit kan komen door een onjuiste serverinstelling of door ander onverwacht gedrag. Neem contact op met de servicebeheerder als u denkt dat dit een fout is. Probeer opnieuw te verbinden om door te gaan met bewerken.\",\n\t\"pad.modals.corruptPad.explanation\": \"De notitie die u wilt openen is beschadigd.\",\n\t\"pad.modals.corruptPad.cause\": \"Dit kan komen door een onjuiste serverinstelling of door ander onverwacht gedrag. Neem contact op met de servicebeheerder.\",\n\t\"pad.modals.deleted\": \"Verwijderd.\",\n\t\"pad.modals.deleted.explanation\": \"Deze notitie is verwijderd.\",\n\t\"pad.modals.rateLimited\": \"Snelheid begrensd.\",\n\t\"pad.modals.rateLimited.explanation\": \"U hebt te veel berichten naar deze notitie gestuurd. Daarom is uw verbinding verbroken.\",\n\t\"pad.modals.rejected.explanation\": \"De server heeft een bericht verworpen dat door uw browser is verzonden.\",\n\t\"pad.modals.rejected.cause\": \"Mogelijk is de server bijgewerkt terwijl u de notitie aan het bekijken was. Of misschien is er een bug in Etherpad. Probeer de pagina opnieuw te laden.\",\n\t\"pad.modals.disconnected\": \"Uw verbinding is verbroken.\",\n\t\"pad.modals.disconnected.explanation\": \"De verbinding met de server is verbroken\",\n\t\"pad.modals.disconnected.cause\": \"De server is mogelijk niet beschikbaar. Stel de servicebeheerder op de hoogte als dit probleem aanhoudt.\",\n\t\"pad.share\": \"Deze notitie delen\",\n\t\"pad.share.readonly\": \"Alleen lezen\",\n\t\"pad.share.link\": \"Koppeling\",\n\t\"pad.share.emebdcode\": \"URL voor insluiten\",\n\t\"pad.chat\": \"Chatten\",\n\t\"pad.chat.title\": \"De chat voor deze notitie openen.\",\n\t\"pad.chat.loadmessages\": \"Meer berichten laden\",\n\t\"pad.chat.stick.title\": \"Chat op scherm vastzetten\",\n\t\"pad.chat.writeMessage.placeholder\": \"Schrijf uw bericht hier\",\n\t\"timeslider.followContents\": \"Volg het bijwerken van de inhoud van deze notitie\",\n\t\"timeslider.pageTitle\": \"Tijdlijn voor {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Terug naar notitie\",\n\t\"timeslider.toolbar.authors\": \"Auteurs:\",\n\t\"timeslider.toolbar.authorsList\": \"Geen auteurs\",\n\t\"timeslider.toolbar.exportlink.title\": \"Exporteren\",\n\t\"timeslider.exportCurrent\": \"Huidige versie exporteren als:\",\n\t\"timeslider.version\": \"Versie {{version}}\",\n\t\"timeslider.saved\": \"Opgeslagen op {{day}} {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Notitie-inhoud afspelen of pauzeren\",\n\t\"timeslider.backRevision\": \"Een versie teruggaan in deze notitie\",\n\t\"timeslider.forwardRevision\": \"Een versie vooruit gaan in deze notitie\",\n\t\"timeslider.dateformat\": \"{{year}}-{{month}}-{{day}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"januari\",\n\t\"timeslider.month.february\": \"februari\",\n\t\"timeslider.month.march\": \"maart\",\n\t\"timeslider.month.april\": \"april\",\n\t\"timeslider.month.may\": \"mei\",\n\t\"timeslider.month.june\": \"juni\",\n\t\"timeslider.month.july\": \"juli\",\n\t\"timeslider.month.august\": \"augustus\",\n\t\"timeslider.month.september\": \"september\",\n\t\"timeslider.month.october\": \"oktober\",\n\t\"timeslider.month.november\": \"november\",\n\t\"timeslider.month.december\": \"december\",\n\t\"timeslider.unnamedauthors\": \"{{num}} onbekende {[plural(num) one: auteur, other: auteurs ]}\",\n\t\"pad.savedrevs.marked\": \"Deze versie is nu gemarkeerd als opgeslagen versie\",\n\t\"pad.savedrevs.timeslider\": \"U kunt opgeslagen versies bekijken via de tijdlijn\",\n\t\"pad.userlist.entername\": \"Geef uw naam op\",\n\t\"pad.userlist.unnamed\": \"zonder naam\",\n\t\"pad.editbar.clearcolors\": \"Auteurskleuren voor het hele document wissen? Dit kan niet ongedaan worden gemaakt.\",\n\t\"pad.impexp.importbutton\": \"Nu importeren\",\n\t\"pad.impexp.importing\": \"Bezig met importeren…\",\n\t\"pad.impexp.confirmimport\": \"Door een bestand te importeren overschrijft u de huidige tekst van de notitie. Wilt u echt doorgaan?\",\n\t\"pad.impexp.convertFailed\": \"Het was niet mogelijk dit bestand te importeren. Gebruik een andere documentopmaak of kopieer en plak de inhoud handmatig\",\n\t\"pad.impexp.padHasData\": \"Het was niet mogelijk dit bestand te importeren omdat er al wijzigingen aan de notitie zijn aangebracht. Importeer in een nieuwe notitie.\",\n\t\"pad.impexp.uploadFailed\": \"Het uploaden is mislukt. Probeer het opnieuw\",\n\t\"pad.impexp.importfailed\": \"Importeren is mislukt\",\n\t\"pad.impexp.copypaste\": \"Gebruik kopiëren en plakken\",\n\t\"pad.impexp.exportdisabled\": \"Het exporteren in de indeling {{type}} is uitgeschakeld. Neem contact op met de systeembeheerder voor details.\",\n\t\"pad.impexp.maxFileSize\": \"Het bestand is te groot. Neem contact op met uw sitebeheerder om de toegestane bestandsgrootte voor importeren te vergroten.\"\n}\n"
  },
  {
    "path": "src/locales/nn.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Unhammer\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Ny blokk\",\n\t\"index.createOpenPad\": \"eller opprett/opna ei blokk med namnet:\",\n\t\"pad.toolbar.bold.title\": \"Feit (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Kursiv (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Understreking (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Gjennomstreking\",\n\t\"pad.toolbar.ol.title\": \"Nummerert liste\",\n\t\"pad.toolbar.ul.title\": \"Punktliste\",\n\t\"pad.toolbar.indent.title\": \"Innrykk\",\n\t\"pad.toolbar.unindent.title\": \"Rykk ut\",\n\t\"pad.toolbar.undo.title\": \"Angra (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Gjer om (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Fjern forfattarfargar\",\n\t\"pad.toolbar.import_export.title\": \"Importer/eksporter til/frå ulike filformat\",\n\t\"pad.toolbar.timeslider.title\": \"Tidslinje\",\n\t\"pad.toolbar.savedRevision.title\": \"Lagra utgåver\",\n\t\"pad.toolbar.settings.title\": \"Innstillingar\",\n\t\"pad.toolbar.embed.title\": \"Bygg inn blokka i ei nettside\",\n\t\"pad.toolbar.showusers.title\": \"Syn brukarane på blokka\",\n\t\"pad.colorpicker.save\": \"Lagra\",\n\t\"pad.colorpicker.cancel\": \"Avbryt\",\n\t\"pad.loading\": \"Lastar …\",\n\t\"pad.permissionDenied\": \"Du har ikkje tilgang til denne blokka\",\n\t\"pad.settings.padSettings\": \"Blokkinnstillingar\",\n\t\"pad.settings.myView\": \"Mi visning\",\n\t\"pad.settings.stickychat\": \"Prat alltid synleg\",\n\t\"pad.settings.colorcheck\": \"Forfattarfargar\",\n\t\"pad.settings.linenocheck\": \"Linjenummer\",\n\t\"pad.settings.fontType\": \"Skrifttype:\",\n\t\"pad.settings.fontType.normal\": \"Vanleg\",\n\t\"pad.settings.language\": \"Språk:\",\n\t\"pad.importExport.import_export\": \"Importer/eksporter\",\n\t\"pad.importExport.import\": \"Last opp tekstfiler eller dokument\",\n\t\"pad.importExport.importSuccessful\": \"Vellukka!\",\n\t\"pad.importExport.export\": \"Eksporter blokka som:\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Rein tekst\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Du kan berre importera frå rein tekst- eller HTML-format. Ver venleg og <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">installer Abiword</a> om du treng meir avanserte importfunksjonar.\",\n\t\"pad.modals.connected\": \"Tilkopla.\",\n\t\"pad.modals.reconnecting\": \"Gjenopprettar tilkoplinga til blokka di …\",\n\t\"pad.modals.forcereconnect\": \"Tving gjentilkopling\",\n\t\"pad.modals.userdup\": \"Opna i eit anna vindauge\",\n\t\"pad.modals.userdup.explanation\": \"Det ser ut som om denne blokka er open i meir enn eitt nettlesarvindauge på denne maskinen.\",\n\t\"pad.modals.userdup.advice\": \"Kopla til om att for å bruka dette vinduage i staden.\",\n\t\"pad.modals.unauth\": \"Ikkje tillate\",\n\t\"pad.modals.unauth.explanation\": \"Rettane dine blei endra under visning av denne sida. Prøv å kopla til på nytt.\",\n\t\"pad.modals.looping.explanation\": \"Det oppstod kommunikasjonsproblem med synkroniseringstenaren.\",\n\t\"pad.modals.looping.cause\": \"Kanskje du kopla til gjennom ein inkompatibel brannmur eller mellomtenar.\",\n\t\"pad.modals.initsocketfail\": \"Klarte ikkje å nå tenaren.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Klarte ikkje å kopla til synkroniseringstenaren.\",\n\t\"pad.modals.initsocketfail.cause\": \"Dette er sannsynlegvis på grunn av eit problem med nettlesaren eller internettilkoplinga di.\",\n\t\"pad.modals.slowcommit.explanation\": \"Tenaren svarer ikkje.\",\n\t\"pad.modals.slowcommit.cause\": \"Dette kan vera på grunn av problem med nettilkoplinga.\",\n\t\"pad.modals.deleted\": \"Sletta.\",\n\t\"pad.modals.deleted.explanation\": \"Denne blokka er fjerna.\",\n\t\"pad.modals.disconnected\": \"Du blei fråkopla.\",\n\t\"pad.modals.disconnected.explanation\": \"Mista tilkoplinga til tenaren\",\n\t\"pad.modals.disconnected.cause\": \"Tenaren er ikkje tilgjengeleg. Ver venleg og gi oss ei melding om dette skjer fleire gonger.\",\n\t\"pad.share\": \"Del denne blokka\",\n\t\"pad.share.readonly\": \"Skriveverna\",\n\t\"pad.share.link\": \"Lenkje\",\n\t\"pad.share.emebdcode\": \"URL for innebygging\",\n\t\"pad.chat\": \"Prat\",\n\t\"pad.chat.title\": \"Opna pratepanelet for denne blokka.\",\n\t\"timeslider.pageTitle\": \"Tidslinje for {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Attende til blokka\",\n\t\"timeslider.toolbar.authors\": \"Forfattarar:\",\n\t\"timeslider.toolbar.authorsList\": \"Ingen forfattarar\",\n\t\"timeslider.toolbar.exportlink.title\": \"Eksporter\",\n\t\"timeslider.exportCurrent\": \"Eksporter denne utgåva som:\",\n\t\"timeslider.version\": \"Utgåve {{version}}\",\n\t\"timeslider.saved\": \"Lagra {{day}}. {{month}}, {{year}}\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}.{{minutes}}.{{seconds}}\",\n\t\"timeslider.month.january\": \"januar\",\n\t\"timeslider.month.february\": \"februar\",\n\t\"timeslider.month.march\": \"mars\",\n\t\"timeslider.month.april\": \"april\",\n\t\"timeslider.month.may\": \"mai\",\n\t\"timeslider.month.june\": \"juni\",\n\t\"timeslider.month.july\": \"juli\",\n\t\"timeslider.month.august\": \"august\",\n\t\"timeslider.month.september\": \"september\",\n\t\"timeslider.month.october\": \"oktober\",\n\t\"timeslider.month.november\": \"november\",\n\t\"timeslider.month.december\": \"desember\",\n\t\"pad.savedrevs.marked\": \"Denne utgåva er no merkt som ei lagra utgåve\",\n\t\"pad.userlist.entername\": \"Skriv namnet ditt\",\n\t\"pad.userlist.unnamed\": \"utan namn\",\n\t\"pad.editbar.clearcolors\": \"Fjern forfattarfargar i heile dokumentet?\",\n\t\"pad.impexp.importbutton\": \"Importer no\",\n\t\"pad.impexp.importing\": \"Importerer …\",\n\t\"pad.impexp.confirmimport\": \"Viss du importerer ei fil, vil denne blokka bli overskriven. Er du sikker på at du vil fortsetja?\",\n\t\"pad.impexp.convertFailed\": \"Me klarte ikkje å importera denne fila. Ver venleg og bruk eit anna dokumentformat, eller kopier og lim inn for hand.\",\n\t\"pad.impexp.uploadFailed\": \"Feil ved opplasting, ver venleg og prøv om att\",\n\t\"pad.impexp.importfailed\": \"Feil ved importering\",\n\t\"pad.impexp.copypaste\": \"Ver venleg og kopier og lim inn\",\n\t\"pad.impexp.exportdisabled\": \"Eksport av {{type}} er skrudd av. Ver venleg og ta kontakt med systemadministrator for meir informasjon.\"\n}\n"
  },
  {
    "path": "src/locales/oc.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Cedric31\",\n\t\t\t\"Quentí\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Panèl d’administracion - Etherpad\",\n\t\"admin_plugins\": \"Gestion de las extensions\",\n\t\"admin_plugins.available\": \"Extensions disponiblas\",\n\t\"admin_plugins.available_not-found\": \"Cap d’extension pas trobada.\",\n\t\"admin_plugins.available_fetching\": \"Recuperacion…\",\n\t\"admin_plugins.available_install.value\": \"Installar\",\n\t\"admin_plugins.available_search.placeholder\": \"Cercar las extensions d’installar\",\n\t\"admin_plugins.description\": \"Descripcion\",\n\t\"admin_plugins.installed\": \"Extensions installadas\",\n\t\"admin_plugins.installed_fetching\": \"Recuperacion de las extensions installadas...\",\n\t\"admin_plugins.installed_nothing\": \"Avètz pas installat cap d’extensions pel moment.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Desinstallar\",\n\t\"admin_plugins.last-update\": \"Darrièra mesa a jorn\",\n\t\"admin_plugins.name\": \"Nom\",\n\t\"admin_plugins.page-title\": \"Gestion de las extensions - Etherpad\",\n\t\"admin_plugins.version\": \"Version\",\n\t\"admin_plugins_info\": \"Informacion de resolucion de problèmas\",\n\t\"admin_plugins_info.plugins\": \"Extensions installadas\",\n\t\"admin_plugins_info.page-title\": \"Informacion d’extension - Etherpad\",\n\t\"admin_plugins_info.version\": \"Version d’Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Darrièra version disponibla\",\n\t\"admin_plugins_info.version_number\": \"Numèro de version\",\n\t\"admin_settings\": \"Paramètres\",\n\t\"admin_settings.current\": \"Configuracion actuala\",\n\t\"admin_settings.current_restart.value\": \"Reaviar Etherpad\",\n\t\"admin_settings.current_save.value\": \"Enregistrar los paramètres\",\n\t\"admin_settings.page-title\": \"Paramètres - Etherpad\",\n\t\"index.newPad\": \"Pad novèl\",\n\t\"index.createOpenPad\": \"o crear/dobrir un Pad intitulat :\",\n\t\"pad.toolbar.bold.title\": \"Gras (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Italica (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Soslinhat (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Raiat (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Lista ordenada (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Lista pas ordenada (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Indentar (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Desindentar (Maj+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Anullar (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Restablir (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Escafar las colors qu'identifican los autors (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importar/Exportar de/cap a un format de fichièr diferent\",\n\t\"pad.toolbar.timeslider.title\": \"Istoric dinamic\",\n\t\"pad.toolbar.savedRevision.title\": \"Enregistrar la revision\",\n\t\"pad.toolbar.settings.title\": \"Paramètres\",\n\t\"pad.toolbar.embed.title\": \"Partejar e integrar aqueste Pad\",\n\t\"pad.toolbar.showusers.title\": \"Afichar los utilizaires del Pad\",\n\t\"pad.colorpicker.save\": \"Enregistrar\",\n\t\"pad.colorpicker.cancel\": \"Anullar\",\n\t\"pad.loading\": \"Cargament...\",\n\t\"pad.noCookie\": \"Lo cookie a pas pogut èsser trobat. Autorizatz los cookies dins vòstre navigador !\",\n\t\"pad.permissionDenied\": \"Vos es pas permés d’accedir a aqueste Pad.\",\n\t\"pad.settings.padSettings\": \"Paramètres del Pad\",\n\t\"pad.settings.myView\": \"Ma vista\",\n\t\"pad.settings.stickychat\": \"Afichar totjorn lo chat\",\n\t\"pad.settings.chatandusers\": \"Afichar la discussion e los utilizaires\",\n\t\"pad.settings.colorcheck\": \"Colors d’identificacion\",\n\t\"pad.settings.linenocheck\": \"Numèros de linhas\",\n\t\"pad.settings.rtlcheck\": \"Lectura de dreita a esquèrra\",\n\t\"pad.settings.fontType\": \"Tipe de poliça :\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Lenga :\",\n\t\"pad.settings.about\": \"A prepaus\",\n\t\"pad.settings.poweredBy\": \"Propulsat per\",\n\t\"pad.importExport.import_export\": \"Importar/Exportar\",\n\t\"pad.importExport.import\": \"Cargar un tèxte o un document\",\n\t\"pad.importExport.importSuccessful\": \"Capitat !\",\n\t\"pad.importExport.export\": \"Exportar lo Pad actual coma :\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Tèxte brut\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Podètz pas importar que de formats tèxte brut o html. Per de foncionalitats d'importacion mai evoluadas, <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">installatz abiword</a>.\",\n\t\"pad.modals.connected\": \"Connectat.\",\n\t\"pad.modals.reconnecting\": \"Reconnexion cap a vòstre Pad...\",\n\t\"pad.modals.forcereconnect\": \"Forçar la reconnexion.\",\n\t\"pad.modals.reconnecttimer\": \"Ensag de reconnexion\",\n\t\"pad.modals.cancel\": \"Anullar\",\n\t\"pad.modals.userdup\": \"Dobèrt dins una autra fenèstra\",\n\t\"pad.modals.userdup.explanation\": \"Sembla qu'aqueste Pad es dobèrt dins mai d'una fenèstra de vòstre navigador sus aqueste ordinator.\",\n\t\"pad.modals.userdup.advice\": \"Se reconnectar en utilizant aquesta fenèstra.\",\n\t\"pad.modals.unauth\": \"Pas autorizat\",\n\t\"pad.modals.unauth.explanation\": \"Vòstras permissions son estadas cambiadas al moment de l'afichatge d'aquesta pagina. Ensajatz de vos reconnectar.\",\n\t\"pad.modals.looping.explanation\": \"Avèm un problèma de comunicacion amb lo servidor de sincronizacion.\",\n\t\"pad.modals.looping.cause\": \"Es possible que vòstra connexion siá protegida per un parafuòc incompatible o un servidor proxy incompatible.\",\n\t\"pad.modals.initsocketfail\": \"Lo servidor es introbable.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Impossible de se connectar al servidor de sincronizacion.\",\n\t\"pad.modals.initsocketfail.cause\": \"Lo problèma pòt venir de vòstre navigador web o de vòstra connexion Internet.\",\n\t\"pad.modals.slowcommit.explanation\": \"Lo servidor respond pas.\",\n\t\"pad.modals.slowcommit.cause\": \"Aqueste problèma pòt venir d'una marrida connectivitat a la ret.\",\n\t\"pad.modals.badChangeset.explanation\": \"Una modificacion qu'avètz efectuada es estada classada coma illegala pel servidor de sincronizacion.\",\n\t\"pad.modals.badChangeset.cause\": \"Aquò pòt èsser degut a una marrida configuracion del servidor o a un autre comportament inesperat. Contactatz l’administrator del servici, si pensatz qu’es una error. Ensajatz de vos reconnectar per contunhar de modificar.\",\n\t\"pad.modals.corruptPad.explanation\": \"Lo blòt al qual ensajatz d’accedir es corromput.\",\n\t\"pad.modals.corruptPad.cause\": \"Aquò pòt èsser degut a una marrida configuracion del servidor o a un autre comportament inesperat. Contactatz l’administrator del servici.\",\n\t\"pad.modals.deleted\": \"Suprimit.\",\n\t\"pad.modals.deleted.explanation\": \"Aqueste Pad es estat suprimit.\",\n\t\"pad.modals.disconnected\": \"Sètz estat desconnectat.\",\n\t\"pad.modals.disconnected.explanation\": \"La connexion al servidor a fracassat.\",\n\t\"pad.modals.disconnected.cause\": \"Es possible que lo servidor siá indisponible. Se lo problèma contunha, informatz-ne l'administrator del servici.\",\n\t\"pad.share\": \"Partejar aqueste Pad\",\n\t\"pad.share.readonly\": \"Lectura sola\",\n\t\"pad.share.link\": \"Ligam\",\n\t\"pad.share.emebdcode\": \"Ligam d'integrar\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Dobrir lo chat associat a aqueste pad.\",\n\t\"pad.chat.loadmessages\": \"Cargar mai de messatges.\",\n\t\"pad.chat.stick.title\": \"Ancorar la discussion a l’ecran\",\n\t\"pad.chat.writeMessage.placeholder\": \"Escrivètz lo vòstre messatge aicí\",\n\t\"timeslider.pageTitle\": \"Istoric dinamic de {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Retorn a aqueste Pad.\",\n\t\"timeslider.toolbar.authors\": \"Autors :\",\n\t\"timeslider.toolbar.authorsList\": \"Pas cap d'autor\",\n\t\"timeslider.toolbar.exportlink.title\": \"Exportar\",\n\t\"timeslider.exportCurrent\": \"Exportar la version actuala en :\",\n\t\"timeslider.version\": \"Version {{version}}\",\n\t\"timeslider.saved\": \"Enregistrat lo {{day}} de {{month}} de {{year}}\",\n\t\"timeslider.playPause\": \"Lectura / Pausa dels contenguts del pad\",\n\t\"timeslider.backRevision\": \"Recular d’una revision dins aqueste pad\",\n\t\"timeslider.forwardRevision\": \"Avançar d’una revision dins aqueste pad\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Genièr\",\n\t\"timeslider.month.february\": \"Febrièr\",\n\t\"timeslider.month.march\": \"Març\",\n\t\"timeslider.month.april\": \"Abril\",\n\t\"timeslider.month.may\": \"Mai\",\n\t\"timeslider.month.june\": \"Junh\",\n\t\"timeslider.month.july\": \"Julhet\",\n\t\"timeslider.month.august\": \"Agost\",\n\t\"timeslider.month.september\": \"Setembre\",\n\t\"timeslider.month.october\": \"Octobre\",\n\t\"timeslider.month.november\": \"Novembre\",\n\t\"timeslider.month.december\": \"Decembre\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: autor anonim, other: autors anonims ]}\",\n\t\"pad.savedrevs.marked\": \"Aquesta revision es ara marcada coma revision enregistrada\",\n\t\"pad.savedrevs.timeslider\": \"Podètz veire las revisions enregistradas en visitant l’ascensor temporal\",\n\t\"pad.userlist.entername\": \"Entratz vòstre nom\",\n\t\"pad.userlist.unnamed\": \"sens nom\",\n\t\"pad.editbar.clearcolors\": \"Escafar las colors de paternitat dins tot lo document ?\",\n\t\"pad.impexp.importbutton\": \"Importar ara\",\n\t\"pad.impexp.importing\": \"Impòrt en cors...\",\n\t\"pad.impexp.confirmimport\": \"Importar un fichièr espotirà lo tèxte actual del blòt. Sètz segur que lo volètz far ?\",\n\t\"pad.impexp.convertFailed\": \"Podèm pas importar aqueste fichièr. Utilizatz un autre format de document o fasètz un copiar/pegar manual\",\n\t\"pad.impexp.padHasData\": \"Avèm pas pogut importar aqueste fichièr perque aqueste blòt a ja agut de modificacions ; importatz cap a un blòt novèl\",\n\t\"pad.impexp.uploadFailed\": \"Lo telecargament a fracassat, reensajatz\",\n\t\"pad.impexp.importfailed\": \"Fracàs de l'importacion\",\n\t\"pad.impexp.copypaste\": \"Copiatz/pegatz\",\n\t\"pad.impexp.exportdisabled\": \"Exportar al format {{type}} es desactivat. Contactatz vòstre administrator del sistèma per mai de detalhs.\"\n}\n"
  },
  {
    "path": "src/locales/olo.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Denö\",\n\t\t\t\"Ilja.mos\",\n\t\t\t\"Mashoi7\"\n\t\t]\n\t},\n\t\"pad.toolbar.underline.title\": \"Alleviivua (Ctrl+U)\",\n\t\"pad.toolbar.settings.title\": \"Azetukset\",\n\t\"pad.colorpicker.save\": \"Tallenda\",\n\t\"pad.colorpicker.cancel\": \"Hylgiä\",\n\t\"pad.settings.linenocheck\": \"Riädynoumerot\",\n\t\"pad.settings.rtlcheck\": \"Luve syväindö oigielpäi huruale?\",\n\t\"pad.settings.language\": \"Kieli:\",\n\t\"pad.importExport.import_export\": \"Tuo/Vie\",\n\t\"pad.importExport.importSuccessful\": \"Ozavui!\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.modals.userdup\": \"Avattu toizes ikkunas\",\n\t\"pad.modals.unauth\": \"Ei lubua\",\n\t\"pad.modals.initsocketfail\": \"Palvelin ei ole tavattavis.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Ei suadu yhtevytty sinhronisaciipalvelimeh.\",\n\t\"pad.modals.slowcommit.explanation\": \"Palvelin ei vastua.\",\n\t\"pad.modals.disconnected.explanation\": \"Yhtevys palvelimeh kavotettu\",\n\t\"timeslider.toolbar.authors\": \"Luadijat:\",\n\t\"timeslider.toolbar.authorsList\": \"Ei luadijoi\",\n\t\"timeslider.toolbar.exportlink.title\": \"Vie\",\n\t\"timeslider.exportCurrent\": \"Vie nygöine versii nimel:\",\n\t\"timeslider.version\": \"Versii {{version}}\",\n\t\"timeslider.saved\": \"Tallendettu {{month}} {{day}}, {{year}}\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"pakkaskuudu\",\n\t\"timeslider.month.february\": \"tuhukuudu\",\n\t\"timeslider.month.march\": \"kevätkuudu\",\n\t\"timeslider.month.april\": \"sulakuudu\",\n\t\"timeslider.month.may\": \"oraskuudu\",\n\t\"timeslider.month.june\": \"kezäkuudu\",\n\t\"timeslider.month.july\": \"heinykuudu\",\n\t\"timeslider.month.august\": \"elokuudu\",\n\t\"timeslider.month.september\": \"syvyskuudu\",\n\t\"timeslider.month.october\": \"ligakuudu\",\n\t\"timeslider.month.november\": \"kylmykuudu\",\n\t\"timeslider.month.december\": \"talvikuudu\",\n\t\"pad.userlist.entername\": \"Kirjuta sinun nimi\",\n\t\"pad.userlist.unnamed\": \"nimetöi\",\n\t\"pad.impexp.importbutton\": \"Tuo nygöi\",\n\t\"pad.impexp.importing\": \"Tuou...\"\n}\n"
  },
  {
    "path": "src/locales/os.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Bouron\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Ног\",\n\t\"index.createOpenPad\": \"кӕнӕ сараз/бакӕн ног документ ахӕм номимӕ:\",\n\t\"pad.toolbar.bold.title\": \"Бӕзджын (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Къӕдз (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Бынылхахх (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Хахх\",\n\t\"pad.toolbar.ol.title\": \"Нымад номхыгъд\",\n\t\"pad.toolbar.ul.title\": \"Ӕнӕнымад номхыгъд\",\n\t\"pad.toolbar.indent.title\": \"Хаст\",\n\t\"pad.toolbar.unindent.title\": \"Ӕттӕмӕхаст\",\n\t\"pad.toolbar.undo.title\": \"Раздӕхын (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Рацаразын (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Фыссӕджы нысӕнттӕ айсынӕн\",\n\t\"pad.toolbar.import_export.title\": \"Импорт/экспорт ӕндӕр файлы форматтӕй/форматтӕм\",\n\t\"pad.toolbar.timeslider.title\": \"Рӕстӕджы хахх\",\n\t\"pad.toolbar.savedRevision.title\": \"Фӕлтӕр бавӕрынӕн\",\n\t\"pad.toolbar.settings.title\": \"Уагӕвӕрдтӕ\",\n\t\"pad.toolbar.embed.title\": \"Ацы документ бафтау æмæ йæ тыххæй ахъæр кæн\",\n\t\"pad.toolbar.showusers.title\": \"Ацы документы архайджыты равдисын\",\n\t\"pad.colorpicker.save\": \"Нывæрын\",\n\t\"pad.colorpicker.cancel\": \"Ныууадзын\",\n\t\"pad.loading\": \"Æвгæд цæуы...\",\n\t\"pad.permissionDenied\": \"Дӕуӕн нӕй бар ацы документмӕ рывналын\",\n\t\"pad.settings.padSettings\": \"Документы уагӕвӕрдтытӕ\",\n\t\"pad.settings.myView\": \"Мӕ уынд\",\n\t\"pad.settings.stickychat\": \"Ныхас алкуыдӕр ӕвдисын\",\n\t\"pad.settings.colorcheck\": \"Фыссӕджы хуызтӕ\",\n\t\"pad.settings.linenocheck\": \"Рӕнхъыты номыртӕ\",\n\t\"pad.settings.rtlcheck\": \"Мидис рахизӕй галиумӕ хъӕуы фӕрсын?\",\n\t\"pad.settings.fontType\": \"Шрифты хуыз:\",\n\t\"pad.settings.fontType.normal\": \"Хуымӕтӕг\",\n\t\"pad.settings.language\": \"Æвзаг:\",\n\t\"pad.importExport.import_export\": \"Импорт/экспорт\",\n\t\"pad.importExport.import\": \"Исты текст файл кӕнӕ документ бавгӕнын\",\n\t\"pad.importExport.importSuccessful\": \"Ӕнтыст!\",\n\t\"pad.importExport.export\": \"Ныры документ сэкпорт кӕнын куыд:\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Хуымæтæг текст\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Дӕ бон у импорт кӕнын ӕрмӕст хуымӕтӕг текст кӕнӕ html форматӕй. Лӕмбынӕг импорты миниуджытӕн, дӕ хорзӕхӕй, <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">сӕвӕр abiword</a>.\",\n\t\"pad.modals.connected\": \"Иугонд.\",\n\t\"pad.modals.reconnecting\": \"Дӕ документмӕ ногӕй иугонд цӕуы..\",\n\t\"pad.modals.forcereconnect\": \"Тыххӕй баиу кӕнын\",\n\t\"pad.modals.userdup\": \"Ног рудзынджы бакӕнын\",\n\t\"pad.modals.userdup.explanation\": \"Ацы документ ӕвӕццӕгӕн ацы компьютеры иуӕй фылдӕр рудзынджы у гом.\",\n\t\"pad.modals.userdup.advice\": \"Ногӕй баиу уын, ацы рудзынгӕй архайыны бӕсты.\",\n\t\"pad.modals.unauth\": \"Нӕ авторизацигонд\",\n\t\"pad.modals.unauth.explanation\": \"Дӕ бартӕ фӕивтой, цалынмӕ ды ацы фарс кастӕ. Бафӕлвар ногӕй баиу уын.\",\n\t\"pad.modals.looping.explanation\": \"Синхронизацийы серверимӕ баиу кӕныны проблемӕ.\",\n\t\"pad.modals.looping.cause\": \"Уӕццӕгӕн ды баиу дӕ ӕнӕмбӕлгӕ файрвол кӕнӕ проксийы уылты.\",\n\t\"pad.modals.initsocketfail\": \"Сервермӕ баиугӕнӕн нӕй.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Нӕ рауадис синхронизацийы сервермӕ баиу уын.\",\n\t\"pad.modals.initsocketfail.cause\": \"Ай уӕццӕгӕн дӕ сгарӕн кӕнӕ дӕ интернеты тыххӕй у.\",\n\t\"pad.modals.slowcommit.explanation\": \"Сервер нӕ дзуапп кӕны.\",\n\t\"pad.modals.slowcommit.cause\": \"Ай гӕнӕн ис у хызы проблемӕйы тыххӕй.\",\n\t\"pad.modals.deleted\": \"Хафт.\",\n\t\"pad.modals.deleted.explanation\": \"Документ хафт ӕрцыд.\",\n\t\"pad.modals.disconnected\": \"Ды хицӕнгонд ӕрцыдтӕ.\",\n\t\"pad.modals.disconnected.explanation\": \"Серверимӕ иугонд фесӕфтис\",\n\t\"pad.modals.disconnected.cause\": \"Сервермӕ гӕнӕн ис баиугӕнӕн нӕй. Дӕ хорзӕхӕй, фехъусын нын ӕй кӕн, кӕд афтӕ дарддӕр кӕна.\",\n\t\"pad.share\": \"Ацы документ райуарын\",\n\t\"pad.share.readonly\": \"Ӕрмӕст фӕрсынӕн\",\n\t\"pad.share.link\": \"Ӕрвитӕн\",\n\t\"pad.share.emebdcode\": \"URL бавӕрын\",\n\t\"pad.chat\": \"Ныхас\",\n\t\"pad.chat.title\": \"Оцы документӕн чат бакӕн.\",\n\t\"pad.chat.loadmessages\": \"Фылдӕр фыстӕг равгӕнын\",\n\t\"timeslider.pageTitle\": \"{{appTitle}}-ы рӕтӕджы хахх\",\n\t\"timeslider.toolbar.returnbutton\": \"Фӕстӕмӕ, документмӕ\",\n\t\"timeslider.toolbar.authors\": \"Фыссӕджытӕ:\",\n\t\"timeslider.toolbar.authorsList\": \"Фыссӕджытӕ нӕй\",\n\t\"timeslider.toolbar.exportlink.title\": \"Экспорт\",\n\t\"timeslider.exportCurrent\": \"Сэкспорт кӕнын ныры фӕлтӕр куыд:\",\n\t\"timeslider.version\": \"Верси {{version}}\",\n\t\"timeslider.saved\": \"Ӕвӕрд ӕрцыд {{year}}-ӕм азы, {{day}}-ӕм {{month}}-ы\",\n\t\"timeslider.dateformat\": \"{{day}}.{{month}}.{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"январь\",\n\t\"timeslider.month.february\": \"февраль\",\n\t\"timeslider.month.march\": \"мартъи\",\n\t\"timeslider.month.april\": \"апрель\",\n\t\"timeslider.month.may\": \"май\",\n\t\"timeslider.month.june\": \"июнь\",\n\t\"timeslider.month.july\": \"июль\",\n\t\"timeslider.month.august\": \"август\",\n\t\"timeslider.month.september\": \"сентябрь\",\n\t\"timeslider.month.october\": \"октябрь\",\n\t\"timeslider.month.november\": \"ноябрь\",\n\t\"timeslider.month.december\": \"декабрь\",\n\t\"timeslider.unnamedauthors\": \"{{num}} ӕнӕном фыссӕджы\",\n\t\"pad.savedrevs.marked\": \"Ацы фӕлтӕр ныр куыд ӕвӕрд фӕлтӕр нысангонд ӕрцыд\",\n\t\"pad.userlist.entername\": \"Дӕ ном бафысс\",\n\t\"pad.userlist.unnamed\": \"ӕнӕном\",\n\t\"pad.editbar.clearcolors\": \"Ӕнӕхъӕн документӕй хъӕуы айсын фыссӕджыты нысӕнттӕ?\",\n\t\"pad.impexp.importbutton\": \"Еныр симпорт кӕнын\",\n\t\"pad.impexp.importing\": \"Импорт цӕуы...\",\n\t\"pad.impexp.confirmimport\": \"Файлы импорт документы ныры текст бынтон фӕивдзӕнис. Ӕцӕг дӕ фӕнды уый саразын?\",\n\t\"pad.impexp.convertFailed\": \"Махӕн нӕ бон не ссис ацы файл симпорт кӕнын. Дӕ хорзӕхӕй, спайда кӕн ӕндӕр файлы форматӕй, кӕнӕ скъопи кӕн ӕмӕ батысс текст дӕхӕдӕг.\",\n\t\"pad.impexp.uploadFailed\": \"Бавгӕнын нӕ рауад, дӕ хорзӕхӕй, фӕстӕдӕр бафӕлвар\",\n\t\"pad.impexp.importfailed\": \"Импорт нӕ рауад\",\n\t\"pad.impexp.copypaste\": \"Дӕ хорзӕхӕй, къопи кӕн ӕмӕ ӕвӕр\",\n\t\"pad.impexp.exportdisabled\": \"{{type}} форматы экспорт хицӕн у. Дӕ хорзӕхӕй, бадзур дӕ системон администратортӕм фылдӕр базонынӕн.\"\n}\n"
  },
  {
    "path": "src/locales/pa.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Aalam\",\n\t\t\t\"Babanwalia\",\n\t\t\t\"Cabal\",\n\t\t\t\"Tow\",\n\t\t\t\"ਗੁਰਪ੍ਰੀਤ ਹੁੰਦਲ\",\n\t\t\t\"ਪ੍ਰਚਾਰਕ\"\n\t\t]\n\t},\n\t\"admin_plugins.available_fetching\": \"ਲਿਆਉਣਾ ਪਿਆਂ...\",\n\t\"admin_plugins.available_install.value\": \"ਜੜੋ\",\n\t\"admin_plugins.description\": \"ਵੇਰਵਾ\",\n\t\"admin_plugins.last-update\": \"ਆਖਰੀ ਵਾਰ ਨਵਿਆਈਆ ਗਿਆ\",\n\t\"admin_plugins.name\": \"ਨਾਂ\",\n\t\"admin_plugins_info\": \"ਸਮੱਸਿਆ ਨਿਵਾਰਣ ਜਾਣਕਾਰੀ\",\n\t\"admin_settings\": \"ਤਰਜੀਹਾਂ\",\n\t\"admin_settings.current\": \"ਮੌਜੂਦਾ ਬਣਤਰ\",\n\t\"admin_settings.current_save.value\": \"ਤਰਜੀਹਾਂ ਸੰਭਾਲੋ\",\n\t\"admin_settings.page-title\": \"ਤਰਜੀਹਾਂ - ਈਥਰਪੈਡ\",\n\t\"index.newPad\": \"ਨਵਾਂ ਪੈਡ\",\n\t\"index.settings\": \"ਤਰਜੀਹਾਂ\",\n\t\"index.copyLink\": \"2. ਕੜੀ ਦੀ ਨਕਲ ਕਰੋ\",\n\t\"index.copyLinkButton\": \"ਕੜੀ ਦਾ ਉਤਾਰਾ ਚੂੰਢੀ-ਤਖਤੀ 'ਤੇ ਲਿਖੋ\",\n\t\"index.createOpenPad\": \"ਜਾਂ ਨਾਂ ਨਾਲ ਨਵਾਂ ਪੈਡ ਬਣਾਓ/ਖੋਲ੍ਹੋ:\",\n\t\"pad.toolbar.bold.title\": \"ਗੂੜ੍ਹਾ (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"ਤਿਰਛਾ (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"ਹੇਠਾਂ-ਰੇਖਾ (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"ਵਿੰਨ੍ਹੋ (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"ਲੜੀਵਾਰ ਸੂਚੀ\",\n\t\"pad.toolbar.ul.title\": \"ਬਿਨ-ਲੜੀਬੱਧ ਸੂਚੀ\",\n\t\"pad.toolbar.indent.title\": \"ਹਾਸ਼ੀਏ ਤੋਂ ਪਰ੍ਹੇ (ਟੈਬ)\",\n\t\"pad.toolbar.unindent.title\": \"ਹਾਸ਼ੀਏ ਵੱਲ (ਸ਼ਿਫ਼ਟ+ਟੈਬ)\",\n\t\"pad.toolbar.undo.title\": \"ਵਾਪਸ (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"ਪਰਤਾਓ (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"ਪਰਮਾਣਕਿਤਾ ਰੰਗ ਸਾਫ਼ ਕਰੋ (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"ਵੱਖ-ਵੱਖ ਫਾਇਲ ਫਾਰਮੈਟ ਤੋਂ/ਵਿੱਚ ਇੰਪੋਰਟ/ਐਕਸਪੋਰਟ ਕਰੋ\",\n\t\"pad.toolbar.timeslider.title\": \"ਸਮਾਂ-ਲਕੀਰ\",\n\t\"pad.toolbar.savedRevision.title\": \"ਦੁਹਰਾਅ ਸਾਂਭੋ\",\n\t\"pad.toolbar.settings.title\": \"ਪਸੰਦਾਂ\",\n\t\"pad.toolbar.embed.title\": \"ਇਹ ਪੈਡ ਸਾਂਝਾ ਤੇ ਇੰਬੈੱਡ ਕਰੋ\",\n\t\"pad.toolbar.home.title\": \"ਮੁੱਢਲੇ ਵਰਕੇ 'ਤੇ ਵਾਪਸ ਜਾਓ\",\n\t\"pad.toolbar.showusers.title\": \"ਇਸ ਫੱਟੀ ਉੱਤੇ ਵਰਤੋਂਕਾਰ ਵਿਖਾਓ\",\n\t\"pad.colorpicker.save\": \"ਸਾਂਭੋ\",\n\t\"pad.colorpicker.cancel\": \"ਰੱਦ ਕਰੋ\",\n\t\"pad.loading\": \"ਲੱਦ ਰਿਹਾ ਏ...\",\n\t\"pad.noCookie\": \"ਕੂਕੀਜ਼ ਨਹੀਂ ਲੱਭੀਅਾਂ। ਕਿਰਪਾ ਕਰਕੇ ਬ੍ਰਾੳੂਜ਼ਰ ਵਿੱਚ ਕੂਕੀਜ਼ ਲਾਗੂ ਕਰੋ।\",\n\t\"pad.permissionDenied\": \"ਇਹ ਪੈਡ ਵਰਤਨ ਲਈ ਤੁਹਾਨੂੰ ਅਧਿਕਾਰ ਨਹੀਂ ਹਨ\",\n\t\"pad.settings.padSettings\": \"ਪੈਡ ਸੈਟਿੰਗ\",\n\t\"pad.settings.myView\": \"ਮੇਰੀ ਝਲਕ\",\n\t\"pad.settings.stickychat\": \"ਹਮੇਸ਼ਾ ਸਕਰੀਨ ਉੱਤੇ ਗੱਲ ਕਰੋ\",\n\t\"pad.settings.chatandusers\": \"ਗੱਲ-ਬਾਤ ਅਤੇ ਵਰਤੋਂਕਾਰ ਦਿਖਾਵੋ\",\n\t\"pad.settings.colorcheck\": \"ਲੇਖਕੀ ਰੰਗ\",\n\t\"pad.settings.linenocheck\": \"ਲਕੀਰ ਨੰਬਰ\",\n\t\"pad.settings.rtlcheck\": \"ਸਮੱਗਰੀ ਸੱਜੇ ਤੋਂ ਖੱਬੇ ਪੜ੍ਹਨੀ ਹੈ?\",\n\t\"pad.settings.fontType\": \"ਅੱਖਰ ਦੀ ਕਿਸਮ:\",\n\t\"pad.settings.fontType.normal\": \"ਆਮ\",\n\t\"pad.settings.language\": \"ਭਾਸ਼ਾ:\",\n\t\"pad.settings.about\": \"ਬਾਬਤ\",\n\t\"pad.importExport.import_export\": \"ਦਰਾਮਦ/ਬਰਾਮਦ\",\n\t\"pad.importExport.import\": \"ਕੋਈ ਵੀ ਟੈਕਸਟ ਫਾਇਲ ਜਾਂ ਦਸਤਾਵੇਜ਼ ਅੱਪਲੋਡ ਕਰੋ\",\n\t\"pad.importExport.importSuccessful\": \"ਸਫ਼ਲ!\",\n\t\"pad.importExport.export\": \"ਮੌਜੂਦਾ ਪੈਡ ਨੂੰ ਐਕਸਪੋਰਟ ਕਰੋ:\",\n\t\"pad.importExport.exportetherpad\": \"ੲੈਥਰਪੈਡ\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"ਸਧਾਰਨ ਟੈਕਸਟ\",\n\t\"pad.importExport.exportword\": \"ਮਾਈਕਰੋਸਾਫਟ ਵਰਡ\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (ਓਪਨ ਡੌਕੂਮੈਂਟ ਫਾਰਮੈਟ)\",\n\t\"pad.importExport.abiword.innerHTML\": \"ਤੁਸੀਂ ਸਿਰਫ਼ ਸਾਦੀਆਂ ਲਿਖਤੀ ਜਾਂ ਐੱਚ.ਟੀ.ਐੱਮ.ਐੱਲ. ਰੂਪ-ਰੇਖਾਵਾਂ ਤੋਂ ਦਰਾਮਦ ਕਰ ਸਕਦੇ ਹੋ। ਹੋਰ ਉੱਨਤ ਦਰਾਮਦੀ ਗੁਣਾਂ ਵਾਸਤੇ ਮਿਹਰਬਾਨੀ ਕਰਕੇ <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">ਐਬੀਵਰਡ ਥਾਪੋ</a>।\",\n\t\"pad.modals.connected\": \"ਜੁੜਿਆ ਹੋਇਆ।\",\n\t\"pad.modals.reconnecting\": \"..ਤੁਹਾਡੇ ਪੈਡ ਨਾਲ ਮੁੜ-ਕੁਨੈਕਟ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ\",\n\t\"pad.modals.forcereconnect\": \"ਧੱਕੇ ਨਾਲ ਮੁੜ-ਜੁੜੋ\",\n\t\"pad.modals.reconnecttimer\": \"ਮੁਡ਼ ਜੋੜਨ ਦੀ ਕੋਸ਼ਿਸ਼ ਕੀਤੀ ਜਾ ਰਹੀ ਏ\",\n\t\"pad.modals.cancel\": \"ਰੱਦ ਕਰੋ\",\n\t\"pad.modals.userdup\": \"ਹੋਰ ਵਿੰਡੋ ਵਿੱਚ ਖੁੱਲ੍ਹਿਆ ਹੈ\",\n\t\"pad.modals.userdup.explanation\": \"ਇਹ ਪੈਡ ਇਸ ਕੰਪਿਊਟਰ 'ਤੇ ਇੱਕ ਤੋਂ ਵੱਧ ਫਰੋਲੂ ਬਾਰੀ ਵਿੱਚ ਖੁੱਲ੍ਹਿਆ ਜਾਪਦਾ ਹੈ।\",\n\t\"pad.modals.userdup.advice\": \"ਸਗੋਂ ਇਹ ਬਾਰੀ ਵਰਤਣ ਵਾਸਤੇ ਮੁੜ ਜੁੜੋ।\",\n\t\"pad.modals.unauth\": \"ਪਰਮਾਣਿਤ ਨਹੀਂ ਹੈ\",\n\t\"pad.modals.unauth.explanation\": \"ਇਹ ਸਫ਼ਾ ਵੇਖਦੇ-ਵੇਖਦੇ ਤੁਹਾਨੂੰ ਮਿਲ਼ੀਆਂ ਇਜਾਜ਼ਤਾਂ ਬਦਲ ਗਈਆਂ ਹਨ। ਮੁੜ ਜੁੜਨ ਦੀ ਕੋਸ਼ਿਸ਼ ਕਰੋ।\",\n\t\"pad.modals.looping.explanation\": \"ਇੱਕਰੂਪੀ ਸਰਵਰ ਨਾਲ਼ ਸੰਚਾਰੀ ਔਕੜਾਂ ਆ ਰਹੀਆਂ ਹਨ।\",\n\t\"pad.modals.looping.cause\": \"ਸ਼ਾਇਦ ਤੁਸੀਂ ਕਿਸੇ ਅਢੁਕਵੀਂ ਸੁਰੱਖਿਆ ਪ੍ਰਨਾਲ਼ੀ ਜਾਂ ਪ੍ਰਾਕਸੀ ਰਾਹੀਂ ਜੁੜੇ ਹੋ।\",\n\t\"pad.modals.initsocketfail\": \"ਸਰਵਰ ਪਹੁੰਚ ਵਿੱਚ ਨਹੀਂ ਹੈ।\",\n\t\"pad.modals.initsocketfail.explanation\": \"ਇੱਕਰੂਪੀ ਸਰਵਰ ਨਾਲ਼ ਰਾਬਤਾ ਨਹੀਂ ਬਣ ਸਕਿਆ।\",\n\t\"pad.modals.initsocketfail.cause\": \"ਇਹ ਸ਼ਾਇਦ ਤੁਹਾਡੇ ਫਰੋਲੂ ਜਾਂ ਇੰਟਰਨੈੱਟ ਜੋੜ ਦੀਆਂ ਗੁੰਝਲਾਂ ਕਰਕੇ ਹੋ ਰਿਹਾ ਹੈ।\",\n\t\"pad.modals.slowcommit.explanation\": \"ਸਰਵਰ ਜਵਾਬ ਨਹੀਂ ਦੇ ਰਿਹਾ ਹੈ।\",\n\t\"pad.modals.slowcommit.cause\": \"ਇਹ ਨੈੱਟਵਰਕ ਕੁਨੈਕਸ਼ਨ ਨਾਲ ਸਮੱਸਿਆ ਕਰਕੇ ਹੋ ਸਕਦਾ ਹੈ।\",\n\t\"pad.modals.badChangeset.explanation\": \"ਤੁਹਾਡੇ ਵੱਲੋਂ ਕੀਤੀ ਇੱਕ ਸੋਧ ਨੂੰ ਇੱਕਰੂਪੀ ਸਰਵਰ ਨੇ ਗ਼ੈਰ-ਕਨੂੰਨੀ ਕਰਾਰ ਦਿੱਤਾ ਹੈ।\",\n\t\"pad.modals.badChangeset.cause\": \"ਇਹ ਸਿਸਟਮ ਦੀ ਕਿਸੇ ਗ਼ਲਤ ਨੁਹਾਰ ਜਾਂ ਕੋਈ ਹੋਰ ਅਣਸੋਚੇ ਵਤੀਰਾ ਕਰਕੇ ਵਾਪਰ ਸਕਦਾ ਹੈ। ਜੇਕਰ ਤੁਹਾਨੂੰ ਇਹ ਇੱਕ ਦੋਸ਼ ਲੱਗਦਾ ਹੈ ਤਾਂ ਮਿਹਰਬਾਨੀ ਕਰਕੇ ਆਪਣੇ ਸਿਸਟਮ ਦੇ ਪ੍ਰਬੰਧਕ ਨਾਲ਼ ਰਾਬਤਾ ਬਣਾਉ। ਸੋਧ ਜਾਰੀ ਰੱਖਣ ਵਾਸਤੇ ਮੁੜ ਜੁੜੋ।\",\n\t\"pad.modals.corruptPad.explanation\": \"ਜਿਸ ਪੈਡ ਤੱਕ ਤੁਸੀਂ ਪਹੁੰਚਣਾ ਚਾਹੁੰਦੇ ਹੋ, ਉਹ ਖੋਟਾ ਹੈ।\",\n\t\"pad.modals.corruptPad.cause\": \"ਇਹ ਸਿਸਟਮ ਦੀ ਕਿਸੇ ਗ਼ਲਤ ਨੁਹਾਰ ਜਾਂ ਕੋਈ ਹੋਰ ਅਣਸੋਚੇ ਵਤੀਰਾ ਕਰਕੇ ਵਾਪਰ ਸਕਦਾ ਹੈ। ਮਿਹਰਬਾਨੀ ਕਰਕੇ ਆਪਣੇ ਸਿਸਟਮ ਦੇ ਪ੍ਰਬੰਧਕ ਨਾਲ਼ ਰਾਬਤਾ ਬਣਾਉ।\",\n\t\"pad.modals.deleted\": \"ਹਟਾਇਆ।\",\n\t\"pad.modals.deleted.explanation\": \"ਇਹ ਪੈਡ ਹਟਾਇਆ ਜਾ ਚੁੱਕਾ ਹੈ।\",\n\t\"pad.modals.disconnected\": \"ਤੁਸੀਂ ਡਿਸ-ਕੁਨੈਕਟ ਹੋ ਚੁੱਕੇ ਹੋ।\",\n\t\"pad.modals.disconnected.explanation\": \"ਸਰਵਰ ਨਾਲ ਕੁਨੈਕਸ਼ਨ ਖਤਮ ਹੋਇਆ ਹੈ\",\n\t\"pad.modals.disconnected.cause\": \"ਸਰਵਰ ਨਾਮੌਜੂਦ ਹੋ ਸਕਦਾ ਹੈ। ਜੇਕਰ ਇਹ ਹੁੰਦਾ ਰਹੇ ਤਾਂ ਮਿਹਰਬਾਨੀ ਕਰਕੇ ਸੇਵਾ ਪ੍ਰਬੰਧਕ ਨੂੰ ਖ਼ਬਰ ਕਰੋ।\",\n\t\"pad.share\": \"ਇਹ ਪੈਡ ਸਾਂਝਾ ਕਰੋ\",\n\t\"pad.share.readonly\": \"ਸਿਰਫ਼ ਪੜ੍ਹਨ ਲਈ\",\n\t\"pad.share.link\": \"ਕੜੀ\",\n\t\"pad.share.emebdcode\": \"ਇੰਬੈੱਡ URL\",\n\t\"pad.chat\": \"ਗੱਲਬਾਤ\",\n\t\"pad.chat.title\": \"ਇਹ ਪੈਡ ਲਈ ਗੱਲਬਾਤ ਖੋਲ੍ਹੋ।\",\n\t\"pad.chat.loadmessages\": \"ਹੋਰ ਸੁਨੇਹੇ ਲੱਦੋ\",\n\t\"pad.chat.writeMessage.placeholder\": \"ਆਪਣਾ ਸੁਨੇਹਾ ਇੱਥੇ ਲਿਖੋ\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} ਸਮਾਂ-ਲਕੀਰ\",\n\t\"timeslider.toolbar.returnbutton\": \"ਪੈਡ ਉੱਤੇ ਵਾਪਸ\",\n\t\"timeslider.toolbar.authors\": \"ਲੇਖਕ:\",\n\t\"timeslider.toolbar.authorsList\": \"ਕੋਈ ਲੇਖਕ ਨਹੀਂ\",\n\t\"timeslider.toolbar.exportlink.title\": \"ਬਰਾਮਦ\",\n\t\"timeslider.exportCurrent\": \"ਮੌਜੂਦਾ ਵਰਜਨ ਇੰਝ ਐਕਸਪੋਰਟ ਕਰੋ:\",\n\t\"timeslider.version\": \"ਰੂਪ {{version}}\",\n\t\"timeslider.saved\": \"{{day}} {{month}} {{year}} ਨੂੰ ਸੰਭਾਲਿਆ\",\n\t\"timeslider.playPause\": \"ਪੈਡ ਸਮੱਗਰੀ ਚਲਾਓ / ਵਿਰਾਮ ਕਰੋ\",\n\t\"timeslider.backRevision\": \"ਇਸ ਪੈਡ ਵਿੱਚ ਪਿਛਲੇ ਰੀਵਿਜ਼ਨ ਤੇ ਜਾਓ\",\n\t\"timeslider.forwardRevision\": \"ਇਸ ਪੈਡ ਵਿੱਚ ਅਗਲੇ ਦੁਹਰਾਅ ਤੇ ਜਾਓ\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"ਜਨਵਰੀ\",\n\t\"timeslider.month.february\": \"ਫ਼ਰਵਰੀ\",\n\t\"timeslider.month.march\": \"ਮਾਰਚ\",\n\t\"timeslider.month.april\": \"ਅਪਰੈਲ\",\n\t\"timeslider.month.may\": \"ਮਈ\",\n\t\"timeslider.month.june\": \"ਜੂਨ\",\n\t\"timeslider.month.july\": \"ਜੁਲਾਈ\",\n\t\"timeslider.month.august\": \"ਅਗਸਤ\",\n\t\"timeslider.month.september\": \"ਸਤੰਬਰ\",\n\t\"timeslider.month.october\": \"ਅਕਤੂਬਰ\",\n\t\"timeslider.month.november\": \"ਨਵੰਬਰ\",\n\t\"timeslider.month.december\": \"ਦਸੰਬਰ\",\n\t\"timeslider.unnamedauthors\": \"{{num}} ਬੇਨਾਮ {[plural(num) one: ਲੇਖਕ, other: ਲੇਖਕ ]}\",\n\t\"pad.savedrevs.marked\": \"ਇਹ ਦੁਹਰਾਅ ਨੂੰ ਹੁਣ ਸੰਭਾਲੇ ਹੋਏ ਦੁਹਰਾਅ ਵਜੋਂ ਮੰਨਿਆ ਗਿਆ ਹੈ\",\n\t\"pad.savedrevs.timeslider\": \"ਤੁਸੀੰ ਸਾੰਭੀਆੰ ਹੋਈਆੰ ਵਰਜਨਾੰ ਸਮਾੰਸਲਾਈਡਰ ਤੇ ਜਾ ਕੇ ਵੇਖ ਸਕਦੇ ਹੋ\",\n\t\"pad.userlist.entername\": \"ਆਪਣਾ ਨਾਂ ਦਿਉ\",\n\t\"pad.userlist.unnamed\": \"ਬੇਨਾਮ\",\n\t\"pad.editbar.clearcolors\": \"ਪੂਰੇ ਦਸਾਤਵੇਜ਼ ਉੱਤੇ ਪਰਮਾਣਕਿਤਾ ਰੰਗ ਸਾਫ਼ ਕਰਨੇ ਹਨ?\",\n\t\"pad.impexp.importbutton\": \"ਹੁਣੇ ਦਰਾਮਦ ਕਰੋ\",\n\t\"pad.impexp.importing\": \"ਦਰਾਮਦ ਜਾਰੀ ਏ...\",\n\t\"pad.impexp.confirmimport\": \"ਕੋਈ ਫ਼ਾਈਲ ਦਰਾਮਦ ਕਾਰਨ ਨਾਲ਼ ਪੈਡ ਦੀ ਮੌਜੂਦਾ ਲਿਖਤ ਉੱਤੇ ਲਿਖਿਆ ਜਾਵੇਗਾ। ਕੀ ਤੁਸੀਂ ਸੱਚੀਂ ਇਹ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ?\",\n\t\"pad.impexp.convertFailed\": \"ਅਸੀਂ ਇਸ ਫ਼ਾਈਲ ਦੀ ਦਰਾਮਦ ਨਹੀਂ ਕਰ ਸਕੇ। ਮਿਹਰਬਾਨੀ ਕਰਕੇ ਕੋਈ ਵੱਖਰੀ ਦਸਤਾਵੇਜ਼ੀ ਰੂਪ-ਰੇਖਾ ਵਰਤੋ ਜਾਂ ਹੱਥੀਂ ਨਕਲ-ਚੇਪੀ ਕਰੋ।\",\n\t\"pad.impexp.padHasData\": \"ਅਸੀ ਇਸ ਫਾਈਲ ਨੂੰ ਦਰਾਮਦ ਨਹੀੰ ਕਰ ਸਕੇ ਕਿਉੰਕਿ ਇਸ ਕਾਗਜ਼ ਉੱਤੇ ਪਹਿਲਾਂ ਹੀ ਤਬਦੀਲੀਆਂ ਕੀਤੀਆਂ ਜਾ ਚੁਕੀਆਂ ਹਨ, ਕਿਰਪਾ ਕਰਕੇ ਨਵੇਂ ਕਾਗਜ਼ ਵਿਚ ਦਰਾਮਦ ਕਰੋ\",\n\t\"pad.impexp.uploadFailed\": \"ਅੱਪਲੋਡ ਲਈ ਫੇਲ੍ਹ ਹੈ, ਫੇਰ ਕੋਸ਼ਿਸ਼ ਕਰੋ ਜੀ।\",\n\t\"pad.impexp.importfailed\": \"ਦਰਾਮਦ ਨਾਕਾਮ\",\n\t\"pad.impexp.copypaste\": \"ਕਾਪੀ ਕਰੋ ਚੇਪੋ ਜੀ\",\n\t\"pad.impexp.exportdisabled\": \"{{type}} ਫਾਰਮੈਟ ਵਜੋਂ ਬਰਾਮਦ ਕਰਨਾ ਬੰਦ ਹੈ। ਵੇਰਵੇ ਵਾਸਤੇ ਆਪਣੇ ਸਿਸਟਮ ਦੇ ਪਰਬੰਧਕ ਨਾਲ ਸੰਪਰਕ ਕਰੋ।\"\n}\n"
  },
  {
    "path": "src/locales/pl.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"DeRudySoulStorm\",\n\t\t\t\"Iketsi\",\n\t\t\t\"Kareyac\",\n\t\t\t\"Macofe\",\n\t\t\t\"Mateon1\",\n\t\t\t\"Matlin\",\n\t\t\t\"Pan Cube\",\n\t\t\t\"Rezonansowy\",\n\t\t\t\"Teeed\",\n\t\t\t\"Ty221\",\n\t\t\t\"Usagi.02808\",\n\t\t\t\"WTM\",\n\t\t\t\"WaldiSt\",\n\t\t\t\"Woytecr\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Panel administracyjny – Etherpad\",\n\t\"admin_plugins\": \"Menedżer wtyczek\",\n\t\"admin_plugins.available\": \"Dostępne wtyczki\",\n\t\"admin_plugins.available_not-found\": \"Nie znaleziono żadnych wtyczek.\",\n\t\"admin_plugins.available_fetching\": \"Pobieranie...\",\n\t\"admin_plugins.available_install.value\": \"Instaluj\",\n\t\"admin_plugins.available_search.placeholder\": \"Wyszukaj wtyczki do zainstalowania\",\n\t\"admin_plugins.description\": \"Opis\",\n\t\"admin_plugins.installed\": \"Zainstalowane wtyczki\",\n\t\"admin_plugins.installed_fetching\": \"Pobieranie zainstalowanych wtyczek…\",\n\t\"admin_plugins.installed_nothing\": \"Nie zainstalowałeś jeszcze żadnych wtyczek.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Odinstaluj\",\n\t\"admin_plugins.last-update\": \"Ostatnia aktualizacja\",\n\t\"admin_plugins.name\": \"Nazwa\",\n\t\"admin_plugins.page-title\": \"Menedżer wtyczek - Etherpad\",\n\t\"admin_plugins.version\": \"Wersja\",\n\t\"admin_plugins_info\": \"Informacje dotyczące rozwiązywania problemów\",\n\t\"admin_plugins_info.parts\": \"Zainstalowane części\",\n\t\"admin_plugins_info.plugins\": \"Zainstalowane wtyczki\",\n\t\"admin_plugins_info.page-title\": \"Informacje o wtyczkach - Etherpad\",\n\t\"admin_plugins_info.version\": \"Wersja Etherpada\",\n\t\"admin_plugins_info.version_latest\": \"Najnowsza dostępna wersja\",\n\t\"admin_plugins_info.version_number\": \"Numer wersji\",\n\t\"admin_settings\": \"Ustawienia\",\n\t\"admin_settings.current\": \"Obecna konfiguracja\",\n\t\"admin_settings.current_example-devel\": \"Przykładowy szablon ustawień deweloperskich\",\n\t\"admin_settings.current_example-prod\": \"Przykładowy szablon ustawień produkcyjnych\",\n\t\"admin_settings.current_restart.value\": \"Zrestartuj Etherpad\",\n\t\"admin_settings.current_save.value\": \"Zapisz ustawienia\",\n\t\"admin_settings.page-title\": \"Ustawienia - Etherpad\",\n\t\"index.newPad\": \"Nowy dokument\",\n\t\"index.createOpenPad\": \"Otwórz dokument znając nazwę\",\n\t\"index.openPad\": \"otwórz istniejący dokument o nazwie:\",\n\t\"index.recentPads\": \"Ostatnie dokumenty\",\n\t\"index.recentPadsEmpty\": \"Nie znaleziono ostatnio używanych dokumentów.\",\n\t\"index.generateNewPad\": \"Wygeneruj losową nazwę dokumentu\",\n\t\"index.labelPad\": \"Nazwa dokumentu (opcjonalna)\",\n\t\"index.placeholderPadEnter\": \"Proszę wpisać nazwę dokumentu...\",\n\t\"index.createAndShareDocuments\": \"Twórz i udostępniaj dokumenty w czasie rzeczywistym\",\n\t\"index.createAndShareDocumentsDescription\": \"Etherpad umożliwia wspólną edycję dokumentów w czasie rzeczywistym, podobnie jak edytor wieloosobowy działający w przeglądarce.\",\n\t\"pad.toolbar.bold.title\": \"Pogrubienie (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Kursywa (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Podkreślenie (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Przekreślenie (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Lista uporządkowana (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Lista nieuporządkowana (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Wcięcie (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Usunięcie wcięcia (Shift + TAB)\",\n\t\"pad.toolbar.undo.title\": \"Cofnij (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Ponów (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Usuń kolory autorów (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Import/eksport z/do różnych formatów plików\",\n\t\"pad.toolbar.timeslider.title\": \"Oś czasu\",\n\t\"pad.toolbar.savedRevision.title\": \"Zapisz wersję\",\n\t\"pad.toolbar.settings.title\": \"Ustawienia\",\n\t\"pad.toolbar.embed.title\": \"Podziel się i osadź ten dokument\",\n\t\"pad.toolbar.home.title\": \"Wróć do strony głównej\",\n\t\"pad.toolbar.showusers.title\": \"Pokaż użytkowników\",\n\t\"pad.colorpicker.save\": \"Zapisz\",\n\t\"pad.colorpicker.cancel\": \"Anuluj\",\n\t\"pad.loading\": \"Ładowanie...\",\n\t\"pad.noCookie\": \"Nie można znaleźć pliku cookie. Proszę zezwolić na pliki cookie w przeglądarce! Twoja sesja i ustawienia nie zostaną zapisane między wizytami. Może to wynikać z włączenia Etherpad do ramki iFrame w niektórych przeglądarkach. Upewnij się, że Etherpad jest w tej samej subdomenie/domenie, co nadrzędna ramka iFrame\",\n\t\"pad.permissionDenied\": \"Nie masz uprawnień dostępu do tego dokumentu\",\n\t\"pad.settings.padSettings\": \"Ustawienia dokumentu\",\n\t\"pad.settings.myView\": \"Mój widok\",\n\t\"pad.settings.stickychat\": \"Czat zawsze na ekranie\",\n\t\"pad.settings.chatandusers\": \"Pokaż czat i użytkowników\",\n\t\"pad.settings.colorcheck\": \"Kolory autorstwa\",\n\t\"pad.settings.linenocheck\": \"Numery linii\",\n\t\"pad.settings.rtlcheck\": \"Czytasz treść od prawej do lewej?\",\n\t\"pad.settings.fontType\": \"Rodzaj czcionki:\",\n\t\"pad.settings.fontType.normal\": \"Normalna\",\n\t\"pad.settings.language\": \"Język:\",\n\t\"pad.settings.deletePad\": \"Usuń dokument\",\n\t\"pad.delete.confirm\": \"Czy na pewno chcesz usunąć ten dokument?\",\n\t\"pad.settings.about\": \"O aplikacji\",\n\t\"pad.settings.poweredBy\": \"Dostarczane przez $1\",\n\t\"pad.importExport.import_export\": \"Import/eksport\",\n\t\"pad.importExport.import\": \"Prześlij dowolny plik tekstowy lub dokument\",\n\t\"pad.importExport.importSuccessful\": \"Sukces!\",\n\t\"pad.importExport.export\": \"Eksportuj bieżący dokument jako:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Zwykły tekst\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Możesz importować pliki tylko w formacie zwykłego tekstu lub HTML. Aby umożliwić bardziej zaawansowane funkcje importu, <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">zainstaluj AbiWord lub LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Połączony.\",\n\t\"pad.modals.reconnecting\": \"Ponowne łączenie z dokumentem...\",\n\t\"pad.modals.forcereconnect\": \"Wymuś ponowne połączenie\",\n\t\"pad.modals.reconnecttimer\": \"Trwa próba ponownego połączenia\",\n\t\"pad.modals.cancel\": \"Anuluj\",\n\t\"pad.modals.userdup\": \"Otwarty w innym oknie\",\n\t\"pad.modals.userdup.explanation\": \"Ten dokument prawdopodobnie został otwarty w więcej niż jednym oknie przeglądarki.\",\n\t\"pad.modals.userdup.advice\": \"Połącz ponownie przy użyciu tego okna.\",\n\t\"pad.modals.unauth\": \"Brak autoryzacji\",\n\t\"pad.modals.unauth.explanation\": \"Twoje uprawnienia uległy zmianie podczas przeglądania tej strony. Spróbuj ponownie się połączyć.\",\n\t\"pad.modals.looping.explanation\": \"Wystąpiły problemy z komunikacją z serwerem synchronizacji.\",\n\t\"pad.modals.looping.cause\": \"Być może jesteś połączony przez niezgodną zaporę lub serwer proxy.\",\n\t\"pad.modals.initsocketfail\": \"Serwer jest nieosiągalny.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Nie udało się połączyć z serwerem synchronizacji.\",\n\t\"pad.modals.initsocketfail.cause\": \"Prawdopodobnie jest to spowodowane problemami z przeglądarką lub połączeniem internetowym.\",\n\t\"pad.modals.slowcommit.explanation\": \"Serwer nie odpowiada.\",\n\t\"pad.modals.slowcommit.cause\": \"Może być to spowodowane problemami z Twoim połączeniem sieciowym.\",\n\t\"pad.modals.badChangeset.explanation\": \"Edycja, którą wykonałeś, została uznana przez serwer synchronizacji jako niepoprawna.\",\n\t\"pad.modals.badChangeset.cause\": \"Może być to spowodowane złą konfiguracją serwera lub innym nieoczekiwanym zachowaniem. Skontaktuj się z administratorem serwisu, jeżeli wydaje Ci się, że to jest błąd. Spróbuj połączyć się ponownie aby kontynuować edycję.\",\n\t\"pad.modals.corruptPad.explanation\": \"Dokument, do którego próbujesz uzyskać dostęp, jest uszkodzony.\",\n\t\"pad.modals.corruptPad.cause\": \"Może być to spowodowane złą konfiguracją serwera lub innym nieoczekiwanym zachowaniem. Skontaktuj się z administratorem serwisu.\",\n\t\"pad.modals.deleted\": \"Usunięto.\",\n\t\"pad.modals.deleted.explanation\": \"Ten dokument został usunięty.\",\n\t\"pad.modals.rateLimited.explanation\": \"Wysłano za dużo wiadomości w tym dokumencie, dlatego nastąpiło rozłączenie.\",\n\t\"pad.modals.rejected.cause\": \"Serwer mógł zostać zaktualizowany podczas przeglądania panelu lub wystąpił błąd w Etherpadzie. Spróbuj odświeżyć stronę.\",\n\t\"pad.modals.disconnected\": \"Zostałeś rozłączony.\",\n\t\"pad.modals.disconnected.explanation\": \"Utracono połączenie z serwerem\",\n\t\"pad.modals.disconnected.cause\": \"Serwer może być niedostępny. Poinformuj administratora serwisu jeżeli problem będzie się powtarzał.\",\n\t\"pad.share\": \"Udostępnij ten dokument\",\n\t\"pad.share.readonly\": \"Tylko do odczytu\",\n\t\"pad.share.link\": \"Link\",\n\t\"pad.share.emebdcode\": \"URL do umieszczenia\",\n\t\"pad.chat\": \"Czat\",\n\t\"pad.chat.title\": \"Otwórz czat dla tego dokumentu.\",\n\t\"pad.chat.loadmessages\": \"Załaduj więcej wiadomości\",\n\t\"pad.chat.stick.title\": \"Przypnij czat do ekranu\",\n\t\"pad.chat.writeMessage.placeholder\": \"Napisz swoją wiadomość tutaj\",\n\t\"timeslider.followContents\": \"Śledź aktualizacje zawartości dokumentu\",\n\t\"timeslider.pageTitle\": \"Oś czasu {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Powróć do dokumentu\",\n\t\"timeslider.toolbar.authors\": \"Autorzy:\",\n\t\"timeslider.toolbar.authorsList\": \"Brak autorów\",\n\t\"timeslider.toolbar.exportlink.title\": \"Eksportuj\",\n\t\"timeslider.exportCurrent\": \"Eksportuj bieżącą wersję jako:\",\n\t\"timeslider.version\": \"Wersja {{version}}\",\n\t\"timeslider.saved\": \"Zapisano {{day}} {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Odtwarzaj / zatrzymaj przewijanie historii dokumentu\",\n\t\"timeslider.backRevision\": \"Przejdź do poprzedniej wersji dokumentu\",\n\t\"timeslider.forwardRevision\": \"Przejdź do następnej wersji dokumentu\",\n\t\"timeslider.dateformat\": \"{{year}}-{{month}}-{{day}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Styczeń\",\n\t\"timeslider.month.february\": \"Luty\",\n\t\"timeslider.month.march\": \"Marzec\",\n\t\"timeslider.month.april\": \"Kwiecień\",\n\t\"timeslider.month.may\": \"Maj\",\n\t\"timeslider.month.june\": \"Czerwiec\",\n\t\"timeslider.month.july\": \"Lipiec\",\n\t\"timeslider.month.august\": \"Sierpień\",\n\t\"timeslider.month.september\": \"Wrzesień\",\n\t\"timeslider.month.october\": \"Październik\",\n\t\"timeslider.month.november\": \"Listopad\",\n\t\"timeslider.month.december\": \"Grudzień\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: nienazwany autor, other: nienazwanych autorów ]}\",\n\t\"pad.savedrevs.marked\": \"Ta wersja została właśnie oznaczona jako zapisana.\",\n\t\"pad.savedrevs.timeslider\": \"Możesz zobaczyć zapisane wersje na osi czasu\",\n\t\"pad.userlist.entername\": \"Wprowadź swoją nazwę\",\n\t\"pad.userlist.unnamed\": \"bez nazwy\",\n\t\"pad.editbar.clearcolors\": \"Wyczyścić kolory autorstwa w całym dokumencie? Nie można będzie tego cofnąć!\",\n\t\"pad.impexp.importbutton\": \"Importuj teraz\",\n\t\"pad.impexp.importing\": \"Importowanie...\",\n\t\"pad.impexp.confirmimport\": \"Importowanie pliku spowoduje zastąpienie bieżącego tekstu. Czy na pewno chcesz kontynuować?\",\n\t\"pad.impexp.convertFailed\": \"Nie byliśmy w stanie zaimportować tego pliku. Proszę użyć innego formatu dokumentu lub skopiować i wkleić ręcznie\",\n\t\"pad.impexp.padHasData\": \"Nie udało się zaimportować tego pliku, bo ten dokument ma już zmiany, proszę zaimportować do nowego dokumentu\",\n\t\"pad.impexp.uploadFailed\": \"Przesyłanie nie powiodło się, proszę spróbować jeszcze raz\",\n\t\"pad.impexp.importfailed\": \"Importowanie nie powiodło się\",\n\t\"pad.impexp.copypaste\": \"Proszę skopiować i wkleić\",\n\t\"pad.impexp.exportdisabled\": \"Eksport do formatu {{type}} jest wyłączony. Proszę skontaktować się z administratorem aby uzyskać więcej szczegółów.\",\n\t\"pad.impexp.maxFileSize\": \"Plik jest zbyt duży. Skontaktuj się z administratorem aby zwiększył maksymalny dopuszczalny rozmiar importowanych plików\"\n}\n"
  },
  {
    "path": "src/locales/pms.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Borichèt\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Cruscòt d'aministrator - Etherpad\",\n\t\"admin_plugins\": \"Mansé dj'anstalassion\",\n\t\"admin_plugins.available\": \"Anstalassion disponìbij\",\n\t\"admin_plugins.available_not-found\": \"Gnun-e anstalassion trovà.\",\n\t\"admin_plugins.available_fetching\": \"Arcuperassion…\",\n\t\"admin_plugins.available_install.value\": \"Anstalé\",\n\t\"admin_plugins.available_search.placeholder\": \"Arserca d'aplicassion da anstalé\",\n\t\"admin_plugins.description\": \"Descrission\",\n\t\"admin_plugins.installed\": \"Aplicassion anstalà\",\n\t\"admin_plugins.installed_fetching\": \"Arcuperassion dj'aplicassion anstalà…\",\n\t\"admin_plugins.installed_nothing\": \"A l'ha ancor nen anstalà d'aplicassion.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Disanstalé\",\n\t\"admin_plugins.last-update\": \"Ùltim agiornament\",\n\t\"admin_plugins.name\": \"Nòm\",\n\t\"admin_plugins.page-title\": \"Mansé d'aplicassion - Etherpad\",\n\t\"admin_plugins.version\": \"Version\",\n\t\"admin_plugins_info\": \"Anformassion an sij problema\",\n\t\"admin_plugins_info.hooks\": \"Gancio anstalà\",\n\t\"admin_plugins_info.hooks_client\": \"Gancio da la part dël client\",\n\t\"admin_plugins_info.hooks_server\": \"Gancio da la part dël servent\",\n\t\"admin_plugins_info.parts\": \"Part anstalà\",\n\t\"admin_plugins_info.plugins\": \"Aplicassion anstalà\",\n\t\"admin_plugins_info.page-title\": \"Anformassion d'aplicassion - Etherpad\",\n\t\"admin_plugins_info.version\": \"Version d'Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Ùltima version disponìbil\",\n\t\"admin_plugins_info.version_number\": \"Nùmer ëd version\",\n\t\"admin_settings\": \"Paràmeter\",\n\t\"admin_settings.current\": \"Configurassion atual\",\n\t\"admin_settings.current_example-devel\": \"Stamp d'esempi dij paràmeter ëd dësvlup\",\n\t\"admin_settings.current_example-prod\": \"Stamp d'esempi dij paràmeter ëd produssion\",\n\t\"admin_settings.current_restart.value\": \"Fé torna parte Etherpad\",\n\t\"admin_settings.current_save.value\": \"Argistré ij paràmeter\",\n\t\"admin_settings.page-title\": \"Paràmeter - Etherpad\",\n\t\"index.newPad\": \"Feuj neuv\",\n\t\"index.settings\": \"Paràmeter\",\n\t\"index.transferSessionTitle\": \"Tramudé la session\",\n\t\"index.receiveSessionTitle\": \"Arsèive na session\",\n\t\"index.receiveSessionDescription\": \"Ambelessì a peul arsèive na session Etherpad da n'àutr navigador o angign. Për piasì, ch'a armarca che, an tute le manere, sòn a dëscancelërà soa session corent, s'a-i na j'é.\",\n\t\"index.transferSession\": \"1. Tramudé la session\",\n\t\"index.transferSessionNow\": \"Tramudé la session adess\",\n\t\"index.copyLink\": \"2. Copié la liura\",\n\t\"index.copyLinkDescription\": \"Ch'a sgnaca an sël boton sì-sota për copié la liura su soa taulëtta.\",\n\t\"index.copyLinkButton\": \"Copié la liura an sla taulëtta\",\n\t\"index.transferToSystem\": \"3. Copié la session ant ël neuv sistema\",\n\t\"index.transferToSystemDescription\": \"Duverté la liura copià ant ël navigador o angign ëd destinassion për tramudé soa session.\",\n\t\"index.transferSessionDescription\": \"Tramudé soa session corenta a 'n navigador o n'angign an ësgnacand ël boton sota. Sòn a copiërà na liura a na pàgina che a tramudërà soa session cand a sarà duvertà ant ël navigador o l'angign ëd destinassion.\",\n\t\"index.createOpenPad\": \"Duverté ël blochèt con sò nòm\",\n\t\"index.openPad\": \"duverté un Pad esistent con ël nòm:\",\n\t\"index.recentPads\": \"Blochèt recent\",\n\t\"index.recentPadsEmpty\": \"Gnun blochèt recent trovà.\",\n\t\"index.generateNewPad\": \"Generé un nòm ëd blochèt a l'ancàpit\",\n\t\"index.labelPad\": \"Nòm dël blochèt (facoltativ)\",\n\t\"index.placeholderPadEnter\": \"Për piasì, ch'a anserissa un nòm ëd blochèt...\",\n\t\"index.createAndShareDocuments\": \"Creé e partagé dij document an temp real\",\n\t\"index.createAndShareDocumentsDescription\": \"Etherpad a-j përmet ëd modifiché dij document an manera colaborativa an temp real, un pò coma n'editor multi-giugador che a marcia an sò navigador.\",\n\t\"pad.toolbar.bold.title\": \"Grassèt (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Corsiv (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Sotlignà (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Barà (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Lista ordinà (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Lista nen ordinà (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Andenté (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Disandenté (Maj+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Anulé (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Ristabilì (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Dëscancelé ij color ch'a identìfico j'autor (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Amporté/Esporté da/vers dij formà d'archivi diferent\",\n\t\"pad.toolbar.timeslider.title\": \"Stòria dinàmica\",\n\t\"pad.toolbar.savedRevision.title\": \"Argistré la revision\",\n\t\"pad.toolbar.settings.title\": \"Paràmeter\",\n\t\"pad.toolbar.embed.title\": \"Partagé e antëgré ës feuj\",\n\t\"pad.toolbar.home.title\": \"Artorn a l'intrada\",\n\t\"pad.toolbar.showusers.title\": \"Smon-e j'utent ansima a 's feuj\",\n\t\"pad.colorpicker.save\": \"Argistré\",\n\t\"pad.colorpicker.cancel\": \"Anulé\",\n\t\"pad.loading\": \"Antramentr ch'as caria…\",\n\t\"pad.noCookie\": \"Ël bëscotin a l'é nen ëstàit trovà. Për piasì, ch'a autorisa ij bëscotin su sò navigador! Soa session e sò paràmeter a saran pa argistrà antra na vìsita e l'àutra. Sòn a peul esse dovù al fàit che Etherpad a l'é contnù an n'iFrame an chèich navigador. Ch'a contròla che Etherpad resta ant l'istess sot-domini/domini ëd sò ce iFrame\",\n\t\"pad.permissionDenied\": \"A l'ha nen ël përmess d'acede a 's feuj-sì\",\n\t\"pad.settings.padSettings\": \"Paràmeter dël feuj\",\n\t\"pad.settings.myView\": \"Mia vista\",\n\t\"pad.settings.stickychat\": \"Ciaciarade sempe an slë scren\",\n\t\"pad.settings.chatandusers\": \"Smon-e le ciaciarade e j'utent\",\n\t\"pad.settings.colorcheck\": \"Color d'identificassion\",\n\t\"pad.settings.linenocheck\": \"Nùmer ëd linia\",\n\t\"pad.settings.rtlcheck\": \"Ël contnù, dev-lo esse lesù da drita a snistra?\",\n\t\"pad.settings.fontType\": \"Sòrt ëd caràter:\",\n\t\"pad.settings.language\": \"Lenga:\",\n\t\"pad.settings.deletePad\": \"Eliminé ël blochet\",\n\t\"pad.delete.confirm\": \"Veul-lo për da bon eliminé cost blochet?\",\n\t\"pad.settings.about\": \"A propòsit\",\n\t\"pad.settings.poweredBy\": \"Potensià da\",\n\t\"pad.importExport.import_export\": \"Amporté/Esporté\",\n\t\"pad.importExport.import\": \"Carié n'archivi o document ëd test\",\n\t\"pad.importExport.importSuccessful\": \"Bele fàit!\",\n\t\"pad.importExport.export\": \"Esporté ël feuj atual coma:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Mach test\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"A peul mach amporté dij formà ëd test sempi o HTML. Për dle fonsionalità d'amportassion pi avansà, ch'a <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">anstala AbiWord o LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Colegà.\",\n\t\"pad.modals.reconnecting\": \"Neuva conession a sò feuj…\",\n\t\"pad.modals.forcereconnect\": \"Forsé la neuva conession\",\n\t\"pad.modals.reconnecttimer\": \"Tentativ ëd neuva conession\",\n\t\"pad.modals.cancel\": \"Anulé\",\n\t\"pad.modals.userdup\": \"Duvertà an n'àutra fnestra\",\n\t\"pad.modals.userdup.explanation\": \"Ës feuj a smija esse duvert an vàire fnestre ansima a st'ordinator.\",\n\t\"pad.modals.userdup.advice\": \"Coleghesse torna për dovré costa fnestra.\",\n\t\"pad.modals.unauth\": \"Nen autorisà\",\n\t\"pad.modals.unauth.explanation\": \"Ij sò përmess a son cangià antramentre ch'a vëdìa costa pàgina. Ch'a sërca ëd coleghesse torna.\",\n\t\"pad.modals.looping.explanation\": \"A-i é dij problema ëd comunicassion con ël servent ëd sincronisassion.\",\n\t\"pad.modals.looping.cause\": \"Peul desse che chiel a l'é colegasse con un para-feu o un mandatari incompatìbil.\",\n\t\"pad.modals.initsocketfail\": \"Ël servent a l'é introvàbil.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Impossìbil coleghesse al servent ëd sincronisassion.\",\n\t\"pad.modals.initsocketfail.cause\": \"A l'é probàbil che sòn a sia dovù a sò navigador o a soa conession an sl'aragnà.\",\n\t\"pad.modals.slowcommit.explanation\": \"Ël servent a rëspond nen.\",\n\t\"pad.modals.slowcommit.cause\": \"Sòn a podrìa esse dovù a dij problema ëd conession a l'aragnà.\",\n\t\"pad.modals.badChangeset.explanation\": \"Na modìfica ch'a l'ha fàit a l'é stàita cassificà tanme ilegal dal servent ëd sincronisassion.\",\n\t\"pad.modals.badChangeset.cause\": \"Sòn a podrìa esse dovù a na bruta configurassion dël servent o a chèich àutr comportament nen ëspetà. Për piasì, ch'a contata l'aministrator dël servissi, s'a pensa ch'a sia n'eror. Ch'a preuva a rintré torna ant ël sistema për andé anans a modifiché.\",\n\t\"pad.modals.corruptPad.explanation\": \"Ël feuj al qual a sërca d'acede a l'é corompù.\",\n\t\"pad.modals.corruptPad.cause\": \"Sòn a podrìa esse dovù a na configurassion ësbalià dël servent o a chèich àutr comportament nen ëspetà. Për piasì, ch'a contata l'aministrator dël servissi.\",\n\t\"pad.modals.deleted\": \"Dëscancelà.\",\n\t\"pad.modals.deleted.explanation\": \"Ës feuj a l'é stàit eliminà.\",\n\t\"pad.modals.rateLimited\": \"Tass limità.\",\n\t\"pad.modals.rateLimited.explanation\": \"A l'ha mandà tròpi mëssagi a 's blòch-sì, antlora a l'ha dëscolegalo.\",\n\t\"pad.modals.rejected.explanation\": \"Ël servent a l'ha arpossà un mëssagi mandà da sò navigador.\",\n\t\"pad.modals.rejected.cause\": \"Ël servent a podrìa esse stàit agiornà antramentre che chiel a beicava ël blòch, o peul desse ch'a-i é un givo an Etherpad. Ch'a preuva a carié torna la pàgina.\",\n\t\"pad.modals.disconnected\": \"A l'é stàit dëscolegà\",\n\t\"pad.modals.disconnected.explanation\": \"La conession al servent a l'é perdusse\",\n\t\"pad.modals.disconnected.cause\": \"Ël servent a podrìa esse indisponìbil. Për piasì, ch'a anforma l'aministrator dël servissi si ël problema a persist.\",\n\t\"pad.share\": \"Partagé 's feuj\",\n\t\"pad.share.readonly\": \"Mach letura\",\n\t\"pad.share.link\": \"Liura\",\n\t\"pad.share.emebdcode\": \"Ancorporé na liura\",\n\t\"pad.chat\": \"Ciaciarada\",\n\t\"pad.chat.title\": \"Duverté la ciaciarada për cost feuj.\",\n\t\"pad.chat.loadmessages\": \"Carié pi 'd mëssagi\",\n\t\"pad.chat.stick.title\": \"Taché la ciaciarada an slë scren\",\n\t\"pad.chat.writeMessage.placeholder\": \"Ch'a scriva sò mëssage ambelessì\",\n\t\"timeslider.followContents\": \"Steje dapress a j'agiornament ëd contnù dël blòch\",\n\t\"timeslider.pageTitle\": \"Stòria dinàmica ëd {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Torné al feuj\",\n\t\"timeslider.toolbar.authors\": \"Autor:\",\n\t\"timeslider.toolbar.authorsList\": \"Gnun autor\",\n\t\"timeslider.toolbar.exportlink.title\": \"Esporté\",\n\t\"timeslider.exportCurrent\": \"Esporté la version corenta tanme:\",\n\t\"timeslider.version\": \"Version {{version}}\",\n\t\"timeslider.saved\": \"Argistrà ai {{day}} {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Letura / Pàusa dij contnù dël feuj\",\n\t\"timeslider.backRevision\": \"Andé andaré ëd na revision ant ës feuj\",\n\t\"timeslider.forwardRevision\": \"Andé anans ëd na revision ant ëd feuj\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Gené\",\n\t\"timeslider.month.february\": \"Fërvé\",\n\t\"timeslider.month.march\": \"Mars\",\n\t\"timeslider.month.april\": \"Avril\",\n\t\"timeslider.month.may\": \"Maj\",\n\t\"timeslider.month.june\": \"Giugn\",\n\t\"timeslider.month.july\": \"Luj\",\n\t\"timeslider.month.august\": \"Ost\",\n\t\"timeslider.month.september\": \"Stèmber\",\n\t\"timeslider.month.october\": \"Otóber\",\n\t\"timeslider.month.november\": \"Novèmber\",\n\t\"timeslider.month.december\": \"Dzèmber\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: autor anònim, other: autor anònim ]}\",\n\t\"pad.savedrevs.marked\": \"Sa revision a l'é adess marcà tanme revision argistrà\",\n\t\"pad.savedrevs.timeslider\": \"A peul vëdde le revision argistrà an visitand la stòria\",\n\t\"pad.userlist.entername\": \"Ch'a buta sò nòm\",\n\t\"pad.userlist.unnamed\": \"anònim\",\n\t\"pad.editbar.clearcolors\": \"Dëscancelé ij color ëd paternità dj'autor an tut ël document? Costa assion a peul nen esse anulà.\",\n\t\"pad.impexp.importbutton\": \"Amporté adess\",\n\t\"pad.impexp.importing\": \"An camin ch'as ampòrta...\",\n\t\"pad.impexp.confirmimport\": \"Amportand n'archivi as dëscancelërà ël test corent dël feuj. É-lo sigur ëd vorèj felo?\",\n\t\"pad.impexp.convertFailed\": \"I l'oma nen podù amporté s'archivi. Për piasì, ch'a deuvra n'àutr formà ëd document o ch'a còpia e ancòla a man\",\n\t\"pad.impexp.padHasData\": \"I l'oma nen podù amporté s'archivi përché 's feuj a l'ha già avù dle modìfiche; për piasì, ch'a ampòrta un feuj neuv\",\n\t\"pad.impexp.uploadFailed\": \"Ël cariament a l'ha falì, për piasì ch'a preuva torna\",\n\t\"pad.impexp.importfailed\": \"Amportassion falìa\",\n\t\"pad.impexp.copypaste\": \"Për piasì, ch'a còpia e ancòla\",\n\t\"pad.impexp.exportdisabled\": \"L'esportassion an formà {{type}} a l'é disativà. Për piasì, ch'a contata sò aministrator ëd sistema për ij detaj.\",\n\t\"pad.impexp.maxFileSize\": \"Archivi tròp gròss. Ch'a contata sò aministrator ëd sit për sumenté la taja dj'archivi consentìa për j'amportassion\"\n}\n"
  },
  {
    "path": "src/locales/ps.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Ahmed-Najib-Biabani-Ibrahimkhel\",\n\t\t\t\"شاه زمان پټان\"\n\t\t]\n\t},\n\t\"admin_plugins.last-update\": \"وروستۍ هم‌مهالېدنه\",\n\t\"admin_plugins.name\": \"نوم\",\n\t\"admin_plugins.version\": \"بل‌بڼه\",\n\t\"admin_plugins_info\": \"ستونزو اواري مالومات\",\n\t\"admin_plugins_info.hooks\": \"ځای‌پرځای‌شوي چنگکونه\",\n\t\"admin_plugins_info.version_latest\": \"وروستۍ د لاسرسي وړ بل‌بڼه\",\n\t\"admin_plugins_info.version_number\": \"بل‌بڼې شمېره\",\n\t\"admin_settings\": \"اوڼنې\",\n\t\"admin_settings.current\": \"اوسنی ترتيب\",\n\t\"admin_settings.current_example-devel\": \"د پراختيااوڼنې کينډۍ بېلگه\",\n\t\"admin_settings.current_example-prod\": \"د توليد اوڼنې کينډۍ بېلگه\",\n\t\"admin_settings.current_save.value\": \"اوڼنې خوندي‌کول\",\n\t\"index.newPad\": \"نوې ليکچه\",\n\t\"index.createOpenPad\": \"ليکچه د نوم له مخې پرانېستل\",\n\t\"index.recentPads\": \"وروستۍ ليکچې\",\n\t\"index.recentPadsEmpty\": \"هېڅ وروستۍ ليکچې ونه موندل شوې.\",\n\t\"index.generateNewPad\": \"ناڅاپي ليکچې نوم پنځول\",\n\t\"index.labelPad\": \"ليکچې نوم (اختياري)\",\n\t\"index.placeholderPadEnter\": \"مهرباني وکړئ ليکچې نوم وليکئ...\",\n\t\"index.createAndShareDocuments\": \"په رښتينې وخت کې لاسوندونه جوړ او شريک کړئ\",\n\t\"pad.toolbar.bold.title\": \"زغرد (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"رېوند (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"لرکرښن (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"کرښکاږلی (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"ترتيب شوی لړليک (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"ناترتيب شوی لړليک (Ctrl+Shift+L)\",\n\t\"pad.toolbar.undo.title\": \"ناکړل (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"بياکړل (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"د ليکوالۍ رنگونه سپينول (Ctrl+Shift+C)\",\n\t\"pad.toolbar.savedRevision.title\": \"مخکتنه خوندي کول\",\n\t\"pad.toolbar.settings.title\": \"اوڼنې\",\n\t\"pad.toolbar.home.title\": \"بېرته کور ته\",\n\t\"pad.colorpicker.save\": \"خوندي کول\",\n\t\"pad.colorpicker.cancel\": \"ناگارل\",\n\t\"pad.loading\": \"رابرسېرېږي...\",\n\t\"pad.settings.padSettings\": \"د ليکچې اوڼنې\",\n\t\"pad.settings.myView\": \"زما کتنه\",\n\t\"pad.settings.stickychat\": \"تل په پردې بانډار کول\",\n\t\"pad.settings.chatandusers\": \"کارنان او بانډار ښکاره کول\",\n\t\"pad.settings.colorcheck\": \"د ليکوالۍ رنگونه\",\n\t\"pad.settings.linenocheck\": \"د کرښو شمېرې\",\n\t\"pad.settings.fontType\": \"ليکبڼې ډول:\",\n\t\"pad.settings.fontType.normal\": \"نورمال\",\n\t\"pad.settings.language\": \"ژبه:\",\n\t\"pad.settings.about\": \"په‌اړه\",\n\t\"pad.settings.poweredBy\": \"چلوونکی\",\n\t\"pad.importExport.import_export\": \"رالېږدول/بهرلېږل\",\n\t\"pad.importExport.importSuccessful\": \"بريالی شو!\",\n\t\"pad.importExport.exportetherpad\": \"اېترپډ\",\n\t\"pad.importExport.exporthtml\": \"اچ ټي ام اېل\",\n\t\"pad.importExport.exportplain\": \"ساده متن\",\n\t\"pad.importExport.exportword\": \"مايکروسافټ ورډ\",\n\t\"pad.importExport.exportpdf\": \"پي ډي اېف\",\n\t\"pad.importExport.exportopen\": \"ODF (اوپن ډاکومنټ فارمټ)\",\n\t\"pad.modals.connected\": \"اړيکمن شو.\",\n\t\"pad.modals.forcereconnect\": \"په زوره بيانښلونه\",\n\t\"pad.modals.reconnecttimer\": \"د بيانښلولو هڅه‌کول\",\n\t\"pad.modals.cancel\": \"ناگارل\",\n\t\"pad.modals.slowcommit.explanation\": \"پالنگر ځواب نه وايي.\",\n\t\"pad.modals.slowcommit.cause\": \"دا کېدای شي د جال د اړيکتيايي ستونزو په سبب وي.\",\n\t\"pad.modals.deleted\": \"ړنگ شو.\",\n\t\"pad.share.readonly\": \"يوازې لوستنه\",\n\t\"pad.share.link\": \"تړنه\",\n\t\"pad.share.emebdcode\": \"يو آر اېل ټومبل\",\n\t\"pad.chat\": \"بانډار\",\n\t\"pad.chat.loadmessages\": \"نور پيغامونه برسېرول\",\n\t\"timeslider.toolbar.authors\": \"ليکوال:\",\n\t\"timeslider.toolbar.authorsList\": \"بې ليکواله\",\n\t\"timeslider.toolbar.exportlink.title\": \"صادرول\",\n\t\"timeslider.version\": \"بڼه {{version}}\",\n\t\"timeslider.saved\": \"خوندي شو {{month}} {{day}}, {{year}}\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"جنوري\",\n\t\"timeslider.month.february\": \"فبروري\",\n\t\"timeslider.month.march\": \"مارچ\",\n\t\"timeslider.month.april\": \"اپرېل\",\n\t\"timeslider.month.may\": \"مۍ\",\n\t\"timeslider.month.june\": \"جون\",\n\t\"timeslider.month.july\": \"جولای\",\n\t\"timeslider.month.august\": \"اگسټ\",\n\t\"timeslider.month.september\": \"سېپتمبر\",\n\t\"timeslider.month.october\": \"اکتوبر\",\n\t\"timeslider.month.november\": \"نومبر\",\n\t\"timeslider.month.december\": \"ډيسمبر\",\n\t\"timeslider.unnamedauthors\": \"{{num}} بې نومه {[ډېرگړي(num) يو: ليکوال، نور: ليکوالان ]}\",\n\t\"pad.savedrevs.marked\": \"اوس دا مخکتنه د يوې خوندي شوې مخکتنې په توگه په نښه شوه\",\n\t\"pad.userlist.entername\": \"نوم مو ورکړۍ\",\n\t\"pad.userlist.unnamed\": \"بې نومه\",\n\t\"pad.impexp.importbutton\": \"اوس واردول\",\n\t\"pad.impexp.importing\": \"په واردولو کې دی...\",\n\t\"pad.impexp.uploadFailed\": \"راپورته‌کول نابرياله شول، مهرباني وکړئ بيا هڅه وکړئ.\",\n\t\"pad.impexp.copypaste\": \"لطفاً لمېسل لېښل ترسره کړئ\"\n}\n"
  },
  {
    "path": "src/locales/pt-br.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Cainamarques\",\n\t\t\t\"Dianakc\",\n\t\t\t\"Eduardo Addad de Oliveira\",\n\t\t\t\"Eduardoaddad\",\n\t\t\t\"Fasouzafreitas\",\n\t\t\t\"Gusta\",\n\t\t\t\"Lpagliari\",\n\t\t\t\"Luckas\",\n\t\t\t\"Macofe\",\n\t\t\t\"Nsharkey\",\n\t\t\t\"Prilopes\",\n\t\t\t\"Rafaelff\",\n\t\t\t\"Rodrigo codignoli\",\n\t\t\t\"TheGabrielZaum\",\n\t\t\t\"Titoncio\",\n\t\t\t\"Tuliouel\",\n\t\t\t\"Walesson\",\n\t\t\t\"Webysther\",\n\t\t\t\"YuriNikolai\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Painel administrativo - Etherpad\",\n\t\"admin_plugins\": \"Gerenciador de complementos\",\n\t\"admin_plugins.available\": \"Plugins disponíveis\",\n\t\"admin_plugins.available_not-found\": \"Nenhum plugin encontrado.\",\n\t\"admin_plugins.available_fetching\": \"Buscando…\",\n\t\"admin_plugins.available_install.value\": \"Instalar\",\n\t\"admin_plugins.available_search.placeholder\": \"Procure plugins para instalar\",\n\t\"admin_plugins.description\": \"Descrição\",\n\t\"admin_plugins.installed\": \"Plugins instalados\",\n\t\"admin_plugins.installed_fetching\": \"Buscando plugins instalados…\",\n\t\"admin_plugins.installed_nothing\": \"Você ainda não instalou nenhum plugin.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Desinstalar\",\n\t\"admin_plugins.last-update\": \"Última atualização\",\n\t\"admin_plugins.name\": \"Nome\",\n\t\"admin_plugins.page-title\": \"Gerenciador de plugins - Etherpad\",\n\t\"admin_plugins.version\": \"Versão\",\n\t\"admin_plugins_info\": \"Resolução de problemas\",\n\t\"admin_plugins_info.hooks\": \"Ganchos instalados\",\n\t\"admin_plugins_info.hooks_client\": \"Ganchos do lado do cliente\",\n\t\"admin_plugins_info.hooks_server\": \"Ganchos do lado do servidor\",\n\t\"admin_plugins_info.parts\": \"Peças instaladas\",\n\t\"admin_plugins_info.plugins\": \"Plugins instalados\",\n\t\"admin_plugins_info.page-title\": \"Informação do plugin - Etherpad\",\n\t\"admin_plugins_info.version\": \"Versão de Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Última versão disponível\",\n\t\"admin_plugins_info.version_number\": \"Número de versão\",\n\t\"admin_settings\": \"Configurações\",\n\t\"admin_settings.current\": \"Configuração atual\",\n\t\"admin_settings.current_example-devel\": \"Exemplo de modelo de configurações de desenvolvimento\",\n\t\"admin_settings.current_example-prod\": \"Exemplo de modelo de configurações de produção\",\n\t\"admin_settings.current_restart.value\": \"Reiniciar Etherpad\",\n\t\"admin_settings.current_save.value\": \"Salvar configurações\",\n\t\"admin_settings.page-title\": \"Configurações - Etherpad\",\n\t\"index.newPad\": \"Nova Nota\",\n\t\"index.createOpenPad\": \"ou criar/abrir uma Nota com o nome:\",\n\t\"index.openPad\": \"abra uma Nota existente com o nome:\",\n\t\"pad.toolbar.bold.title\": \"Negrito (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Itálico (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Sublinhado (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Tachado (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Lista ordenada (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Lista não ordenada (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Aumentar Recuo (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Diminuir Recuo (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Desfazer (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Refazer (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Limpar as cores de identificação de autoria (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importar/Exportar de/para diferentes formatos de arquivo\",\n\t\"pad.toolbar.timeslider.title\": \"Linha do tempo\",\n\t\"pad.toolbar.savedRevision.title\": \"Salvar revisão\",\n\t\"pad.toolbar.settings.title\": \"Configurações\",\n\t\"pad.toolbar.embed.title\": \"Compartilhar e incorporar esta Nota\",\n\t\"pad.toolbar.showusers.title\": \"Mostrar os usuarios nesta Nota\",\n\t\"pad.colorpicker.save\": \"Salvar\",\n\t\"pad.colorpicker.cancel\": \"Cancelar\",\n\t\"pad.loading\": \"Carregando...\",\n\t\"pad.noCookie\": \"Não foi possível encontrar o cookie. Por favor, permita cookies no seu navegador! Sua sessão e configurações não serão salvas entre as visitas. Isso pode ser devido ao fato de o Etherpad ser incluído em um iFrame em alguns navegadores. Verifique se o Etherpad está no mesmo subdomínio/domínio que o iFrame pai\",\n\t\"pad.permissionDenied\": \"Você não tem permissão para acessar esta Nota\",\n\t\"pad.settings.padSettings\": \"Configurações da Nota\",\n\t\"pad.settings.myView\": \"Minha Visão\",\n\t\"pad.settings.stickychat\": \"Bate-papo sempre visível\",\n\t\"pad.settings.chatandusers\": \"Mostrar o bate-papo e os usuários\",\n\t\"pad.settings.colorcheck\": \"Cores de autoria\",\n\t\"pad.settings.linenocheck\": \"Números de linha\",\n\t\"pad.settings.rtlcheck\": \"Ler conteúdo da direita para esquerda?\",\n\t\"pad.settings.fontType\": \"Tipo de fonte:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Idioma:\",\n\t\"pad.settings.deletePad\": \"Apagar Pad\",\n\t\"pad.delete.confirm\": \"Tem certeza de que quer deletar este pad?\",\n\t\"pad.settings.about\": \"Sobre\",\n\t\"pad.settings.poweredBy\": \"Fornecido por\",\n\t\"pad.importExport.import_export\": \"Importar/Exportar\",\n\t\"pad.importExport.import\": \"Enviar um arquivo texto ou documento\",\n\t\"pad.importExport.importSuccessful\": \"Completo!\",\n\t\"pad.importExport.export\": \"Exportar a nota atual como:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Texto puro\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Só é possível importar texto sem formatação ou HTML. Para obter funcionalidades de importação mais avançadas, por favor <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">instale o AbiWordor ou LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Conectado.\",\n\t\"pad.modals.reconnecting\": \"Reconectando à sua nota…\",\n\t\"pad.modals.forcereconnect\": \"Forçar reconexão\",\n\t\"pad.modals.reconnecttimer\": \"Tentando se reconectar\",\n\t\"pad.modals.cancel\": \"Cancelar\",\n\t\"pad.modals.userdup\": \"Aberto em outra janela\",\n\t\"pad.modals.userdup.explanation\": \"Esta nota parece estar aberta em mais de uma janela de navegador deste computador.\",\n\t\"pad.modals.userdup.advice\": \"Reconectar para usar esta janela.\",\n\t\"pad.modals.unauth\": \"Não autorizado\",\n\t\"pad.modals.unauth.explanation\": \"Suas permissões foram mudadas enquanto visualizava esta página. Tente reconectar.\",\n\t\"pad.modals.looping.explanation\": \"Há problemas de comunicação com o servidor de sincronização.\",\n\t\"pad.modals.looping.cause\": \"Talvez você tenha conectado por um firewall ou proxy incompatível.\",\n\t\"pad.modals.initsocketfail\": \"Servidor está indisponível.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Não foi possível conectar com o servidor de sincronização.\",\n\t\"pad.modals.initsocketfail.cause\": \"Isto provavelmente ocorreu por um problema em seu navegador ou conexão.\",\n\t\"pad.modals.slowcommit.explanation\": \"O servidor não responde.\",\n\t\"pad.modals.slowcommit.cause\": \"Isto pode ser por problemas com a conexão de rede.\",\n\t\"pad.modals.badChangeset.explanation\": \"Uma edição que você fez foi classificada como ilegal pelo servidor de sincronização.\",\n\t\"pad.modals.badChangeset.cause\": \"Isto pode ocorrer devido a uma configuração errada do servidor ou algum outro comportamento inesperado. Por favor contate o administrador, se você acredita que é um erro. Tente reconectar para continuar editando.\",\n\t\"pad.modals.corruptPad.explanation\": \"A nota que você está tentando acessar está corrompida.\",\n\t\"pad.modals.corruptPad.cause\": \"Isto pode ocorrer devido a uma configuração errada do servidor ou algum outro comportamento inesperado. Por favor contate o administrador.\",\n\t\"pad.modals.deleted\": \"Excluído.\",\n\t\"pad.modals.deleted.explanation\": \"Esta nota foi removida.\",\n\t\"pad.modals.rateLimited\": \"Limitado.\",\n\t\"pad.modals.rateLimited.explanation\": \"Você enviou muitas mensagens para esta nota por isso será desconectado.\",\n\t\"pad.modals.rejected.explanation\": \"O servidor rejeitou uma mensagem que foi enviada pelo seu navegador.\",\n\t\"pad.modals.rejected.cause\": \"O server pode ter sido atualizado enquanto visualizava esta nota, ou talvez seja apenas um bug do Etherpad. Tenta recarregar a página.\",\n\t\"pad.modals.disconnected\": \"Você foi desconectado.\",\n\t\"pad.modals.disconnected.explanation\": \"A conexão com o servidor foi perdida\",\n\t\"pad.modals.disconnected.cause\": \"O servidor pode estar indisponível. Por favor, notifique o administrador caso isso continue.\",\n\t\"pad.share\": \"Compartilhar esta nota\",\n\t\"pad.share.readonly\": \"Somente leitura\",\n\t\"pad.share.link\": \"Link\",\n\t\"pad.share.emebdcode\": \"Incorporar o URL\",\n\t\"pad.chat\": \"Bate-papo\",\n\t\"pad.chat.title\": \"Abrir o bate-papo desta nota.\",\n\t\"pad.chat.loadmessages\": \"Carregar mais mensagens\",\n\t\"pad.chat.stick.title\": \"Cole o bate-papo na tela\",\n\t\"pad.chat.writeMessage.placeholder\": \"Escreva sua mensagem aqui\",\n\t\"timeslider.followContents\": \"Siga as atualizações de conteúdo da nota\",\n\t\"timeslider.pageTitle\": \"Linha do tempo de {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Retornar para a nota\",\n\t\"timeslider.toolbar.authors\": \"Autores:\",\n\t\"timeslider.toolbar.authorsList\": \"Sem autores\",\n\t\"timeslider.toolbar.exportlink.title\": \"Exportar\",\n\t\"timeslider.exportCurrent\": \"Exportar a versão atual em formato:\",\n\t\"timeslider.version\": \"Versão {{version}}\",\n\t\"timeslider.saved\": \"Salvo em {{day}} de {{month}} de {{year}}\",\n\t\"timeslider.playPause\": \"Reproduzir / Pausar conteúdos da Nota\",\n\t\"timeslider.backRevision\": \"Voltar a uma revisão anterior nesta Nota\",\n\t\"timeslider.forwardRevision\": \"Ir a uma revisão posterior nesta Nota\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Janeiro\",\n\t\"timeslider.month.february\": \"Fevereiro\",\n\t\"timeslider.month.march\": \"Março\",\n\t\"timeslider.month.april\": \"Abril\",\n\t\"timeslider.month.may\": \"Maio\",\n\t\"timeslider.month.june\": \"Junho\",\n\t\"timeslider.month.july\": \"Julho\",\n\t\"timeslider.month.august\": \"Agosto\",\n\t\"timeslider.month.september\": \"Setembro\",\n\t\"timeslider.month.october\": \"Outubro\",\n\t\"timeslider.month.november\": \"Novembro\",\n\t\"timeslider.month.december\": \"Dezembro\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: autor anônimo, other: autores anônimos ]}\",\n\t\"pad.savedrevs.marked\": \"Esta revisão foi marcada como salva\",\n\t\"pad.savedrevs.timeslider\": \"Você pode consultar as revisões salvas visitando a linha do tempo\",\n\t\"pad.userlist.entername\": \"Insira o seu nome\",\n\t\"pad.userlist.unnamed\": \"Sem título\",\n\t\"pad.editbar.clearcolors\": \"Limpar as cores de autoria em todo o documento? isto não pode ser anulado\",\n\t\"pad.impexp.importbutton\": \"Importar agora\",\n\t\"pad.impexp.importing\": \"Importando...\",\n\t\"pad.impexp.confirmimport\": \"Importar um arquivo sobrescreverá o texto atual da nota. Tem certeza de que deseja prosseguir?\",\n\t\"pad.impexp.convertFailed\": \"Não foi possível importar este arquivo. Use outro formato ou copie e cole manualmente\",\n\t\"pad.impexp.padHasData\": \"Não foi possível importar este arquivo porque esta nota já tinha alterações, consulte como importar para uma nova nota\",\n\t\"pad.impexp.uploadFailed\": \"O envio falhou. Tente outra vez\",\n\t\"pad.impexp.importfailed\": \"A importação falhou\",\n\t\"pad.impexp.copypaste\": \"Copie e cole\",\n\t\"pad.impexp.exportdisabled\": \"A exportação em formato {{type}} está desativada. Comunique-se com o administrador do sistema para detalhes.\",\n\t\"pad.impexp.maxFileSize\": \"Arquivo muito grande. Entre em contato com o administrador do site para aumentar o tamanho do arquivo permitido para importação\"\n}\n"
  },
  {
    "path": "src/locales/pt.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Athena in Wonderland\",\n\t\t\t\"Cainamarques\",\n\t\t\t\"GoEThe\",\n\t\t\t\"Guilha\",\n\t\t\t\"Hamilton Abreu\",\n\t\t\t\"Imperadeiro98\",\n\t\t\t\"Luckas\",\n\t\t\t\"Macofe\",\n\t\t\t\"Mansil alfalb\",\n\t\t\t\"MuratTheTurkish\",\n\t\t\t\"Ti4goc\",\n\t\t\t\"Tuliouel\",\n\t\t\t\"Unamane\",\n\t\t\t\"Waldir\",\n\t\t\t\"Waldyrious\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Painel do administrador - Etherpad\",\n\t\"admin_plugins\": \"Gestor de plugins\",\n\t\"admin_plugins.available\": \"Plugins disponíveis\",\n\t\"admin_plugins.available_not-found\": \"Não foram encontrados plugins.\",\n\t\"admin_plugins.available_fetching\": \"A obter...\",\n\t\"admin_plugins.available_install.value\": \"Instalar\",\n\t\"admin_plugins.available_search.placeholder\": \"Procura plugins para instalar\",\n\t\"admin_plugins.description\": \"Descrição\",\n\t\"admin_plugins.installed\": \"Plugins instalados\",\n\t\"admin_plugins.installed_fetching\": \"A obter plugins instalados...\",\n\t\"admin_plugins.installed_nothing\": \"Não instalas-te nenhum plugin ainda.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Desinstalar\",\n\t\"admin_plugins.last-update\": \"Última atualização\",\n\t\"admin_plugins.name\": \"Nome\",\n\t\"admin_plugins.page-title\": \"Gestor de plugins - Etherpad\",\n\t\"admin_plugins.version\": \"Versão\",\n\t\"admin_plugins_info\": \"Informação de resolução de problemas\",\n\t\"admin_plugins_info.hooks\": \"Hooks instalados\",\n\t\"admin_plugins_info.hooks_client\": \"Hooks do lado-do-cliente\",\n\t\"admin_plugins_info.hooks_server\": \"Hooks do lado-do-servidor\",\n\t\"admin_plugins_info.parts\": \"Partes instaladas\",\n\t\"admin_plugins_info.plugins\": \"Plugins instalados\",\n\t\"admin_plugins_info.page-title\": \"Informação do plugin - Etherpad\",\n\t\"admin_plugins_info.version\": \"Versão do Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Última versão disponível\",\n\t\"admin_plugins_info.version_number\": \"Número de versão\",\n\t\"admin_settings\": \"Definições\",\n\t\"admin_settings.current\": \"Configuração atual\",\n\t\"admin_settings.current_example-devel\": \"Exemplo do modo de Desenvolvedor\",\n\t\"admin_settings.current_example-prod\": \"Exemplo do modo de Produção\",\n\t\"admin_settings.current_restart.value\": \"Reiniciar Etherpad\",\n\t\"admin_settings.current_save.value\": \"Guardar Definições\",\n\t\"admin_settings.page-title\": \"Definições - Etherpad\",\n\t\"index.newPad\": \"Nova Nota\",\n\t\"index.createOpenPad\": \"ou cria/abre uma nota com o nome:\",\n\t\"index.openPad\": \"abrir uma Nota existente com o nome:\",\n\t\"pad.toolbar.bold.title\": \"Negrito (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Itálico (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Sublinhado (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Riscar (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Lista ordenada (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Lista desordenada (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Indentar (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Remover indentação (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Desfazer (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Refazer (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Limpar cores de autoria (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importar/exportar de/para diferentes formatos de ficheiro\",\n\t\"pad.toolbar.timeslider.title\": \"Linha de tempo\",\n\t\"pad.toolbar.savedRevision.title\": \"Gravar revisão\",\n\t\"pad.toolbar.settings.title\": \"Configurações\",\n\t\"pad.toolbar.embed.title\": \"Partilhar e incorporar esta nota\",\n\t\"pad.toolbar.showusers.title\": \"Mostrar os utilizadores nesta nota\",\n\t\"pad.colorpicker.save\": \"Gravar\",\n\t\"pad.colorpicker.cancel\": \"Cancelar\",\n\t\"pad.loading\": \"A carregar…\",\n\t\"pad.noCookie\": \"Não foi possível encontrar o ''cookie''. Por favor, permita ''cookies'' no seu navegador! A sua sessão e as definições não foram guardadas entre as visitas. Isto poderá ter ocorrido porque Etherpad foi incluído numa iFrame em alguns «Navegadores». Por favor, certifique-se que Etherpad está no mesmo subdomínio / domínio que a iFrame fonte\",\n\t\"pad.permissionDenied\": \"Não tem permissão para aceder a esta nota\",\n\t\"pad.settings.padSettings\": \"Configurações da nota\",\n\t\"pad.settings.myView\": \"A minha vista\",\n\t\"pad.settings.stickychat\": \"Conversação sempre no ecrã\",\n\t\"pad.settings.chatandusers\": \"Mostrar a conversação e os utilizadores\",\n\t\"pad.settings.colorcheck\": \"Cores de autoria\",\n\t\"pad.settings.linenocheck\": \"Números de linha\",\n\t\"pad.settings.rtlcheck\": \"Ler o conteúdo da direita para a esquerda?\",\n\t\"pad.settings.fontType\": \"Tipo de letra:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Língua:\",\n\t\"pad.settings.about\": \"Sobre\",\n\t\"pad.settings.poweredBy\": \"Desenvolvido por\",\n\t\"pad.importExport.import_export\": \"Importar/Exportar\",\n\t\"pad.importExport.import\": \"Carregar qualquer ficheiro de texto ou documento\",\n\t\"pad.importExport.importSuccessful\": \"Completo!\",\n\t\"pad.importExport.export\": \"Exportar a nota atual como:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Texto simples\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Só pode fazer importações de texto não formatado ou com formato HTML. Para funcionalidades de importação de texto  mais avançadas, <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">instale AbiWord ou LibreOffice</a>, por favor.\",\n\t\"pad.modals.connected\": \"Ligado.\",\n\t\"pad.modals.reconnecting\": \"A restabelecer ligação à nota…\",\n\t\"pad.modals.forcereconnect\": \"Forçar restabelecimento de ligação\",\n\t\"pad.modals.reconnecttimer\": \"A tentar restabelecer ligação\",\n\t\"pad.modals.cancel\": \"Cancelar\",\n\t\"pad.modals.userdup\": \"Aberto noutra janela\",\n\t\"pad.modals.userdup.explanation\": \"Esta nota parece estar aberta em mais do que uma janela do navegador neste computador.\",\n\t\"pad.modals.userdup.advice\": \"Religar para utilizar esta janela.\",\n\t\"pad.modals.unauth\": \"Não autorizado\",\n\t\"pad.modals.unauth.explanation\": \"As suas permissões foram alteradas enquanto revia esta página. Tente religar.\",\n\t\"pad.modals.looping.explanation\": \"Existem problemas de comunicação com o servidor de sincronização.\",\n\t\"pad.modals.looping.cause\": \"Talvez tenha ligado por um firewall ou proxy incompatível.\",\n\t\"pad.modals.initsocketfail\": \"O servidor está inacessível.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Não foi possível ligar ao servidor de sincronização.\",\n\t\"pad.modals.initsocketfail.cause\": \"Isto provavelmente ocorreu por um problema no seu navegador ou na sua ligação de Internet.\",\n\t\"pad.modals.slowcommit.explanation\": \"O servidor não está a responder.\",\n\t\"pad.modals.slowcommit.cause\": \"Isto pode ser por problemas com a ligação de rede.\",\n\t\"pad.modals.badChangeset.explanation\": \"Uma edição que fez foi classificada como ilegal pelo servidor de sincronização.\",\n\t\"pad.modals.badChangeset.cause\": \"Isto pode ocorrer devido a uma configuração errada do servidor ou algum outro comportamento inesperado. Por favor contacte o administrador, se acredita que é um erro. Tente religar para continuar a editar.\",\n\t\"pad.modals.corruptPad.explanation\": \"A nota que está a tentar aceder está corrompida.\",\n\t\"pad.modals.corruptPad.cause\": \"Isto pode ocorrer devido a uma configuração errada do servidor ou algum outro comportamento inesperado. Por favor contacte o administrador.\",\n\t\"pad.modals.deleted\": \"Eliminado.\",\n\t\"pad.modals.deleted.explanation\": \"Esta nota foi removida.\",\n\t\"pad.modals.rateLimited\": \"Limitado.\",\n\t\"pad.modals.rateLimited.explanation\": \"Enviou demasiadas mensagens para este pad, por isso foi desligado.\",\n\t\"pad.modals.rejected.explanation\": \"O servidor rejeitou a mensagem que foi enviada pelo teu navegador.\",\n\t\"pad.modals.rejected.cause\": \"O server foi atualizado enquanto estávas a ver esta nota, ou talvez seja apenas um bug do Etherpad. Tenta recarregar a página.\",\n\t\"pad.modals.disconnected\": \"Você foi desligado.\",\n\t\"pad.modals.disconnected.explanation\": \"A ligação ao servidor foi perdida\",\n\t\"pad.modals.disconnected.cause\": \"O servidor pode estar indisponível. Por favor, notifique o administrador de serviço se isto continuar a acontecer.\",\n\t\"pad.share\": \"Partilhar esta nota\",\n\t\"pad.share.readonly\": \"Somente para leitura\",\n\t\"pad.share.link\": \"Hiperligação\",\n\t\"pad.share.emebdcode\": \"Incorporar o URL\",\n\t\"pad.chat\": \"Conversação\",\n\t\"pad.chat.title\": \"Abrir a conversação para esta nota.\",\n\t\"pad.chat.loadmessages\": \"Carregar mais mensagens\",\n\t\"pad.chat.stick.title\": \"Colar conversação no ecrã\",\n\t\"pad.chat.writeMessage.placeholder\": \"Escreva a sua mensagem aqui\",\n\t\"timeslider.followContents\": \"Siga as atualizações do conteúdo do pad\",\n\t\"timeslider.pageTitle\": \"Linha do tempo de {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Voltar à nota\",\n\t\"timeslider.toolbar.authors\": \"Autores:\",\n\t\"timeslider.toolbar.authorsList\": \"Sem Autores\",\n\t\"timeslider.toolbar.exportlink.title\": \"Exportar\",\n\t\"timeslider.exportCurrent\": \"Exportar versão atual como:\",\n\t\"timeslider.version\": \"Versão {{version}}\",\n\t\"timeslider.saved\": \"Gravado a {{day}} de {{month}} de {{year}}\",\n\t\"timeslider.playPause\": \"Reproduzir / pausar conteúdo da nota\",\n\t\"timeslider.backRevision\": \"Voltar a uma revisão anterior desta nota\",\n\t\"timeslider.forwardRevision\": \"Avançar para uma revisão posterior desta nota\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Janeiro\",\n\t\"timeslider.month.february\": \"Fevereiro\",\n\t\"timeslider.month.march\": \"Março\",\n\t\"timeslider.month.april\": \"Abril\",\n\t\"timeslider.month.may\": \"Maio\",\n\t\"timeslider.month.june\": \"Junho\",\n\t\"timeslider.month.july\": \"Julho\",\n\t\"timeslider.month.august\": \"Agosto\",\n\t\"timeslider.month.september\": \"Setembro\",\n\t\"timeslider.month.october\": \"Outubro\",\n\t\"timeslider.month.november\": \"Novembro\",\n\t\"timeslider.month.december\": \"Dezembro\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: autor anónimo, other: autores anónimos ]}\",\n\t\"pad.savedrevs.marked\": \"Esta revisão está agora marcada como gravada\",\n\t\"pad.savedrevs.timeslider\": \"Pode consultar as revisões gravadas visitando a linha do tempo\",\n\t\"pad.userlist.entername\": \"Insira o seu nome\",\n\t\"pad.userlist.unnamed\": \"sem nome\",\n\t\"pad.editbar.clearcolors\": \"Limpar as cores de autoria do documento todo? Esta operação não pode ser desfeita\",\n\t\"pad.impexp.importbutton\": \"Importar agora\",\n\t\"pad.impexp.importing\": \"Importando...\",\n\t\"pad.impexp.confirmimport\": \"A importação de um ficheiro irá substituir o texto atual da nota. Tem certeza que deseja continuar?\",\n\t\"pad.impexp.convertFailed\": \"Não foi possível importar este ficheiro. Utilize outro formato ou copie e insira manualmente\",\n\t\"pad.impexp.padHasData\": \"Não fomos capazes de importar este ficheiro porque esta nota já tinha alterações; importe para uma nota nova, por favor\",\n\t\"pad.impexp.uploadFailed\": \"O carregamento falhou; tente novamente, por favor\",\n\t\"pad.impexp.importfailed\": \"A importação falhou\",\n\t\"pad.impexp.copypaste\": \"Copie e insira, por favor\",\n\t\"pad.impexp.exportdisabled\": \"A exportação no formato {{type}} está desativada. Por favor, contacte o administrador do sistema para mais informações.\",\n\t\"pad.impexp.maxFileSize\": \"Ficheiro demasiado grande. Contacte o administrador do ''site'' para aumentar o tamanho máximo dos ficheiros importados\"\n}\n"
  },
  {
    "path": "src/locales/qqq.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"BryanDavis\",\n\t\t\t\"Liuxinyu970226\",\n\t\t\t\"Mklehr\",\n\t\t\t\"Nemo bis\",\n\t\t\t\"Robby\",\n\t\t\t\"Shirayuki\",\n\t\t\t\"Siebrand\",\n\t\t\t\"Tim.krieger\"\n\t\t]\n\t},\n\t\"admin_plugins.available_install.value\": \"{{Identical|Install}}\",\n\t\"admin_plugins.description\": \"{{Identical|Description}}\",\n\t\"admin_plugins.installed_uninstall.value\": \"{{Identical|Uninstall}}\",\n\t\"admin_plugins.last-update\": \"{{Identical|Last update}}\",\n\t\"admin_plugins.name\": \"{{Identical|Name}}\",\n\t\"admin_plugins.version\": \"{{Identical|Version}}\",\n\t\"admin_settings\": \"{{identical|Settings}}\",\n\t\"index.newPad\": \"Used as button text.\\nA pad, in the context of Etherpad, is a notepad, something to write on.\",\n\t\"index.createOpenPad\": \"label for an input field that allows the user to choose a custom name for his new pad. In case the pad already exists the user will be redirected to its url.\",\n\t\"pad.toolbar.bold.title\": \"Used as tooltip of button\",\n\t\"pad.toolbar.italic.title\": \"Used as tooltip of button\",\n\t\"pad.toolbar.underline.title\": \"Used as tooltip of button\",\n\t\"pad.toolbar.strikethrough.title\": \"Used as tooltip of button.\\n{{Identical|Strikethrough}}\",\n\t\"pad.toolbar.ol.title\": \"Used as tooltip for button\",\n\t\"pad.toolbar.ul.title\": \"Used as tooltip for button\",\n\t\"pad.toolbar.indent.title\": \"Used as tooltip of button.\\n\\n\\\"TAB\\\" refers to \\\"Tab key\\\".\\n\\nSee also:\\n* {{msg-etherpadlite|Pad.toolbar.unindent.title}}\\n{{Identical|Indent}}\",\n\t\"pad.toolbar.unindent.title\": \"Used as tooltip of button.\\n\\n\\\"TAB\\\" refers to \\\"Tab key\\\".\\n\\nSee also:\\n* {{msg-etherpadlite|Pad.toolbar.indent.title}}\",\n\t\"pad.toolbar.undo.title\": \"Used as tooltip of button\",\n\t\"pad.toolbar.redo.title\": \"Used as tooltip of button\\n{{Identical|Redo}}\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Used as tooltip of button\",\n\t\"pad.toolbar.import_export.title\": \"Used as tooltip of button\",\n\t\"pad.toolbar.timeslider.title\": \"The timeslider is a separate \\\"page\\\" that allows you to browse through the history of your pad's contents\",\n\t\"pad.toolbar.savedRevision.title\": \"Used as tooltip for button.\",\n\t\"pad.toolbar.settings.title\": \"settings that determine how the pad content is displayed\\n{{Identical|Settings}}\",\n\t\"pad.toolbar.embed.title\": \"Used as tooltip for button\",\n\t\"pad.toolbar.showusers.title\": \"Used as tooltip for button\",\n\t\"pad.colorpicker.save\": \"Used as button text in the \\\"Color picker\\\" window.\\n\\nSee also:\\n* {{msg-etherpadlite|Pad.colorpicker.cancel}}\\n{{Identical|Save}}\",\n\t\"pad.colorpicker.cancel\": \"Used as button text in the \\\"Color picker\\\" window.\\n\\nSee also:\\n* {{msg-etherpadlite|Pad.colorpicker.save}}\\n{{Identical|Cancel}}\",\n\t\"pad.loading\": \"Used to indicate the pad is being loaded.\\n{{Identical|Loading}}\",\n\t\"pad.permissionDenied\": \"Used as error message.\",\n\t\"pad.settings.padSettings\": \"Used as heading of settings window\",\n\t\"pad.settings.myView\": \"Section heading for a users personal settings, meaning changes to the settings in this section will only affect the current view (this browser window) of the pad.\",\n\t\"pad.settings.stickychat\": \"Used as checkbox label\",\n\t\"pad.settings.colorcheck\": \"Used as checkbox label\",\n\t\"pad.settings.linenocheck\": \"Used as checkbox label\",\n\t\"pad.settings.rtlcheck\": \"Used as label for checkbox for RTL (right-to-left) languages\",\n\t\"pad.settings.fontType\": \"Used as label for the \\\"Font type\\\" select box which has the following options:\\n* {{msg-etherpadlite|Pad.settings.fontType.normal}}\\n* {{msg-etherpadlite|Pad.settings.fontType.monospaced}}\",\n\t\"pad.settings.fontType.normal\": \"Used as an option in the \\\"Font type\\\" select box which is labeled {{msg-etherpadlite|Pad.settings.fontType}}.\\n{{Identical|Normal}}\",\n\t\"pad.settings.language\": \"This is a label for a select list of languages.\\n{{Identical|Language}}\",\n\t\"pad.settings.about\": \"{{Identical|About}}\",\n\t\"pad.importExport.import_export\": \"Used as HTML <code><nowiki><h1></nowiki></code> heading of window.\\n\\nFollowed by the child heading {{msg-etherpadlite|Pad.importExport.import}}.\",\n\t\"pad.importExport.import\": \"Used as HTML <code><nowiki><h2></nowiki></code> heading.\\n\\nPreceded by the parent heading {{msg-etherpadlite|Pad.importExport.import_export}}.\",\n\t\"pad.importExport.importSuccessful\": \"Used as success message to indicate that the pad has been imported successfully.\\n{{Identical|Successful}}\",\n\t\"pad.importExport.export\": \"Used as HTML <code><nowiki><h2></nowiki></code> heading.\\n\\nFollowed by the following link texts:\\n* {{msg-etherpadlite|Pad.importExport.exporthtml}}\\n* {{msg-etherpadlite|Pad.importExport.exportplain}}\\n* {{msg-etherpadlite|Pad.importExport.exportword}}\\n* {{msg-etherpadlite|Pad.importExport.exportpdf}}\\n* {{msg-etherpadlite|Pad.importExport.exportopen}}\\n* {{msg-etherpadlite|Pad.importExport.exportdokuwiki}}\",\n\t\"pad.importExport.exportetherpad\": \"{{Identical|Etherpad}}\",\n\t\"pad.importExport.exporthtml\": \"Used as link text, preceded by {{msg-etherpadlite|Pad.importExport.export}}.\\n{{Related|Pad.importExport.export}}\\n{{Identical|HTML}}\",\n\t\"pad.importExport.exportplain\": \"Used as link text, preceded by {{msg-etherpadlite|Pad.importExport.export}}.\\n{{Related|Pad.importExport.export}}\\n{{Identical|Plain text}}\",\n\t\"pad.importExport.exportword\": \"Used as link text, preceded by {{msg-etherpadlite|Pad.importExport.export}}.\\n{{Related|Pad.importExport.export}}\",\n\t\"pad.importExport.exportpdf\": \"Used as link text, preceded by {{msg-etherpadlite|Pad.importExport.export}}.\\n{{Related|Pad.importExport.export}}\",\n\t\"pad.importExport.exportopen\": \"Used as link text, preceded by {{msg-etherpadlite|Pad.importExport.export}}.\\n{{Related|Pad.importExport.export}}\",\n\t\"pad.importExport.abiword.innerHTML\": \"Used as intro text for the \\\"Import file\\\" form.\\n\\nPreceded by the heading {{msg-etherpadlite|pad.importExport.import}}.\",\n\t\"pad.modals.connected\": \"Used as HTML <code><nowiki><h2></nowiki></code> heading to indicate the status.\\n\\nSee also:\\n* {{msg-etherpadlite|Pad.modals.reconnecting}}\\n{{Identical|Connected}}\",\n\t\"pad.modals.reconnecting\": \"Used as HTML <code><nowiki><h2></nowiki></code> heading to indicate the status.\\n\\nSee also:\\n* {{msg-etherpadlite|Pad.modals.connected}}\",\n\t\"pad.modals.forcereconnect\": \"Label of a button that will make the browser reconnect to the synchronization server.\",\n\t\"pad.modals.cancel\": \"{{Identical|Cancel}}\",\n\t\"pad.modals.userdup\": \"Used as HTML <code><nowiki><h1></nowiki></code> heading to indicate that the pad is opened in another window on this computer.\\n\\nFollowed by the following messages:\\n* {{msg-etherpadlite|Pad.modals.userdup.explanation}} - <code><nowiki><h2></nowiki></code> heading\\n* {{msg-etherpadlite|Pad.modals.userdup.advice}}\",\n\t\"pad.modals.userdup.explanation\": \"Used as HTML <code><nowiki><h2></nowiki></code> heading.\\n\\nPreceded by the parent heading {{msg-etherpadlite|Pad.modals.userdup}}.\\n\\nFollowed by the message {{msg-etherpadlite|Pad.modals.userdup.advice}}.\",\n\t\"pad.modals.userdup.advice\": \"Preceded by the following headings:\\n* {{msg-etherpadlite|Pad.modals.userdup}}\\n* {{msg-etherpadlite|Pad.modals.userdup.explanation}}\",\n\t\"pad.modals.unauth\": \"Used as HTML <code><nowiki><h1></nowiki></code> heading to indicate that the user is not authorized.\\n\\nFollowed by the explanation {{msg-etherpadlite|Pad.modals.unauth.explanation}}.\\n{{Identical|Not authorized}}\",\n\t\"pad.modals.unauth.explanation\": \"Used to indicate that the user is not authorized.\\n\\nPreceded by the heading {{msg-etherpadlite|Pad.modals.unauth}}.\",\n\t\"pad.modals.looping.explanation\": \"Used as HTML <code><nowiki><h2></nowiki></code> heading.\\n\\nPreceded by the parent heading {{msg-etherpadlite|Pad.modals.looping}}.\\n\\nFollowed by the message {{msg-etherpadlite|Pad.modals.looping.cause}}.\",\n\t\"pad.modals.looping.cause\": \"Preceded by the following messages:\\n* {{msg-etherpadlite|Pad.modals.looping}}\\n* {{msg-etherpadlite|Pad.modals.looping.explanation}}\",\n\t\"pad.modals.initsocketfail\": \"Used as HTML <code><nowiki><h1></nowiki></code> heading.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Used as HTML <code><nowiki><h2></nowiki></code> heading.\",\n\t\"pad.modals.initsocketfail.cause\": \"Preceded by the following headings:\\n* {{msg-etherpadlite|Pad.modals.initsocketfail}}\\n* {{msg-etherpadlite|Pad.modals.initsocketfail.explanation}}\",\n\t\"pad.modals.slowcommit.explanation\": \"Used as HTML <code><nowiki><h2></nowiki></code> heading.\",\n\t\"pad.modals.slowcommit.cause\": \"Preceded by the following headings:\\n* {{msg-etherpadlite|Pad.modals.slowcommit}}\\n* {{msg-etherpadlite|Pad.modals.slowcommit.explanation}}\\nFollowed by the Submit button which is labeled {{msg-etherpadlite|Pad.modals.forcereconnect}}.\",\n\t\"pad.modals.deleted\": \"Used as HTML <code><nowiki><h1></nowiki></code> heading.\\n{{Identical|Deleted}}\",\n\t\"pad.modals.deleted.explanation\": \"Preceded by the heading {{msg-etherpadlite|Pad.modals.deleted}}.\",\n\t\"pad.modals.disconnected\": \"Used as HTML <code><nowiki><h1></nowiki></code> heading.\",\n\t\"pad.modals.disconnected.explanation\": \"Used as HTML <code><nowiki><h2></nowiki></code> heading.\",\n\t\"pad.modals.disconnected.cause\": \"Preceded by the following headings:\\n* {{msg-etherpadlite|Pad.modals.disconnected}}\\n* {{msg-etherpadlite|Pad.modals.disconnected.explanation}}\\nFollowed by the Submit button which is labeled {{msg-etherpadlite|Pad.modals.forcereconnect}}.\",\n\t\"pad.share\": \"Used as heading of window\",\n\t\"pad.share.readonly\": \"Used as checkbox label\",\n\t\"pad.share.link\": \"Used as label for a field providing URL of the pad.\\n{{Identical|Link}}\",\n\t\"pad.share.emebdcode\": \"Label for a field providing code that allows you to embed the pad into your website.\",\n\t\"pad.chat\": \"Used as button text and as title of Chat window.\\n{{Identical|Chat}}\",\n\t\"pad.chat.title\": \"Used as tooltip for the Chat button\",\n\t\"pad.chat.loadmessages\": \"chat messages\",\n\t\"pad.chat.stick.title\": \"Tooltip for the stick chat button\",\n\t\"pad.chat.writeMessage.placeholder\": \"Placeholder for the chat input\",\n\t\"timeslider.pageTitle\": \"{{doc-important|Please leave <code><nowiki>{{appTitle}}</nowiki></code> parameter untouched. It will be replaced by app title.}}\\nInserted into HTML title tag.\",\n\t\"timeslider.toolbar.returnbutton\": \"Used as link title\",\n\t\"timeslider.toolbar.authors\": \"A list of Authors follows after the colon.\\n{{Identical|Author}}\",\n\t\"timeslider.toolbar.authorsList\": \"Displayed when there are no authors of the currently viewed revision.\",\n\t\"timeslider.toolbar.exportlink.title\": \"Used in Timeslider view.\\n\\nUsed as tooltip for the \\\"Export\\\" button which enables to export the current pad as HTML, plain text, or DokuWiki.\\n\\nIf the button is clicked, the following messages appear:\\n* {{msg-etherpadlite|Timeslider.exportCurrent}}\\n* {{msg-etherpadlite|Pad.importExport.exporthtml}}\\n* {{msg-etherpadlite|Pad.importExport.exportplain}}\\n* {{msg-etherpadlite|Pad.importExport.exportdokuwiki}}\\n{{Identical|Export}}\",\n\t\"timeslider.exportCurrent\": \"Used as label in the Timeslider view.\\n\\nFollowed by the following link texts (which are used to export the current pad):\\n* {{msg-etherpadlite|Pad.importExport.exporthtml}}\\n* {{msg-etherpadlite|Pad.importExport.exportplain}}\\n* {{msg-etherpadlite|Pad.importExport.exportword}}\\n* {{msg-etherpadlite|Pad.importExport.exportpdf}}\\n* {{msg-etherpadlite|Pad.importExport.exportopen}}\\n* {{msg-etherpadlite|Pad.importExport.exportdokuwiki}}\",\n\t\"timeslider.version\": \"{{doc-important|Please leave <nowiki>{{version}}</nowiki> parameter untouched. It will be replaced with the version number}}\",\n\t\"timeslider.saved\": \"{{doc-important|Do not translate <code><nowiki>{{month}}</nowiki></code>, <code><nowiki>{{day}}</nowiki></code> and <code><nowiki>{{year}}</nowiki></code> parameters. These will be replaced.}}\\nParameters:\\n* <nowiki>{{month}}</nowiki> - month name such as {{msg-etherpadlite|Timeslider.month.january}}, {{msg-etherpadlite|Timeslider.month.february}} and so on\\n* <nowiki>{{day}}</nowiki> - day of the month (01-31)\\n* <nowiki>{{year}}</nowiki> - year in 4 digit format\",\n\t\"timeslider.dateformat\": \"{{doc-important|Do not translate <code><nowiki>month</nowiki></code>, <code><nowiki>day</nowiki></code>, <code><nowiki>year</nowiki></code>, <code><nowiki>hours</nowiki></code>, <code><nowiki>minutes</nowiki></code> and <code><nowiki>seconds</nowiki></code> parameters. These will be replaced.}}\\n* <nowiki>{{month}}</nowiki> - a month number (01-12), NOT {{msg-etherpadlite|Timeslider.month.january}} etc.\\n* <nowiki>{{day}}</nowiki> - day of the month (01-31)\\n* <nowiki>{{year}}</nowiki> - year in 4 digit format\\n* <nowiki>{{hours}}</nowiki> - hours (00-23)\\n* <nowiki>{{minutes}}</nowiki> - minutes (00-59)\\n* <nowiki>{{seconds}}</nowiki> - seconds (00-59)\",\n\t\"timeslider.month.january\": \"Example usage: <samp>Saved on August 26, 2014</samp>. This message is substituted for:\\n* {{msg-etherpadlite|Timeslider.saved|notext=1}}\\n* {{msg-etherpadlite|Timeslider.dateformat|notext=1}}\\n{{Identical|January}}\",\n\t\"timeslider.month.february\": \"Example usage: <samp>Saved on August 26, 2014</samp>.\\n{{Identical|February}}\",\n\t\"timeslider.month.march\": \"Example usage: <samp>Saved on August 26, 2014</samp>.\\n{{Identical|March}}\",\n\t\"timeslider.month.april\": \"Example usage: <samp>Saved on August 26, 2014</samp>.\\n{{Identical|April}}\",\n\t\"timeslider.month.may\": \"Example usage: <samp>Saved on August 26, 2014</samp>.\\n{{Identical|May}}\",\n\t\"timeslider.month.june\": \"Example usage: <samp>Saved on August 26, 2014</samp>.\\n{{Identical|June}}\",\n\t\"timeslider.month.july\": \"Example usage: <samp>Saved on August 26, 2014</samp>.\\n{{Identical|July}}\",\n\t\"timeslider.month.august\": \"Example usage: <samp>Saved on August 26, 2014</samp>.\\n{{Identical|August}}\",\n\t\"timeslider.month.september\": \"Example usage: <samp>Saved on August 26, 2014</samp>.\\n{{Identical|September}}\",\n\t\"timeslider.month.october\": \"Example usage: <samp>Saved on August 26, 2014</samp>.\\n{{Identical|October}}\",\n\t\"timeslider.month.november\": \"Example usage: <samp>Saved on August 26, 2014</samp>.\\n{{Identical|November}}\",\n\t\"timeslider.month.december\": \"Example usage: <samp>Saved on August 26, 2014</samp>.\\n{{Identical|December}}\",\n\t\"timeslider.unnamedauthors\": \"See also:\\n* {{msg-etherpadlite|Timeslider.unnamedauthor}}\",\n\t\"pad.savedrevs.marked\": \"more like bookmarked, or tagged/starred\",\n\t\"pad.userlist.entername\": \"Used as placeholder for the \\\"Name\\\" input box in the upper right corner of the screen. It's important to keep it short: Long sentences aren't displayed.\",\n\t\"pad.userlist.unnamed\": \"Displayed, if a user has not set a nick yet\",\n\t\"pad.editbar.clearcolors\": \"Used as confirmation message (JavaScript <code>confirm()</code> function).\\n\\nThis message means \\\"Are you sure you want to clear authorship colors on entire document? This cannot be undone\\\".\",\n\t\"pad.impexp.importbutton\": \"Used as label for the Submit button.\",\n\t\"pad.impexp.importing\": \"Used to indicate that the file is being imported.\\n{{Identical|Importing}}\",\n\t\"pad.impexp.confirmimport\": \"Used as confirmation message (JavaScript <code>confirm()</code> function).\",\n\t\"pad.impexp.convertFailed\": \"Used as error message when importing a file.\",\n\t\"pad.impexp.uploadFailed\": \"Used as error message when uploading a file.\\n\\nThis message means \\\"The upload has been failed. Please try again.\\\".\",\n\t\"pad.impexp.importfailed\": \"Used as error message.\\n\\nThis message means \\\"The import has been failed\\\".\\n\\nFollowed by any one of the following messages:\\n* {{msg-etherpadlite|Pad.impexp.convertFailed}}\\n* {{msg-etherpadlite|Pad.impexp.uploadFailed}}\\n* {{msg-etherpadlite|Pad.impexp.copypaste}}\",\n\t\"pad.impexp.copypaste\": \"Displayed in case the import failed\",\n\t\"pad.impexp.exportdisabled\": \"{{doc-important|Please leave <nowiki>{{type}}</nowiki> parameter untouched. It will be replaced}}\"\n}\n"
  },
  {
    "path": "src/locales/ro.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Carcassonne93\",\n\t\t\t\"Hedwig\",\n\t\t\t\"ImGelu\",\n\t\t\t\"MSClaudiu\",\n\t\t\t\"Minisarm\",\n\t\t\t\"SZ475\",\n\t\t\t\"Strainu\",\n\t\t\t\"Wintereu\"\n\t\t]\n\t},\n\t\"admin_plugins.available_install.value\": \"Instalează\",\n\t\"admin_plugins.description\": \"Descriere\",\n\t\"admin_plugins.installed\": \"Plugin-uri instalate\",\n\t\"admin_plugins.installed_uninstall.value\": \"Dezinstalează\",\n\t\"admin_plugins.last-update\": \"Ultima actualizare\",\n\t\"admin_plugins.name\": \"Nume\",\n\t\"admin_plugins.version\": \"Versiune\",\n\t\"admin_plugins_info.version_number\": \"Numărul versiunii\",\n\t\"admin_settings\": \"Setări\",\n\t\"index.newPad\": \"Pad nou\",\n\t\"index.settings\": \"Setări\",\n\t\"index.createOpenPad\": \"Deschideți pad-ul după nume\",\n\t\"index.openPad\": \"deschide un Pad existent cu numele:\",\n\t\"pad.toolbar.bold.title\": \"Aldin (Ctrl + B)\",\n\t\"pad.toolbar.italic.title\": \"Cursiv (Ctrl + I)\",\n\t\"pad.toolbar.underline.title\": \"Subliniază (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Taie (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Listă ordonată (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Listă neordonată (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Cursiv (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Outdent (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Anulează (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Refă (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Curăță culorile autorilor (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importă/Exportă din/în diferite formate\",\n\t\"pad.toolbar.timeslider.title\": \"Glisor de timp\",\n\t\"pad.toolbar.savedRevision.title\": \"Salvează revizia\",\n\t\"pad.toolbar.settings.title\": \"Setări\",\n\t\"pad.toolbar.embed.title\": \"Partajați și încorporați acest pad\",\n\t\"pad.toolbar.home.title\": \"Înapoi acasă\",\n\t\"pad.toolbar.showusers.title\": \"Arată utilizatorii de pe acest pad\",\n\t\"pad.colorpicker.save\": \"Salvează\",\n\t\"pad.colorpicker.cancel\": \"Anulează\",\n\t\"pad.loading\": \"Se încarcă...\",\n\t\"pad.noCookie\": \"Cookie-ul nu a putut fi găsit. Vă rugăm să permiteți cookie-urile în browser! Sesiunea și setările nu vor fi salvate între vizite. Aceasta se poate datora faptului că Etherpad este inclus într-un iFrame în unele browsere. Vă rugăm să vă asigurați că Etherpad este pe același subdomeniu/domeniu ca iFrame părinte\",\n\t\"pad.permissionDenied\": \"Nu ai permisiunea să accesezi acest pad\",\n\t\"pad.settings.padSettings\": \"Setări pentru Pad\",\n\t\"pad.settings.myView\": \"Perspectiva mea\",\n\t\"pad.settings.stickychat\": \"Chat-ul întotdeauna pe ecran\",\n\t\"pad.settings.chatandusers\": \"Afișează Chat-ul și Utilizatorii\",\n\t\"pad.settings.colorcheck\": \"Culorile autorilor\",\n\t\"pad.settings.linenocheck\": \"Numere de linie\",\n\t\"pad.settings.rtlcheck\": \"Citiți conținut de la dreapta la stânga?\",\n\t\"pad.settings.fontType\": \"Tipul fontului:\",\n\t\"pad.settings.language\": \"Limbă:\",\n\t\"pad.settings.about\": \"Despre\",\n\t\"pad.importExport.import_export\": \"Import/Export\",\n\t\"pad.importExport.import\": \"Încarcă orice fișier text sau document\",\n\t\"pad.importExport.importSuccessful\": \"Succes!\",\n\t\"pad.importExport.export\": \"Exportă pad-ul curent ca:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Text brut\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Puteți importa doar din format simplu sau HTML. Pentru funcții de import mai avansate, vă rugăm <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">instalați AbiWord sau LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Conectat.\",\n\t\"pad.modals.reconnecting\": \"Se reconectează la pad…\",\n\t\"pad.modals.forcereconnect\": \"Forțează reconectarea\",\n\t\"pad.modals.reconnecttimer\": \"Încercați să vă reconectați în\",\n\t\"pad.modals.cancel\": \"Anulează\",\n\t\"pad.modals.userdup\": \"Deschis în altă fereastră\",\n\t\"pad.modals.userdup.explanation\": \"Acest pad pare a fi deschis în mai multe ferestre de browser de pe acest computer.\",\n\t\"pad.modals.userdup.advice\": \"Reconectați-vă dacă doriți să utilizați această fereastră.\",\n\t\"pad.modals.unauth\": \"Nu ești autorizat\",\n\t\"pad.modals.unauth.explanation\": \"Permisiunile dvs. s-au schimbat în timpul vizualizării acestei pagini. Încercați să vă reconectați.\",\n\t\"pad.modals.looping.explanation\": \"Există probleme de comunicare cu serverul de sincronizare.\",\n\t\"pad.modals.looping.cause\": \"Poate că v-ați conectat printr-un firewall sau proxy incompatibil.\",\n\t\"pad.modals.initsocketfail\": \"Serverul nu este disponibil.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Nu s-a putut conecta la serverul de sincronizare.\",\n\t\"pad.modals.initsocketfail.cause\": \"Acest lucru se datorează probabil unei probleme cu browserul sau conexiunea la internet.\",\n\t\"pad.modals.slowcommit.explanation\": \"Serverul nu răspunde.\",\n\t\"pad.modals.slowcommit.cause\": \"Aceasta poate fi cauzată de probleme cu conexiunea la rețea.\",\n\t\"pad.modals.badChangeset.explanation\": \"O editare pe care ai făcut-o a fost clasificată ilegal de serverul de sincronizare.\",\n\t\"pad.modals.badChangeset.cause\": \"Aceasta s-ar putea datora unei configurații greșite a serverului sau a unui alt comportament neașteptat. Vă rugăm să contactați administratorul de serviciu, dacă considerați că aceasta este o eroare. Încercați să vă reconectați pentru a continua editarea.\",\n\t\"pad.modals.corruptPad.explanation\": \"Pad-ul pe care încercați să îl accesați este corupt.\",\n\t\"pad.modals.corruptPad.cause\": \"Aceasta se poate datora unei configurații greșite a serverului sau a unui alt comportament neașteptat. Vă rugăm să contactați administratorul de servicii.\",\n\t\"pad.modals.deleted\": \"Șters.\",\n\t\"pad.modals.deleted.explanation\": \"Acest pad a fost șters.\",\n\t\"pad.modals.disconnected\": \"Ai fost deconectat.\",\n\t\"pad.modals.disconnected.explanation\": \"S-a pierdut conexiunea la server\",\n\t\"pad.modals.disconnected.cause\": \"Este posibil ca serverul să nu fie disponibil. Vă rugăm să anunțați administratorul de servicii dacă acest lucru se întâmplă în continuare.\",\n\t\"pad.share\": \"Distribuie acest pad\",\n\t\"pad.share.readonly\": \"Doar în citire\",\n\t\"pad.share.link\": \"Legătură\",\n\t\"pad.share.emebdcode\": \"Adresa URL încorporată\",\n\t\"pad.chat\": \"Chat\",\n\t\"pad.chat.title\": \"Deschide chat-ul pentru acest pad.\",\n\t\"pad.chat.loadmessages\": \"Încarcă mai multe mesaje\",\n\t\"pad.chat.stick.title\": \"Lipiți chatul pe ecran\",\n\t\"pad.chat.writeMessage.placeholder\": \"Scrie-ți mesajul aici\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Glisor de timp\",\n\t\"timeslider.toolbar.returnbutton\": \"Înapoi la pad\",\n\t\"timeslider.toolbar.authors\": \"Autori:\",\n\t\"timeslider.toolbar.authorsList\": \"Niciun autor\",\n\t\"timeslider.toolbar.exportlink.title\": \"Exportă\",\n\t\"timeslider.exportCurrent\": \"Exportă versiunea curentă ca:\",\n\t\"timeslider.version\": \"Versiunea {{version}}\",\n\t\"timeslider.saved\": \"Salvat pe {{day}} {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Redare / Pauză conținut Pad\",\n\t\"timeslider.backRevision\": \"Reveniți la o revizuire în acest Pad\",\n\t\"timeslider.forwardRevision\": \"Continuați o revizuire în acest Pad\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"ianuarie\",\n\t\"timeslider.month.february\": \"februarie\",\n\t\"timeslider.month.march\": \"martie\",\n\t\"timeslider.month.april\": \"aprilie\",\n\t\"timeslider.month.may\": \"mai\",\n\t\"timeslider.month.june\": \"iunie\",\n\t\"timeslider.month.july\": \"iulie\",\n\t\"timeslider.month.august\": \"august\",\n\t\"timeslider.month.september\": \"septembrie\",\n\t\"timeslider.month.october\": \"octombrie\",\n\t\"timeslider.month.november\": \"noiembrie\",\n\t\"timeslider.month.december\": \"decembrie\",\n\t\"timeslider.unnamedauthors\": \"{{num}} anonim {[plural (num) unul: autor, altul: autori ]}\",\n\t\"pad.savedrevs.marked\": \"Această revizie este marcată acum ca o revizuire salvată\",\n\t\"pad.savedrevs.timeslider\": \"Puteți vedea reviziile salvate accesând cursorul de timp\",\n\t\"pad.userlist.entername\": \"Introduceți numele dumneavoastră\",\n\t\"pad.userlist.unnamed\": \"fără nume\",\n\t\"pad.editbar.clearcolors\": \"Ștergeți culorile de autor pe întreg documentul? Acest lucru nu poate fi anulat\",\n\t\"pad.impexp.importbutton\": \"Importă acum\",\n\t\"pad.impexp.importing\": \"Importare...\",\n\t\"pad.impexp.confirmimport\": \"Importarea unui fișier va suprascrie textul curent al padului. Ești sigur că vrei să continui?\",\n\t\"pad.impexp.convertFailed\": \"Nu am putut importa acest fișier. Vă rugăm să utilizați un alt format de document sau să copiați pasta manual\",\n\t\"pad.impexp.padHasData\": \"Nu am putut importa acest fișier, deoarece acest Pad a avut deja modificări, vă rugăm să importați pe un nou pad\",\n\t\"pad.impexp.uploadFailed\": \"Încărcarea a eșuat. Încercați din nou\",\n\t\"pad.impexp.importfailed\": \"Import eșuat\",\n\t\"pad.impexp.copypaste\": \"Vă rugăm să copiați și să lipiți\",\n\t\"pad.impexp.exportdisabled\": \"Exportul ca format {{type}} este dezactivat. Vă rugăm să contactați administratorul de sistem pentru detalii.\",\n\t\"pad.impexp.maxFileSize\": \"Fișier prea mare. Contactați administratorul site-ului pentru a crește dimensiunea permisă a fișierului pentru import\"\n}\n"
  },
  {
    "path": "src/locales/ru.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Amire80\",\n\t\t\t\"DCamer\",\n\t\t\t\"Diralik\",\n\t\t\t\"Eleferen\",\n\t\t\t\"Facenapalm\",\n\t\t\t\"Kareyac\",\n\t\t\t\"Lvova\",\n\t\t\t\"MSClaudiu\",\n\t\t\t\"Megakott\",\n\t\t\t\"Movses\",\n\t\t\t\"Nzeemin\",\n\t\t\t\"Okras\",\n\t\t\t\"Pacha Tchernof\",\n\t\t\t\"Patrick Star\",\n\t\t\t\"Teretalexev\",\n\t\t\t\"Volkov\",\n\t\t\t\"Арсен Асхат\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Панель администратора — Etherpad\",\n\t\"admin_plugins\": \"Менеджер плагинов\",\n\t\"admin_plugins.available\": \"Доступные плагины\",\n\t\"admin_plugins.available_not-found\": \"Плагины не найдены.\",\n\t\"admin_plugins.available_fetching\": \"Получение…\",\n\t\"admin_plugins.available_install.value\": \"Установить\",\n\t\"admin_plugins.available_search.placeholder\": \"Искать плагины для установки\",\n\t\"admin_plugins.description\": \"Описание\",\n\t\"admin_plugins.installed\": \"Установленные плагины\",\n\t\"admin_plugins.installed_fetching\": \"Получение установленных плагинов…\",\n\t\"admin_plugins.installed_nothing\": \"Вы еще не установили ни одного плагина.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Удалить\",\n\t\"admin_plugins.last-update\": \"Последнее обновление\",\n\t\"admin_plugins.name\": \"Название\",\n\t\"admin_plugins.page-title\": \"Менеджер плагинов — Etherpad\",\n\t\"admin_plugins.version\": \"Версия\",\n\t\"admin_plugins_info\": \"Информация об устранении неполадок\",\n\t\"admin_plugins_info.hooks\": \"Установленные крючки\",\n\t\"admin_plugins_info.hooks_client\": \"Клиентские хуки\",\n\t\"admin_plugins_info.hooks_server\": \"Серверные хуки\",\n\t\"admin_plugins_info.parts\": \"Установленные части\",\n\t\"admin_plugins_info.plugins\": \"Установленные плагины\",\n\t\"admin_plugins_info.page-title\": \"Информация о плагине — Etherpad\",\n\t\"admin_plugins_info.version\": \"Версия Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Последняя доступная версия\",\n\t\"admin_plugins_info.version_number\": \"Номер версии\",\n\t\"admin_settings\": \"Настройки\",\n\t\"admin_settings.current\": \"Текущая конфигурация\",\n\t\"admin_settings.current_example-devel\": \"Пример шаблона настроек для среда разработки\",\n\t\"admin_settings.current_example-prod\": \"Пример шаблона настроек для боевой среды\",\n\t\"admin_settings.current_restart.value\": \"Перезагрузить Etherpad\",\n\t\"admin_settings.current_save.value\": \"Сохранить настройки\",\n\t\"admin_settings.page-title\": \"Настройки — Etherpad\",\n\t\"index.newPad\": \"Создать\",\n\t\"index.settings\": \"Настройки\",\n\t\"index.transferSessionTitle\": \"Передача сеанса\",\n\t\"index.receiveSessionTitle\": \"Приём сеанса\",\n\t\"index.receiveSessionDescription\": \"Здесь вы можете принять сеанс Etherpad из другого браузера или устройства. Обратите внимание, что это удалит ваш текущий сеанс, если он есть.\",\n\t\"index.transferSession\": \"1. Перенос сеанса\",\n\t\"index.transferSessionNow\": \"Перенести сеанс сейчас\",\n\t\"index.copyLink\": \"2. Скопировать ссылку\",\n\t\"index.copyLinkDescription\": \"Нажмите на кнопку ниже, чтобы скопировать ссылку в буфер обмена.\",\n\t\"index.copyLinkButton\": \"Скопировать в буфер обмена\",\n\t\"index.transferToSystem\": \"3. Копировать сеанс в новую систему\",\n\t\"index.transferToSystemDescription\": \"Откройте скопированную ссылку в нужном браузере или устройстве, чтобы перенести сеанс.\",\n\t\"index.transferSessionDescription\": \"Перенесите текущий сеанс в браузер или на устройство, нажав кнопку ниже. Будет скопирована ссылка на страницу, которая перенесёт ваш сеанс при открытии в целевом браузере или на целевом устройстве.\",\n\t\"index.createOpenPad\": \"Открыть документ по имени\",\n\t\"index.openPad\": \"откройте существующий документ с именем:\",\n\t\"index.recentPads\": \"Последние документы\",\n\t\"index.recentPadsEmpty\": \"Свежих документов не найдено.\",\n\t\"index.generateNewPad\": \"Создать случайное имя документа\",\n\t\"index.labelPad\": \"Название документа (необязательно)\",\n\t\"index.placeholderPadEnter\": \"Введите название документа…\",\n\t\"index.createAndShareDocuments\": \"Создать и поделиться документами в режиме реального времени\",\n\t\"index.createAndShareDocumentsDescription\": \"Etherpad позволяет вам совместно редактировать документы в режиме реального времени, подобно многопользовательскому редактору, работающему в вашем браузере.\",\n\t\"pad.toolbar.bold.title\": \"Полужирный (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Курсив (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"подчёркивание (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Зачёркивание (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Упорядоченный список (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Неупорядоченный список (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Отступ (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Выступ (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Отменить (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Вернуть (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Очистить цвета документа (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Импорт/экспорт из/в другие форматы файлов\",\n\t\"pad.toolbar.timeslider.title\": \"Шкала времени\",\n\t\"pad.toolbar.savedRevision.title\": \"Сохранить версию\",\n\t\"pad.toolbar.settings.title\": \"Настройки\",\n\t\"pad.toolbar.embed.title\": \"Поделиться и встроить этот документ\",\n\t\"pad.toolbar.home.title\": \"Вернуться в начало\",\n\t\"pad.toolbar.showusers.title\": \"Показать пользователей в документе\",\n\t\"pad.colorpicker.save\": \"Сохранить\",\n\t\"pad.colorpicker.cancel\": \"Отмена\",\n\t\"pad.loading\": \"Загружается…\",\n\t\"pad.noCookie\": \"Куки не найдены. Пожалуйста, включите куки в вашем браузере! Ваш сеанс и настройки не будут сохранены между посещениями. Это может быть связано с тем, что Etherpad включен в iFrame в некоторых браузерах. Убедитесь, что Etherpad находится в том же поддомене/домене, что и родительский iFrame.\",\n\t\"pad.permissionDenied\": \"У вас нет разрешения на доступ\",\n\t\"pad.settings.padSettings\": \"Настройки документа\",\n\t\"pad.settings.myView\": \"Мой вид\",\n\t\"pad.settings.stickychat\": \"Всегда отображать чат\",\n\t\"pad.settings.chatandusers\": \"Показать чат и пользователей\",\n\t\"pad.settings.colorcheck\": \"Цвета документа\",\n\t\"pad.settings.linenocheck\": \"Номера строк\",\n\t\"pad.settings.rtlcheck\": \"Читать содержимое справа налево?\",\n\t\"pad.settings.fontType\": \"Тип шрифта:\",\n\t\"pad.settings.fontType.normal\": \"Обычный\",\n\t\"pad.settings.language\": \"Язык:\",\n\t\"pad.settings.deletePad\": \"Удалить документ\",\n\t\"pad.delete.confirm\": \"Вы действительно хотите удалить этот документ?\",\n\t\"pad.settings.about\": \"О проекте\",\n\t\"pad.settings.poweredBy\": \"Проект основан на\",\n\t\"pad.importExport.import_export\": \"Импорт/экспорт\",\n\t\"pad.importExport.import\": \"Загрузить любой текстовый файл или документ\",\n\t\"pad.importExport.importSuccessful\": \"Успешно!\",\n\t\"pad.importExport.export\": \"Экспортировать текущий документ как:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Обычный текст\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (документ OpenOffice)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Вы можете импортировать только из обычного текста или HTML. Для более продвинутых функций импорта <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">установите AbiWord или LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Подключен.\",\n\t\"pad.modals.reconnecting\": \"Повторное подключение к вашему документу…\",\n\t\"pad.modals.forcereconnect\": \"Принудительное переподключение\",\n\t\"pad.modals.reconnecttimer\": \"Попытка переподключения\",\n\t\"pad.modals.cancel\": \"Отмена\",\n\t\"pad.modals.userdup\": \"Открыто в другом окне\",\n\t\"pad.modals.userdup.explanation\": \"Документ, возможно, открыт более чем в одном окне браузера на этом компьютере.\",\n\t\"pad.modals.userdup.advice\": \"Повторно подключить с использованием этого окна.\",\n\t\"pad.modals.unauth\": \"Не авторизован\",\n\t\"pad.modals.unauth.explanation\": \"Ваши разрешения были изменены во время просмотра этой страницы. Попробуйте подключиться повторно.\",\n\t\"pad.modals.looping.explanation\": \"Проблемы связи с сервером синхронизации.\",\n\t\"pad.modals.looping.cause\": \"Возможно, вы подключились через несовместимый брандмауэр или прокси.\",\n\t\"pad.modals.initsocketfail\": \"Сервер недоступен.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Не удалось подключиться к серверу синхронизации.\",\n\t\"pad.modals.initsocketfail.cause\": \"Вероятно, это вызвано проблемами с вашим браузером или интернет-соединением.\",\n\t\"pad.modals.slowcommit.explanation\": \"Сервер не отвечает.\",\n\t\"pad.modals.slowcommit.cause\": \"Это может быть вызвано проблемами с сетевым подключением.\",\n\t\"pad.modals.badChangeset.explanation\": \"Правка, которую вы сделали, была классифицирована сервером синхронизации как недопустимая.\",\n\t\"pad.modals.badChangeset.cause\": \"Это может быть из-за неправильной конфигурации сервера или некоторых других неожиданных действий. Пожалуйста, свяжитесь с администратором службы, если вы считаете, что это ошибка. Попробуйте переподключиться для того, чтобы продолжить редактирование.\",\n\t\"pad.modals.corruptPad.explanation\": \"Документ, к которому вы пытаетесь получить доступ, повреждён.\",\n\t\"pad.modals.corruptPad.cause\": \"Это может быть из-за неправильной конфигурации сервера или некоторых других неожиданных действий. Пожалуйста, свяжитесь с администратором службы.\",\n\t\"pad.modals.deleted\": \"Удалён.\",\n\t\"pad.modals.deleted.explanation\": \"Этот документ был удалён.\",\n\t\"pad.modals.rateLimited\": \"Скорость ограничена.\",\n\t\"pad.modals.rateLimited.explanation\": \"Вы отправили слишком много сообщений в этот документ, поэтому вы были отключены.\",\n\t\"pad.modals.rejected.explanation\": \"Сервер отклонил сообщение, посланное вашим браузером.\",\n\t\"pad.modals.rejected.cause\": \"Возможно, сервер обновился, пока вы просматривали документ, а, может, это ошибка в Etherpad. Попробуйте перезагрузить страницу.\",\n\t\"pad.modals.disconnected\": \"Соединение разорвано.\",\n\t\"pad.modals.disconnected.explanation\": \"Подключение к серверу потеряно\",\n\t\"pad.modals.disconnected.cause\": \"Сервер, возможно, недоступен. Пожалуйста, сообщите администратору службы, если проблема будет повторятся.\",\n\t\"pad.share\": \"Поделиться\",\n\t\"pad.share.readonly\": \"Только чтение\",\n\t\"pad.share.link\": \"Ссылка\",\n\t\"pad.share.emebdcode\": \"Вставить URL\",\n\t\"pad.chat\": \"Чат\",\n\t\"pad.chat.title\": \"Открыть чат для этого документа.\",\n\t\"pad.chat.loadmessages\": \"Ещё сообщения\",\n\t\"pad.chat.stick.title\": \"Закрепить чат на экране\",\n\t\"pad.chat.writeMessage.placeholder\": \"Напишите своё сообщение сюда\",\n\t\"timeslider.followContents\": \"Следить за обновлениями содержимого документа\",\n\t\"timeslider.pageTitle\": \"Временная шкала {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Вернуться к документу\",\n\t\"timeslider.toolbar.authors\": \"Авторы:\",\n\t\"timeslider.toolbar.authorsList\": \"Нет авторов\",\n\t\"timeslider.toolbar.exportlink.title\": \"Экспорт\",\n\t\"timeslider.exportCurrent\": \"Экспортировать текущую версию как:\",\n\t\"timeslider.version\": \"Версия {{version}}\",\n\t\"timeslider.saved\": \"Сохранено {{day}}.{{month}}.{{year}}\",\n\t\"timeslider.playPause\": \"Воспроизведение / Пауза содержимого документа\",\n\t\"timeslider.backRevision\": \"Назад на одну версию документа\",\n\t\"timeslider.forwardRevision\": \"Вперёд на одну версию документа\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"январь\",\n\t\"timeslider.month.february\": \"февраль\",\n\t\"timeslider.month.march\": \"март\",\n\t\"timeslider.month.april\": \"апрель\",\n\t\"timeslider.month.may\": \"май\",\n\t\"timeslider.month.june\": \"июнь\",\n\t\"timeslider.month.july\": \"июль\",\n\t\"timeslider.month.august\": \"август\",\n\t\"timeslider.month.september\": \"сентябрь\",\n\t\"timeslider.month.october\": \"октябрь\",\n\t\"timeslider.month.november\": \"ноябрь\",\n\t\"timeslider.month.december\": \"декабрь\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: безымянный автор, few: безымянных автора, many: безымянных авторов, other: безымянных авторов]}\",\n\t\"pad.savedrevs.marked\": \"Эта версия теперь помечена как сохраненная\",\n\t\"pad.savedrevs.timeslider\": \"Вы можете увидеть сохранённые версии на шкале времени\",\n\t\"pad.userlist.entername\": \"Введите ваше имя\",\n\t\"pad.userlist.unnamed\": \"безымянный\",\n\t\"pad.editbar.clearcolors\": \"Очистить авторские цвета во всем документе? Это действие не может быть отменено.\",\n\t\"pad.impexp.importbutton\": \"Импортировать сейчас\",\n\t\"pad.impexp.importing\": \"Импортирование…\",\n\t\"pad.impexp.confirmimport\": \"Импорт файла перезапишет текущий текст. Вы уверены, что вы хотите продолжить?\",\n\t\"pad.impexp.convertFailed\": \"Не удалось импортировать этот файл. Пожалуйста, используйте другой формат или скопируйте вручную\",\n\t\"pad.impexp.padHasData\": \"Не получилось импортировать этот файл, потому что этот документ уже имеет изменения, пожалуйста, импортируйте в новый документ\",\n\t\"pad.impexp.uploadFailed\": \"Загрузка не удалась, пожалуйста, попробуйте ещё раз\",\n\t\"pad.impexp.importfailed\": \"Ошибка при импорте\",\n\t\"pad.impexp.copypaste\": \"Пожалуйста, скопируйте\",\n\t\"pad.impexp.exportdisabled\": \"Экспорт в формате {{type}} отключён. Для подробной информации обратитесь к системному администратору.\",\n\t\"pad.impexp.maxFileSize\": \"Файл слишком большой. Обратитесь к администратору сайта, чтобы увеличить разрешённый размер файла для импорта\"\n}\n"
  },
  {
    "path": "src/locales/sc.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Adr mm\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Pannellu de amministratzione - Etherpad\",\n\t\"admin_plugins\": \"Gestore de connetores\",\n\t\"admin_plugins.available\": \"Connetores a disponimentu\",\n\t\"admin_plugins.available_not-found\": \"Nissunu connetore a disponimentu\",\n\t\"admin_plugins.available_fetching\": \"Recuperende...\",\n\t\"admin_plugins.available_install.value\": \"Installa\",\n\t\"admin_plugins.available_search.placeholder\": \"Chirca connetores de installare\",\n\t\"admin_plugins.description\": \"Descritzione\",\n\t\"admin_plugins.installed\": \"Connetores installados\",\n\t\"admin_plugins.installed_fetching\": \"Recuperende connetores installados...\",\n\t\"admin_plugins.installed_nothing\": \"No as installadu ancora nissunu connetore.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Disinstalla\",\n\t\"admin_plugins.last-update\": \"Ùrtima atualizatzione\",\n\t\"admin_plugins.name\": \"Nòmine\",\n\t\"admin_plugins.page-title\": \"Gestore de connetores - Etherpad\",\n\t\"admin_plugins.version\": \"Versione\",\n\t\"admin_plugins_info\": \"Informatzione pro sa risolutzione de problemas\",\n\t\"admin_plugins_info.hooks\": \"Gantzos installados\",\n\t\"admin_plugins_info.hooks_client\": \"Gantzos dae su costadu de su cliente\",\n\t\"admin_plugins_info.hooks_server\": \"Gantzos dae su costadu de su serbidore\",\n\t\"admin_plugins_info.parts\": \"Partes installadas\",\n\t\"admin_plugins_info.plugins\": \"Connetores installados\",\n\t\"admin_plugins_info.page-title\": \"Informatzione de su connetore - Etherpad\",\n\t\"admin_plugins_info.version\": \"Versione de Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Ùrtima versione a disponimentu\",\n\t\"admin_plugins_info.version_number\": \"Nùmeru de versione\",\n\t\"admin_settings\": \"Cunfiguratzione\",\n\t\"admin_settings.current\": \"Cunfiguratzione atuale\",\n\t\"admin_settings.current_example-devel\": \"Modellu de esempru de cunfiguratzione de isvilupu\",\n\t\"admin_settings.current_example-prod\": \"Modellu de esempru de cunfiguratzione de produtzione\",\n\t\"admin_settings.current_restart.value\": \"Torra a aviare Etherpad\",\n\t\"admin_settings.current_save.value\": \"Sarva sa cunfiguratzione\",\n\t\"admin_settings.page-title\": \"Cunfiguratzione - Etherpad\",\n\t\"index.newPad\": \"Pad nou\",\n\t\"index.createOpenPad\": \"o crea/aberi unu pad cun su nòmine:\",\n\t\"index.openPad\": \"aberi unu pad esistente cun su nòmine:\",\n\t\"pad.toolbar.bold.title\": \"Grassetu (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Cursivu (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Sutaliniadu (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Istangadu (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Lista numerada (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Lista cun puntos (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Indentatzione a dereta (Tab)\",\n\t\"pad.toolbar.unindent.title\": \"Indentatzione a manca (Shift+Tab)\",\n\t\"pad.toolbar.undo.title\": \"Iscontza (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Torra a fàghere (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Lìmpia is colores de autoria (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importa/esporta dae/a formados de archìviu diferentes\",\n\t\"pad.toolbar.timeslider.title\": \"Presentatzione cronologia\",\n\t\"pad.toolbar.savedRevision.title\": \"Sarva sa versione\",\n\t\"pad.toolbar.settings.title\": \"Cunfiguratzione\",\n\t\"pad.toolbar.embed.title\": \"Cumpartzi e incòrpora custu pad\",\n\t\"pad.toolbar.showusers.title\": \"Ammustra is utentes in custu pad\",\n\t\"pad.colorpicker.save\": \"Sarva\",\n\t\"pad.colorpicker.cancel\": \"Annulla\",\n\t\"pad.loading\": \"Carrighende...\",\n\t\"pad.noCookie\": \"Su testimòngiu no est istètiu agatadu. Permite is testimòngios in su navigadore tuo. Sa sessione e sa cunfiguratzione tuas no ant a èssere sarvadas intre bìsitas. Podet èssere pro more de s'inclusione de Etherpad comente iFrame in tzertos navigadores. Assegura·ti chi Etherpad s'agatat in su pròpiu sutadomìniu/domìniu chi s'iFrame printzipale\",\n\t\"pad.permissionDenied\": \"Non tenes permissu pro atzèdere a custu pad\",\n\t\"pad.settings.padSettings\": \"Cunfiguratzione de su pad\",\n\t\"pad.settings.myView\": \"Sa visualizatzione mia\",\n\t\"pad.settings.stickychat\": \"Ammustra semper sa tzarrada\",\n\t\"pad.settings.chatandusers\": \"Ammustra sa tzarrada e is utentes\",\n\t\"pad.settings.colorcheck\": \"Colores de autoria\",\n\t\"pad.settings.linenocheck\": \"Nùmeros de lìnia\",\n\t\"pad.settings.rtlcheck\": \"Boles lèghere su cuntenutu dae dereta a manca?\",\n\t\"pad.settings.fontType\": \"Tipu de caràtere:\",\n\t\"pad.settings.fontType.normal\": \"Normale\",\n\t\"pad.settings.language\": \"Lìngua:\",\n\t\"pad.settings.about\": \"Informatziones\",\n\t\"pad.settings.poweredBy\": \"Realizadu cun\",\n\t\"pad.importExport.import_export\": \"Importatzione/esportatzione\",\n\t\"pad.importExport.import\": \"Càrriga un'archìviu de testu o unu documentu\",\n\t\"pad.importExport.importSuccessful\": \"Carrigadu.\",\n\t\"pad.importExport.export\": \"Esporta su pad atuale comente:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Testu sèmplitze\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Documentu Formadu)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Isceti is formados de testu sèmplitze o HTML podent èssere importados. Pro mètodos avantzados de importatzione, <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">installa AbiWord o LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Connètidu.\",\n\t\"pad.modals.reconnecting\": \"Connetende a su pad tuo...\",\n\t\"pad.modals.forcereconnect\": \"Fortza sa connesione\",\n\t\"pad.modals.reconnecttimer\": \"Torrende a connètere in\",\n\t\"pad.modals.cancel\": \"Annulla\",\n\t\"pad.modals.userdup\": \"Abertu in una àtera ventana\",\n\t\"pad.modals.userdup.explanation\": \"Podet dare chi custu pad siat abertu in un'àtera ischeda de custu navigadore in custu ordinadore.\",\n\t\"pad.modals.userdup.advice\": \"Torra a connètere pro impreare custa ventana.\",\n\t\"pad.modals.unauth\": \"Chena autorizatzione\",\n\t\"pad.modals.unauth.explanation\": \"Is permissos tuos sunt istados cambiados in su mentras chi fias bidende custa pàgina. Prova de ti torrare a connètere.\",\n\t\"pad.modals.looping.explanation\": \"Bi sunt problemas de comunicatzione cun su serbidore de sincronizatzione.\",\n\t\"pad.modals.looping.cause\": \"Forsis sa connessione at impreadu unu serbidore intermediàriu (proxy) o unu firewall chi no est cumpatìbile.\",\n\t\"pad.modals.initsocketfail\": \"Su serbidore no est atzessìbile.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Impossìbile connètere cun su serbidore de sincronizatzione.\",\n\t\"pad.modals.initsocketfail.cause\": \"Podet èssere a càusa de unu problema cun su navigadore tuo o cun sa connessione de internet.\",\n\t\"pad.modals.slowcommit.explanation\": \"Su serbidore non rispondet.\",\n\t\"pad.modals.slowcommit.cause\": \"Podet èssere a càusa de problemas cun sa connessione de internet.\",\n\t\"pad.modals.badChangeset.explanation\": \"Una modìfica tua est istada cunsiderada illegale dae su serbidore de sincronizatzione.\",\n\t\"pad.modals.badChangeset.cause\": \"Podet èssere a càusa de una cunfiguratzione de serbidore isballiada o calicunu àteru cumportamentu imprevistu. Iscrie a s'amministratzione de su servìtziu si pensas chi siat un'errore. Prova a connètere torra pro sighire a modificare.\",\n\t\"pad.modals.corruptPad.explanation\": \"Su pad a su chi ses chirchende de atzèdere est dannadu.\",\n\t\"pad.modals.corruptPad.cause\": \"Podet èssere a càusa de una cunfiguratzione de serbidore non curreta o pro unu cumportamentu imprevistu. Iscrie a s'amministratzione de su servìtziu.\",\n\t\"pad.modals.deleted\": \"Cantzelladu.\",\n\t\"pad.modals.deleted.explanation\": \"Pad cantzelladu.\",\n\t\"pad.modals.rateLimited\": \"Frecuèntzia limitada.\",\n\t\"pad.modals.rateLimited.explanation\": \"As imbiadu tropu messàgios a custu pad e t'at disconnètidu.\",\n\t\"pad.modals.rejected.explanation\": \"Su serbidore at rifiutadu unu messàgiu imbiadu dae su navigadore tuo.\",\n\t\"pad.modals.rejected.cause\": \"Podet èssere chi su serbidore siat istadu atualizadu in su mentras chi fias bidende su pad, o podet èssere chi bi siat un'errore in Etherpad. Prova a atualizare sa pàgina.\",\n\t\"pad.modals.disconnected\": \"Disconnètidu.\",\n\t\"pad.modals.disconnected.explanation\": \"Connessione cun su serbidore pèrdida.\",\n\t\"pad.modals.disconnected.cause\": \"Su serbidore no est a disponimentu. Iscrie a s'amministratzione de su servìtziu si su problema persistet.\",\n\t\"pad.share\": \"Cumpartzi custu pad\",\n\t\"pad.share.readonly\": \"Letura isceti\",\n\t\"pad.share.link\": \"Ligàmene\",\n\t\"pad.share.emebdcode\": \"Incòrpora URL\",\n\t\"pad.chat\": \"Tzarrada\",\n\t\"pad.chat.title\": \"Aberi sa tzarrada pro custu pad.\",\n\t\"pad.chat.loadmessages\": \"Càrriga àteros messàgios\",\n\t\"pad.chat.stick.title\": \"Apica sa tzarrada in s'ischermu\",\n\t\"pad.chat.writeMessage.placeholder\": \"Iscrie su messàgiu tuo inoghe\",\n\t\"timeslider.followContents\": \"Sighi sas atualizatziones de cuntenutu de su pad\",\n\t\"timeslider.pageTitle\": \"Cronologia {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Torra a su pad\",\n\t\"timeslider.toolbar.authors\": \"Autores:\",\n\t\"timeslider.toolbar.authorsList\": \"Nissunu autore\",\n\t\"timeslider.toolbar.exportlink.title\": \"Esporta\",\n\t\"timeslider.exportCurrent\": \"Esporta sa versione atuale comente:\",\n\t\"timeslider.version\": \"Versione {{version}}\",\n\t\"timeslider.saved\": \"Sarvadu su {{day}} de {{month}} de su {{year}}\",\n\t\"timeslider.playPause\": \"Riprodutzione/pàusa de is cuntenutos de su pad\",\n\t\"timeslider.backRevision\": \"Bae a una versione pretzedente de custu pad\",\n\t\"timeslider.forwardRevision\": \"Bae a una versione imbeniente de custu pad\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Ghennàrgiu\",\n\t\"timeslider.month.february\": \"Freàrgiu\",\n\t\"timeslider.month.march\": \"Martzu\",\n\t\"timeslider.month.april\": \"Abrile\",\n\t\"timeslider.month.may\": \"Maju\",\n\t\"timeslider.month.june\": \"Làmpadas\",\n\t\"timeslider.month.july\": \"Mese de argiolas\",\n\t\"timeslider.month.august\": \"Austu\",\n\t\"timeslider.month.september\": \"Cabudanni\",\n\t\"timeslider.month.october\": \"Ledàmine\",\n\t\"timeslider.month.november\": \"Onniasantu\",\n\t\"timeslider.month.december\": \"Mese de idas\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: autore, other: autores ]} chena nòmine\",\n\t\"pad.savedrevs.marked\": \"Custa revisione est istada marcada comente revisione sarvada\",\n\t\"pad.savedrevs.timeslider\": \"Podes bìdere is versiones sarvadas bisitende sa cronologia\",\n\t\"pad.userlist.entername\": \"Inserta su nòmine tuo\",\n\t\"pad.userlist.unnamed\": \"Chena nòmine\",\n\t\"pad.editbar.clearcolors\": \"Seguru chi boles limpiare is colores de autoria de totu su documentu? Custa atzione no dda podes annullare\",\n\t\"pad.impexp.importbutton\": \"Importa immoe\",\n\t\"pad.impexp.importing\": \"Importende...\",\n\t\"pad.impexp.confirmimport\": \"S'importatzione de un'archìviu at a subraiscrìere su testu atuale de su pad. Seguru chi boles sighire?\",\n\t\"pad.impexp.convertFailed\": \"Impossìbile importare custu archìviu. Imprea unu formadu de documentu diferente o còpia e incolla a manu\",\n\t\"pad.impexp.padHasData\": \"Impossìbile importare custu archìviu pro ite custu pad est istadu giai modificadu. Importa·ddu in unu pad nou\",\n\t\"pad.impexp.uploadFailed\": \"Errore in sa càrriga. Torra a provare\",\n\t\"pad.impexp.importfailed\": \"Errore de importatzione\",\n\t\"pad.impexp.copypaste\": \"Còpia e incolla\",\n\t\"pad.impexp.exportdisabled\": \"S'esportatzione comente {{type}} est disativada. Iscrie a s'amministratzione de su sistema pro àteras informatziones.\",\n\t\"pad.impexp.maxFileSize\": \"S'archìviu est tropu manu. Iscrie a s'amministratzione pro ismanniare sa dimensione permìtida pro s'importatzione\"\n}\n"
  },
  {
    "path": "src/locales/sco.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"AmaryllisGardener\",\n\t\t\t\"CiphriusKane\",\n\t\t\t\"John Reid\",\n\t\t\t\"Nintendofan885\"\n\t\t]\n\t},\n\t\"index.newPad\": \"New Pad\",\n\t\"index.createOpenPad\": \"or mak/apen ae Pad wi the name:\",\n\t\"pad.toolbar.bold.title\": \"Bold (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Italic (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Underline (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Strikethrou (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Ordered leet (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Unordered Leet (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Indent (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Ootdent (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Ondae (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Redae (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Clear Authorship Colours (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Import/Export fae/til different file formats\",\n\t\"pad.toolbar.timeslider.title\": \"Timeslider\",\n\t\"pad.toolbar.savedRevision.title\": \"Hain Reveesion\",\n\t\"pad.toolbar.settings.title\": \"Settins\",\n\t\"pad.toolbar.embed.title\": \"Shair n Embed this pad\",\n\t\"pad.toolbar.showusers.title\": \"Shaw the uisers oan this pad\",\n\t\"pad.colorpicker.save\": \"Hain\",\n\t\"pad.colorpicker.cancel\": \"Cancel\",\n\t\"pad.loading\": \"Laidin...\",\n\t\"pad.noCookie\": \"Cookie could nae be foond. Please allae cookies in yer brouser!\",\n\t\"pad.permissionDenied\": \"Ye dinna hae permeession tae access this pad\",\n\t\"pad.settings.padSettings\": \"Pad Settins\",\n\t\"pad.settings.myView\": \"Ma Luik\",\n\t\"pad.settings.stickychat\": \"Tauk aye oan screen\",\n\t\"pad.settings.chatandusers\": \"Shaw Chat an Uisers\",\n\t\"pad.settings.colorcheck\": \"Authorship colours\",\n\t\"pad.settings.linenocheck\": \"Line nummers\",\n\t\"pad.settings.rtlcheck\": \"Read content fae richt til cair?\",\n\t\"pad.settings.fontType\": \"Font type:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Leid:\",\n\t\"pad.importExport.import_export\": \"Import/Export\",\n\t\"pad.importExport.import\": \"Upload oni tex file or document\",\n\t\"pad.importExport.importSuccessful\": \"Success!\",\n\t\"pad.importExport.export\": \"Export current pad as:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Plain tex\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Ye can anely import fae plain tex or HTML formats. Fur mair advanced import features please <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">install abiword</a>.\",\n\t\"pad.modals.connected\": \"Connected.\",\n\t\"pad.modals.reconnecting\": \"Reconnectin til yer pad..\",\n\t\"pad.modals.forcereconnect\": \"Force reconnect\",\n\t\"pad.modals.userdup\": \"Apened in anither windae\",\n\t\"pad.modals.userdup.explanation\": \"This pad seems tae be apened in mair than yin brouser windae on this computer.\",\n\t\"pad.modals.userdup.advice\": \"Reconnect fer tae uise this windae instead.\",\n\t\"pad.modals.unauth\": \"Naw authorized\",\n\t\"pad.modals.unauth.explanation\": \"Yer permeessions hae chynged while viewing this page. Try tae reconnect.\",\n\t\"pad.modals.looping.explanation\": \"Thaur ar communication proablems wi the synchronization server.\",\n\t\"pad.modals.looping.cause\": \"Meyhaps ye connected through aen incompatible firewa or proxy.\",\n\t\"pad.modals.initsocketfail\": \"Server canna be reached.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Coudna connect til the synchronization server.\",\n\t\"pad.modals.initsocketfail.cause\": \"This is possably cause o ae problem wi yer brouser or yer wab connection.\",\n\t\"pad.modals.slowcommit.explanation\": \"The server isna respondin.\",\n\t\"pad.modals.slowcommit.cause\": \"This coud be cause o problems wi netwairk connecteevitie.\",\n\t\"pad.modals.badChangeset.explanation\": \"Aen eedit that ye'v makit wis classeefied aes onlegal bi the synchronization server.\",\n\t\"pad.modals.badChangeset.cause\": \"This coud be cause o ae wrang server confeeguration or some ither onexpected behavior. Please contact the service admeenistrator, gif ye feel that this is ae mistak. Try tae reconnect in order tae continue editing.\",\n\t\"pad.modals.corruptPad.explanation\": \"The pad ye'r trying te access is mingin.\",\n\t\"pad.modals.corruptPad.cause\": \"This micht be cause o ae wrang server confeeguration or some ither onexpected behavior. Please contact the service admeenistrater.\",\n\t\"pad.modals.deleted\": \"Deletit.\",\n\t\"pad.modals.deleted.explanation\": \"This pad has been hif't.\",\n\t\"pad.modals.disconnected\": \"Ye'v been disconnected.\",\n\t\"pad.modals.disconnected.explanation\": \"The connection til the server wis loast\",\n\t\"pad.modals.disconnected.cause\": \"The server micht be onavailable. Please notify the service admeenistrater gif this continues tae happen.\",\n\t\"pad.share\": \"Share this pad\",\n\t\"pad.share.readonly\": \"Read anely\",\n\t\"pad.share.link\": \"Airtin\",\n\t\"pad.share.emebdcode\": \"Embed URL\",\n\t\"pad.chat\": \"Chait\",\n\t\"pad.chat.title\": \"Apen the tauk fer this pad.\",\n\t\"pad.chat.loadmessages\": \"Laid mair messages\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Timeslider\",\n\t\"timeslider.toolbar.returnbutton\": \"Return til pad\",\n\t\"timeslider.toolbar.authors\": \"Authers:\",\n\t\"timeslider.toolbar.authorsList\": \"Nae Authers\",\n\t\"timeslider.toolbar.exportlink.title\": \"Export\",\n\t\"timeslider.exportCurrent\": \"Export current version as:\",\n\t\"timeslider.version\": \"Version {{version}}\",\n\t\"timeslider.saved\": \"Saved {{day}} {{month}}, {{year}}\",\n\t\"timeslider.playPause\": \"Playback / Pause Pad Contents\",\n\t\"timeslider.backRevision\": \"Gang back ae revision in this Pad\",\n\t\"timeslider.forwardRevision\": \"Gang forrit ae revision in this pad\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Januair\",\n\t\"timeslider.month.february\": \"Febuair\",\n\t\"timeslider.month.march\": \"Mairch\",\n\t\"timeslider.month.april\": \"Apryle\",\n\t\"timeslider.month.may\": \"Mey\",\n\t\"timeslider.month.june\": \"Juin\",\n\t\"timeslider.month.july\": \"Julie\",\n\t\"timeslider.month.august\": \"August\",\n\t\"timeslider.month.september\": \"September\",\n\t\"timeslider.month.october\": \"October\",\n\t\"timeslider.month.november\": \"November\",\n\t\"timeslider.month.december\": \"December\",\n\t\"timeslider.unnamedauthors\": \"{{num}} onnamed {[plural(num) one: writer, other: writers ]}\",\n\t\"pad.savedrevs.marked\": \"This reveesion is nou tagged aes ae hained reveesion\",\n\t\"pad.savedrevs.timeslider\": \"Ye can see saved reveesions bi veesitin the timeslider\",\n\t\"pad.userlist.entername\": \"Enter yer name\",\n\t\"pad.userlist.unnamed\": \"onnamed\",\n\t\"pad.editbar.clearcolors\": \"Clear authership colours oan the entire document?\",\n\t\"pad.impexp.importbutton\": \"Import Nou\",\n\t\"pad.impexp.importing\": \"Importing...\",\n\t\"pad.impexp.confirmimport\": \"Importin ae file will owerwrite the current tex o the pad. Ar ye sair ye want tae proceed?\",\n\t\"pad.impexp.convertFailed\": \"We coudna import this file. Please uise ae different document format or copy paste manually\",\n\t\"pad.impexp.padHasData\": \"We war nae able tae import this file acause this Pad haes awready haed chynges, please import tae a new pad\",\n\t\"pad.impexp.uploadFailed\": \"The upload failed, please try again\",\n\t\"pad.impexp.importfailed\": \"The import failed\",\n\t\"pad.impexp.copypaste\": \"Please copy paste\",\n\t\"pad.impexp.exportdisabled\": \"Exporting as {{type}} format is disabled. Please contact yer system admeenistrator fer details.\"\n}\n"
  },
  {
    "path": "src/locales/sd.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"BaRaN6161 TURK\",\n\t\t\t\"Kaleem Bhatti\",\n\t\t\t\"Mehtab ahmed\",\n\t\t\t\"Tweety\"\n\t\t]\n\t},\n\t\"admin_settings\": \"ترتيبون\",\n\t\"index.newPad\": \"نئين پٽي\",\n\t\"index.createOpenPad\": \"يا نالي سان ڪا پٽي تخليق ڪريو\\\\کوليو:\",\n\t\"pad.toolbar.bold.title\": \"وزني (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"اطالوي (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"هيٺان سٽ ڏيو (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"ليڪو ڏيو (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"ترتيب وار فهرست (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"ٻي ترتيب فهرست (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"وڌايو (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"گھٽايو (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"اڻ ڪريو (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"ٻيهر ڪريو (Ctrl+Y)\",\n\t\"pad.toolbar.timeslider.title\": \"وقت ڦيرڻو\",\n\t\"pad.toolbar.savedRevision.title\": \"نظرثاني سانڍيو\",\n\t\"pad.toolbar.settings.title\": \"ترتيبون\",\n\t\"pad.colorpicker.save\": \"سانڍيو\",\n\t\"pad.colorpicker.cancel\": \"رد\",\n\t\"pad.loading\": \"لاهيندي...\",\n\t\"pad.settings.padSettings\": \"پٽي جو ترتيبون\",\n\t\"pad.settings.myView\": \"منهنجو نظارو\",\n\t\"pad.settings.stickychat\": \"ڳالھ ٻولھ هميشه پردي تي ڪريو\",\n\t\"pad.settings.chatandusers\": \"ڳالھ ٻولھ ۽ يوزر ڏيکاريو\",\n\t\"pad.settings.linenocheck\": \"سٽ جا انگ\",\n\t\"pad.settings.rtlcheck\": \"مواد ساڄي کان کاٻي طرف پڙهندئو؟\",\n\t\"pad.settings.fontType\": \"اکرن جو قسم:\",\n\t\"pad.settings.language\": \"ٻولي:\",\n\t\"pad.importExport.import_export\": \"برآمد/درآمد\",\n\t\"pad.importExport.import\": \"ڪو به متن وارو فائيل يا دستاويز چاڙهيو\",\n\t\"pad.importExport.importSuccessful\": \"ڪامياب!\",\n\t\"pad.importExport.export\": \"هاڻوڪي پٽي برآمد ڪريو جي طور:\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"سدا اکر\",\n\t\"pad.importExport.exportword\": \"مائيڪرسافٽ ورڊ\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (کليل دستاويز فارميٽ)\",\n\t\"pad.modals.connected\": \"ڳنڍيل.\",\n\t\"pad.modals.reconnecting\": \"توهان جي پٽي سان ٻيهر ڳنڍي رهيو آهي...\",\n\t\"pad.modals.forcereconnect\": \"جبري طور ٻيهر ڳنڍيو\",\n\t\"pad.modals.cancel\": \"رد\",\n\t\"pad.modals.userdup\": \"هڪ ٻي دري ۾ کليل\",\n\t\"pad.modals.unauth\": \"اختيار نه آهي\",\n\t\"pad.modals.initsocketfail\": \"سَروَرَ تائين پُڄي نٿو سگهجي.\",\n\t\"pad.modals.slowcommit.explanation\": \"سَروَر جواب نٿو ڏي.\",\n\t\"pad.modals.corruptPad.explanation\": \"جيڪا پٽي توهان حاصل ڪرڻ چاهيو ٿا اها بدعنوان آهي.\",\n\t\"pad.modals.deleted\": \"ختم ڪيل.\",\n\t\"pad.modals.deleted.explanation\": \"هي پٽي هٽائجي چڪي آهي.\",\n\t\"pad.modals.disconnected\": \"توهان سان ڳانڍاپو ختم ڪيو ويو آهي.\",\n\t\"pad.share\": \"هي پٽي ونڊيو\",\n\t\"pad.share.readonly\": \"صرف پڙهو\",\n\t\"pad.share.link\": \"ڳنڍڻو\",\n\t\"pad.chat\": \"ڳالھ ٻولھ\",\n\t\"pad.chat.title\": \"هن پٽي لاءِ ڳالھ ٻولھ کوليو.\",\n\t\"pad.chat.loadmessages\": \"وڌيڪ پيغام لوڊ ڪريو\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} وقت ڦيرڻو\",\n\t\"timeslider.toolbar.returnbutton\": \"پٽي ڏانهن ورو\",\n\t\"timeslider.toolbar.authors\": \"ليکڪ:\",\n\t\"timeslider.toolbar.authorsList\": \"ڪوبه ليکڪ ناهي\",\n\t\"timeslider.toolbar.exportlink.title\": \"برآمد ڪريو\",\n\t\"timeslider.version\": \"ورزن {{version}}\",\n\t\"timeslider.saved\": \"سانڍيل {{month}} {{day}}، {{year}}\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"جنوري\",\n\t\"timeslider.month.february\": \"فيبروري\",\n\t\"timeslider.month.march\": \"مارچ\",\n\t\"timeslider.month.april\": \"اپريل\",\n\t\"timeslider.month.may\": \"مئي\",\n\t\"timeslider.month.june\": \"جون\",\n\t\"timeslider.month.july\": \"جولاءِ\",\n\t\"timeslider.month.august\": \"آگسٽ\",\n\t\"timeslider.month.september\": \"سيپٽمبر\",\n\t\"timeslider.month.october\": \"آڪٽوبر\",\n\t\"timeslider.month.november\": \"نومبر\",\n\t\"timeslider.month.december\": \"ڊسمبر\",\n\t\"pad.userlist.entername\": \"پنهنجو نالو داخل ڪريو\",\n\t\"pad.userlist.unnamed\": \"بينام\",\n\t\"pad.impexp.importbutton\": \"هاڻي درآمد ڪريو\",\n\t\"pad.impexp.importing\": \"درآمد ڪندي...\",\n\t\"pad.impexp.uploadFailed\": \"چاڙھ ناڪام ويو، براءِ مهرباني ٻيهر ڪوشش ڪريو\",\n\t\"pad.impexp.importfailed\": \"درآمد ناڪام\",\n\t\"pad.impexp.copypaste\": \"براءِ مهرباني ڪاپي ڪري لڳايو\"\n}\n"
  },
  {
    "path": "src/locales/sh-latn.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Winston Sung\"\n\t\t]\n\t},\n\t\"admin_plugins.available_not-found\": \"Nijedan plugin nije pronađen.\",\n\t\"admin_plugins.description\": \"Opis\",\n\t\"admin_plugins.installed_uninstall.value\": \"Deinstaliraj\",\n\t\"admin_plugins.last-update\": \"Posljednja podnova\",\n\t\"admin_plugins.name\": \"Naziv\",\n\t\"admin_plugins.version\": \"Verzija\",\n\t\"admin_settings.current_save.value\": \"Sačuvaj podešavanja\",\n\t\"index.newPad\": \"Novi blokić\",\n\t\"index.createOpenPad\": \"ili napravite/otvorite blokić s imenom:\",\n\t\"index.openPad\": \"otvori postojeći blokić Etherpada s imenom:\",\n\t\"pad.toolbar.bold.title\": \"Podebljano (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Ukošeno (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Podcrtano (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Precrtano (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Poredani spisak (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Neporedani spisak (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Uvlaka (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Izvlačenje (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Vrati (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Ponovi (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Ukloni boje autorstva (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Uvoz/Izvoz iz/na različite datotečne formate\",\n\t\"pad.toolbar.timeslider.title\": \"Historijski pregled\",\n\t\"pad.toolbar.savedRevision.title\": \"Snimi inačicu\",\n\t\"pad.toolbar.settings.title\": \"Postavke\",\n\t\"pad.toolbar.embed.title\": \"Dijelite i umetnite ovaj blokić\",\n\t\"pad.toolbar.showusers.title\": \"Pokaži korisnike ovoga blokića\",\n\t\"pad.colorpicker.save\": \"Snimi\",\n\t\"pad.colorpicker.cancel\": \"Otkaži\",\n\t\"pad.loading\": \"Učitavam...\",\n\t\"pad.noCookie\": \"Kolačić nije pronađen. Molimo Vas, omogućite kolačiće u Vašem pregledniku! Sesija i podešavanja neće biti sačuvana uz sljedeće posjećivanje. Razlog može biti uključenost Etherpada u iFrame u nekim preglednicima. Molimo Vas, osigurajte da je Etherpad na istoj poddomeni/domeni kao i roditeljski iFrame\",\n\t\"pad.permissionDenied\": \"Za ovdje nije potrebna dozvola za pristup\",\n\t\"pad.settings.padSettings\": \"Postavke blokića\",\n\t\"pad.settings.myView\": \"Moj prikaz\",\n\t\"pad.settings.stickychat\": \"Ćaskanje uvijek na ekranu\",\n\t\"pad.settings.chatandusers\": \"Prikaži ćaskanje i korisnike\",\n\t\"pad.settings.colorcheck\": \"Boje autorstva\",\n\t\"pad.settings.linenocheck\": \"Brojevi redova\",\n\t\"pad.settings.rtlcheck\": \"Da prikažem sadržaj zdesna ulijevo?\",\n\t\"pad.settings.fontType\": \"Tip fonta:\",\n\t\"pad.settings.language\": \"Jezik:\",\n\t\"pad.settings.about\": \"O projektu\",\n\t\"pad.settings.poweredBy\": \"Omogućeno od strane\",\n\t\"pad.importExport.import_export\": \"Uvoz/Izvoz\",\n\t\"pad.importExport.import\": \"Otpremanje bilo koje tekstualne datoteke ili dokumenta\",\n\t\"pad.importExport.importSuccessful\": \"Uspješno!\",\n\t\"pad.importExport.export\": \"Izvezi trenutni blokić kao:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Obični tekst\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Možete uvoziti samo iz običnog teksta te datoteke u formatima HTML-a. Naprednije mogućnosti uvoza dobit ćete ako <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">instalirajte AbiWord ili LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Povezano.\",\n\t\"pad.modals.reconnecting\": \"Prepovezujemo Vas s blokićem...\",\n\t\"pad.modals.forcereconnect\": \"Nametni prepovezivanje\",\n\t\"pad.modals.reconnecttimer\": \"Se prepovezivam za\",\n\t\"pad.modals.cancel\": \"Otkaži\",\n\t\"pad.modals.userdup\": \"Otvoreno u drugom prozoru\",\n\t\"pad.modals.userdup.explanation\": \"Ovaj je blokić otvoren u više od jednoga prozora (u pregledniku) na računalu.\",\n\t\"pad.modals.userdup.advice\": \"Prepovežite se da biste koristili ovaj prozor.\",\n\t\"pad.modals.unauth\": \"Neovlašteno\",\n\t\"pad.modals.unauth.explanation\": \"Vaše su dozvole izmijenjene za vrijeme dok ste pregledavali ovu stranicu. Pokušajte se prepovezati.\",\n\t\"pad.modals.looping.explanation\": \"Postoje problemi s vezom sa usklađivnim poslužiteljem.\",\n\t\"pad.modals.looping.cause\": \"Možda ste se spojili preko neskladne sigurnosne stijene ili proxyja.\",\n\t\"pad.modals.initsocketfail\": \"Server je nedostupan.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Nisam mogao se povezati sa usklađivnim serverom.\",\n\t\"pad.modals.initsocketfail.cause\": \"Ovo je vjerojatno zbog problema s vašim preglednikom ili svemrežnom vezom.\",\n\t\"pad.modals.slowcommit.explanation\": \"Server se ne odaziva.\",\n\t\"pad.modals.slowcommit.cause\": \"Ovo je vjerojatno zbog problema s mrežnim povezivanjem.\",\n\t\"pad.modals.badChangeset.explanation\": \"Poslužitelj za usklađivanje smatra da je izmjena koju ste napravili nedopuštena.\",\n\t\"pad.modals.badChangeset.cause\": \"Ovo može biti zbog pogrešne postavljenosti poslužitelja ili nekog drugog neočekivanog ponašanja. Obratite se administratoru ukoliko držite da je to greška. Pokušajte se preuključiti kako biste nastavili s uređivanjem.\",\n\t\"pad.modals.corruptPad.explanation\": \"Blokić što pokušavate otvoriti je oštećen.\",\n\t\"pad.modals.corruptPad.cause\": \"Ovo može biti zbog pogrešne postavljenosti poslužitelja ili nekog drugog neočekivanog ponašanja. Obratite se administratoru.\",\n\t\"pad.modals.deleted\": \"Obrisano.\",\n\t\"pad.modals.deleted.explanation\": \"Ovaj blokić je uklonjen.\",\n\t\"pad.modals.rateLimited\": \"Ograničenje stopa.\",\n\t\"pad.modals.rateLimited.explanation\": \"Poslali ste previše poruka na ovaj blokić, te ste stoga odspojeni.\",\n\t\"pad.modals.rejected.explanation\": \"Poslužitelj je odbio poruku koju je poslao vaš preglednik.\",\n\t\"pad.modals.rejected.cause\": \"Poslužitelj je možda podnovljen dok ste gledali blokić, ili možda postoji greška u Etherpadu. Pokušajte ponovo učitati stranicu.\",\n\t\"pad.modals.disconnected\": \"Veza je prekinuta.\",\n\t\"pad.modals.disconnected.explanation\": \"Veza s poslužiteljem je prekinuta\",\n\t\"pad.modals.disconnected.cause\": \"Moguće je da server nije dostupan. Obavijestite administratora ako se ovo nastavi događati.\",\n\t\"pad.share\": \"Dijeli ovaj blokić\",\n\t\"pad.share.readonly\": \"Samo čitanje\",\n\t\"pad.share.link\": \"Link\",\n\t\"pad.share.emebdcode\": \"Umetni URL\",\n\t\"pad.chat\": \"Ćaskanje\",\n\t\"pad.chat.title\": \"Otvori ćaskanje uz ovaj blokić.\",\n\t\"pad.chat.loadmessages\": \"Učitaj više poruka\",\n\t\"pad.chat.stick.title\": \"Zalijepi ćaskanje na ekranu\",\n\t\"pad.chat.writeMessage.placeholder\": \"Ovdje napišite poruku\",\n\t\"timeslider.followContents\": \"Prati podnove sadržaja blokića\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Historijski pregled\",\n\t\"timeslider.toolbar.returnbutton\": \"Natrag na blokić\",\n\t\"timeslider.toolbar.authors\": \"Autori:\",\n\t\"timeslider.toolbar.authorsList\": \"Nema autora\",\n\t\"timeslider.toolbar.exportlink.title\": \"Izvoz\",\n\t\"timeslider.exportCurrent\": \"Izvezi trenutnu verziju kao:\",\n\t\"timeslider.version\": \"Verzija {{version}}\",\n\t\"timeslider.saved\": \"Spremljeno {{day}}. {{month}} {{year}}.\",\n\t\"timeslider.playPause\": \"Izvrti/pauziraj sadržaj blokića\",\n\t\"timeslider.backRevision\": \"Nazad na jednu inačicu ovog blokića\",\n\t\"timeslider.forwardRevision\": \"Naprijed na jednu inačicu ovog blokića\",\n\t\"timeslider.dateformat\": \"{{day}}. {{month}}. {{year}}. {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"januara\",\n\t\"timeslider.month.february\": \"februara\",\n\t\"timeslider.month.march\": \"marta\",\n\t\"timeslider.month.april\": \"aprila\",\n\t\"timeslider.month.may\": \"maja\",\n\t\"timeslider.month.june\": \"juna\",\n\t\"timeslider.month.july\": \"jula\",\n\t\"timeslider.month.august\": \"augusta\",\n\t\"timeslider.month.september\": \"septembra\",\n\t\"timeslider.month.october\": \"oktobra\",\n\t\"timeslider.month.november\": \"novembra\",\n\t\"timeslider.month.december\": \"decembra\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: neimenovani autor, plural(num) two: neimenovana autora, plural(num) other: neimenovanih autora ]}\",\n\t\"pad.savedrevs.marked\": \"Ova inačica označena je sada kao spremljena\",\n\t\"pad.savedrevs.timeslider\": \"Možete pogledati spremljene inačice rabeći vremesledni klizač\",\n\t\"pad.userlist.entername\": \"Upišite svoje ime\",\n\t\"pad.userlist.unnamed\": \"bez imena\",\n\t\"pad.editbar.clearcolors\": \"Ukloniti boje autorstva sa cijelog dokumenta? Radnju nije moguće poništiti\",\n\t\"pad.impexp.importbutton\": \"Uvezi odmah\",\n\t\"pad.impexp.importing\": \"Uvozim...\",\n\t\"pad.impexp.confirmimport\": \"Uvoženje datoteke presnimit će trenutni sadržaj blokića.\\nJeste li sigurni da želite nastaviti?\",\n\t\"pad.impexp.convertFailed\": \"Nisam mogao uvesti datoteku. Poslužite se uz neki drugi format ili prekopirajte tekst ručno.\",\n\t\"pad.impexp.padHasData\": \"Nismo mogli uvesti ovu datoteku jer je ovaj blokić već ima promjene. Uvezite je u novi blokić.\",\n\t\"pad.impexp.uploadFailed\": \"Postavljanje nije uspjelo. Pokušajte ponovo.\",\n\t\"pad.impexp.importfailed\": \"Uvoz nije uspio\",\n\t\"pad.impexp.copypaste\": \"Prekopirajte\",\n\t\"pad.impexp.exportdisabled\": \"Izvoz u formatu {{type}} je onemogućen. Ako želite saznati više o ovome, obratite se administratoru sustava.\",\n\t\"pad.impexp.maxFileSize\": \"Datoteka je prevelika. Kontaktirajte administratora kako biste zatražili povećanje dopuštene veličine datoteke za uvoz\"\n}\n"
  },
  {
    "path": "src/locales/shn.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Ninjastrikers\",\n\t\t\t\"Saimawnkham\",\n\t\t\t\"Saosukham\"\n\t\t]\n\t},\n\t\"index.newPad\": \"ၽႅတ်ႉမႂ်ႇ\",\n\t\"index.createOpenPad\": \"ဢမ်ႇၼၼ် ၶူင်မႂ်ႇ/ပိုတ်ႇၽႅတ်ႉၵိုၵ်းၸိုဝ်ႈ\",\n\t\"pad.toolbar.bold.title\": \"လမ် (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"ၵိူင်း (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"ထတ်းထႅဝ်တႂ်ႈ (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"လတ်းၵၢင် (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"ၵုမ်းသဵၼ်ႈ (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"ဢမ်ႇၵုမ်းသဵၼ်ႈ (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"ၶၼ်ႈ (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"ၶၼ်ႈႁၢင်ႇ (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"ၶိုၼ်းဢမ်ႇႁဵတ်း (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"ၶိုၼ်းႁဵတ်း (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"သၢင်းလၢင်းပႅတ်ႈ သီဢၼ်မီးဝႆႉၵဝ်ႇ (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"သူင်ႇၶဝ်ႈ/သူင်ႇဢွၵ်ႇ တမ်ႈတီႈ/ထိုင် ၾၢႆႇၾေႃးမိတ်ႉ ဢၼ်ဢမ်ႇမိူၼ်ၵၼ်ၸိူဝ်းၼၼ်ႉ\",\n\t\"pad.toolbar.timeslider.title\": \"ၶၢဝ်းယၢမ်းထွၵ်ႇလၢႆႈ\",\n\t\"pad.toolbar.savedRevision.title\": \"သိမ်းလွင်ႈၶိုၼ်းမႄး\",\n\t\"pad.toolbar.settings.title\": \"ပိူင်ႁႅၼ်း\",\n\t\"pad.toolbar.embed.title\": \"ၽႄပၼ်ၽႅတ်ႉဢၼ်ၼႆႉသေ ၽိူမ်ႉပၼ်\",\n\t\"pad.toolbar.showusers.title\": \"ၼႄပၼ်ၵေႃႉၸႂ်ႉ တီႈၼိူဝ်ၽႅတ်ႉၼႆႉ\",\n\t\"pad.colorpicker.save\": \"ၵဵပ်းသိမ်း\",\n\t\"pad.colorpicker.cancel\": \"ယႃႉၶိုၼ်း\",\n\t\"pad.loading\": \"တိုၵ်ႉလူတ်ႇ\",\n\t\"pad.noCookie\": \"ၶုၵ်းၶီး ဢမ်ႇႁၼ်လႆႈ။ ၶႅၼ်းတေႃႈ ၶႂၢင်းပၼ် ၶုၵ်းၶီး တီႈၼႂ်း ပရၢဝ်ႇသႃႇၸဝ်ႈၵဝ်ႇ\",\n\t\"pad.permissionDenied\": \"ၸဝ်ႈၵဝ်ႇ ဢမ်ႇမီးၶေႃႈၶႂၢင်ႉ တႃႇၶဝ်ႈၼႂ်းၽႅတ်ႉၼႆႉ\",\n\t\"pad.settings.padSettings\": \"ပိူင်သၢင်ႈၽႅတ်ႉ\",\n\t\"pad.settings.myView\": \"ဝိဝ်းၵဝ်\",\n\t\"pad.settings.stickychat\": \"ၶျၢတ်ႉၼိူဝ်ၼႃႈၽိဝ် တႃႇသေႇ\",\n\t\"pad.settings.chatandusers\": \"ၼႄၶျၢတ်ႉဢိၵ်ႇၵေႃႉၸႂ်ႉ\",\n\t\"pad.settings.colorcheck\": \"သီၸိူဝ်းမီးဝႆႉၵဝ်ႇၵဝ်ႇ\",\n\t\"pad.settings.linenocheck\": \"တူဝ်လိၵ်ႈႁေႈႁၢႆး\",\n\t\"pad.settings.rtlcheck\": \"လူၵႂၢမ်းၼၢမ်း တႄႇၶႂႃတေႃႇသၢႆႉ\",\n\t\"pad.settings.fontType\": \"ၾွၼ်ႉတူဝ်လိၵ်ႈ\",\n\t\"pad.settings.language\": \"ၽႃႇသႃႇၵႂၢမ်း:\",\n\t\"pad.importExport.import_export\": \"သူင်ႇၶဝ်/သူင်ႇဢွၵ်ႇ\",\n\t\"pad.importExport.import\": \"လူတ်ႇၶိုၼ်ႈ ၾၢႆႇလိၵ်ႈၵမ်ႈၽွင်ႈ ဢမ်ႇၼၼ် ပွင်ႈလိၵ်ႈ\",\n\t\"pad.importExport.importSuccessful\": \"ဢွင်ႇယဝ်ႉ!\",\n\t\"pad.importExport.export\": \"ဢဝ်ဢွၵ်ႇၽႅတ်ႉဢၼ်ပိူၼ်ႈသူင် ၸိူင်ႉၼင်ႇ:\",\n\t\"pad.importExport.exportetherpad\": \"ၽႅတ်ႉပဝ်ႇ\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"တူဝ်လိၵ်ႈပဝ်ႇ\",\n\t\"pad.importExport.exportword\": \"မၢႆႇၶရူဝ်ႇသွပ်ႉဝၢတ်ႉ\",\n\t\"pad.importExport.exportpdf\": \"ၽီႇတီႇဢႅပ်ႉၾ်\",\n\t\"pad.importExport.exportopen\": \"ဢူဝ်တီႇဢႅပ်ႉၾ် (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"ၸဝ်ႈၵဝ်ႇတေ ၸၢင်ႈလုၵ်ႉတီႈ တူဝ်လိၵ်ႈလွၼ်ႉလွၼ်ႉ ဢမ်ႇၼၼ် HTML သေ သူင်ႇၶဝ်ႈၵႂႃႇၵူၺ်း။ တွၼ်ႈတႃႇ လၢႆးၵၢၼ်သူင်ႇၶဝ်ႈၶိုၵ်ႉတွၼ်းတၢင်ႇၸိူဝ်းၼၼ်ႉ ၶႅၼ်းတေႃႈ <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">ဢူၼ်းသႂ်ႇ AbiWord</a>.\",\n\t\"pad.modals.connected\": \"ၵွင်ႉသၢၼ်ယဝ်ႉ\",\n\t\"pad.modals.reconnecting\": \"ၶိုၼ်းၵွင်ႉသၢၼ်ၸူး ၽႅတ်ႉၸဝ်ႈၵဝ်ႇယူႇ\",\n\t\"pad.modals.forcereconnect\": \"တဵၵ်းၸႂ်ႉ ၶိုၼ်းၵွင်ႉသၢၼ်\",\n\t\"pad.modals.reconnecttimer\": \"ၶတ်းၸႂ်တူၺ်း တႃႇၶိုၼ်းၵွင်ႉသိုပ်ႇၸူး\",\n\t\"pad.modals.cancel\": \"ယႃႉၶိုၼ်း\",\n\t\"pad.modals.userdup\": \"ပိုတ်ႇတမ်ႈတီႈ ၼႃႈတူမႂ်ႇ\",\n\t\"pad.modals.userdup.explanation\": \"တမ်ႈတီႈၼႂ်းၶွမ်းတၢင်ႇဢၼ်ၼၼ်ႉ ၽႅတ်ႉဢၼ်ၼႆႉ လႅပ်ႉပိုတ်ႇဝႆႉ တမ်ႈတီႈ ပရၢဝ်ႇသႃႇတၢင်ႇတီႈယူႇ\",\n\t\"pad.modals.userdup.advice\": \"ၶိုၼ်းၵွင်ႉသၢၼ်တၢင် တမ်ႈတီႈ ဝိၼ်းတူဝ်းၼႆႉ\",\n\t\"pad.modals.unauth\": \"ဢမ်ႇမီးသုၼ်ႇႁဵတ်း\",\n\t\"pad.modals.unauth.explanation\": \"ၽွင်းၼႄၼႃႈလိၵ်ႈၼႆႉယူႇၼၼ်ႉ ၶေႃႈၶႂၢင်းၸဝ်ႈၵဝ်ႇ လႅၵ်ႈလၢႆႉယဝ်ႉယဝ်ႉ။ ၶတ်းၸႂ် ၶိုၼ်းၵွင်ႉသၢၼ်တႅင်ႈ\",\n\t\"pad.modals.looping.explanation\": \"ၸိူဝ်းၼႆႉ မီးဝႆႉပၼ်ႁႃ သၢႆတိတ်းတေႃႇ ၵိုၵ်းလူၺ်ႈ သႃႇဝႃႇ ဢၼ်ၸၼ်ထိင်းဝႆႉ\",\n\t\"pad.modals.looping.cause\": \"သင်ပဵၼ်လႆႈ မႂ်းၶဝ်ႈၵွင်ႉသၢၼ် ၾၢႆးယႃးဝေႃး ဢမ်ႇၼၼ် ပရွၵ်ႉသီႇ ဢၼ်ဢမ်ႇငၢမ်ႇၵၼ်\",\n\t\"pad.modals.initsocketfail\": \"သႃႇဝႃႇ ဢမ်ႇၵွင်ႉလႆႈ\",\n\t\"pad.modals.initsocketfail.explanation\": \"ဢမ်ႇၵွင်ႉၸူးလႆႈ သႃႇဝႃႇဢၼ်ၸၼ်ထိင်းဝႆႉ\",\n\t\"pad.modals.initsocketfail.cause\": \"ၼႆႉပဵၼ်ပၼ်ႁႃၶိုၵ်ႉလူင် ၵိုၵ်းလူၺ်ႈ ပရၢဝ်ႇသႃႇၸဝ်ႈၵဝ်ႇ ဢမ်ႇၼၼ် သၢႆၼႅင်ႈၸဝ်ႈၵဝ်ႇ\",\n\t\"pad.modals.slowcommit.explanation\": \"သႃႇဝႃႇ ဢမ်ႇတွပ်ႇပၼ်\",\n\t\"pad.modals.slowcommit.cause\": \"ၼႆႉပူပ်ႉၺႃး ပၼ်ႁႃ ၵိုၵ်းလူၺ်ႈ သၢႆၼႅင်ႈၵွင်ႉသၢၼ်\",\n\t\"pad.modals.badChangeset.explanation\": \"ၶေႃႈထတ်း ဢၼ်ၸဝ်ႈၵဝ်ႇႁဵတ်းၼၼ်ႉ မၼ်းဢမ်ႇႁူမ်ႈၶဝ်ႈၶႂၢင်ႇ ၸွမ်းၼင်ႇ သႃႇဝႃႇဢၼ် ၸၼ်ထိင်းဝႆႉ\",\n\t\"pad.modals.badChangeset.cause\": \"ၼႆႉမၼ်းၸၢင်ႈပဵၼ်ယွၼ်ႉပိူဝ်ႈ လွင်ႈၵုမ်းၵၢၼ်သႃႇပိူဝ်ႇ ၽိတ်းပိူင်ႈဝႆႉ ဢမ်ႇၼၼ် ပဵၼ်ယွၼ်ႉလွင်ႈဢၼ်ဢမ်ႇမုင်ႈမွင်းဝႆႉ။ သင်ၸိူဝ်ႉဝႃႈ ၸဝ်ႈၵဝ်ႇယိၼ်းဝႃႈပဵၼ်လွင်ႈၽိတ်းပိူင်ႈၼႆ ၶႅၼ်းတေႃႈၵပ်းသိုပ်ႇၸူးတင်း ၽူႈၵုမ်းၵၢၼ် ၵၢၼ်ၸွႆႈသၢင်ႈလႄႈ။ ၶိုၼ်းၶတ်းၸႂ်ၵွင်ႉသိုပ်ႇသေ တွၼ်ႈတႃႇ သိုပ်ႇႁဵတ်းလွင်ႈမႄးထတ်း။\",\n\t\"pad.modals.corruptPad.explanation\": \"ၽႅတ်ႉဢၼ်ၸဝ်ႈၵဝ်ႇပေႃႉၼၼ်ႉ ၶဝ်ႈၽိတ်းဝႆႉ\",\n\t\"pad.modals.corruptPad.cause\": \"ဢၼ်ၼႆႉ သႃႇဝႃႇဢၼ်ၸၼ်ထိင်းမၼ်း ၽိတ်းဝႆႉ ဢမ်ႇၼၼ် ဢမ်ႇမုင်ႈမွင်းသေ ၽိတ်းပိူင်ႈဝႆႉယဝ်ႉ။ ၶႅၼ်းတေႃႈ ၵပ်းသိုပ်ႇတမ်ႈတီႈ ၽူႈၵုမ်းၵၢၼ်.\",\n\t\"pad.modals.deleted\": \"မွတ်ႇပႅတ်ႈယဝ်ႉ။\",\n\t\"pad.modals.deleted.explanation\": \"ၽႅတ်ႉဢၼ်ၼႆႉ ၶၢႆႉပႅတ်ႈယဝ်ႉ\",\n\t\"pad.modals.disconnected\": \"ၸဝ်ႈၵဝ်ႇ ဢမ်ႇၵွင်ႉသၢၼ်ဝႆႉ\",\n\t\"pad.modals.disconnected.explanation\": \"လွင်ႈၵွင်ႉသၢၼ် ၵႂႃႇၸူးသႃႇဝႃႇၼၼ်ႉ ႁၢႆဝႆႉ\",\n\t\"pad.modals.disconnected.cause\": \"သႃႇဝႃႇတေဢမ်ႇၸၢင်ႈယိပ်းတိုဝ်း။ တႃႇႁႂ်ႈသိုပ်ႇပဵၼ်ၵၢၼ်ၵႂႃႇၼၼ်ႉ ၶႅၼ်းတေႃႈ ပွင်ႇၶၢဝ်ႇ တမ်ႈတီႈ ၽူႈၵုမ်းၵၢၼ်\",\n\t\"pad.share\": \"ၽႄၽႅတ်ႉၼႆႉ\",\n\t\"pad.share.readonly\": \"လူလၢႆလၢႆ\",\n\t\"pad.share.link\": \"လိင်ႉၶ်\",\n\t\"pad.share.emebdcode\": \"သႂ်ႇ URL\",\n\t\"pad.chat\": \"ၶျၢတ်ႉ\",\n\t\"pad.chat.title\": \"ပိုတ်ႇၶျၢတ်ႉ တႃႇၽႅတ်ႉၼႆႉ\",\n\t\"pad.chat.loadmessages\": \"လူတ်ႇၶေႃႈၶၢဝ်ႇ လိူဝ်\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} ၶၢဝ်းယၢမ်းထေႃပူၼ်ႉ\",\n\t\"timeslider.toolbar.returnbutton\": \"ၶိုၼ်းၵႂႃႇၸူး ၽႅတ်ႉ\",\n\t\"timeslider.toolbar.authors\": \"ၽူႈတႅမ်ႈလိၵ်ႈ\",\n\t\"timeslider.toolbar.authorsList\": \"ဢမ်ႇၸႂ်ႈ ၽူႈတႅမ်ႈလိၵ်ႈ\",\n\t\"timeslider.toolbar.exportlink.title\": \"သူင်ႇဢွၵ်ႇ\",\n\t\"timeslider.exportCurrent\": \"သူင်ႇဢွၵ်ႇ လုၼ်ႈပၢၼ်မႂ်ႇ ၼင်ႇ:\",\n\t\"timeslider.version\": \"လုၼ်ႈ {{version}}\",\n\t\"timeslider.saved\": \"သိမ်းယဝ်ႉ{{month}} {{day}}, {{year}}\",\n\t\"timeslider.playPause\": \"လဵၼ်ႈလင်/ၵိုတ်းသဝ်း ၵႂၢမ်းၼၢမ်း ၽႅတ်ႉ\",\n\t\"timeslider.backRevision\": \"ႁူၼ်လင် ၶိုၼ်းမႄး ၽႅတ်ႉၼႆႉ\",\n\t\"timeslider.forwardRevision\": \"ၵႂႃႇၼႃႈ ၶိုၼ်းမႄး ၽႅတ်ႉၼႆႉ\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"ၵျၼ်ႇၼိဝ်ႇရီႇ\",\n\t\"timeslider.month.february\": \"ၾႅပ်ႇဝႃႇရီႇ\",\n\t\"timeslider.month.march\": \"မၢတ်ႉၶျ်\",\n\t\"timeslider.month.april\": \"ဢေႇပရႄႇ\",\n\t\"timeslider.month.may\": \"မေႇ\",\n\t\"timeslider.month.june\": \"ၵျုၼ်ႇ\",\n\t\"timeslider.month.july\": \"ၵျူႇလၢႆႇ\",\n\t\"timeslider.month.august\": \"ဢေႃးၵၢတ်ႉ\",\n\t\"timeslider.month.september\": \"သႅပ်ႉထိမ်ႇပႃႇ\",\n\t\"timeslider.month.october\": \"ဢွၵ်ႇထူဝ်ႇပႃႇ\",\n\t\"timeslider.month.november\": \"ၼူဝ်ႇဝႅမ်ႇပႃႇ\",\n\t\"timeslider.month.december\": \"တီႇသႅမ်ႇပႃႇ\",\n\t\"timeslider.unnamedauthors\": \"{{num}} ဢမ်ႇသႂ်ႇၸိုဝ်ႈ {[plural(num) ၼိုင်ႈ: ၽူႈတႅမ်ႈလိၵ်ႈ, တၢင်ႇၸိူဝ်း: ၽူႈတႅမ်ႈလိၵ်ႈၶဝ် ]}\",\n\t\"pad.savedrevs.marked\": \"ဢၼ်မႄးဝႆႉၼႆႉ ယၢမ်းလဵဝ် ၶိုၼ်းမႄး ၵဵပ်းသိမ်းဝႆႉ ၸိူင်ႉၼင်ႇဢၼ်ၼိုင်ႈ\",\n\t\"pad.savedrevs.timeslider\": \"ၸဝ်ႈၵဝ်ႇတေတူၺ်းလႆႈ ဢၼ်ၶိုၼ်းမႄးသိမ်းဝႆႉၼၼ်ႉယူႇ ပေႃးၶဝ်ႈတူၺ်းတီႈ ၶၢဝ်းယၢမ်းထေႃပူၼ်ႉ\",\n\t\"pad.userlist.entername\": \"ပေႃႇသႂ်ႇပၼ် ၸိုဝ်ႈၽူႈၸႂ်ႉတိုဝ်း ၸဝ်ႈၵဝ်ႇ\",\n\t\"pad.userlist.unnamed\": \"ဢမ်ႇသႂ်ႇၸိုဝ်ႈ\",\n\t\"pad.editbar.clearcolors\": \"လၢင်ႉပႅတ်ႈသီၵဝ်ႇ တမ်ႈတီႈ ၼိူဝ်ၶေႃႈလိၵ်ႈတင်းသဵင်ႈ\",\n\t\"pad.impexp.importbutton\": \"သူင်ႇၶဝ်ႈယၢမ်းလဵဝ်\",\n\t\"pad.impexp.importing\": \"တိုၵ်ႉသူင်ႇၶဝ်ႈယူႇ\",\n\t\"pad.impexp.confirmimport\": \"ဢဝ်ၾၢႆႇၶဝ်ႈမႃးၼႆႉ တေမႃး တဵင်သႂ်ႇ လိၵ်ႈဢၼ်မီးဝႆႉ တီႈၼႂ်းၽႅတ်ႉၼႆႉယဝ်ႉ။ ၸွင်ႇၸဝ်ႈၵဝ်ႇ လပ်ႉလွင်းဝႃႈ ၸဝ်ႈၵဝ်ႇ တေၶႂ်ႈ ငူပ်ႉငိႁိုဝ်?\",\n\t\"pad.impexp.convertFailed\": \"ႁဝ်းဢမ်ႇၸၢင်းႁဵတ်းႁိုဝ် ဢဝ်ၾၢႆႇၼႆႉ သူင်ႇၶဝ်ႈၵႂႃႇ။ ၶႅၼ်းတေႃႈ ၸႂ်ႉတိုဝ်း ၶေႃႈလိၵ်ႈ ဢၼ်ပႅၵ်ႇပိူင်ႈၵၼ် ဢမ်ႇၼၼ် ၵူးထုတ်ႇဝႆႉလႄႈ\",\n\t\"pad.impexp.padHasData\": \"ႁဝ်းဢမ်ႇၸၢင်းႁဵတ်းႁိုဝ် ဢဝ်ၾၢႆႇၼႆႉ သူင်ႇၶဝ်ႈၵႂႃႇ ၵွပ်ႈပိူဝ်ႈဝႃႈ ၽႅတ်ႉဢၼ်ၼႆႉ မၼ်းလႅၵ်ႈလၢႆႈၵႂႃႇယဝ်ႉယဝ်ႈ။ ၶွပ်ႈၸႂ် သူင်ႇၶႂ်ႈၼႂ်း ၽႅတ်ႉမႂ်ႇလႄႈ\",\n\t\"pad.impexp.uploadFailed\": \"ဢၼ်လူတ်ႇၶိုၼ်ႈ ၶၢတ်ႇတူၵ်းယဝ်ႉ။ ၶႅၼ်းတေႃႈ ၶတ်းၸႂ်ထႅင်ႈ\",\n\t\"pad.impexp.importfailed\": \"ဢၼ်သူင်ႇၶဝ်ႈ ၶၢတ်ႇတူၵ်းယဝ်ႉ\",\n\t\"pad.impexp.copypaste\": \"ၶႅၼ်းတေႃႈ ၵူးသေ ဢဝ်ဝႆႉတ\",\n\t\"pad.impexp.exportdisabled\": \"ၸိူင်ႉၼင်ႇ {{type}} သူင်ႇဢွၵ်ႇ ၾေႃးမဵတ်ႉ ၼၼ်ႉ ဢမ်ႇၸၢင်ႈ။ ၶႅၼ်းတေႃႈ ၵပ်းသိုပ်ႇ ၽူႈၵုမ်းၵၢၼ်ၸၢၵ်ႈ တႃႇႁူဝ်ယွႆႈမၼ်းတ\"\n}\n"
  },
  {
    "path": "src/locales/sk.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Jose1711\",\n\t\t\t\"Kusavica\",\n\t\t\t\"Lexected\",\n\t\t\t\"Mark\",\n\t\t\t\"Rudko\",\n\t\t\t\"Teslaton\",\n\t\t\t\"Yardom78\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Ovládací panel správu - Etherpad\",\n\t\"admin_plugins\": \"Správca doplnkov\",\n\t\"admin_plugins.available\": \"Dostupné doplnky\",\n\t\"admin_plugins.available_not-found\": \"Doplnky neboli nájdené.\",\n\t\"admin_plugins.available_fetching\": \"Načítavanie...\",\n\t\"admin_plugins.available_install.value\": \"Inštalovať\",\n\t\"admin_plugins.available_search.placeholder\": \"Vyhľadať doplnky na inštaláciu\",\n\t\"admin_plugins.description\": \"Popis\",\n\t\"admin_plugins.installed\": \"Nainštalované doplnky\",\n\t\"admin_plugins.installed_fetching\": \"Načítavanie nainštalovaných doplnkov...\",\n\t\"admin_plugins.installed_nothing\": \"Ešte ste nenainštalovali žiadne doplnky.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Odinštalovať\",\n\t\"admin_plugins.last-update\": \"Posledná aktualizácia\",\n\t\"admin_plugins.name\": \"Názov\",\n\t\"admin_plugins.page-title\": \"Správca doplnkov - Etherpad\",\n\t\"admin_plugins.version\": \"Verzia\",\n\t\"admin_plugins_info\": \"Informácie k riešeniu problémov\",\n\t\"admin_plugins_info.hooks\": \"Nainštalované súčasti\",\n\t\"admin_plugins_info.hooks_client\": \"Súčasti na strane klienta\",\n\t\"admin_plugins_info.hooks_server\": \"Súčasti na strane servera\",\n\t\"admin_plugins_info.parts\": \"Nainštalované súčasti\",\n\t\"admin_plugins_info.plugins\": \"Nainštalované doplnky\",\n\t\"admin_plugins_info.page-title\": \"Informácie o doplnkoch - Etherpad\",\n\t\"admin_plugins_info.version\": \"Verzia Etherpadu\",\n\t\"admin_plugins_info.version_latest\": \"Posledná dostupná verzia\",\n\t\"admin_plugins_info.version_number\": \"Číslo verzie\",\n\t\"admin_settings\": \"Nastavenia\",\n\t\"admin_settings.current\": \"Aktuálne nastavenia\",\n\t\"admin_settings.current_example-devel\": \"Príklad šablóny vývojárskeho nastavenia\",\n\t\"admin_settings.current_example-prod\": \"Príklad šablóny výrobného nastavenia\",\n\t\"admin_settings.current_restart.value\": \"Reštartovať Etherpad\",\n\t\"admin_settings.current_save.value\": \"Uložiť nastavenia\",\n\t\"admin_settings.page-title\": \"Nastavenia - Etherpad\",\n\t\"index.newPad\": \"Nový poznámkový blok\",\n\t\"index.createOpenPad\": \"alebo vytvoriť/otvoriť poznámkový blok s názvom:\",\n\t\"index.openPad\": \"otvoriť poznámkový blok s názvom:\",\n\t\"pad.toolbar.bold.title\": \"Tučné (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Kurzíva (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Podčiarknuté (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Prečiarknuté (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Zoradený zoznam (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Nezoradený zoznam (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Zväčšiť okraj (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Zmenšiť okraj (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Späť (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Opakovať (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Odstrániť farebné označovanie autorov (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Import/export z/do rôznych formátov súborov\",\n\t\"pad.toolbar.timeslider.title\": \"Časová os\",\n\t\"pad.toolbar.savedRevision.title\": \"Uložiť revíziu\",\n\t\"pad.toolbar.settings.title\": \"Nastavenia\",\n\t\"pad.toolbar.embed.title\": \"Zdieľať alebo vložiť tento poznámkový blok\",\n\t\"pad.toolbar.showusers.title\": \"Zobraziť používateľov tohoto poznámkového bloku\",\n\t\"pad.colorpicker.save\": \"Uložiť\",\n\t\"pad.colorpicker.cancel\": \"Zrušiť\",\n\t\"pad.loading\": \"Načítava sa...\",\n\t\"pad.noCookie\": \"Cookie nebolo možné nájsť. Povoľte prosím cookies vo vašom prehliadači. Vaše sedenie a nastavenia sa medzi návštevami stránky neuložia. To môže byť spôsobené tým že Etherpad je zahrnutý do iFrame v niektorých prehliadačoch. Prosím uistite sa, že Etherpad sa nachádza na tej istej doméne ako hlavný iFrame\",\n\t\"pad.permissionDenied\": \"Ľutujeme, nemáte oprávnenie pristupovať k tomuto poznámkovému bloku\",\n\t\"pad.settings.padSettings\": \"Nastavenia poznámkového bloku\",\n\t\"pad.settings.myView\": \"Vlastný pohľad\",\n\t\"pad.settings.stickychat\": \"Rozhovor stále na obrazovke\",\n\t\"pad.settings.chatandusers\": \"Zobraziť rozhovor a používateľov\",\n\t\"pad.settings.colorcheck\": \"Farby autorov\",\n\t\"pad.settings.linenocheck\": \"Čísla riadkov\",\n\t\"pad.settings.rtlcheck\": \"Čítať obsah sprava doľava?\",\n\t\"pad.settings.fontType\": \"Typ písma:\",\n\t\"pad.settings.fontType.normal\": \"Normálne\",\n\t\"pad.settings.language\": \"Jazyk:\",\n\t\"pad.settings.about\": \"O Etherpade\",\n\t\"pad.settings.poweredBy\": \"Poháňané cez\",\n\t\"pad.importExport.import_export\": \"Import/Export\",\n\t\"pad.importExport.import\": \"Nahrať ľubovoľný textový súbor alebo dokument\",\n\t\"pad.importExport.importSuccessful\": \"Import úspešný!\",\n\t\"pad.importExport.export\": \"Exportovať aktuálny poznámkový blok ako:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Čistý text\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Importovať môžete len čistý text alebo HTML. Pre pokročilejšie funkcie importu prosím nainštalujte „<a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">AbiWord</a>“.\",\n\t\"pad.modals.connected\": \"Pripojené.\",\n\t\"pad.modals.reconnecting\": \"Opätovné pripájanie k vášmu poznámkovému bloku...\",\n\t\"pad.modals.forcereconnect\": \"Vynútiť znovupripojenie\",\n\t\"pad.modals.reconnecttimer\": \"Skúšam sa pripojiť\",\n\t\"pad.modals.cancel\": \"Zrušiť\",\n\t\"pad.modals.userdup\": \"Otvorené v inom okne\",\n\t\"pad.modals.userdup.explanation\": \"Zdá sa, že tento poznámkový blok je na tomto počítači otvorený vo viacerých oknách prehliadača.\",\n\t\"pad.modals.userdup.advice\": \"Pre použitie tohoto okna se musíte znovu pripojiť.\",\n\t\"pad.modals.unauth\": \"Nie ste autorizovaný\",\n\t\"pad.modals.unauth.explanation\": \"Vaše oprávnenia sa počas prehliadania tejto stránky zmenili. Skúste sa pripojiť znovu.\",\n\t\"pad.modals.looping.explanation\": \"Nastali problémy pri komunikácii so synchronizačným serverom.\",\n\t\"pad.modals.looping.cause\": \"Možno ste pripojení cez nekompatibilný firewall alebo proxy server.\",\n\t\"pad.modals.initsocketfail\": \"Server je nedostupný.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Nepodarilo sa pripojiť k synchronizačnému serveru.\",\n\t\"pad.modals.initsocketfail.cause\": \"Príčinou je pravdepodobne problém s prehliadačom alebo internetovým pripojením.\",\n\t\"pad.modals.slowcommit.explanation\": \"Server neodpovedá.\",\n\t\"pad.modals.slowcommit.cause\": \"Príčinou môže byť problém so sieťovým pripojením.\",\n\t\"pad.modals.badChangeset.explanation\": \"Úprava, ktorú ste vykonali bola synchronizáciou serveru vyhodnotená ako nepovolená.\",\n\t\"pad.modals.badChangeset.cause\": \"To môže byť z dôvodu nesprávnej konfigurácie servera alebo iného neočakávaného správania. Ak máte pocit že došlo k chybe, kontaktuje prosím správcu služby. Pokúste sa pripojiť znova a pokračovať v úpravách.\",\n\t\"pad.modals.corruptPad.explanation\": \"Poznámkový blok ku ktorému sa snažíte získať prístup je poškodený.\",\n\t\"pad.modals.corruptPad.cause\": \"To môže byť z dôvodu nesprávnej konfigurácie servera alebo iného neočakávaného správania. Prosím, obráťte sa na správcu služby.\",\n\t\"pad.modals.deleted\": \"Odstránené.\",\n\t\"pad.modals.deleted.explanation\": \"Tento poznámkový blok bol odstránený.\",\n\t\"pad.modals.rateLimited\": \"Rýchlosť obmedzená.\",\n\t\"pad.modals.rateLimited.explanation\": \"Do tohto poznámkového bloku ste poslali príliš veľa správ a preto ste boli odpojení.\",\n\t\"pad.modals.rejected.explanation\": \"Server odmietol správu poslanú Vašim prehliadačom.\",\n\t\"pad.modals.rejected.cause\": \"Počas prehliadania poznámkové bloku mohlo dôjsť k aktualizácii servera alebo je niekde v Etherpade chyba. Skúste stránku načítať znovu.\",\n\t\"pad.modals.disconnected\": \"Boli ste odpojení.\",\n\t\"pad.modals.disconnected.explanation\": \"Spojenie so serverom sa prerušilo\",\n\t\"pad.modals.disconnected.cause\": \"Server môže byť nedostupný. Ak by problém pretrvával, informujte správcu služby.\",\n\t\"pad.share\": \"Zdieľať tento poznámkový blok\",\n\t\"pad.share.readonly\": \"Len na čítanie\",\n\t\"pad.share.link\": \"Odkaz\",\n\t\"pad.share.emebdcode\": \"Vložiť URL\",\n\t\"pad.chat\": \"Rozhovor\",\n\t\"pad.chat.title\": \"Otvoriť rozhovor tohoto poznámkového bloku.\",\n\t\"pad.chat.loadmessages\": \"Načítať ďalšie správy\",\n\t\"pad.chat.stick.title\": \"Prilepiť rozhovor na obrazovku\",\n\t\"pad.chat.writeMessage.placeholder\": \"Sem napíšte svoju správu\",\n\t\"timeslider.followContents\": \"Sledovať aktualizácie obsahu poznámkového bloku\",\n\t\"timeslider.pageTitle\": \"Časová os {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Späť do poznámkového bloku\",\n\t\"timeslider.toolbar.authors\": \"Autori:\",\n\t\"timeslider.toolbar.authorsList\": \"Bez autorov\",\n\t\"timeslider.toolbar.exportlink.title\": \"Export\",\n\t\"timeslider.exportCurrent\": \"Exportovať aktuálnu verziu ako:\",\n\t\"timeslider.version\": \"Verzia {{version}}\",\n\t\"timeslider.saved\": \"Uložené {{day}}. {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Pustiť / Pozastaviť obsah poznámkového bloku\",\n\t\"timeslider.backRevision\": \"Ísť v tomto poznámkovom bloku o jednu revíziu späť\",\n\t\"timeslider.forwardRevision\": \"Ísť v tomto poznámkovom bloku o jednu revíziu vpred\",\n\t\"timeslider.dateformat\": \"{{day}}. {{month}} {{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"januára\",\n\t\"timeslider.month.february\": \"februára\",\n\t\"timeslider.month.march\": \"marca\",\n\t\"timeslider.month.april\": \"apríla\",\n\t\"timeslider.month.may\": \"mája\",\n\t\"timeslider.month.june\": \"júna\",\n\t\"timeslider.month.july\": \"júla\",\n\t\"timeslider.month.august\": \"augusta\",\n\t\"timeslider.month.september\": \"septembra\",\n\t\"timeslider.month.october\": \"októbra\",\n\t\"timeslider.month.november\": \"novembra\",\n\t\"timeslider.month.december\": \"decembra\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: nemenovaný autor, few: nemenovaní autori, other: nemenovaných autorov ]}\",\n\t\"pad.savedrevs.marked\": \"Táto revízia bola označená ako uložená\",\n\t\"pad.savedrevs.timeslider\": \"Návštevou časovej osi môžete zobraziť uložené revízie\",\n\t\"pad.userlist.entername\": \"Zadajte svoje meno\",\n\t\"pad.userlist.unnamed\": \"nemenovaný\",\n\t\"pad.editbar.clearcolors\": \"Odstrániť farby autorov z celého  dokumentu? Táto akcia sa nedá vrátiť\",\n\t\"pad.impexp.importbutton\": \"Importovať teraz\",\n\t\"pad.impexp.importing\": \"Prebieha import...\",\n\t\"pad.impexp.confirmimport\": \"Import súboru prepíše celý súčasný obsah poznámkového bloku. Skutočne si želáte vykonať túto akciu?\",\n\t\"pad.impexp.convertFailed\": \"Tento súbor nie je možné importovať. Použite prosím iný formát súboru alebo nakopírujte text manuálne\",\n\t\"pad.impexp.padHasData\": \"Nebolo možné importovať tento súbor, pretože tento poznámkový blok už bol pozmenený. Importujte prosím súbor do nového poznámkového bloku\",\n\t\"pad.impexp.uploadFailed\": \"Nahrávanie zlyhalo, skúste to prosím znovu\",\n\t\"pad.impexp.importfailed\": \"Import zlyhal\",\n\t\"pad.impexp.copypaste\": \"Vložte prosím kópiu cez schránku\",\n\t\"pad.impexp.exportdisabled\": \"Export do formátu {{type}} nie je povolený. Kontaktujte prosím administrátora pre zistenie detailov.\",\n\t\"pad.impexp.maxFileSize\": \"Súbor je príliš veľký. Kontaktujte správcu pre zväčšenie povolenej veľkosti súborov pre import\"\n}\n"
  },
  {
    "path": "src/locales/skr-arab.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Saraiki\"\n\t\t]\n\t},\n\t\"admin_plugins\": \"پلگ ان منیجر\",\n\t\"admin_plugins.available\": \"دستیاب پلگ ان\",\n\t\"admin_plugins.available_not-found\": \"کوئی پلگ ان کائنی لبھے۔\",\n\t\"admin_plugins.available_install.value\": \"انسٹال\",\n\t\"admin_plugins.description\": \"تفصیل\",\n\t\"admin_plugins.installed_uninstall.value\": \"ان انسٹال\",\n\t\"admin_plugins.last-update\": \"چھیکڑی تبدیلی\",\n\t\"admin_plugins.name\": \"ناں\",\n\t\"admin_plugins.version\": \"ورژن\",\n\t\"admin_plugins_info.parts\": \"انسٹال تھئے حصے\",\n\t\"admin_plugins_info.plugins\": \"انسٹال تھئے پلگ ان\",\n\t\"admin_plugins_info.version_number\": \"ورشن نمبر\",\n\t\"admin_settings\": \"ترتیباں\",\n\t\"admin_settings.current_save.value\": \"ترتیباں محفوظ کرو\",\n\t\"admin_settings.page-title\": \"ترتیباں ــ ایتھرپیڈ\",\n\t\"index.newPad\": \"نواں پیڈ\",\n\t\"pad.toolbar.bold.title\": \"بولڈ(Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"ترچھے (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"ہیٹھ لکیر (Ctrl+U)\",\n\t\"pad.toolbar.indent.title\": \"حاشیہ (ٹیب)\",\n\t\"pad.toolbar.unindent.title\": \"حاشیہ ٻاہر دوں (شفٹ + ٹیٻ)\",\n\t\"pad.toolbar.undo.title\": \"اݨ کیتا (کنٹرول + زیڈ)\",\n\t\"pad.toolbar.redo.title\": \"ولدا کرو (کنٹرول + وائی)\",\n\t\"pad.toolbar.savedRevision.title\": \"رویژن بچاؤ\",\n\t\"pad.toolbar.settings.title\": \"ترتیباں\",\n\t\"pad.colorpicker.save\": \"بچاؤ\",\n\t\"pad.colorpicker.cancel\": \"منسوخ\",\n\t\"pad.loading\": \"لوڈ تھیندا پئے۔۔۔\",\n\t\"pad.settings.padSettings\": \"پیڈ ترتیباں\",\n\t\"pad.settings.fontType\": \"فونٹ قسم:\",\n\t\"pad.settings.language\": \"زبان:\",\n\t\"pad.settings.about\": \"تعارف\",\n\t\"pad.settings.poweredBy\": \"تکڑا کرݨ آلے\",\n\t\"pad.importExport.importSuccessful\": \"کامیاب!\",\n\t\"pad.importExport.exportetherpad\": \"ایتھرپیڈ\",\n\t\"pad.importExport.exporthtml\": \"ایچ ٹی ایم ایل\",\n\t\"pad.importExport.exportplain\": \"سادہ متن\",\n\t\"pad.importExport.exportword\": \"مائیکروسافٹ ورڈ\",\n\t\"pad.importExport.exportpdf\": \"پی ڈی ایف\",\n\t\"pad.modals.connected\": \"ڄُڑ ڳیا۔\",\n\t\"pad.modals.cancel\": \"منسوخ\",\n\t\"pad.modals.unauth\": \"اجازت کائنی\",\n\t\"pad.modals.initsocketfail\": \"سرور تائیں پہنچݨ ممکن کائنی\",\n\t\"pad.modals.slowcommit.explanation\": \"سرور توں جواب کائنی امدا پیا\",\n\t\"pad.modals.deleted\": \"مٹا ݙتے\",\n\t\"pad.modals.deleted.explanation\": \"ایہ پیڈ ہٹا ݙتا ڳئے۔\",\n\t\"pad.modals.disconnected\": \"تہاݙا کنکشن مُک ڳئے\",\n\t\"pad.share\": \"ایہ پیڈ شیئر کرو\",\n\t\"pad.share.readonly\": \"صرف پڑھو\",\n\t\"pad.share.link\": \"ربط\",\n\t\"pad.share.emebdcode\": \"امنیڈ یو آر ایل\",\n\t\"pad.chat\": \"چیٹ\",\n\t\"pad.chat.loadmessages\": \"ٻئے سنیہے لوڈ کرو\",\n\t\"pad.chat.writeMessage.placeholder\": \"آپݨاں سنیہا اتھ لکھو\",\n\t\"timeslider.toolbar.returnbutton\": \"واپس پیڈ تے ونڄو\",\n\t\"timeslider.toolbar.authors\": \"مصنف:\",\n\t\"timeslider.toolbar.authorsList\": \"کوئی مصنف کائنی\",\n\t\"timeslider.toolbar.exportlink.title\": \"ٻاہر بھیڄو\",\n\t\"timeslider.version\": \"ورژن {{version}}\",\n\t\"timeslider.saved\": \"محفوظ تھیا {{month}} {{day}}, {{year}}\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"جنوری\",\n\t\"timeslider.month.february\": \"فروری\",\n\t\"timeslider.month.march\": \"مارچ\",\n\t\"timeslider.month.april\": \"اپريل\",\n\t\"timeslider.month.may\": \"مئی\",\n\t\"timeslider.month.june\": \"جون\",\n\t\"timeslider.month.july\": \"جولائی\",\n\t\"timeslider.month.august\": \"اگست\",\n\t\"timeslider.month.september\": \"ستمبر\",\n\t\"timeslider.month.october\": \"اکتوبر\",\n\t\"timeslider.month.november\": \"نومبر\",\n\t\"timeslider.month.december\": \"دسمبر\",\n\t\"pad.userlist.entername\": \"آپݨا ناں درج کرو\",\n\t\"pad.userlist.unnamed\": \"بغیر ناں\",\n\t\"pad.impexp.importbutton\": \"ہݨ ٻاہروں گھن آؤ\",\n\t\"pad.impexp.importing\": \"اندر آندا پئے۔۔۔\",\n\t\"pad.impexp.uploadFailed\": \"فائل اپ لوڈ نی تھی سڳی، چڑھاوݨ کیتےولدا کوشش کرو\",\n\t\"pad.impexp.importfailed\": \"ٻاہروں آ نی سڳے\"\n}\n"
  },
  {
    "path": "src/locales/sl.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Dbc334\",\n\t\t\t\"Eleassar\",\n\t\t\t\"HairyFotr\",\n\t\t\t\"Mateju\",\n\t\t\t\"Skalcaa\",\n\t\t\t\"Upwinxp\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Administratorska nadzorna plošča – Etherpad\",\n\t\"admin_plugins\": \"Upravitelj vtičnikov\",\n\t\"admin_plugins.available\": \"Razpoložljivi vtičniki\",\n\t\"admin_plugins.available_not-found\": \"Ni najdenih vtičnikov.\",\n\t\"admin_plugins.available_fetching\": \"Pridobivanje ...\",\n\t\"admin_plugins.available_install.value\": \"Namesti\",\n\t\"admin_plugins.available_search.placeholder\": \"Poiščite vtičnike za namestitev\",\n\t\"admin_plugins.description\": \"Opis\",\n\t\"admin_plugins.installed\": \"Nameščeni vtičniki\",\n\t\"admin_plugins.installed_fetching\": \"Pridobivanje nameščenih vtičnikov ...\",\n\t\"admin_plugins.installed_nothing\": \"Namestili niste še nobenega vtičnika.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Odmesti\",\n\t\"admin_plugins.last-update\": \"Zadnja posodobitev\",\n\t\"admin_plugins.name\": \"Ime\",\n\t\"admin_plugins.page-title\": \"Upravitelj vtičnikov – Etherpad\",\n\t\"admin_plugins.version\": \"Različica\",\n\t\"admin_plugins_info\": \"Informacije o odpravljanju težav\",\n\t\"admin_plugins_info.hooks\": \"Nameščene razširitvene točke\",\n\t\"admin_plugins_info.hooks_client\": \"Razširitvene točke na strani odjemalca\",\n\t\"admin_plugins_info.hooks_server\": \"Razširitvene točke na strani strežnika\",\n\t\"admin_plugins_info.parts\": \"Nameščeni deli\",\n\t\"admin_plugins_info.plugins\": \"Nameščeni vtičniki\",\n\t\"admin_plugins_info.page-title\": \"Informacije o vtičniku – Etherpad\",\n\t\"admin_plugins_info.version\": \"Različica Etherpada\",\n\t\"admin_plugins_info.version_latest\": \"Najnovejša razpoložljiva različica\",\n\t\"admin_plugins_info.version_number\": \"Številka različice\",\n\t\"admin_settings\": \"Nastavitve\",\n\t\"admin_settings.current\": \"Trenutna konfiguracija\",\n\t\"admin_settings.current_example-devel\": \"Zgled predloge za razvojne nastavitve\",\n\t\"admin_settings.current_example-prod\": \"Zgled predloge za roizvodne nastavitve\",\n\t\"admin_settings.current_restart.value\": \"Znova zaženi Etherpad\",\n\t\"admin_settings.current_save.value\": \"Shrani nastavitve\",\n\t\"admin_settings.page-title\": \"Nastavitve – Etherpad\",\n\t\"index.newPad\": \"Nov blokec\",\n\t\"index.createOpenPad\": \"ali pa ustvari/odpri blokec z imenom:\",\n\t\"index.openPad\": \"odpri obstoječ blokec z imenom:\",\n\t\"pad.toolbar.bold.title\": \"Krepko (Ctrl + B)\",\n\t\"pad.toolbar.italic.title\": \"Ležeče (Ctrl + I)\",\n\t\"pad.toolbar.underline.title\": \"Podčrtano (Ctrl + U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Prečrtano (Ctrl + 5)\",\n\t\"pad.toolbar.ol.title\": \"Urejen seznam (Ctrl + dvigalka + N)\",\n\t\"pad.toolbar.ul.title\": \"Neurejen seznam (Ctrl + dvigalka + L)\",\n\t\"pad.toolbar.indent.title\": \"Zamik desno (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Zmanjšanje zamika (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Razveljavi (Ctrl + Z)\",\n\t\"pad.toolbar.redo.title\": \"Znova uveljavi (Ctrl + Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Počisti barve avtorstva (Ctrl + dvigalka + C)\",\n\t\"pad.toolbar.import_export.title\": \"Uvozi/Izvozi iz/v različne datotečne formate\",\n\t\"pad.toolbar.timeslider.title\": \"Časovni trak\",\n\t\"pad.toolbar.savedRevision.title\": \"Shrani redakcijo\",\n\t\"pad.toolbar.settings.title\": \"Nastavitve\",\n\t\"pad.toolbar.embed.title\": \"Deli in vključi ta blokec\",\n\t\"pad.toolbar.showusers.title\": \"Pokaži uporabnike blokca\",\n\t\"pad.colorpicker.save\": \"Shrani\",\n\t\"pad.colorpicker.cancel\": \"Prekliči\",\n\t\"pad.loading\": \"Nalaganje ...\",\n\t\"pad.noCookie\": \"Piškotka ni bilo mogoče najti. Prosimo, dovolite piškotke v vašem brskalniku! Vaša seja in nastavitve se med obiski ne bodo shranili. Razlog za to je morda, da je Etherpad v nekaterih brskalnikih vključen v iFrame. Zsgotovite, da je Etherpad na isti poddomeni/domeni kot nadrejeni iFrame.\",\n\t\"pad.permissionDenied\": \"Nimate dovoljenja za dostop do tega blokca.\",\n\t\"pad.settings.padSettings\": \"Nastavitve blokca.\",\n\t\"pad.settings.myView\": \"Moj prikaz\",\n\t\"pad.settings.stickychat\": \"Vsebina klepeta je vedno na zaslonu\",\n\t\"pad.settings.chatandusers\": \"Prikaži klepet in uporabnike\",\n\t\"pad.settings.colorcheck\": \"Barve avtorstva\",\n\t\"pad.settings.linenocheck\": \"Številke vrstic\",\n\t\"pad.settings.rtlcheck\": \"Ali naj se vsebina bere od desne proti levi?\",\n\t\"pad.settings.fontType\": \"Vrsta pisave:\",\n\t\"pad.settings.fontType.normal\": \"Normalno\",\n\t\"pad.settings.language\": \"Jezik:\",\n\t\"pad.settings.deletePad\": \"Izbriši ploščico\",\n\t\"pad.delete.confirm\": \"Res želite izbrisati to ploščico?\",\n\t\"pad.settings.about\": \"Kolofon\",\n\t\"pad.settings.poweredBy\": \"Omogoča\",\n\t\"pad.importExport.import_export\": \"Uvoz/Izvoz\",\n\t\"pad.importExport.import\": \"Naložite katero koli besedilno datoteko ali dokument.\",\n\t\"pad.importExport.importSuccessful\": \"Uspešno!\",\n\t\"pad.importExport.export\": \"Izvozi trenutni blokec kot:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Golo besedilo\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF\",\n\t\"pad.importExport.abiword.innerHTML\": \"Uvoziti je mogoče le golo besedilo in formate HTML. Za naprednejše možnosti uvoza namestite program <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">AbiWord</a>.\",\n\t\"pad.modals.connected\": \"Povezano.\",\n\t\"pad.modals.reconnecting\": \"Poteka povezovanje z blokcem ...\",\n\t\"pad.modals.forcereconnect\": \"Vsili ponovno povezavo\",\n\t\"pad.modals.reconnecttimer\": \"Poskus ponovne vzpostavitve povezave čez\",\n\t\"pad.modals.cancel\": \"Prekliči\",\n\t\"pad.modals.userdup\": \"Blokec je že odprt v drugem oknu\",\n\t\"pad.modals.userdup.explanation\": \"Videti je, da je ta blokec na tem računalniku odprt v več kot enem oknu brskalnika.\",\n\t\"pad.modals.userdup.advice\": \"Znova vzpostavite povezavo in uporabljajte to okno.\",\n\t\"pad.modals.unauth\": \"Nepooblaščeni dostop\",\n\t\"pad.modals.unauth.explanation\": \"Med ogledovanjem strani so se vaša dovoljenja za ogled spremenila. Poskusite se znova povezati.\",\n\t\"pad.modals.looping.explanation\": \"Pri komunikaciji s sinhronizacijskim strežnikom je prišlo do težav.\",\n\t\"pad.modals.looping.cause\": \"Morda ste se povezali skozi neustrezno nastavljen požarni zid ali prek posredniškega strežnika.\",\n\t\"pad.modals.initsocketfail\": \"Strežnik je nedosegljiv.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Povezovanje s sinhronizacijskim strežnikom ni uspelo.\",\n\t\"pad.modals.initsocketfail.cause\": \"Najverjetneje gre za težavo z vašim brskalnikom ali internetno povezavo.\",\n\t\"pad.modals.slowcommit.explanation\": \"Strežnik se ne odziva.\",\n\t\"pad.modals.slowcommit.cause\": \"Možen vzrok so težave z omrežno povezljivostjo.\",\n\t\"pad.modals.badChangeset.explanation\": \"Urejanje, ki ste ga naredili, je sinhronizacijski strežnik prepoznal kot nedovoljeno.\",\n\t\"pad.modals.badChangeset.cause\": \"Razlog za to je morda napačna konfiguracija strežnika ali neko drugo nepričakovano vedenje. Če menite, da gre za napako, stopite v stik z administratorjem storitve. Za nadaljevanje urejanja se poskusite znova povezati.\",\n\t\"pad.modals.corruptPad.explanation\": \"Blokec, do katerega želite dostopati, je poškodovan.\",\n\t\"pad.modals.corruptPad.cause\": \"Razlog za to je morda napačna konfiguracija strežnika ali neko drugo nepričakovano vedenje. Prosimo, stopite v stik z administratorjem storitve.\",\n\t\"pad.modals.deleted\": \"Izbrisano.\",\n\t\"pad.modals.deleted.explanation\": \"Blokec je odstranjen.\",\n\t\"pad.modals.rateLimited\": \"Omejena hitrost.\",\n\t\"pad.modals.rateLimited.explanation\": \"Na ta blokec ste poslali preveč sporočil, zato ste bili odklopljeni.\",\n\t\"pad.modals.rejected.explanation\": \"Strežnik je zavrnil sporočilo, ki ga je poslal vaš brskalnik.\",\n\t\"pad.modals.rejected.cause\": \"Strežnik je bil morda posodobljen, ko ste si ogledovali blokec, ali pa je v Etherpadu napaka. Poskusite znova naložiti stran.\",\n\t\"pad.modals.disconnected\": \"Vaša povezava je bila prekinjena.\",\n\t\"pad.modals.disconnected.explanation\": \"Povezava s strežnikom je bila izgubljena.\",\n\t\"pad.modals.disconnected.cause\": \"Strežnik morda ni na voljo. Če se to ponavlja, obvestite administratorja storitve.\",\n\t\"pad.share\": \"Deljenje blokca\",\n\t\"pad.share.readonly\": \"Samo za branje\",\n\t\"pad.share.link\": \"Povezava\",\n\t\"pad.share.emebdcode\": \"URL za vključitev\",\n\t\"pad.chat\": \"Klepet\",\n\t\"pad.chat.title\": \"Odpri klepetalno okno za blokec.\",\n\t\"pad.chat.loadmessages\": \"Naloži več sporočil\",\n\t\"pad.chat.stick.title\": \"Prilepi klepet na zaslon\",\n\t\"pad.chat.writeMessage.placeholder\": \"Napišite sporočilo\",\n\t\"timeslider.followContents\": \"Spremljajte posodobitve vsebine blokca\",\n\t\"timeslider.pageTitle\": \"Časovni trak {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Nazaj na blokec\",\n\t\"timeslider.toolbar.authors\": \"Avtorji:\",\n\t\"timeslider.toolbar.authorsList\": \"Ni določenih avtorjev\",\n\t\"timeslider.toolbar.exportlink.title\": \"Izvozi\",\n\t\"timeslider.exportCurrent\": \"Izvozi trenutno različico kot:\",\n\t\"timeslider.version\": \"Različica {{version}}\",\n\t\"timeslider.saved\": \"Shranjeno {{day}}.{{month}}.{{year}}\",\n\t\"timeslider.playPause\": \"Predvajaj/zaustavi vsebino blokca\",\n\t\"timeslider.backRevision\": \"Pojdi v tem blokcu eno redakcijo nazaj\",\n\t\"timeslider.forwardRevision\": \"Pojdi v tem blokcu eno redakcijo naprej\",\n\t\"timeslider.dateformat\": \"{{day}}.{{month}}.{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"januarja\",\n\t\"timeslider.month.february\": \"februarja\",\n\t\"timeslider.month.march\": \"marca\",\n\t\"timeslider.month.april\": \"aprila\",\n\t\"timeslider.month.may\": \"maja\",\n\t\"timeslider.month.june\": \"junija\",\n\t\"timeslider.month.july\": \"julija\",\n\t\"timeslider.month.august\": \"avgusta\",\n\t\"timeslider.month.september\": \"septembra\",\n\t\"timeslider.month.october\": \"oktobra\",\n\t\"timeslider.month.november\": \"novembra\",\n\t\"timeslider.month.december\": \"decembra\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: neimenovan avtor, plural(num) two: neimenovana avtorja, plural(num) few: neimenovani avtorji, other: neimenovanih avtorjev ]}\",\n\t\"pad.savedrevs.marked\": \"Ta redakcija je zdaj označena kot shranjena redakcija\",\n\t\"pad.savedrevs.timeslider\": \"Shranjene redakcije si lahko ogledate z odprtjem časovnega traku\",\n\t\"pad.userlist.entername\": \"Vnesite svoje ime\",\n\t\"pad.userlist.unnamed\": \"neimenovana oseba\",\n\t\"pad.editbar.clearcolors\": \"Ali naj se počistijo barve avtorstva v vsem dokumentu? Tega ni mogoče razveljaviti.\",\n\t\"pad.impexp.importbutton\": \"Uvozi takoj\",\n\t\"pad.impexp.importing\": \"Poteka uvažanje ...\",\n\t\"pad.impexp.confirmimport\": \"Uvoz datoteke bo prepisal obstoječe besedilo blokca. Ali res želite nadaljevati?\",\n\t\"pad.impexp.convertFailed\": \"Datoteke ni bilo mogoče uvoziti. Prosimo, uporabite drug format dokumenta ali pa vsebino kopirajte in prilepite ročno.\",\n\t\"pad.impexp.padHasData\": \"Datoteke ni bilo mogoče uvoziti, ker blokec že vsebuje spremembe. Prosimo, uvozite datoteko v nov blokec.\",\n\t\"pad.impexp.uploadFailed\": \"Nalaganje je spodletelo, prosimo poskusite znova\",\n\t\"pad.impexp.importfailed\": \"Uvoz je spodletel\",\n\t\"pad.impexp.copypaste\": \"Vsebino kopirajte in prilepite\",\n\t\"pad.impexp.exportdisabled\": \"Izvoz v format {{type}} je onemogočen. Za več podrobnosti stopite v stik z administratorjem.\",\n\t\"pad.impexp.maxFileSize\": \"Datoteka je prevelika. Za povečanje dovoljene velikosti datoteke za uvoz se obrnite na administratorja spletnega mesta\"\n}\n"
  },
  {
    "path": "src/locales/sms.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Yupik\"\n\t\t]\n\t},\n\t\"admin_plugins.description\": \"Deskriptt\",\n\t\"admin_plugins.name\": \"Nõmm\",\n\t\"admin_plugins.version\": \"Versio\",\n\t\"admin_plugins_info.version\": \"Etherpad-versio\",\n\t\"admin_plugins_info.version_number\": \"Versionââmar\",\n\t\"admin_settings\": \"Asetõõzz\",\n\t\"admin_settings.current_save.value\": \"Ruõkk asetõõzzid\",\n\t\"admin_settings.page-title\": \"Asetõõzz - Etherpad\",\n\t\"index.newPad\": \"Ođđ mošttʼtõspõʹmmai\",\n\t\"index.copyLink\": \"2. Kopiââʹst liiŋk\",\n\t\"index.createOpenPad\": \"Ääʹved mošttʼtõspõʹmmai nõõmin\",\n\t\"pad.toolbar.underline.title\": \"Vuâllacertldâsttmõš (CTRL-U)\",\n\t\"pad.toolbar.undo.title\": \"Kååʹmet (Ctrl+Z)\",\n\t\"pad.toolbar.savedRevision.title\": \"Ruõkk muttâz\",\n\t\"pad.toolbar.settings.title\": \"Asetõõzz\",\n\t\"pad.toolbar.showusers.title\": \"Čuäʼjet tän mošttʼtõspõʹmmai õõʹnnʼjid\",\n\t\"pad.colorpicker.save\": \"Ruõkk\",\n\t\"pad.colorpicker.cancel\": \"Jõõsk\",\n\t\"pad.settings.padSettings\": \"Mošttʼtõspõʹmmai asetõõzz\",\n\t\"pad.settings.chatandusers\": \"Čuäʹjet čääʹtt da õõʹnnʼjid\",\n\t\"pad.settings.language\": \"Ǩiõll:\",\n\t\"pad.settings.about\": \"Lââʹssteâđ\",\n\t\"pad.settings.poweredBy\": \"Kääzzkõõzz vueiʹtlvâstt\",\n\t\"pad.importExport.importSuccessful\": \"Oʹnnsti!\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.modals.cancel\": \"Jõõsk\",\n\t\"pad.modals.slowcommit.explanation\": \"Server ij vaʹstted.\",\n\t\"pad.modals.deleted\": \"Jaukkuum.\",\n\t\"pad.modals.deleted.explanation\": \"Tät mošttʼtõspõʹmmai lij jaukkuum.\",\n\t\"pad.share\": \"Jueʼjj mošttʼtõspõʹmmai\",\n\t\"pad.share.link\": \"Liŋkk\",\n\t\"pad.chat\": \"Čäʹtt\",\n\t\"pad.chat.writeMessage.placeholder\": \"Ǩeeʹrjet jiijjad saaǥǥ täzz\",\n\t\"timeslider.toolbar.returnbutton\": \"Määʹcc mošttʼtõspõʹmma\",\n\t\"timeslider.version\": \"Versio {{version}}\",\n\t\"timeslider.saved\": \"Ruõkkum {{month}} {{day}}. peeiʹv {{year}}\",\n\t\"timeslider.dateformat\": \"{{day}}.{{month}}.{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"ođđeeʹjjmannu\",\n\t\"timeslider.month.february\": \"täʹlvvmannu\",\n\t\"timeslider.month.march\": \"pâʹsslašttâm-mannu\",\n\t\"timeslider.month.april\": \"njuhččmannu\",\n\t\"timeslider.month.may\": \"vueʹssmannu\",\n\t\"timeslider.month.june\": \"ǩieʹssmannu\",\n\t\"timeslider.month.july\": \"sueiʹnnmannu\",\n\t\"timeslider.month.august\": \"påʹrǧǧmannu\",\n\t\"timeslider.month.september\": \"čõhččmannu\",\n\t\"timeslider.month.october\": \"kålggmannu\",\n\t\"timeslider.month.november\": \"skamm-mannu\",\n\t\"timeslider.month.december\": \"rosttovmannu\",\n\t\"pad.userlist.entername\": \"Ǩeeʹrjet jiijjad nõõm\"\n}\n"
  },
  {
    "path": "src/locales/sq.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Besnik b\",\n\t\t\t\"Eraldkerciku\",\n\t\t\t\"Kosovastar\",\n\t\t\t\"Liridon\",\n\t\t\t\"Xhulianoo\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Pult Përgjegjësi - Etherpad\",\n\t\"admin_plugins\": \"Përgjegjës shtojcash\",\n\t\"admin_plugins.available\": \"Shtojca të gatshme\",\n\t\"admin_plugins.available_not-found\": \"S’u gjetën shtojca.\",\n\t\"admin_plugins.available_fetching\": \"Po sillet…\",\n\t\"admin_plugins.available_install.value\": \"Instaloje\",\n\t\"admin_plugins.available_search.placeholder\": \"Kërkoni për shtojca për instalim\",\n\t\"admin_plugins.description\": \"Përshkrim\",\n\t\"admin_plugins.installed\": \"Shtojca të instaluara\",\n\t\"admin_plugins.installed_fetching\": \"Po sillen shtojcat e instaluara…\",\n\t\"admin_plugins.installed_nothing\": \"S’keni instaluar ende ndonjë shtojcë.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Çinstaloje\",\n\t\"admin_plugins.last-update\": \"Përditësimi i fundit më\",\n\t\"admin_plugins.name\": \"Emër\",\n\t\"admin_plugins.page-title\": \"Përgjegjës shtojcash - Etherpad\",\n\t\"admin_plugins.version\": \"Version\",\n\t\"admin_plugins_info\": \"Të dhëna diagnostikimi\",\n\t\"admin_plugins_info.hooks\": \"Hook-e të instaluar\",\n\t\"admin_plugins_info.hooks_client\": \"Hook-e më anë të klientit\",\n\t\"admin_plugins_info.hooks_server\": \"Hook-e më anë të shërbyesit\",\n\t\"admin_plugins_info.parts\": \"Pjesë të instaluara\",\n\t\"admin_plugins_info.plugins\": \"Shtojca të instaluara\",\n\t\"admin_plugins_info.page-title\": \"Të dhëna shtojce - Etherpad\",\n\t\"admin_plugins_info.version\": \"Version Etherpad-i\",\n\t\"admin_plugins_info.version_latest\": \"Versioni më i ri i gatshëm\",\n\t\"admin_plugins_info.version_number\": \"Numër versioni\",\n\t\"admin_settings\": \"Rregullime\",\n\t\"admin_settings.current\": \"Formësimi i tanishëm\",\n\t\"admin_settings.current_example-devel\": \"Gjedhe rregullimesh shembulli zhvillimi\",\n\t\"admin_settings.current_example-prod\": \"Gjedhe rregullimesh shembulli për instalim real\",\n\t\"admin_settings.current_restart.value\": \"Rinise Etherpad-in\",\n\t\"admin_settings.current_save.value\": \"Ruaji Rregullimet\",\n\t\"admin_settings.page-title\": \"Rregullime - Etherpad\",\n\t\"index.newPad\": \"Bllok i Ri\",\n\t\"index.createOpenPad\": \"ose krijoni/hapni një Bllok me emrin:\",\n\t\"index.openPad\": \"hapni një Bllok ekzistues me emrin:\",\n\t\"pad.toolbar.bold.title\": \"Të trasha (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Të pjerrëta (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Të nënvizuara (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Hequr vije (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Listë e renditur (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Listë e parenditur (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Brendazi (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Jashtazi (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Zhbëje (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Ribëje (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Hiqu Ngjyra Autorësish (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importoni/Eksportoni nga/në formate të tjera kartelash\",\n\t\"pad.toolbar.timeslider.title\": \"Rrjedha kohore\",\n\t\"pad.toolbar.savedRevision.title\": \"Ruaje Rishikimin\",\n\t\"pad.toolbar.settings.title\": \"Rregullime\",\n\t\"pad.toolbar.embed.title\": \"Ndajeni me të tjerët dhe Trupëzojeni këtë bllok\",\n\t\"pad.toolbar.showusers.title\": \"Shfaq përdoruesit në këtë bllok\",\n\t\"pad.colorpicker.save\": \"Ruaje\",\n\t\"pad.colorpicker.cancel\": \"Anuloje\",\n\t\"pad.loading\": \"Po ngarkohet…\",\n\t\"pad.noCookie\": \"S’u gjet dot cookie. Ju lutemi, lejoni cookie-t te shfletuesi juaj! Sesioni dhe rregullimet tuaja s’do të ruhen nga një sesion në tjetër. Kjo mund të vijë ngaqë, në disa shfletues, Etherpad përfshihet brenda një iFrame. Ju lutemi, sigurohuni që Etherpad-i të jetë në të njëjtën nënpërkatësi/përkatësi si iFrame-i mëmë.\",\n\t\"pad.permissionDenied\": \"S’keni leje të hyni në këtë bllok\",\n\t\"pad.settings.padSettings\": \"Rregullime Blloku\",\n\t\"pad.settings.myView\": \"Pamja Ime\",\n\t\"pad.settings.stickychat\": \"Fjalosje përherë në ekran\",\n\t\"pad.settings.chatandusers\": \"Shfaq Fjalosje dhe Përdorues\",\n\t\"pad.settings.colorcheck\": \"Ngjyra autorësish\",\n\t\"pad.settings.linenocheck\": \"Numra rreshtash\",\n\t\"pad.settings.rtlcheck\": \"Të lexohet lënda nga e djathta në të majtë?\",\n\t\"pad.settings.fontType\": \"Lloj shkronjash:\",\n\t\"pad.settings.fontType.normal\": \"Normale\",\n\t\"pad.settings.language\": \"Gjuhë:\",\n\t\"pad.settings.about\": \"Mbi\",\n\t\"pad.settings.poweredBy\": \"Bazuar në\",\n\t\"pad.importExport.import_export\": \"Import/Eksport\",\n\t\"pad.importExport.import\": \"Ngarkoni cilëndo kartelë tekst ose dokument\",\n\t\"pad.importExport.importSuccessful\": \"Me sukses!\",\n\t\"pad.importExport.export\": \"Eksportojeni bllokun e tanishëm si:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Tekst të thjeshtë\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Mund të importoni vetëm prej formati tekst i thjeshtë ose HTML. Për veçori më të thelluara importimi, ju lutemi, <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">instaloni AbiWord-in ose LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"I lidhur.\",\n\t\"pad.modals.reconnecting\": \"Po rilidheni te blloku juaj…\",\n\t\"pad.modals.forcereconnect\": \"Detyro rilidhje\",\n\t\"pad.modals.reconnecttimer\": \"Provë për rilidhje pas\",\n\t\"pad.modals.cancel\": \"Anuloje\",\n\t\"pad.modals.userdup\": \"Hapur në një tjetër dritare\",\n\t\"pad.modals.userdup.explanation\": \"Ky bllok duket se gjendet i hapur në më shumë se një dritare shfletuesi në këtë kompjuter.\",\n\t\"pad.modals.userdup.advice\": \"Që të përdoret kjo dritare, rilidhuni.\",\n\t\"pad.modals.unauth\": \"I paautorizuar\",\n\t\"pad.modals.unauth.explanation\": \"Lejet tuaja ndryshuan teksa shihnit këtë dritare. Provoni të rilidheni.\",\n\t\"pad.modals.looping.explanation\": \"Ka probleme komunikimi me shërbyesin e njëkohësimit.\",\n\t\"pad.modals.looping.cause\": \"Ndoshta jeni lidhur përmes një firewall-i ose ndërmjetësi të papërputhshëm.\",\n\t\"pad.modals.initsocketfail\": \"Shërbyesi është i pakapshëm.\",\n\t\"pad.modals.initsocketfail.explanation\": \"S’u lidh dot te shërbyesi i njëkohësimit.\",\n\t\"pad.modals.initsocketfail.cause\": \"Ka gjasa që kjo vjen për shkak të një problemi me shfletuesin tuaj ose lidhjen tuaj në internet.\",\n\t\"pad.modals.slowcommit.explanation\": \"Shërbyesi s’po përgjigjet.\",\n\t\"pad.modals.slowcommit.cause\": \"Kjo mund të vijë për shkak problemesh lidhjeje me rrjetin.\",\n\t\"pad.modals.badChangeset.explanation\": \"Një përpunim që keni bërë, u vlerësua si i paligjshëm nga shërbyesi i njëkohësimit.\",\n\t\"pad.modals.badChangeset.cause\": \"Kjo mund të jetë për shkak të një formësimi të gabuar të shërbyesit ose ndonjë tjetër sjelljeje të papritur. Ju lutemi, lidhuni me përgjegjësin e shërbimit, nëse mendoni se ky është një gabim. Që të vazhdoni përpunimin, provoni të rilidheni.\",\n\t\"pad.modals.corruptPad.explanation\": \"Blloku te i cili po përpiqeni të hyni është i dëmtuar.\",\n\t\"pad.modals.corruptPad.cause\": \"Kjo mund të vijë nga një formësim i gabuar shërbyesi ose ndonjë tjetër sjellje e papritur. Ju lutemi, lidhuni me përgjegjësin e shërbimit.\",\n\t\"pad.modals.deleted\": \"I fshirë.\",\n\t\"pad.modals.deleted.explanation\": \"Ky bllok është hequr.\",\n\t\"pad.modals.rateLimited\": \"Shpejtësi e Kufizuar.\",\n\t\"pad.modals.rateLimited.explanation\": \"Dërguat shumë mesazhe te ky bllok, ndaj u bë shkëputja juaj.\",\n\t\"pad.modals.rejected.explanation\": \"Shërbyesi hodhi poshtë një mesazh që qe dërguar nga shfletuesi juaj.\",\n\t\"pad.modals.rejected.cause\": \"Shërbyes mund të jetë përditësuar, ndërkohë që po shihnit bllokun, ose ndoshta ka një të metë te Etherpad-i. Provoni të ringarkoni faqen.\",\n\t\"pad.modals.disconnected\": \"Jeni shkëputur.\",\n\t\"pad.modals.disconnected.explanation\": \"U ndërpre lidhja me shërbyesin\",\n\t\"pad.modals.disconnected.cause\": \"Shërbyesi mund të mos jetë në punë. Ju lutemi, njoftoni përgjegjësin e shërbimit, nëse kjo vazhdon të ndodhë.\",\n\t\"pad.share\": \"Ndajeni këtë bllok me të tjerët\",\n\t\"pad.share.readonly\": \"Vetëm për lexim\",\n\t\"pad.share.link\": \"Lidhje\",\n\t\"pad.share.emebdcode\": \"URL trupëzimi\",\n\t\"pad.chat\": \"Fjalosje\",\n\t\"pad.chat.title\": \"Hapni fjalosjen për këtë bllok.\",\n\t\"pad.chat.loadmessages\": \"Ngarko më tepër mesazhe\",\n\t\"pad.chat.stick.title\": \"Ngjit bisedën në ekran\",\n\t\"pad.chat.writeMessage.placeholder\": \"Shkruajeni mesazhin tuaj këtu\",\n\t\"timeslider.followContents\": \"Ndiqni përditësime lënde blloku\",\n\t\"timeslider.pageTitle\": \"Rrjedhë kohore e {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Rikthehuni te blloku\",\n\t\"timeslider.toolbar.authors\": \"Autorë:\",\n\t\"timeslider.toolbar.authorsList\": \"S’ka Autorë\",\n\t\"timeslider.toolbar.exportlink.title\": \"Eksportoje\",\n\t\"timeslider.exportCurrent\": \"Eksportojeni versionin e tanishëm si:\",\n\t\"timeslider.version\": \"Versioni {{version}}\",\n\t\"timeslider.saved\": \"Ruajtur më {{day}} {{month}}, {{year}}\",\n\t\"timeslider.playPause\": \"Luaj / Pusho Lëndë Blloku\",\n\t\"timeslider.backRevision\": \"Kalo një rishikim mbrapsht në këtë Bllok\",\n\t\"timeslider.forwardRevision\": \"Kalo një rishikim përpara në këtë Bllok\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Janar\",\n\t\"timeslider.month.february\": \"Shkurt\",\n\t\"timeslider.month.march\": \"Mars\",\n\t\"timeslider.month.april\": \"Prill\",\n\t\"timeslider.month.may\": \"Maj\",\n\t\"timeslider.month.june\": \"Qershor\",\n\t\"timeslider.month.july\": \"Korrik\",\n\t\"timeslider.month.august\": \"Gusht\",\n\t\"timeslider.month.september\": \"Shtator\",\n\t\"timeslider.month.october\": \"Tetor\",\n\t\"timeslider.month.november\": \"Nëntor\",\n\t\"timeslider.month.december\": \"Dhjetor\",\n\t\"timeslider.unnamedauthors\": \"{{num}} i paemër {[plural(num) një: autor, tjetër:{{num}} autorë ]}\",\n\t\"pad.savedrevs.marked\": \"Ky rishikim tani është shënuar si rishikim i ruajtur\",\n\t\"pad.savedrevs.timeslider\": \"Rishikimet e ruajtura mund t’i shihni duke vizituar rrjedhën kohore\",\n\t\"pad.userlist.entername\": \"Jepni emrin tuaj\",\n\t\"pad.userlist.unnamed\": \"pa emër\",\n\t\"pad.editbar.clearcolors\": \"Të hiqen ngjyra autorësish në krejt dokumentin? KJo s’mund të zhbëhet\",\n\t\"pad.impexp.importbutton\": \"Importoje Tani\",\n\t\"pad.impexp.importing\": \"Po importohet…\",\n\t\"pad.impexp.confirmimport\": \"Importimi i një kartele do të mbishkruajë tekstin e tanishëm të bllokut. Jeni i sigurt se doni të vazhdohet?\",\n\t\"pad.impexp.convertFailed\": \"Nuk qemë në gjendje ta importonim këtë kartelë. Ju lutemi, përdorni një format tjetër dokumentesh ose kopjojeni dhe hidheni dorazi\",\n\t\"pad.impexp.padHasData\": \"S’qemë në gjendje të importojmë këtë kartelë, ngaqë ky Bllok kish tashmë ndryshime, ju lutemi,  importojeni tek një bllok i ri\",\n\t\"pad.impexp.uploadFailed\": \"Ngarkimi dështoi, ju lutemi, riprovoni\",\n\t\"pad.impexp.importfailed\": \"Importimi dështoi\",\n\t\"pad.impexp.copypaste\": \"Ju lutemi, kopjojeni dhe ngjiteni\",\n\t\"pad.impexp.exportdisabled\": \"Eksportimi në formatin {{type}} është i çaktivizuar. Për hollësi, ju lutemi, lidhuni me administratorin e sistemit.\",\n\t\"pad.impexp.maxFileSize\": \"Kartelë shumë e madhe. Lidhuni me përgjegjësin e sajtit tuaj që të rritni madhësinë e lejuar për importim kartelash\"\n}\n"
  },
  {
    "path": "src/locales/sr-ec.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Aca\",\n\t\t\t\"Acamicamacaraca\",\n\t\t\t\"Aktron\",\n\t\t\t\"BadDog\",\n\t\t\t\"Kizule\",\n\t\t\t\"Milicevic01\",\n\t\t\t\"Obsuser\",\n\t\t\t\"Srdjan m\",\n\t\t\t\"Srđan\",\n\t\t\t\"Zenfiric\",\n\t\t\t\"Милан Јелисавчић\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Нови Пад\",\n\t\"index.createOpenPad\": \"или направите/отворите пад следећег назива:\",\n\t\"pad.toolbar.bold.title\": \"Подебљано (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Искошено (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Подвучено (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Прецртано (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Уређен списак (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Неуређен списак (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Увлачење (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Извлачење (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Опозови (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Понови (Ctrl+Z)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Очисти ауторске боје (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Увези/извези из/на друге датотечне формате\",\n\t\"pad.toolbar.timeslider.title\": \"Временска линија\",\n\t\"pad.toolbar.savedRevision.title\": \"Сачувај верзију\",\n\t\"pad.toolbar.settings.title\": \"Подешавања\",\n\t\"pad.toolbar.embed.title\": \"Сачувај и угради овај пад\",\n\t\"pad.toolbar.showusers.title\": \"Прикажи кориснике на овом паду\",\n\t\"pad.colorpicker.save\": \"Сачувај\",\n\t\"pad.colorpicker.cancel\": \"Откажи\",\n\t\"pad.loading\": \"Учитавам…\",\n\t\"pad.permissionDenied\": \"Немате дозволу да приступите овом паду\",\n\t\"pad.settings.padSettings\": \"Подешавања пада\",\n\t\"pad.settings.myView\": \"Мој приказ\",\n\t\"pad.settings.stickychat\": \"Ћаскање увек на екрану\",\n\t\"pad.settings.chatandusers\": \"Прикажи ћаскање и кориснике\",\n\t\"pad.settings.colorcheck\": \"Ауторске боје\",\n\t\"pad.settings.linenocheck\": \"Бројеви редова\",\n\t\"pad.settings.rtlcheck\": \"Читај садржај с десна на лево?\",\n\t\"pad.settings.fontType\": \"Врста фонта:\",\n\t\"pad.settings.fontType.normal\": \"Нормално\",\n\t\"pad.settings.language\": \"Језик:\",\n\t\"pad.settings.about\": \"О пројекту\",\n\t\"pad.settings.poweredBy\": \"Покреће\",\n\t\"pad.importExport.import_export\": \"Увоз/извоз\",\n\t\"pad.importExport.import\": \"Отпремите било коју текстуалну датотеку или документ\",\n\t\"pad.importExport.importSuccessful\": \"Успешно!\",\n\t\"pad.importExport.export\": \"Извези тренутни пад као:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Чист текст\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Једино можете увести са једноставног текстуалног формата или HTML формата. За компликованије функције о увозу, молимо да <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">инсталирате AbiWord или LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Повезано.\",\n\t\"pad.modals.reconnecting\": \"Поново се повезујем на ваш пад...\",\n\t\"pad.modals.forcereconnect\": \"Присилно се поново повежи\",\n\t\"pad.modals.reconnecttimer\": \"Покушавам се поново повезати\",\n\t\"pad.modals.cancel\": \"Откажи\",\n\t\"pad.modals.userdup\": \"Отворено у другом прозору\",\n\t\"pad.modals.userdup.explanation\": \"Изгледа да је овај пад отворен у два или више прозора на овом рачунару.\",\n\t\"pad.modals.userdup.advice\": \"Поново се повежите на овој прозор.\",\n\t\"pad.modals.unauth\": \"Нисте овлашћени\",\n\t\"pad.modals.unauth.explanation\": \"Ваша допуштења се се променила док сте прегледавали страницу. Покушајте се поново повезати.\",\n\t\"pad.modals.looping.explanation\": \"Постоје комуникацијски проблеми са синхронизационим сервером.\",\n\t\"pad.modals.looping.cause\": \"Можда сте се повезали преко неподржаног заштитног зида или проксија.\",\n\t\"pad.modals.initsocketfail\": \"Сервер је недоступан.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Не могу се повезати на синхронизациони сервер.\",\n\t\"pad.modals.initsocketfail.cause\": \"Највероватније је дошло до проблем са вашим прегледачем или вашом интернетском везом.\",\n\t\"pad.modals.slowcommit.explanation\": \"Сервер не одговара.\",\n\t\"pad.modals.slowcommit.cause\": \"Највероватније је дошло до проблема са мрежном повезаношћу.\",\n\t\"pad.modals.badChangeset.explanation\": \"Синхронизациони сервер је уређивање које сте начили означио као неисправно.\",\n\t\"pad.modals.badChangeset.cause\": \"Могуће да је дошло до погрешне конфигурације сервера или неког другог неочекиваног догађаја. Молимо вас да контактирате сервисног администратора ако мислите да је ово грешка. Покушајте се поново повезати како бисте наставили с уређивањем.\",\n\t\"pad.modals.corruptPad.explanation\": \"Пад којем покушавате приступити је оштећен.\",\n\t\"pad.modals.corruptPad.cause\": \"Могуће да је дошло до погрешне конфигурације сервера или неког другог неочекиваног догађаја. Молимо вас да контактирате сервисног администратора.\",\n\t\"pad.modals.deleted\": \"Обрисано.\",\n\t\"pad.modals.deleted.explanation\": \"Овај пад је уклоњен.\",\n\t\"pad.modals.disconnected\": \"Веза је прекинута.\",\n\t\"pad.modals.disconnected.explanation\": \"Изгубљена је веза са сервером\",\n\t\"pad.modals.disconnected.cause\": \"Сервер није доступан. Обавестите сервисног администратора ако се ово настави дешавати.\",\n\t\"pad.share\": \"Пофели овај пад\",\n\t\"pad.share.readonly\": \"Само за читање\",\n\t\"pad.share.link\": \"Веза\",\n\t\"pad.share.emebdcode\": \"Угради везу\",\n\t\"pad.chat\": \"Ћаскање\",\n\t\"pad.chat.title\": \"Отворите ћаскање за овај пад.\",\n\t\"pad.chat.loadmessages\": \"Учитај више порука\",\n\t\"pad.chat.stick.title\": \"Залепите ћаскање на екран\",\n\t\"pad.chat.writeMessage.placeholder\": \"Напишите поруку овде\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} временска линија\",\n\t\"timeslider.toolbar.returnbutton\": \"Врати се на пад\",\n\t\"timeslider.toolbar.authors\": \"Аутори:\",\n\t\"timeslider.toolbar.authorsList\": \"Нема аутора\",\n\t\"timeslider.toolbar.exportlink.title\": \"Извези\",\n\t\"timeslider.exportCurrent\": \"Извези тренутну верзију као:\",\n\t\"timeslider.version\": \"Издање {{version}}\",\n\t\"timeslider.saved\": \"Сачувано на {{day}}. {{month}}. {{year}}\",\n\t\"timeslider.playPause\": \"Пусти/паузирај садржај пада\",\n\t\"timeslider.backRevision\": \"Иди на претходну верзију овог пада\",\n\t\"timeslider.forwardRevision\": \"Иди на следеће издање пада\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"јануар\",\n\t\"timeslider.month.february\": \"фебруар\",\n\t\"timeslider.month.march\": \"март\",\n\t\"timeslider.month.april\": \"април\",\n\t\"timeslider.month.may\": \"мај\",\n\t\"timeslider.month.june\": \"јун\",\n\t\"timeslider.month.july\": \"јул\",\n\t\"timeslider.month.august\": \"август\",\n\t\"timeslider.month.september\": \"септембар\",\n\t\"timeslider.month.october\": \"октобар\",\n\t\"timeslider.month.november\": \"новембар\",\n\t\"timeslider.month.december\": \"децембар\",\n\t\"timeslider.unnamedauthors\": \"{{num}} неименован(и) {[plural(num) one: аутор, other: аутори ]}\",\n\t\"pad.savedrevs.marked\": \"Ова измена је сада означена као сачувана\",\n\t\"pad.savedrevs.timeslider\": \"Можете видети сачуване измене користећи се временском линијом\",\n\t\"pad.userlist.entername\": \"Упишите своје име\",\n\t\"pad.userlist.unnamed\": \"неименован\",\n\t\"pad.editbar.clearcolors\": \"Очисти ауторске боје за цели документ? Ово се не може поништити.\",\n\t\"pad.impexp.importbutton\": \"Увези одмах\",\n\t\"pad.impexp.importing\": \"Увозим...\",\n\t\"pad.impexp.confirmimport\": \"Увоз датотеке ће преписати тренутни текст пада. Да ли сте сигурни да желите наставити?\",\n\t\"pad.impexp.convertFailed\": \"Не могу да увезем ову датотеку. Молимо да користите други формат документа или да документ копирате ручно\",\n\t\"pad.impexp.padHasData\": \"Не могу да увезем ову датотеку зато што је већ било промена на овом паду, молимо да увезете нови пад\",\n\t\"pad.impexp.uploadFailed\": \"Нисам успео да отпремим, молимо покушате поново\",\n\t\"pad.impexp.importfailed\": \"Нисам успео да увезем\",\n\t\"pad.impexp.copypaste\": \"Копирајте и залепите\",\n\t\"pad.impexp.exportdisabled\": \"Извоз у формату {{type}} није дозвољен. Контактирајте системског администратора за детаље.\",\n\t\"pad.impexp.maxFileSize\": \"Датотека је превелика. Контактирајте администратора сајта да повећа допуштену величину датотеке за увоз.\"\n}\n"
  },
  {
    "path": "src/locales/sr-el.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": []\n\t},\n\t\"index.newPad\": \"Novi Pad\",\n\t\"index.createOpenPad\": \"ili napravite/otvorite pad sledećeg naziva:\",\n\t\"pad.toolbar.bold.title\": \"Podebljano (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Iskošeno (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Podvučeno (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Precrtano (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Uređen spisak (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Neuređen spisak (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Uvlačenje (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Izvlačenje (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Opozovi (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Ponovi (Ctrl+Z)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Očisti autorske boje (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Uvezi/izvezi iz/na druge datotečne formate\",\n\t\"pad.toolbar.timeslider.title\": \"Vremenska linija\",\n\t\"pad.toolbar.savedRevision.title\": \"Sačuvaj verziju\",\n\t\"pad.toolbar.settings.title\": \"Podešavanja\",\n\t\"pad.toolbar.embed.title\": \"Sačuvaj i ugradi ovaj pad\",\n\t\"pad.toolbar.showusers.title\": \"Prikaži korisnike na ovom padu\",\n\t\"pad.colorpicker.save\": \"Sačuvaj\",\n\t\"pad.colorpicker.cancel\": \"Otkaži\",\n\t\"pad.loading\": \"Učitavam…\",\n\t\"pad.noCookie\": \"Kolačić nije pronađen. Molimo da uključite kolačiće u vašem pregledavaču!\",\n\t\"pad.permissionDenied\": \"Nemate dozvolu da pristupite ovom padu\",\n\t\"pad.settings.padSettings\": \"Podešavanja pada\",\n\t\"pad.settings.myView\": \"Moj prikaz\",\n\t\"pad.settings.stickychat\": \"Ćaskanje uvek na ekranu\",\n\t\"pad.settings.chatandusers\": \"Prikaži ćaskanje i korisnike\",\n\t\"pad.settings.colorcheck\": \"Autorske boje\",\n\t\"pad.settings.linenocheck\": \"Brojevi redova\",\n\t\"pad.settings.rtlcheck\": \"Čitaj sadržaj s desna na levo?\",\n\t\"pad.settings.fontType\": \"Vrsta fonta:\",\n\t\"pad.settings.fontType.normal\": \"Normalno\",\n\t\"pad.settings.language\": \"Jezik:\",\n\t\"pad.importExport.import_export\": \"Uvoz/izvoz\",\n\t\"pad.importExport.import\": \"Otpremite bilo koju tekstualnu datoteku ili dokument\",\n\t\"pad.importExport.importSuccessful\": \"Uspešno!\",\n\t\"pad.importExport.export\": \"Izvezi trenutni pad kao:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Čist tekst\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Jedino možete uvesti sa jednostavnog tekstualnog formata ili HTML formata. Za komplikovanije funkcije o uvozu, molimo da <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">instalirate AbiWord</a>.\",\n\t\"pad.modals.connected\": \"Povezano.\",\n\t\"pad.modals.reconnecting\": \"Ponovo se povezujem na vaš pad..\",\n\t\"pad.modals.forcereconnect\": \"Prisilno se ponovo poveži\",\n\t\"pad.modals.reconnecttimer\": \"Pokušavam se ponovo povezati\",\n\t\"pad.modals.cancel\": \"Otkaži\",\n\t\"pad.modals.userdup\": \"Otvoreno u drugom prozoru\",\n\t\"pad.modals.userdup.explanation\": \"Izgleda da je ovaj pad otvoren u dva ili više prozora na ovom računaru.\",\n\t\"pad.modals.userdup.advice\": \"Ponovo se povežite na ovoj prozor.\",\n\t\"pad.modals.unauth\": \"Niste ovlašćeni\",\n\t\"pad.modals.unauth.explanation\": \"Vaša dopuštenja se se promenila dok ste pregledavali stranicu. Pokušajte se ponovo povezati.\",\n\t\"pad.modals.looping.explanation\": \"Postoje komunikacijski problemi sa sinhronizacionim serverom.\",\n\t\"pad.modals.looping.cause\": \"Možda ste se povezali preko nepodržanog zaštitnog zida ili proksija.\",\n\t\"pad.modals.initsocketfail\": \"Server je nedostupan.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Ne mogu se povezati na sinhronizacioni server.\",\n\t\"pad.modals.initsocketfail.cause\": \"Najverovatnije je došlo do problem sa vašim pregledačem ili vašom internetskom vezom.\",\n\t\"pad.modals.slowcommit.explanation\": \"Server ne odgovara.\",\n\t\"pad.modals.slowcommit.cause\": \"Najverovatnije je došlo do problema sa mrežnom povezanošću.\",\n\t\"pad.modals.badChangeset.explanation\": \"Sinhronizacioni server je uređivanje koje ste načili označio kao neispravno.\",\n\t\"pad.modals.badChangeset.cause\": \"Moguće da je došlo do pogrešne konfiguracije servera ili nekog drugog neočekivanog događaja. Molimo vas da kontaktirate servisnog administratora ako mislite da je ovo greška. Pokušajte se ponovo povezati kako biste nastavili s uređivanjem.\",\n\t\"pad.modals.corruptPad.explanation\": \"Pad kojem pokušavate pristupiti je oštećen.\",\n\t\"pad.modals.corruptPad.cause\": \"Moguće da je došlo do pogrešne konfiguracije servera ili nekog drugog neočekivanog događaja. Molimo vas da kontaktirate servisnog administratora.\",\n\t\"pad.modals.deleted\": \"Obrisano.\",\n\t\"pad.modals.deleted.explanation\": \"Ovaj pad je uklonjen.\",\n\t\"pad.modals.disconnected\": \"Veza je prekinuta.\",\n\t\"pad.modals.disconnected.explanation\": \"Izgubljena je veza sa serverom\",\n\t\"pad.modals.disconnected.cause\": \"Server nije dostupan. Obavestite servisnog administratora ako se ovo nastavi dešavati.\",\n\t\"pad.share\": \"Pofeli ovaj pad\",\n\t\"pad.share.readonly\": \"Samo za čitanje\",\n\t\"pad.share.link\": \"Veza\",\n\t\"pad.share.emebdcode\": \"Ugradi vezu\",\n\t\"pad.chat\": \"Ćaskanje\",\n\t\"pad.chat.title\": \"Otvorite ćaskanje za ovaj pad.\",\n\t\"pad.chat.loadmessages\": \"Učitaj više poruka\",\n\t\"pad.chat.stick.title\": \"Postavi čet na ekran\",\n\t\"pad.chat.writeMessage.placeholder\": \"Napiši poruku ovde\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} vremenska linija\",\n\t\"timeslider.toolbar.returnbutton\": \"Vrati se na pad\",\n\t\"timeslider.toolbar.authors\": \"Autori:\",\n\t\"timeslider.toolbar.authorsList\": \"Nema autora\",\n\t\"timeslider.toolbar.exportlink.title\": \"Izvezi\",\n\t\"timeslider.exportCurrent\": \"Izvezi trenutnu verziju kao:\",\n\t\"timeslider.version\": \"Izdanje {{version}}\",\n\t\"timeslider.saved\": \"Sačuvano na {{day}}. {{month}}. {{year}}\",\n\t\"timeslider.playPause\": \"Pusti/pauziraj sadržaj pada\",\n\t\"timeslider.backRevision\": \"Idi na prethodnu verziju ovog pada\",\n\t\"timeslider.forwardRevision\": \"Idi na sledeće izdanje pada\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"januar\",\n\t\"timeslider.month.february\": \"februar\",\n\t\"timeslider.month.march\": \"mart\",\n\t\"timeslider.month.april\": \"april\",\n\t\"timeslider.month.may\": \"maj\",\n\t\"timeslider.month.june\": \"jun\",\n\t\"timeslider.month.july\": \"jul\",\n\t\"timeslider.month.august\": \"avgust\",\n\t\"timeslider.month.september\": \"septembar\",\n\t\"timeslider.month.october\": \"oktobar\",\n\t\"timeslider.month.november\": \"novembar\",\n\t\"timeslider.month.december\": \"decembar\",\n\t\"timeslider.unnamedauthors\": \"{{num}} neimenovan(i) {[plural(num) one: autor, other: autori ]}\",\n\t\"pad.savedrevs.marked\": \"Ova izmena je sada označena kao sačuvana\",\n\t\"pad.savedrevs.timeslider\": \"Možete videti sačuvane izmene koristeći se vremenskom linijom\",\n\t\"pad.userlist.entername\": \"Upišite svoje ime\",\n\t\"pad.userlist.unnamed\": \"neimenovan\",\n\t\"pad.editbar.clearcolors\": \"Očisti autorske boje za celi dokument?\",\n\t\"pad.impexp.importbutton\": \"Uvezi odmah\",\n\t\"pad.impexp.importing\": \"Uvozim...\",\n\t\"pad.impexp.confirmimport\": \"Uvoz datoteke će prepisati trenutni tekst pada. Da li ste sigurni da želite nastaviti?\",\n\t\"pad.impexp.convertFailed\": \"Ne mogu da uvezem ovu datoteku. Molimo da koristite drugi format dokumenta ili da dokument kopirate ručno\",\n\t\"pad.impexp.padHasData\": \"Ne mogu da uvezem ovu datoteku zato što je već bilo promena na ovom padu, molimo da uvezete novi pad\",\n\t\"pad.impexp.uploadFailed\": \"Nisam uspeo da otpremim, molimo pokušate ponovo\",\n\t\"pad.impexp.importfailed\": \"Nisam uspeo da uvezem\",\n\t\"pad.impexp.copypaste\": \"Kopirajte i zalepite\",\n\t\"pad.impexp.exportdisabled\": \"Izvoz u formatu {{type}} nije dozvoljen. Kontaktirajte sistemskog administratora za detalje.\"\n}\n"
  },
  {
    "path": "src/locales/sro.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Adr mm\",\n\t\t\t\"F Samaritani\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Pannellu amministrativu - Etherpad\",\n\t\"admin_plugins\": \"Gestore de connetores\",\n\t\"admin_plugins.available\": \"Connetores a disponimentu\",\n\t\"admin_plugins.available_not-found\": \"Nissunu connetore a disponimentu\",\n\t\"admin_plugins.available_fetching\": \"Recuperende...\",\n\t\"admin_plugins.available_install.value\": \"Installa\",\n\t\"admin_plugins.available_search.placeholder\": \"Chirca connetores de installare\",\n\t\"admin_plugins.description\": \"Descritzione\",\n\t\"admin_plugins.installed\": \"Connetores installados\",\n\t\"admin_plugins.installed_fetching\": \"Recuperende connetores installados...\",\n\t\"admin_plugins.installed_nothing\": \"No as installadu ancora nissunu connetore.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Disinstalla\",\n\t\"admin_plugins.last-update\": \"Ùrtima atualizatzione\",\n\t\"admin_plugins.name\": \"Nòmine\",\n\t\"admin_plugins.page-title\": \"Gestore de connetores - Etherpad\",\n\t\"admin_plugins.version\": \"Versione\",\n\t\"admin_plugins_info\": \"Informatzione pro sa risolutzione de problemas\",\n\t\"admin_plugins_info.parts\": \"Partes installadas\",\n\t\"admin_plugins_info.plugins\": \"Connetores installados\",\n\t\"admin_plugins_info.page-title\": \"Informatzione de su connetore - Etherpad\",\n\t\"admin_plugins_info.version\": \"Versione de Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Ùrtima versione a disponimentu\",\n\t\"admin_plugins_info.version_number\": \"Nùmeru de versione\",\n\t\"admin_settings\": \"Cunfiguratzione\",\n\t\"admin_settings.current\": \"Cunfiguratzione atuale\",\n\t\"admin_settings.current_restart.value\": \"Torra a aviare Etherpad\",\n\t\"admin_settings.current_save.value\": \"Sarva sa cunfiguratzione\",\n\t\"admin_settings.page-title\": \"Cunfiguratzione - Etherpad\",\n\t\"index.newPad\": \"Pad nou\",\n\t\"index.createOpenPad\": \"o crea/aberi unu pad cun su nòmine:\",\n\t\"index.openPad\": \"aberi unu pad esistente cun su nòmine:\",\n\t\"pad.toolbar.bold.title\": \"Grassetu (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Cursivu (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Sutaliniadu (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Istangadu (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Lista numerada (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Lista cun puntos (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Indentatzione a dereta (Tab)\",\n\t\"pad.toolbar.unindent.title\": \"Indentatzione a manca (Shift+Tab)\",\n\t\"pad.toolbar.undo.title\": \"Iscontza (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Torra a fàghere (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Lìmpia is colores de autoria (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importa/esporta dae/a formados de archìviu diferentes\",\n\t\"pad.toolbar.timeslider.title\": \"Presentatzione cronologia\",\n\t\"pad.toolbar.savedRevision.title\": \"Sarva sa versione\",\n\t\"pad.toolbar.settings.title\": \"Cunfiguratzione\",\n\t\"pad.toolbar.embed.title\": \"Cumpartzi e incòrpora custu Pad\",\n\t\"pad.toolbar.showusers.title\": \"Ammustra is utentes in custu Pad\",\n\t\"pad.colorpicker.save\": \"Sarva\",\n\t\"pad.colorpicker.cancel\": \"Annulla\",\n\t\"pad.loading\": \"Carrighende...\",\n\t\"pad.noCookie\": \"Su testimòngiu no est istètiu agatadu. Permite is testimòngios in su navigadore tuo. Sa sessione e sa cunfiguratzione tuas no ant a èssere sarvadas intre bìsitas. Podet èssere pro more de s'inclusione de Etherpad comente iFrame in tzertos navigadores. Assegura·ti chi Etherpad s'agatat in su pròpiu sutadomìniu/domìniu chi s'iFrame printzipale\",\n\t\"pad.permissionDenied\": \"Non tenes permissu pro atzèdere a custu pad\",\n\t\"pad.settings.padSettings\": \"Cunfiguratzione de su pad\",\n\t\"pad.settings.myView\": \"Sa visualizatzione mia\",\n\t\"pad.settings.stickychat\": \"Ammustra semper sa tzarrada\",\n\t\"pad.settings.chatandusers\": \"Ammustra sa tzarrada e is utentes\",\n\t\"pad.settings.colorcheck\": \"Colores de autoria\",\n\t\"pad.settings.linenocheck\": \"Nùmeros de lìnia\",\n\t\"pad.settings.rtlcheck\": \"Cuntenutu dae manca a dereta\",\n\t\"pad.settings.fontType\": \"Tipu de caràtere:\",\n\t\"pad.settings.fontType.normal\": \"Normale\",\n\t\"pad.settings.language\": \"Lìngua:\",\n\t\"pad.settings.about\": \"Informatziones\",\n\t\"pad.settings.poweredBy\": \"Realizadu cun\",\n\t\"pad.importExport.import_export\": \"Importatzione/esportatzione\",\n\t\"pad.importExport.import\": \"Càrriga un'archìviu de testu o unu documentu\",\n\t\"pad.importExport.importSuccessful\": \"Carrigadu.\",\n\t\"pad.importExport.export\": \"Esporta su pad atuale comente:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Testu sèmplitze\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Isceti is formados de testu sèmplitze o HTML podent èssere importados. Pro mètodos avantzados de importatzione, <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">installa AbiWord o LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Connètidu.\",\n\t\"pad.modals.reconnecting\": \"Connetende a su pad tuo...\",\n\t\"pad.modals.forcereconnect\": \"Fortza sa connesione\",\n\t\"pad.modals.reconnecttimer\": \"Torrende a connètere in\",\n\t\"pad.modals.cancel\": \"Annulla\",\n\t\"pad.modals.userdup\": \"Abertu in un'àtera ventana\",\n\t\"pad.modals.userdup.explanation\": \"Podet èssere chi custu pad siat abertu in un'àtera ischeda de custu navigadore in custu ordinadore.\",\n\t\"pad.modals.userdup.advice\": \"Torra a connètere pro impreare custa ventana.\",\n\t\"pad.modals.unauth\": \"Chena autorizatzione\",\n\t\"pad.modals.unauth.explanation\": \"Is permissos tuos funt istètios cambiados in su mentras chi fias bidende custa pàgina. Prova de ti torrare a connètere.\",\n\t\"pad.modals.looping.explanation\": \"Nci funt problemas de comunicatzione cun su serbidore de sincronizatzione.\",\n\t\"pad.modals.looping.cause\": \"Forsis sa connessione at impreadu unu serbidore intermediàriu (proxy) o unu firewall chi no est cumpatìbile.\",\n\t\"pad.modals.initsocketfail\": \"Su serbidore no est atzessìbile.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Impossìbile connètere cun su serbidore de sincronizatzione.\",\n\t\"pad.modals.initsocketfail.cause\": \"Podet èssere a càusa de unu problema cun su navigadore tuo o cun sa connessione de internet.\",\n\t\"pad.modals.slowcommit.explanation\": \"Su serbidore non rispondet.\",\n\t\"pad.modals.slowcommit.cause\": \"Podet èssere a càusa de problemas cun sa connessione de internet.\",\n\t\"pad.modals.badChangeset.explanation\": \"Una modìfica tua est istètia cunsiderada illegale dae su serbidore de sincronizatzione.\",\n\t\"pad.modals.badChangeset.cause\": \"Podet èssere a càusa de una cunfiguratzione de serbidore isballiada o calicunu àteru cumportamentu imprevistu. Iscrie a s'amministratzione de su servìtziu si pensas chi siat un'errore. Prova a connètere torra pro sighire a modificare.\",\n\t\"pad.modals.corruptPad.explanation\": \"Su pad a su chi ses chirchende de atzèdere est dannadu.\",\n\t\"pad.modals.corruptPad.cause\": \"Podet èssere a càusa de una cunfiguratzione de serbidore non curreta o pro unu cumportamentu imprevistu. Iscrie a s'amministratzione de su servìtziu.\",\n\t\"pad.modals.deleted\": \"Cantzelladu.\",\n\t\"pad.modals.deleted.explanation\": \"Pad cantzelladu.\",\n\t\"pad.modals.rateLimited.explanation\": \"As imbiadu tropu messàgios a custu pad e t'at disconnètidu.\",\n\t\"pad.modals.rejected.explanation\": \"Su serbidore at rifiutadu unu messàgiu imbiadu dae su navigadore tuo.\",\n\t\"pad.modals.rejected.cause\": \"Podet èssere chi su serbidore siat istètiu atualizadu in su mentras chi fias bidende su pad, o podet èssere chi nci siat un'errore in Etherpad. Prova a atualizare sa pàgina.\",\n\t\"pad.modals.disconnected\": \"Disconnètidu.\",\n\t\"pad.modals.disconnected.explanation\": \"Connessione cun su serbidore pèrdida.\",\n\t\"pad.modals.disconnected.cause\": \"Su serbidore no est a disponimentu. Iscrie a s'amministratzione de su servìtziu si su problema persistet.\",\n\t\"pad.share\": \"Cumpartzi custu pad\",\n\t\"pad.share.link\": \"Ligòngiu\",\n\t\"pad.share.emebdcode\": \"Incòrpora URL\",\n\t\"pad.chat\": \"Tzarrada\",\n\t\"pad.chat.title\": \"Aberi sa tzarrada pro custu pad.\",\n\t\"pad.chat.loadmessages\": \"Càrriga àteros messàgios\",\n\t\"pad.chat.stick.title\": \"Apica sa tzarrada in s'ischermu\",\n\t\"pad.chat.writeMessage.placeholder\": \"Iscrie su messàgiu tuo inoghe\",\n\t\"timeslider.followContents\": \"Sighi is atualizatziones de cuntenutu de su pad\",\n\t\"timeslider.pageTitle\": \"Cronologia {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Torra a su pad\",\n\t\"timeslider.toolbar.authors\": \"Autores:\",\n\t\"timeslider.toolbar.authorsList\": \"Nissunu autore\",\n\t\"timeslider.toolbar.exportlink.title\": \"Esporta\",\n\t\"timeslider.exportCurrent\": \"Esporta sa versione atuale comente:\",\n\t\"timeslider.version\": \"Versione {{version}}\",\n\t\"timeslider.saved\": \"Sarvadu su {{day}} de {{month}} de su {{year}}\",\n\t\"timeslider.playPause\": \"Riprodutzione/pàusa de is cuntenutos de su pad\",\n\t\"timeslider.backRevision\": \"Bae a una versione pretzedente de custu pad\",\n\t\"timeslider.forwardRevision\": \"Bae a una versione imbeniente de custu pad\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Ghennàrgiu\",\n\t\"timeslider.month.february\": \"Freàrgiu\",\n\t\"timeslider.month.march\": \"Martzu\",\n\t\"timeslider.month.april\": \"Abrile\",\n\t\"timeslider.month.may\": \"Maju\",\n\t\"timeslider.month.june\": \"Làmpadas\",\n\t\"timeslider.month.july\": \"Mese de argiolas\",\n\t\"timeslider.month.august\": \"Austu\",\n\t\"timeslider.month.september\": \"Cabudanni\",\n\t\"timeslider.month.october\": \"Ledàmine\",\n\t\"timeslider.month.november\": \"Onniasantu\",\n\t\"timeslider.month.december\": \"Mese de idas\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: autore, other: autores ]} chena nòmine\",\n\t\"pad.savedrevs.marked\": \"Custa revisione est istètia marcada comente revisione sarvada\",\n\t\"pad.savedrevs.timeslider\": \"Podes bìdere is versiones sarvadas bisitende sa cronologia\",\n\t\"pad.userlist.entername\": \"Inserta su nòmine tuo\",\n\t\"pad.userlist.unnamed\": \"Chena nòmine\",\n\t\"pad.editbar.clearcolors\": \"Seguru chi boles limpiare is colores de autoria de totu su documentu? Custa atzione no dda podes annullare\",\n\t\"pad.impexp.importbutton\": \"Importa immoe\",\n\t\"pad.impexp.importing\": \"Importende...\",\n\t\"pad.impexp.confirmimport\": \"S'importatzione de un'archìviu at a subraiscrìere su testu atuale de su pad. Seguru chi boles sighire?\",\n\t\"pad.impexp.convertFailed\": \"Impossìbile importare custu archìviu. Imprea unu formadu de documentu diferente o còpia e incolla a manu\",\n\t\"pad.impexp.padHasData\": \"Impossìbile importare custu archìviu pro ite custu pad est istètiu giai modificadu. Importa·ddu in unu pad nou\",\n\t\"pad.impexp.uploadFailed\": \"Errore in sa càrriga. Torra a provare\",\n\t\"pad.impexp.importfailed\": \"Errore de importatzione\",\n\t\"pad.impexp.copypaste\": \"Còpia e incolla\",\n\t\"pad.impexp.exportdisabled\": \"S'esportatzione comente {{type}} est disativada. Iscrie a s'amministratzione de su sistema pro àteras informatziones.\",\n\t\"pad.impexp.maxFileSize\": \"S'archìviu est tropu manu. Iscrie a s'amministratzione pro ismanniare sa dimensione permìtida pro s'importatzione\"\n}\n"
  },
  {
    "path": "src/locales/sv.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Bengtsson96\",\n\t\t\t\"Jopparn\",\n\t\t\t\"Lokal Profil\",\n\t\t\t\"Sabelöga\",\n\t\t\t\"WikiPhoenix\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Administratörernas instrumentpanel - Etherpad\",\n\t\"admin_plugins\": \"Insticksprogramhanterare\",\n\t\"admin_plugins.available\": \"Tillgängliga insticksprogram\",\n\t\"admin_plugins.available_not-found\": \"Inga insticksmoduler hittades.\",\n\t\"admin_plugins.available_fetching\": \"Hämtar…\",\n\t\"admin_plugins.available_install.value\": \"Installera\",\n\t\"admin_plugins.available_search.placeholder\": \"Sök efter insticksprogram att installera\",\n\t\"admin_plugins.description\": \"Beskrivning\",\n\t\"admin_plugins.installed\": \"Installerade insticksprogram\",\n\t\"admin_plugins.installed_fetching\": \"Hämtar installerade insticksprogram…\",\n\t\"admin_plugins.installed_nothing\": \"Du har ännu inte installerat några insticksprogram.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Avinstallera\",\n\t\"admin_plugins.last-update\": \"Senast uppdaterat\",\n\t\"admin_plugins.name\": \"Namn\",\n\t\"admin_plugins.page-title\": \"Insticksprogramhanterare - Etherpad\",\n\t\"admin_plugins.version\": \"Version\",\n\t\"admin_plugins_info\": \"Felsökningsinformation\",\n\t\"admin_plugins_info.hooks\": \"Installerade hooks\",\n\t\"admin_plugins_info.hooks_client\": \"Klient-hooks\",\n\t\"admin_plugins_info.hooks_server\": \"Server-hooks\",\n\t\"admin_plugins_info.parts\": \"Installerade delar\",\n\t\"admin_plugins_info.plugins\": \"Installerade insticksprogram\",\n\t\"admin_plugins_info.page-title\": \"Insticksprograminformation - Etherpad\",\n\t\"admin_plugins_info.version\": \"Etherpad-version\",\n\t\"admin_plugins_info.version_latest\": \"Senast tillgängliga version\",\n\t\"admin_plugins_info.version_number\": \"Versionsnummer\",\n\t\"admin_settings\": \"Inställningar\",\n\t\"admin_settings.current\": \"Nuvarande konfiguration\",\n\t\"admin_settings.current_example-devel\": \"Exempelmall för utvecklingsinställningar\",\n\t\"admin_settings.current_example-prod\": \"Exempelmall för produktionsinställningar\",\n\t\"admin_settings.current_restart.value\": \"Starta om Etherpad\",\n\t\"admin_settings.current_save.value\": \"Spara inställningar\",\n\t\"admin_settings.page-title\": \"Inställningar - Etherpad\",\n\t\"index.newPad\": \"Nytt block\",\n\t\"index.settings\": \"Inställningar\",\n\t\"index.transferSessionTitle\": \"Överför session\",\n\t\"index.receiveSessionTitle\": \"Ta emot session\",\n\t\"index.receiveSessionDescription\": \"Här kan du ta emot en Etherpad-session från en annan webbläsare eller enhet. Observera att detta kommer att radera din aktuella session, om en sådan finns.\",\n\t\"index.transferSession\": \"1. Överför session\",\n\t\"index.transferSessionNow\": \"Överför session nu\",\n\t\"index.copyLink\": \"2. Kopiera länk\",\n\t\"index.copyLinkDescription\": \"Klicka på knappen nedan för att kopiera länken till urklipp.\",\n\t\"index.copyLinkButton\": \"Kopiera länk till urklipp\",\n\t\"index.transferToSystem\": \"3. Kopiera sessionen till det nya systemet\",\n\t\"index.transferToSystemDescription\": \"Öppna den kopierade länken i målwebbläsaren eller -enheten för att överföra din session.\",\n\t\"index.transferSessionDescription\": \"Överför din nuvarande session till webbläsaren eller enheten genom att klicka på knappen nedan. Detta kopierar en länk till en sida som överför din session när den öppnas i målwebbläsaren eller -enheten.\",\n\t\"index.createOpenPad\": \"Öppna block med namn\",\n\t\"index.openPad\": \"öppna ett befintligt block med namnet:\",\n\t\"index.recentPads\": \"Senaste block\",\n\t\"index.recentPadsEmpty\": \"Inga senaste block hittades.\",\n\t\"index.generateNewPad\": \"Generera slumpat blocknamn\",\n\t\"index.labelPad\": \"Blocknamn (valfritt)\",\n\t\"index.placeholderPadEnter\": \"Ange ett blocknamn...\",\n\t\"index.createAndShareDocuments\": \"Skapa och dela dokument i realtid\",\n\t\"index.createAndShareDocumentsDescription\": \"Med Etherpad kan ni redigera dokument tillsammans i realtid, ungefär som en live-redigerare för flera spelare som körs i din webbläsare.\",\n\t\"pad.toolbar.bold.title\": \"Fet (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Kursiv (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Understruken (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Genomstruken (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Numrerad lista (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Punktlista (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Öka indrag (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Minska indrag (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Ångra (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Gör om (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Rensa författarfärger (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Importera/exportera från/till olika filformat\",\n\t\"pad.toolbar.timeslider.title\": \"Tidsreglage\",\n\t\"pad.toolbar.savedRevision.title\": \"Spara version\",\n\t\"pad.toolbar.settings.title\": \"Inställningar\",\n\t\"pad.toolbar.embed.title\": \"Dela och bädda in detta block\",\n\t\"pad.toolbar.home.title\": \"Tillbaka till hemsidan\",\n\t\"pad.toolbar.showusers.title\": \"Visa användarna på detta block\",\n\t\"pad.colorpicker.save\": \"Spara\",\n\t\"pad.colorpicker.cancel\": \"Avbryt\",\n\t\"pad.loading\": \"Läser in …\",\n\t\"pad.noCookie\": \"Kunde inte hitta några kakor. Var god tillåt kakor i din webbläsare! Din session och inställningar kommer inte sparas mellan dina besök. Detta kan bero på att Etherpad inte ligger inuti en iFrame i vissa webbläsare. Se till att Etherpad är i samma underdomän/domän som det överordnade iFrame-elementet.\",\n\t\"pad.permissionDenied\": \"Du har inte åtkomstbehörighet för detta block\",\n\t\"pad.settings.padSettings\": \"Blockinställningar\",\n\t\"pad.settings.myView\": \"Min vy\",\n\t\"pad.settings.stickychat\": \"Chatten alltid på skärmen\",\n\t\"pad.settings.chatandusers\": \"Visa chatt och användare\",\n\t\"pad.settings.colorcheck\": \"Författarskapsfärger\",\n\t\"pad.settings.linenocheck\": \"Radnummer\",\n\t\"pad.settings.rtlcheck\": \"Vill du läsa innehållet från höger till vänster?\",\n\t\"pad.settings.fontType\": \"Typsnitt:\",\n\t\"pad.settings.fontType.normal\": \"Normal\",\n\t\"pad.settings.language\": \"Språk:\",\n\t\"pad.settings.deletePad\": \"Radera block\",\n\t\"pad.delete.confirm\": \"Vill du verkligen radera detta block?\",\n\t\"pad.settings.about\": \"Om\",\n\t\"pad.settings.poweredBy\": \"Drivs av\",\n\t\"pad.importExport.import_export\": \"Importera/Exportera\",\n\t\"pad.importExport.import\": \"Ladda upp textfiler eller dokument\",\n\t\"pad.importExport.importSuccessful\": \"Åtgärden slutfördes!\",\n\t\"pad.importExport.export\": \"Exportera aktuellt block som:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Oformaterad text\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Du kan endast importera från oformaterad text eller HTML-format. För mer avancerade importfunktioner, var god <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">installera AbiWord eller LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"Ansluten.\",\n\t\"pad.modals.reconnecting\": \"Återansluter till ditt block…\",\n\t\"pad.modals.forcereconnect\": \"Tvinga återanslutning\",\n\t\"pad.modals.reconnecttimer\": \"Försöker ansluta igen\",\n\t\"pad.modals.cancel\": \"Avbryt\",\n\t\"pad.modals.userdup\": \"Öppnades i ett nytt fönster\",\n\t\"pad.modals.userdup.explanation\": \"Detta block verkar vara öppet i mer än ett fönster på denna dator.\",\n\t\"pad.modals.userdup.advice\": \"Återanslut för att använda detta fönster istället.\",\n\t\"pad.modals.unauth\": \"Inte godkänd\",\n\t\"pad.modals.unauth.explanation\": \"Din behörighet ändrades medan du visade denna sida. Försök att återansluta.\",\n\t\"pad.modals.looping.explanation\": \"Kommunikationsproblem med synkroniseringsservern har uppstått.\",\n\t\"pad.modals.looping.cause\": \"Kanske du är ansluten via en inkompatibel brandvägg eller proxy.\",\n\t\"pad.modals.initsocketfail\": \"Servern kan inte nås.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Det gick inte att ansluta till synkroniseringsservern.\",\n\t\"pad.modals.initsocketfail.cause\": \"Detta beror troligen på ett problem med din webbläsare eller din internetanslutning.\",\n\t\"pad.modals.slowcommit.explanation\": \"Servern svarar inte.\",\n\t\"pad.modals.slowcommit.cause\": \"Detta kan bero på problem med nätverksanslutningen.\",\n\t\"pad.modals.badChangeset.explanation\": \"En redigering som du gjort klassificerades som otillåten av synkroniseringsservern.\",\n\t\"pad.modals.badChangeset.cause\": \"Detta kan bero på en felaktig konfiguration av servern eller något annat oväntad beteende. Var god kontakta tjänsteadministratören om du upplever att detta är ett fel. Försök att ansluta igen för att fortsätta redigera.\",\n\t\"pad.modals.corruptPad.explanation\": \"Blocket du försöker komma åt är skadat.\",\n\t\"pad.modals.corruptPad.cause\": \"Detta kan bero på en felaktig konfiguration av servern eller något annat oväntad beteende. Var god kontakta tjänstadministratören.\",\n\t\"pad.modals.deleted\": \"Raderad.\",\n\t\"pad.modals.deleted.explanation\": \"Detta block har tagits bort.\",\n\t\"pad.modals.rateLimited\": \"Begränsad frekvens.\",\n\t\"pad.modals.rateLimited.explanation\": \"Du skickade för många meddelanden till detta block så du blev frånkopplad.\",\n\t\"pad.modals.rejected.explanation\": \"Servern avvisade ett meddelande som skickades av din webbläsare.\",\n\t\"pad.modals.rejected.cause\": \"Servern kan ha uppdaterats medan du visade blocket, eller så finns det kanske en bugg i Etherpad. Försök att ladda om sidan.\",\n\t\"pad.modals.disconnected\": \"Du har blivit frånkopplad.\",\n\t\"pad.modals.disconnected.explanation\": \"Anslutningen till servern avbröts\",\n\t\"pad.modals.disconnected.cause\": \"Servern kanske är otillgänglig. Var god meddela tjänstadministratören om detta fortsätter att hända.\",\n\t\"pad.share\": \"Dela detta block\",\n\t\"pad.share.readonly\": \"Skrivskyddad\",\n\t\"pad.share.link\": \"Länk\",\n\t\"pad.share.emebdcode\": \"Bädda in URL\",\n\t\"pad.chat\": \"Chatt\",\n\t\"pad.chat.title\": \"Öppna chatten för detta block.\",\n\t\"pad.chat.loadmessages\": \"Läs in fler meddelanden\",\n\t\"pad.chat.stick.title\": \"Fäst chatten på skärmen\",\n\t\"pad.chat.writeMessage.placeholder\": \"Skriv ditt meddelande här\",\n\t\"timeslider.followContents\": \"Följ uppdateringar om blockets innehåll\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} tidsreglage\",\n\t\"timeslider.toolbar.returnbutton\": \"Återvänd till blocket\",\n\t\"timeslider.toolbar.authors\": \"Författare:\",\n\t\"timeslider.toolbar.authorsList\": \"Inga författare\",\n\t\"timeslider.toolbar.exportlink.title\": \"Exportera\",\n\t\"timeslider.exportCurrent\": \"Exportera aktuell version som:\",\n\t\"timeslider.version\": \"Version {{version}}\",\n\t\"timeslider.saved\": \"Sparades den {{day}} {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"Spela upp/pausa blockets innehåll\",\n\t\"timeslider.backRevision\": \"Gå tillbaka en version av detta block\",\n\t\"timeslider.forwardRevision\": \"Gå framåt en version av detta block\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"januari\",\n\t\"timeslider.month.february\": \"februari\",\n\t\"timeslider.month.march\": \"mars\",\n\t\"timeslider.month.april\": \"april\",\n\t\"timeslider.month.may\": \"maj\",\n\t\"timeslider.month.june\": \"juni\",\n\t\"timeslider.month.july\": \"juli\",\n\t\"timeslider.month.august\": \"augusti\",\n\t\"timeslider.month.september\": \"september\",\n\t\"timeslider.month.october\": \"oktober\",\n\t\"timeslider.month.november\": \"november\",\n\t\"timeslider.month.december\": \"december\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: namnlös författare, other: namnlösa författare]}\",\n\t\"pad.savedrevs.marked\": \"Denna version är nu markerad som en sparad version\",\n\t\"pad.savedrevs.timeslider\": \"Du kan se sparade versioner med tidsreglaget\",\n\t\"pad.userlist.entername\": \"Ange ditt namn\",\n\t\"pad.userlist.unnamed\": \"namnlös\",\n\t\"pad.editbar.clearcolors\": \"Rensa författarfärger för hela dokumentet? Detta kan inte ångras\",\n\t\"pad.impexp.importbutton\": \"Importera nu\",\n\t\"pad.impexp.importing\": \"Importerar …\",\n\t\"pad.impexp.confirmimport\": \"Att importera en fil kommer att skriva över den aktuella texten i blocket. Är du säker på att du vill fortsätta?\",\n\t\"pad.impexp.convertFailed\": \"Vi kunde inte importera denna fil. Var god använd ett annat dokumentformat eller kopiera och klistra in den manuellt\",\n\t\"pad.impexp.padHasData\": \"Vi kunde inte importera denna fil eftersom detta block redan har redigerats. Importera den till ett nytt block.\",\n\t\"pad.impexp.uploadFailed\": \"Uppladdningen misslyckades, var god försök igen\",\n\t\"pad.impexp.importfailed\": \"Importering misslyckades\",\n\t\"pad.impexp.copypaste\": \"Var god kopiera och klistra in\",\n\t\"pad.impexp.exportdisabled\": \"Exportering av formatet {{type}} är inaktiverad. Var god kontakta din systemadministratör för mer information.\",\n\t\"pad.impexp.maxFileSize\": \"Filen är för stor. Kontakta din systemadministratör för att öka den tillåtna filstorleken för importering\"\n}\n"
  },
  {
    "path": "src/locales/sw.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Andibecker\",\n\t\t\t\"Edwingudfriend\",\n\t\t\t\"Muddyb\",\n\t\t\t\"Samuel Kiongo\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Dashibodi ya Usimamizi - Etherpad\",\n\t\"admin_plugins\": \"Meneja wa programu-jalizi\",\n\t\"admin_plugins.available\": \"Programu-jalizi zinazopatikana\",\n\t\"admin_plugins.available_not-found\": \"Hakuna programu-jalizi zilizopatikana.\",\n\t\"admin_plugins.available_fetching\": \"Inaleta…\",\n\t\"admin_plugins.available_install.value\": \"Sakinisha\",\n\t\"admin_plugins.available_search.placeholder\": \"Tafuta programu-jalizi ili usakinishe\",\n\t\"admin_plugins.description\": \"Maelezo\",\n\t\"admin_plugins.installed\": \"Programu-jalizi zilizosanikishwa\",\n\t\"admin_plugins.installed_fetching\": \"Inaleta programu-jalizi zilizosakinishwa…\",\n\t\"admin_plugins.installed_nothing\": \"Bado hujasakinisha programu-jalizi yoyote.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Ondoa\",\n\t\"admin_plugins.last-update\": \"Sasisho la mwisho\",\n\t\"admin_plugins.name\": \"Jina\",\n\t\"admin_plugins.page-title\": \"Meneja wa programu-jalizi - Etherpad\",\n\t\"admin_plugins.version\": \"Toleo\",\n\t\"admin_plugins_info\": \"Maelezo ya utatuzi\",\n\t\"admin_plugins_info.hooks\": \"Ndoano zilizowekwa\",\n\t\"admin_plugins_info.hooks_client\": \"Kulabu za mteja\",\n\t\"admin_plugins_info.hooks_server\": \"Kulabu za upande wa seva\",\n\t\"admin_plugins_info.parts\": \"Sehemu zilizowekwa\",\n\t\"admin_plugins_info.plugins\": \"Programu-jalizi zilizosanikishwa\",\n\t\"admin_plugins_info.page-title\": \"Habari ya programu-jalizi - Etherpad\",\n\t\"admin_plugins_info.version\": \"Toleo la Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Toleo la hivi karibuni linalopatikana\",\n\t\"admin_plugins_info.version_number\": \"Nambari ya toleo\",\n\t\"admin_settings\": \"Mipangilio\",\n\t\"admin_settings.current\": \"Usanidi wa sasa\",\n\t\"admin_settings.current_example-devel\": \"Mfano mipangilio ya mipangilio ya maendeleo\",\n\t\"admin_settings.current_example-prod\": \"Mfano mipangilio ya mipangilio ya uzalishaji\",\n\t\"admin_settings.current_restart.value\": \"Anzisha upya Etherpad\",\n\t\"admin_settings.current_save.value\": \"Hifadhi Mipangilio\",\n\t\"admin_settings.page-title\": \"Mipangilio - Etherpad\",\n\t\"index.newPad\": \"Pad Mpya\",\n\t\"index.createOpenPad\": \"au tunga/fungua Pad yenye jina:\",\n\t\"index.openPad\": \"fungua Pad iliyopo na jina:\",\n\t\"index.placeholderPadEnter\": \"Tafadhali weka jina la mtumiaji.\",\n\t\"pad.toolbar.bold.title\": \"Koozesha (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Mlalo (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Pigia mstari (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Kata (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Orodha iliyopangliwa (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Orodha isiyopangiliwa (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Jongeza (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Punguza (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Tengua (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Fanyaupya (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Futa Rangi za Uandishi (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Ingiza/Toa kutoka/kwa muundo wa faili tofauti\",\n\t\"pad.toolbar.timeslider.title\": \"Kiburuzawakati\",\n\t\"pad.toolbar.savedRevision.title\": \"Hifadhi Mapitio\",\n\t\"pad.toolbar.settings.title\": \"Marekebisho\",\n\t\"pad.toolbar.embed.title\": \"Shiriki na Pachika pedi hii\",\n\t\"pad.toolbar.showusers.title\": \"Onyesha watumiaji kwenye pedi hii\",\n\t\"pad.colorpicker.save\": \"Okoa\",\n\t\"pad.colorpicker.cancel\": \"Ghairi\",\n\t\"pad.loading\": \"Inapakiwa...\",\n\t\"pad.noCookie\": \"Kuki haikuweza kupatikana. Tafadhali ruhusu kuki katika kivinjari chako! Kikao na mipangilio yako haitahifadhiwa kati ya ziara. Hii inaweza kuwa ni kutokana na Etherpad kuingizwa katika iFrame katika Vivinjari vingine. Tafadhali hakikisha Etherpad iko kwenye kikoa / kikoa sawa na iFrame ya mzazi\",\n\t\"pad.permissionDenied\": \"Huna ruhusa ya kufikia pedi hii\",\n\t\"pad.settings.padSettings\": \"Mipangilio ya pedi\",\n\t\"pad.settings.myView\": \"Mtazamo Wangu\",\n\t\"pad.settings.stickychat\": \"Ongea kila wakati kwenye skrini\",\n\t\"pad.settings.chatandusers\": \"Onyesha Gumzo na Watumiaji\",\n\t\"pad.settings.colorcheck\": \"Rangi za uandishi\",\n\t\"pad.settings.linenocheck\": \"Nambari za laini\",\n\t\"pad.settings.rtlcheck\": \"Soma yaliyomo kutoka kulia kwenda kushoto?\",\n\t\"pad.settings.fontType\": \"Aina ya herufi:\",\n\t\"pad.settings.language\": \"Lugha:\",\n\t\"pad.settings.about\": \"Kuhusu\",\n\t\"pad.settings.poweredBy\": \"Kinatumia\",\n\t\"pad.importExport.import_export\": \"Ingiza / Hamisha\",\n\t\"pad.importExport.import\": \"Pakia faili yoyote ya maandishi au hati\",\n\t\"pad.importExport.importSuccessful\": \"Imefanikiwa!\",\n\t\"pad.importExport.export\": \"Hamisha pedi ya sasa kama:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Maandishi wazi\",\n\t\"pad.importExport.exportword\": \"Neno la Microsoft\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Fungua Fomati ya Hati)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Unaweza kuagiza tu kutoka kwa maandishi wazi au fomati za HTML. Kwa vipengee vya hali ya juu zaidi tafadhali <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\"> weka AbiWord au LibreOffice </a>.\",\n\t\"pad.modals.connected\": \"Imeunganishwa\",\n\t\"pad.modals.reconnecting\": \"Inaunganisha tena pedi yako…\",\n\t\"pad.modals.forcereconnect\": \"Lazimisha kuunganisha tena\",\n\t\"pad.modals.reconnecttimer\": \"Kujaribu kuungana tena\",\n\t\"pad.modals.cancel\": \"Ghairi\",\n\t\"pad.modals.userdup\": \"Imefunguliwa kwenye dirisha lingine\",\n\t\"pad.modals.userdup.explanation\": \"Pedi hii inaonekana kufunguliwa katika zaidi ya dirisha moja la kivinjari kwenye kompyuta hii.\",\n\t\"pad.modals.userdup.advice\": \"Unganisha tena ili utumie dirisha hili badala yake.\",\n\t\"pad.modals.unauth\": \"Haijaidhinishwa\",\n\t\"pad.modals.unauth.explanation\": \"Ruhusa zako zimebadilika wakati wa kutazama ukurasa huu. Jaribu kuunganisha tena.\",\n\t\"pad.modals.looping.explanation\": \"Kuna shida za mawasiliano na seva ya maingiliano.\",\n\t\"pad.modals.looping.cause\": \"Labda uliunganisha kupitia firewall isiyokubaliana au wakala.\",\n\t\"pad.modals.initsocketfail\": \"Seva haipatikani.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Imeshindwa kuunganisha kwenye seva ya usawazishaji.\",\n\t\"pad.modals.initsocketfail.cause\": \"Labda hii ni kwa sababu ya shida na kivinjari chako au muunganisho wako wa mtandao.\",\n\t\"pad.modals.slowcommit.explanation\": \"Seva haijibu.\",\n\t\"pad.modals.slowcommit.cause\": \"Hii inaweza kuwa ni kwa sababu ya shida na muunganisho wa mtandao.\",\n\t\"pad.modals.badChangeset.explanation\": \"Hariri uliyoifanya iliainishwa kuwa haramu na seva ya maingiliano.\",\n\t\"pad.modals.badChangeset.cause\": \"Hii inaweza kuwa ni kwa sababu ya usanidi mbaya wa seva au tabia zingine zisizotarajiwa. Tafadhali wasiliana na msimamizi wa huduma, ikiwa unahisi hii ni kosa. Jaribu kuunganisha tena ili uendelee kuhariri.\",\n\t\"pad.modals.corruptPad.explanation\": \"Pedi unayojaribu kufikia ni mbovu.\",\n\t\"pad.modals.corruptPad.cause\": \"Hii inaweza kuwa ni kwa sababu ya usanidi mbaya wa seva au tabia zingine zisizotarajiwa. Tafadhali wasiliana na msimamizi wa huduma.\",\n\t\"pad.modals.deleted\": \"Imefutwa.\",\n\t\"pad.modals.deleted.explanation\": \"Pedi hii imeondolewa.\",\n\t\"pad.modals.rateLimited\": \"Kiwango kidogo.\",\n\t\"pad.modals.rateLimited.explanation\": \"Ulituma ujumbe mwingi kwenye pedi hii kwa hivyo ikakukata.\",\n\t\"pad.modals.rejected.explanation\": \"Seva ilikataa ujumbe ambao ulitumwa na kivinjari chako.\",\n\t\"pad.modals.rejected.cause\": \"Seva inaweza kuwa imesasishwa wakati unatazama pedi, au labda kuna mdudu katika Etherpad. Jaribu kupakia upya ukurasa.\",\n\t\"pad.modals.disconnected\": \"Umetenganishwa\",\n\t\"pad.modals.disconnected.explanation\": \"Muunganisho wa seva ulipotea\",\n\t\"pad.modals.disconnected.cause\": \"Huenda seva haipatikani. Tafadhali mjulishe msimamizi wa huduma ikiwa hii itaendelea kutokea.\",\n\t\"pad.share\": \"Shiriki pedi hii\",\n\t\"pad.share.readonly\": \"Soma tu\",\n\t\"pad.share.link\": \"Kiungo\",\n\t\"pad.share.emebdcode\": \"Pachika URL\",\n\t\"pad.chat\": \"Ongea\",\n\t\"pad.chat.title\": \"Fungua gumzo kwa pedi hii.\",\n\t\"pad.chat.loadmessages\": \"Pakia ujumbe zaidi\",\n\t\"pad.chat.stick.title\": \"Funga mazungumzo kwenye skrini\",\n\t\"pad.chat.writeMessage.placeholder\": \"Andika ujumbe wako hapa\",\n\t\"timeslider.followContents\": \"Fuata sasisho za yaliyomo kwenye pedi\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Mpangaji Nyakati\",\n\t\"timeslider.toolbar.returnbutton\": \"Rudi kwenye pedi\",\n\t\"timeslider.toolbar.authors\": \"Waandishi\",\n\t\"timeslider.toolbar.authorsList\": \"Hakuna Waandishi\",\n\t\"timeslider.toolbar.exportlink.title\": \"Hamisha\",\n\t\"timeslider.exportCurrent\": \"Hamisha toleo la sasa kama:\",\n\t\"timeslider.version\": \"Toleo {{version}}\",\n\t\"timeslider.saved\": \"Imehifadhiwa {{month}} {{day}}, {{year}}\",\n\t\"timeslider.playPause\": \"Uchezaji / Sitisha Yaliyomo ya Pad\",\n\t\"timeslider.backRevision\": \"Rudi nyuma kwenye toleo hili\",\n\t\"timeslider.forwardRevision\": \"Nenda mbele kwa marekebisho katika Pad hii\",\n\t\"timeslider.dateformat\": \"{{month}} / {{day}} / {{year}} {{hours}}: {{minutes}}: {{seconds}}\",\n\t\"timeslider.month.january\": \"Januari\",\n\t\"timeslider.month.february\": \"Februari\",\n\t\"timeslider.month.march\": \"Machi\",\n\t\"timeslider.month.april\": \"Aprili\",\n\t\"timeslider.month.may\": \"Mei\",\n\t\"timeslider.month.june\": \"Juni\",\n\t\"timeslider.month.july\": \"Julai\",\n\t\"timeslider.month.august\": \"Agosti\",\n\t\"timeslider.month.september\": \"Septemba\",\n\t\"timeslider.month.october\": \"Oktoba\",\n\t\"timeslider.month.november\": \"Novemba\",\n\t\"timeslider.month.december\": \"Desemba\",\n\t\"timeslider.unnamedauthors\": \"{{num}} haijatajwa jina {[wingi (num) moja: mwandishi, mwingine: waandishi]}\",\n\t\"pad.savedrevs.marked\": \"Marekebisho haya sasa yamewekwa alama kama marekebisho yaliyohifadhiwa\",\n\t\"pad.savedrevs.timeslider\": \"Unaweza kuona marekebisho yaliyohifadhiwa kwa kutembelea mpangilio wa nyakati\",\n\t\"pad.userlist.entername\": \"Ingiza jina lako\",\n\t\"pad.userlist.unnamed\": \"bila jina\",\n\t\"pad.editbar.clearcolors\": \"Futa rangi za uandishi kwenye hati nzima? Hii haiwezi kutenduliwa\",\n\t\"pad.impexp.importbutton\": \"Ingiza Sasa\",\n\t\"pad.impexp.importing\": \"Inaleta ...\",\n\t\"pad.impexp.confirmimport\": \"Kuingiza faili kutaondoa maandishi ya sasa ya pedi. Je! Una uhakika unataka kuendelea?\",\n\t\"pad.impexp.convertFailed\": \"Hatukuweza kuleta faili hii. Tafadhali tumia fomati ya hati tofauti au nakili ubandike mwenyewe\",\n\t\"pad.impexp.padHasData\": \"Hatukuweza kuagiza faili hii kwa sababu pedi hii tayari imekuwa na mabadiliko, tafadhali ingiza kwa pedi mpya\",\n\t\"pad.impexp.uploadFailed\": \"Upakiaji umeshindwa, tafadhali jaribu tena\",\n\t\"pad.impexp.importfailed\": \"Uingizaji haukufaulu\",\n\t\"pad.impexp.copypaste\": \"Tafadhali nakili kuweka\",\n\t\"pad.impexp.exportdisabled\": \"Kuhamisha kama muundo wa {{type}} kumezimwa. Tafadhali wasiliana na msimamizi wako wa mfumo kwa maelezo.\",\n\t\"pad.impexp.maxFileSize\": \"Faili kubwa sana. Wasiliana na msimamizi wa wavuti yako ili kuongeza saizi iliyoruhusiwa ya kuagiza\"\n}\n"
  },
  {
    "path": "src/locales/ta.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Balajijagadesh\",\n\t\t\t\"ElangoRamanujam\",\n\t\t\t\"Sank\"\n\t\t]\n\t},\n\t\"index.newPad\": \"புதிய அட்டை\",\n\t\"index.createOpenPad\": \"அல்லது பெயருடன் ஒரு அட்டையை உருவாக்கு/திற\",\n\t\"pad.toolbar.bold.title\": \"தடித்த (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"சாய்ந்த (Ctrl+l)\",\n\t\"pad.toolbar.underline.title\": \"அடிக்கோடு (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"குறுக்குக்கோடு (Ctrl+5)\",\n\t\"pad.toolbar.timeslider.title\": \"நேர வழுக்கி\",\n\t\"pad.toolbar.settings.title\": \"அமைப்புகள்\",\n\t\"pad.toolbar.embed.title\": \"இவ்வட்டையை பகிர் மற்றும் பதி\",\n\t\"pad.toolbar.showusers.title\": \"இவ்வட்டையின் பயனர்களை காட்டவும்\",\n\t\"pad.colorpicker.save\": \"சேமி\",\n\t\"pad.colorpicker.cancel\": \"இரத்து செய்\",\n\t\"pad.loading\": \"ஏற்றப்படுகிறது...\",\n\t\"pad.permissionDenied\": \"இவ்வட்டையை அணுக தங்களுக்கு அனுமதி இல்லை\",\n\t\"pad.settings.padSettings\": \"அட்டை அமைவுகள்\",\n\t\"pad.settings.myView\": \"என் பார்வை\",\n\t\"pad.settings.stickychat\": \"திரையில் எப்பொழுதும் அரட்டை\",\n\t\"pad.settings.chatandusers\": \"அரட்டை மற்றும் பயனர்களை காட்டுக\",\n\t\"pad.settings.colorcheck\": \"ஆசிரியர் நிறங்கள்\",\n\t\"pad.settings.linenocheck\": \"வரி எண்கள்\",\n\t\"pad.settings.fontType\": \"எழுத்துரு வகை:\",\n\t\"pad.settings.language\": \"மொழி:\",\n\t\"pad.importExport.import_export\": \"இறக்குமதி/ஏற்றுமதி\",\n\t\"pad.importExport.importSuccessful\": \"வெற்றி!\",\n\t\"pad.modals.connected\": \"இணைக்கப்பட்டது.\",\n\t\"pad.modals.initsocketfail\": \"வழங்கியை தொடர்பு கொள்ளமுடியவில்லை\",\n\t\"pad.modals.deleted\": \"நீக்கப்பட்டது\",\n\t\"pad.modals.deleted.explanation\": \"இந்த அட்டை நீக்கப்பட்டது.\",\n\t\"pad.modals.disconnected\": \"தாங்கள் துண்டிக்கப்பட்டுள்ளீர்கள்\",\n\t\"pad.modals.disconnected.explanation\": \"வழங்கியின் தொடர்பு தொலைந்து\",\n\t\"pad.share\": \"இவ்வட்டையை பகிர்க\",\n\t\"pad.share.readonly\": \"வாசிக்க மாத்திரம்\",\n\t\"pad.share.link\": \"இணைப்பு\",\n\t\"pad.share.emebdcode\": \"உரலியை பதிக\",\n\t\"pad.chat\": \"அரட்டை\",\n\t\"pad.chat.title\": \"இவ்வட்டைக்கு அரட்டையை திறக்கவும்\",\n\t\"pad.chat.loadmessages\": \"மேலும் தகவல்களை பதிவேற்றவும்\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} நேரவழுக்கி\",\n\t\"timeslider.toolbar.returnbutton\": \"அட்டைக்கு திரும்பவும்\",\n\t\"timeslider.toolbar.authors\": \"ஆசிரியர்கள்:\",\n\t\"timeslider.toolbar.authorsList\": \"ஆசிரியர்கள் இல்லை\",\n\t\"timeslider.toolbar.exportlink.title\": \"ஏற்றுமதி செய்க\",\n\t\"timeslider.version\": \"பதிப்பு {{version}}\",\n\t\"timeslider.month.january\": \"சனவரி\",\n\t\"timeslider.month.february\": \"பெப்ரவரி\",\n\t\"timeslider.month.march\": \"மார்ச்\",\n\t\"timeslider.month.april\": \"ஏப்ரல்\",\n\t\"timeslider.month.may\": \"மே\",\n\t\"timeslider.month.june\": \"சூன்\",\n\t\"timeslider.month.july\": \"சூலை\",\n\t\"timeslider.month.august\": \"ஆகஸ்ட்\",\n\t\"timeslider.month.september\": \"செப்டம்பர்\",\n\t\"timeslider.month.october\": \"அக்டோபர்\",\n\t\"timeslider.month.november\": \"நவம்பர்\",\n\t\"timeslider.month.december\": \"டிசம்பர்\",\n\t\"pad.userlist.entername\": \"உங்கள் பெயரை உள்ளிடுக\",\n\t\"pad.userlist.unnamed\": \"பெயரிடப்படாதது\",\n\t\"pad.impexp.importbutton\": \"இப்பொழுது இறக்குக\",\n\t\"pad.impexp.importing\": \"இறக்குகிறது...\",\n\t\"pad.impexp.uploadFailed\": \"பதிவேற்றம் தோல்வியடைந்தது, தயவுசெய்து மீண்டும் முயலவும்.\",\n\t\"pad.impexp.importfailed\": \"இறக்குமதி தோல்வியடைந்தது\",\n\t\"pad.impexp.copypaste\": \"படியெடுத்து ஒட்டுக\"\n}\n"
  },
  {
    "path": "src/locales/tcy.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"BHARATHESHA ALASANDEMAJALU\",\n\t\t\t\"VASANTH S.N.\"\n\t\t]\n\t},\n\t\"index.newPad\": \"ಪೊಸ ಪ್ಯಾಡ್\",\n\t\"index.createOpenPad\": \"ಅತಂಡ ಈ ಪುದರ್ತ ಪ್ಯಾಡನ್ನು ಉಂಡು ಮನ್ಪು/ತೋಜಾಲ:\",\n\t\"pad.toolbar.bold.title\": \"ದಪ್ಪೊ(Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"ಓರೆ (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"ಅಡಿಗೆರೆ(Ctrl-U)\",\n\t\"pad.toolbar.indent.title\": \"Indent (TAB)\",\n\t\"pad.toolbar.undo.title\": \"ಪಿರವುತ(Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"ದುಂಬುತ್ತ(Ctrl+Y)\",\n\t\"pad.toolbar.settings.title\": \"ಸಂಯೋಜನೆಲು\",\n\t\"pad.toolbar.showusers.title\": \"ಈ ಪ್ಯಾಡ್ ಟ್ ಗಲಸುನಾಯಾನ್ ತೋಜಾಲೆ\",\n\t\"pad.colorpicker.save\": \"ಒರಿಪಾಲೆ\",\n\t\"pad.colorpicker.cancel\": \"ವಜಾ ಮಲ್ಪುಲೆ\",\n\t\"pad.loading\": \"ದಿಂಜಾವೊಂದುಂಡು......\",\n\t\"pad.settings.padSettings\": \"ಪ್ಯಾಡ್ ಸಂಯೋಜನೆ\",\n\t\"pad.settings.language\": \"ಬಾಸೆ:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.modals.connected\": \"ನೆಟ್ ವರ್ಕ್ ತಿಕೊಂತುಂಡು.\",\n\t\"pad.modals.cancel\": \"ವಜಾ ಮಲ್ಪುಲೆ\",\n\t\"pad.modals.deleted\": \"ಮಾಜಾಯಿನ.\",\n\t\"pad.share.readonly\": \"ಓದ್ಯರಾ ಮಾತ್ರ\",\n\t\"pad.share.link\": \"ಕೊಂಡಿಲು\",\n\t\"timeslider.month.january\": \"ಜನವರಿ\",\n\t\"timeslider.month.february\": \"ಪೆಬ್ರವರಿ\",\n\t\"timeslider.month.march\": \"ಮಾರ್ಚಿ\",\n\t\"timeslider.month.april\": \"ಎಪ್ರಿಲ್\",\n\t\"timeslider.month.may\": \"ಮೇ\",\n\t\"timeslider.month.june\": \"ಜೂನ್\",\n\t\"timeslider.month.july\": \"ಜುಲಾಯಿ\",\n\t\"timeslider.month.august\": \"ಆಗೋಸ್ಟು\",\n\t\"timeslider.month.september\": \"ಸಪ್ಟಂಬರೊ\",\n\t\"timeslider.month.october\": \"ಅಕ್ಟೋಬರ\",\n\t\"timeslider.month.november\": \"ನವಂಬರೊ\",\n\t\"timeslider.month.december\": \"ದಸಂಬರೊ\",\n\t\"pad.userlist.entername\": \"ಈರೆನೆ ಪುದರ್ ಬರೆಲೆ\",\n\t\"pad.userlist.unnamed\": \"ಪುದರ್ ಇಜ್ಜಂತಿನವು\"\n}\n"
  },
  {
    "path": "src/locales/te.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Chaduvari\",\n\t\t\t\"JVRKPRASAD\",\n\t\t\t\"Kiranmayee\",\n\t\t\t\"Malkum\",\n\t\t\t\"Ravichandra\",\n\t\t\t\"Veeven\"\n\t\t]\n\t},\n\t\"admin_plugins.name\": \"పేరు\",\n\t\"index.newPad\": \"కొత్త పలక\",\n\t\"index.createOpenPad\": \"ఒక పేరుతో పలకని సృష్టించండి లేదా అదే పేరుతో ఉన్న పలకని తెరవండి\",\n\t\"pad.toolbar.bold.title\": \"బొద్దు (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"వాలు (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"క్రిందగీత\",\n\t\"pad.toolbar.strikethrough.title\": \"కొట్టివేత (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"క్రమ జాబితా (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"బిందు జాబితా (Ctrl+Shift+L)\",\n\t\"pad.toolbar.undo.title\": \"చేయవద్దు\",\n\t\"pad.toolbar.redo.title\": \"తిరిగిచెయ్యి\",\n\t\"pad.toolbar.clearAuthorship.title\": \"మూలకర్తపు వర్ణాలను తీసివేయండి\",\n\t\"pad.toolbar.import_export.title\": \"భిన్నమైన రూపలావన్యాలను బయట నుండి దిగుమతి లేదా బయటకు ఎగుమతి చేయండి\",\n\t\"pad.toolbar.timeslider.title\": \"పనిసమయ సూచిక పరికరం\",\n\t\"pad.toolbar.savedRevision.title\": \"పునరుచ్చరణలు దాచు\",\n\t\"pad.toolbar.settings.title\": \"అమరికలు\",\n\t\"pad.toolbar.embed.title\": \"ఈ పలకని పొదగించి పంచిపెట్టండి\",\n\t\"pad.toolbar.showusers.title\": \"ఈ పలక యొక్క వినియోగదారులను చూపించు\",\n\t\"pad.colorpicker.save\": \"భద్రపరచు\",\n\t\"pad.colorpicker.cancel\": \"రద్దుచేయి\",\n\t\"pad.loading\": \"లోడవుతోంది...\",\n\t\"pad.permissionDenied\": \"ఈ పేజీని చూడడానికి మీరు అనుమతి లేదు.\",\n\t\"pad.settings.padSettings\": \"పలక అమరికలు\",\n\t\"pad.settings.myView\": \"నా ఉద్దేశ్యము\",\n\t\"pad.settings.stickychat\": \"తెరపైనే మాటామంతిని ఎల్లపుడు చేయుము\",\n\t\"pad.settings.colorcheck\": \"రచయితలకు రంగులు\",\n\t\"pad.settings.linenocheck\": \"వరుస సంఖ్యలు\",\n\t\"pad.settings.fontType\": \"అక్షరశైలి రకం:\",\n\t\"pad.settings.fontType.normal\": \"సాధారణ\",\n\t\"pad.settings.language\": \"భాష\",\n\t\"pad.settings.about\": \"గురించి\",\n\t\"pad.importExport.import_export\": \"దిగుమతి/ఎగుమతి\",\n\t\"pad.importExport.import\": \"పాఠము దస్త్రము లేదా పత్రమును దిగుమతి చేయుము\",\n\t\"pad.importExport.importSuccessful\": \"విజయవంతం!\",\n\t\"pad.importExport.export\": \"ప్రస్తుత పలకని ఈ విధముగా ఎగుమతి చేయుము:\",\n\t\"pad.importExport.exporthtml\": \"హెచ్ టి ఎం ఎల్\",\n\t\"pad.importExport.exportplain\": \"సాదా పాఠ్యం\",\n\t\"pad.importExport.exportword\": \"మైక్రోసాఫ్ట్ వర్డ్\",\n\t\"pad.importExport.exportpdf\": \"పీ డి ఎఫ్\",\n\t\"pad.importExport.exportopen\": \"ఓ డి ఎఫ్ (ఓపెన్ డాక్యుమెంట్ ఫార్మాట్)\",\n\t\"pad.modals.connected\": \"సంబంధం కుదిరింది.\",\n\t\"pad.modals.reconnecting\": \"మీ పలకకు మరల సంబంధం కలుపుతుంది...\",\n\t\"pad.modals.forcereconnect\": \"బలవంతంగానైనా సంబంధం కుదిరించు\",\n\t\"pad.modals.cancel\": \"రద్దుచేయి\",\n\t\"pad.modals.userdup.explanation\": \"ఈ పలక, ఈ కంప్యూటర్లో ఒకటికన్న ఎక్కువ గవాక్షములలో  తెరుచుకున్నట్లు అనిపిస్తుంది.\",\n\t\"pad.modals.userdup.advice\": \"బదులుగా ఈ గవాక్షమును వాడడానికి మరల సంబంధం కలపండి\",\n\t\"pad.modals.unauth\": \"అధికారం లేదు\",\n\t\"pad.modals.unauth.explanation\": \"మీరు ఈ పుటను చూస్తూన్నప్పుడు మీ అనుమతులు మారాయి. మరల సంబంధం కలపడానికి ప్రయత్నించండి.\",\n\t\"pad.modals.initsocketfail\": \"సర్వరు అందుబాటులో లేదు.\",\n\t\"pad.modals.slowcommit.explanation\": \"సర్వరు స్పందించడం లేదు.\",\n\t\"pad.modals.deleted\": \"తొలగించబడింది ( తొలగించినది )\",\n\t\"pad.share\": \"ఈ పలకను పంచుకొను\",\n\t\"pad.share.readonly\": \"చదువుటకు మాత్రమే\",\n\t\"pad.share.link\": \"లంకె\",\n\t\"pad.share.emebdcode\": \"యు ఆర్ ఎల్ ను పొదగించండి\",\n\t\"pad.chat\": \"మాటామంతి\",\n\t\"pad.chat.title\": \"ఈ పలకకు మాటామంతిని తెరిచి ఉంచండి.\",\n\t\"pad.chat.loadmessages\": \"మరిన్ని సందేశాలు తీసుకురా\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} పనిసమయ సూచిక పరికరం\",\n\t\"timeslider.toolbar.returnbutton\": \"పలకకి తిరిగి వెళ్ళండి\",\n\t\"timeslider.toolbar.authors\": \"రచయితలు:\",\n\t\"timeslider.toolbar.authorsList\": \"రచయితలు లేరు\",\n\t\"timeslider.toolbar.exportlink.title\": \"ఎగుమతి చెయ్యి\",\n\t\"timeslider.exportCurrent\": \"ప్రస్తుత అవతారాన్ని ఈ విధంగా ఎగుమతి చేయుము:\",\n\t\"timeslider.saved\": \"{{year}}, {{month}} {{day}} న భద్రపరచబడింది\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"జనవరి\",\n\t\"timeslider.month.february\": \"ఫిబ్రవరి\",\n\t\"timeslider.month.march\": \"మార్చి\",\n\t\"timeslider.month.april\": \"ఏప్రిల్\",\n\t\"timeslider.month.may\": \"మే\",\n\t\"timeslider.month.june\": \"జూన్\",\n\t\"timeslider.month.july\": \"జూలై\",\n\t\"timeslider.month.august\": \"ఆగష్టు\",\n\t\"timeslider.month.september\": \"సెప్టెంబరు\",\n\t\"timeslider.month.october\": \"అక్టోబరు\",\n\t\"timeslider.month.november\": \"నవంబరు\",\n\t\"timeslider.month.december\": \"డిసెంబరు\",\n\t\"pad.userlist.entername\": \"మీ పేరు ఇవ్వండి\",\n\t\"pad.userlist.unnamed\": \"అనామకం\",\n\t\"pad.impexp.importbutton\": \"దిగుమతి చేసెయ్యి\",\n\t\"pad.impexp.importing\": \"దిగుమతి చేస్తున్నాం...\",\n\t\"pad.impexp.confirmimport\": \"దిగుమతి చేసుకోవడం వల్ల ప్యాడ్ లోఉన్న పాఠ్యం తుడిచిపెట్టుకుపోతుంది. ఇది మీకు అంగీకారమేనా?\",\n\t\"pad.impexp.convertFailed\": \"ఈ ఫైలును దిగుమతి చేసుకోలేకపోయాం. వేరే డాక్యుమెంట్ ఫార్మాటును వాడండి లేదా మీరే కాపీ చేసి అతికించండి\",\n\t\"pad.impexp.uploadFailed\": \"ఎక్కింపు విఫలమైంది, మళ్ళీ ప్రయత్నించండి.\",\n\t\"pad.impexp.importfailed\": \"దిగుమతి విఫలమైంది\",\n\t\"pad.impexp.copypaste\": \"నకలు చేసి అతికించండి\"\n}\n"
  },
  {
    "path": "src/locales/th.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Aefgh39622\",\n\t\t\t\"Andibecker\",\n\t\t\t\"Ekminarin\",\n\t\t\t\"Patsagorn Y.\",\n\t\t\t\"Trisorn Triboon\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"แดชบอร์ดผู้ดูแลระบบ - Etherpad\",\n\t\"admin_plugins\": \"ตัวจัดการปลั๊กอิน\",\n\t\"admin_plugins.available\": \"ปลั๊กอินที่มีอยู่\",\n\t\"admin_plugins.available_not-found\": \"ไม่พบปลั๊กอิน\",\n\t\"admin_plugins.available_fetching\": \"กำลังเรียก…\",\n\t\"admin_plugins.available_install.value\": \"ติดตั้ง\",\n\t\"admin_plugins.available_search.placeholder\": \"ค้นหาปลั๊กอินที่จะติดตั้ง\",\n\t\"admin_plugins.description\": \"คำอธิบาย\",\n\t\"admin_plugins.installed\": \"ปลั๊กอินที่ติดตั้ง\",\n\t\"admin_plugins.installed_fetching\": \"กำลังเรียกปลั๊กอินที่ติดตั้ง…\",\n\t\"admin_plugins.installed_nothing\": \"คุณยังไม่ได้ติดตั้งปลั๊กอินใด ๆ\",\n\t\"admin_plugins.installed_uninstall.value\": \"ถอนการติดตั้ง\",\n\t\"admin_plugins.last-update\": \"การปรับปรุงครั้งล่าสุด\",\n\t\"admin_plugins.name\": \"ชื่อ\",\n\t\"admin_plugins.page-title\": \"ตัวจัดการปลั๊กอิน - Etherpad\",\n\t\"admin_plugins.version\": \"เวอร์ชัน\",\n\t\"admin_plugins_info\": \"ข้อมูลการแก้ไขปัญหา\",\n\t\"admin_plugins_info.hooks\": \"ติดตั้งตะขอ\",\n\t\"admin_plugins_info.hooks_client\": \"ตะขอฝั่งไคลเอ็นต์\",\n\t\"admin_plugins_info.hooks_server\": \"ตะขอฝั่งเซิร์ฟเวอร์\",\n\t\"admin_plugins_info.parts\": \"ชิ้นส่วนที่ติดตั้ง\",\n\t\"admin_plugins_info.plugins\": \"ปลั๊กอินที่ติดตั้ง\",\n\t\"admin_plugins_info.page-title\": \"ข้อมูลปลั๊กอิน - Etherpad\",\n\t\"admin_plugins_info.version\": \"รุ่น Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"เวอร์ชันล่าสุดที่มีอยู่\",\n\t\"admin_plugins_info.version_number\": \"หมายเลขเวอร์ชัน\",\n\t\"admin_settings\": \"การตั้งค่า\",\n\t\"admin_settings.current\": \"การกำหนดค่าปัจจุบัน\",\n\t\"admin_settings.current_example-devel\": \"ตัวอย่างเทมเพลตการตั้งค่าการพัฒนา\",\n\t\"admin_settings.current_example-prod\": \"ตัวอย่างแม่แบบการตั้งค่าการผลิต\",\n\t\"admin_settings.current_restart.value\": \"รีสตาร์ท Etherpad\",\n\t\"admin_settings.current_save.value\": \"บันทึกการตั้งค่า\",\n\t\"admin_settings.page-title\": \"การตั้งค่า - Etherpad\",\n\t\"index.newPad\": \"สร้างแผ่นจดบันทึกใหม่\",\n\t\"index.createOpenPad\": \"หรือสร้าง/เปิดแผ่นจดบันทึกที่มีชื่อ:\",\n\t\"index.openPad\": \"เปิดแพดที่มีอยู่แล้วด้วยชื่อ:\",\n\t\"pad.toolbar.bold.title\": \"ตัวหนา (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"ตัวเอียง (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"ขีดเส้นใต้ (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"ขีดทับ (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"รายการที่เรียงลำดับ (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"รายการที่ไม่เรียงลำดับ (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"เยื้องเข้า (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"เยื้องออก (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"เลิกทำ (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"ทำซ้ำ (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"ลบสีผู้เขียน (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"นำเข้า/ส่งออกไฟล์จาก/เป็นรูปแบบต่าง ๆ\",\n\t\"pad.toolbar.timeslider.title\": \"ตัวเลื่อนเวลา\",\n\t\"pad.toolbar.savedRevision.title\": \"บันทึกรุ่นแก้ไข\",\n\t\"pad.toolbar.settings.title\": \"การตั้งค่า\",\n\t\"pad.toolbar.embed.title\": \"แชร์และฝังแผ่นจดบันทึกนี้\",\n\t\"pad.toolbar.showusers.title\": \"แสดงผู้ใช้บนแผ่นจดบันทึกนี้\",\n\t\"pad.colorpicker.save\": \"บันทึก\",\n\t\"pad.colorpicker.cancel\": \"ยกเลิก\",\n\t\"pad.loading\": \"กำลังโหลด...\",\n\t\"pad.noCookie\": \"ไม่พบคุกกี้ กรุณาอนุญาติคุกกี้บนเบราว์เซอร์ของคุณ การเข้าสู่ระบบและการตั้งค่าจะไม่ถูกบันทึกขณะเยี่ยมชม อาจเกิดปัญหาจากอีเทอร์แพดถูกฝังไว้ในหน้าผ่าน iFrame ในบางเบราว์เซอร์ กรุณาตรวจสอบว่าอีเทอร์แพดอยู่ในโดเมนหรือโดเมนรองเดียวกันกับหน้าที่ฝัง iFrame\",\n\t\"pad.permissionDenied\": \"คุณไม่มีสิทธิ์เข้าถึงแผ่นจดบันทึกนี้\",\n\t\"pad.settings.padSettings\": \"การตั้งค่าแผ่นจดบันทึก\",\n\t\"pad.settings.myView\": \"มุมมองของฉัน\",\n\t\"pad.settings.stickychat\": \"แสดงการแชทบนหน้าจอเสมอ\",\n\t\"pad.settings.chatandusers\": \"แสดงการแชทและผู้ใช้\",\n\t\"pad.settings.colorcheck\": \"สีผู้เขียน\",\n\t\"pad.settings.linenocheck\": \"เลขบรรทัด\",\n\t\"pad.settings.rtlcheck\": \"อ่านเนื้อหาจากขวาไปซ้ายหรือไม่?\",\n\t\"pad.settings.fontType\": \"ชนิดแบบอักษร:\",\n\t\"pad.settings.language\": \"ภาษา:\",\n\t\"pad.settings.about\": \"เกี่ยวกับ\",\n\t\"pad.settings.poweredBy\": \"ขับเคลื่อนโดย\",\n\t\"pad.importExport.import_export\": \"นำเข้า/ส่งออก\",\n\t\"pad.importExport.import\": \"อัปโหลดไฟล์ข้อความหรือเอกสารใด ๆ\",\n\t\"pad.importExport.importSuccessful\": \"สำเร็จ!\",\n\t\"pad.importExport.export\": \"ส่งออกแผ่นจดบันทึกปัจจุบันเป็น:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"ข้อความธรรมดา\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Open Document Format)\",\n\t\"pad.importExport.abiword.innerHTML\": \"คุณสามารถนำเข้าได้จากรูปแบบ HTML หรือข้อความธรรมดาเท่านั้น สำหรับคุณสมบัติการนำเข้าขั้นสูงเพิ่มเติม โปรด<a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">ติดตั้ง AbiWord หรือ LibraOffice</a>\",\n\t\"pad.modals.connected\": \"เชื่อมต่อแล้ว\",\n\t\"pad.modals.reconnecting\": \"กำลังเชื่อมต่อกับแผ่นจดบันทึกของคุณใหม่..\",\n\t\"pad.modals.forcereconnect\": \"บังคับเชื่อมต่อใหม่\",\n\t\"pad.modals.reconnecttimer\": \"กำลังพยายามเชื่อมต่อใหม่ใน\",\n\t\"pad.modals.cancel\": \"ยกเลิก\",\n\t\"pad.modals.userdup\": \"เปิดในหน้าต่างอื่นแล้ว\",\n\t\"pad.modals.userdup.explanation\": \"แผ่นจดบันทึกนี้ดูเหมือนว่าจะถูกเปิดในหน้าต่างเบราว์เซอร์มากกว่าหนึ่งหน้าต่างบนคอมพิวเตอร์นี้\",\n\t\"pad.modals.userdup.advice\": \"เชื่อมต่อใหม่เพื่อใช้หน้าต่างนี้แทน\",\n\t\"pad.modals.unauth\": \"ไม่ได้รับอนุญาต\",\n\t\"pad.modals.unauth.explanation\": \"สิทธิของคุณถูกเปลี่ยนขณะที่คุณดูหน้านี้อยู่ พยายามเชื่อมต่อใหม่\",\n\t\"pad.modals.looping.explanation\": \"มีปัญหาการสื่อสารกับเซิร์ฟเวอร์การซิงค์ข้อมูล\",\n\t\"pad.modals.looping.cause\": \"บางทีอาจเป็นเพราะคุณเชื่อมต่อกับไฟร์วอลล์หรือพร็อกซีที่เข้ากันไม่ได้\",\n\t\"pad.modals.initsocketfail\": \"เซิร์ฟเวอร์ไม่สามารถเข้าถึงได้\",\n\t\"pad.modals.initsocketfail.explanation\": \"ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์การซิงค์ข้อมูล\",\n\t\"pad.modals.initsocketfail.cause\": \"อาจเป็นเนื่องจากเบราว์เซอร์ของคุณหรือการเชื่อมต่ออินเทอร์เน็ตของคุณมีปัญหา\",\n\t\"pad.modals.slowcommit.explanation\": \"เซิร์ฟเวอร์ไม่ตอบสนอง\",\n\t\"pad.modals.slowcommit.cause\": \"อาจเป็นเนื่องจากปัญหาเกี่ยวกับการเชื่อมต่อเครือข่าย\",\n\t\"pad.modals.badChangeset.explanation\": \"การแก้ไขที่คุณกระทำถูกจัดว่าไม่เหมาะสมโดยเซิร์ฟเวอร์การซิงค์ข้อมูล\",\n\t\"pad.modals.badChangeset.cause\": \"อาจเป็นเนื่องจากการกำหนดค่าเซิร์ฟเวอร์ไม่ถูกต้องหรือมีลักษณะการทำงานอื่นๆ บางอย่างที่ไม่คาดคิด โปรดติดต่อผู้ดูแลการให้บริการ ถ้าคุณรู้สึกว่านี่คือข้อผิดพลาด โปรดทำการเชื่อมต่อใหม่อีกครั้งเพื่อทำการแก้ไขต่อไป\",\n\t\"pad.modals.corruptPad.explanation\": \"แผ่นจดบันทึกที่คุณกำลังพยายามเข้าถึงเสียหาย\",\n\t\"pad.modals.corruptPad.cause\": \"อาจเป็นเนื่องจากการกำหนดค่าเซิร์ฟเวอร์ไม่ถูกต้องหรือมีลักษณะการทำงานอื่นๆ บางอย่างที่ไม่คาดคิด โปรดติดต่อผู้ดูแลการให้บริการ\",\n\t\"pad.modals.deleted\": \"ลบแล้ว\",\n\t\"pad.modals.deleted.explanation\": \"แผ่นจดบันทึกนี้ได้ถูกลบออกแล้ว\",\n\t\"pad.modals.rateLimited\": \"ถึงขีดจำกัด\",\n\t\"pad.modals.rateLimited.explanation\": \"คณส่งข้อความถึงแพดนี้มากเกินไปจึงถูกตัดการเชื่อมโดยโปรแกรมอัตโนมัติ\",\n\t\"pad.modals.rejected.explanation\": \"เซิร์ฟเวอร์ปฏิเสธข้อความที่ส่งโดยเบราว์เซอร์ของคุณ\",\n\t\"pad.modals.rejected.cause\": \"เซิร์ฟเวอร์อาจได้รับการอัปเดตในขณะที่คุณกำลังดูแพด หรืออาจมีข้อบกพร่องใน Etherpad ลองโหลดหน้านี้ใหม่\",\n\t\"pad.modals.disconnected\": \"คุณได้ตัดการเชื่อมต่อแล้ว\",\n\t\"pad.modals.disconnected.explanation\": \"การเชื่อมต่อกับเซิร์ฟเวอร์ถูกตัด\",\n\t\"pad.modals.disconnected.cause\": \"เซิร์ฟเวอร์อาจใช้ไม่ได้ชั่วคราว โปรดแจ้งให้ผู้ดูแลการให้บริการทราบถ้าปัญหานี้ยังคงเกิดขึ้น\",\n\t\"pad.share\": \"แชร์แผ่นจดบันทึกนี้\",\n\t\"pad.share.readonly\": \"อ่านเท่านั้น\",\n\t\"pad.share.link\": \"ลิงก์\",\n\t\"pad.share.emebdcode\": \"URL แบบฝังตัว\",\n\t\"pad.chat\": \"แชต\",\n\t\"pad.chat.title\": \"เปิดการแชทสำหรับแผ่นจดบันทึกนี้\",\n\t\"pad.chat.loadmessages\": \"โหลดข้อความเพิ่มเติม\",\n\t\"pad.chat.stick.title\": \"ปักการสนทนาไว้บนหน้าจอ\",\n\t\"pad.chat.writeMessage.placeholder\": \"เขียนข้อความของคุณที่นี่\",\n\t\"timeslider.followContents\": \"ติดตามการอัพเดตเนื้อหาแพด\",\n\t\"timeslider.pageTitle\": \"ตัวเลื่อนเวลา {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"กลับไปแผ่นจดบันทึก\",\n\t\"timeslider.toolbar.authors\": \"ผู้เขียน:\",\n\t\"timeslider.toolbar.authorsList\": \"ไม่มีผู้เขียน\",\n\t\"timeslider.toolbar.exportlink.title\": \"ส่งออก\",\n\t\"timeslider.exportCurrent\": \"ส่งออกรุ่นปัจจุบันเป็น:\",\n\t\"timeslider.version\": \"เวอร์ชัน {{version}}\",\n\t\"timeslider.saved\": \"บันทึกแล้วเมื่อ {{day}} {{month}} {{year}}\",\n\t\"timeslider.playPause\": \"เล่น / พักเนื้อหาแผ่นจดบันทึก\",\n\t\"timeslider.backRevision\": \"กลับไปรุ่นแก้ไขเก่าของแผ่นจดบันทึกนี้\",\n\t\"timeslider.forwardRevision\": \"ไปยังรุ่นแก้ไขใหม่ของแผ่นจดบันทึกนี้\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"มกราคม\",\n\t\"timeslider.month.february\": \"กุมภาพันธ์\",\n\t\"timeslider.month.march\": \"มีนาคม\",\n\t\"timeslider.month.april\": \"เมษายน\",\n\t\"timeslider.month.may\": \"พฤษภาคม\",\n\t\"timeslider.month.june\": \"มิถุนายน\",\n\t\"timeslider.month.july\": \"กรกฎาคม\",\n\t\"timeslider.month.august\": \"สิงหาคม\",\n\t\"timeslider.month.september\": \"กันยายน\",\n\t\"timeslider.month.october\": \"ตุลาคม\",\n\t\"timeslider.month.november\": \"พฤศจิกายน\",\n\t\"timeslider.month.december\": \"ธันวาคม\",\n\t\"timeslider.unnamedauthors\": \"{{num}} ผู้เขียนที่ไม่มีชื่อ\",\n\t\"pad.savedrevs.marked\": \"รุ่นแก้ไขนี้ถูกทำเครื่องหมายเป็นรุ่นแก้ไขที่บันทึกแล้ว\",\n\t\"pad.savedrevs.timeslider\": \"คุณสามารถดูรุ่นแก้ไขที่บันทึกแล้วโดยเยี่ยมชมตัวเลื่อนเวลา\",\n\t\"pad.userlist.entername\": \"ป้อนชื่อของคุณ\",\n\t\"pad.userlist.unnamed\": \"ไม่มีชื่อ\",\n\t\"pad.editbar.clearcolors\": \"ลบการเน้นความเป็นเจ้าของข้อความหรือไม่? การกระทำนี่ไม่สามารถย้อนได้\",\n\t\"pad.impexp.importbutton\": \"นำเข้าตอนนี้\",\n\t\"pad.impexp.importing\": \"กำลังนำเข้า...\",\n\t\"pad.impexp.confirmimport\": \"การนำเข้าไฟล์จะเป็นการเขียนทับข้อความปัจจุบันบนแผ่นจดบันทึก คุณแน่ใจหรือว่าคุณต้องการดำเนินการต่อ?\",\n\t\"pad.impexp.convertFailed\": \"เราไม่สามารถนำเข้าไฟล์นี้ได้ โปรดใช้รูปแบบเอกสารอื่นหรือคัดลอกแล้ววางด้วยตนเอง\",\n\t\"pad.impexp.padHasData\": \"เราไม่สามารถนำเข้าไฟล์นี้ได้เนื่องจากแผ่นจดบันทึกนี้มีการเปลี่ยนแปลงอยู่แล้ว โปรดนำเข้าไปแผ่นจดบันทึกใหม่แทน\",\n\t\"pad.impexp.uploadFailed\": \"การอัปโหลดล้มเหลว โปรดลองอีกครั้ง\",\n\t\"pad.impexp.importfailed\": \"การนำเข้าล้มเหลว\",\n\t\"pad.impexp.copypaste\": \"โปรดคัดลอกแล้ววาง\",\n\t\"pad.impexp.exportdisabled\": \"การส่งออกเป็นรูปแบบ {{type}} ถูกปิดใช้งาน โปรดติดต่อผู้ดูแลระบบของคุณสำหรับรายละเอียดเพิ่มเติม\",\n\t\"pad.impexp.maxFileSize\": \"ไฟล์ใหญ่เกินไป ติดต่อผู้ดูแลไซต์เพื่อให้ขยายขนาดไฟล์ที่นำเข้าได้\"\n}\n"
  },
  {
    "path": "src/locales/tr.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"BaRaN6161 TURK\",\n\t\t\t\"Emperyan\",\n\t\t\t\"Erdemaslancan\",\n\t\t\t\"Grkn gll\",\n\t\t\t\"Hedda\",\n\t\t\t\"Joseph\",\n\t\t\t\"Leo\",\n\t\t\t\"McAang\",\n\t\t\t\"Meelo\",\n\t\t\t\"MuratTheTurkish\",\n\t\t\t\"Trockya\",\n\t\t\t\"Vito Genovese\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Yönetim Panosu - Etherpad\",\n\t\"admin_plugins\": \"Eklenti yöneticisi\",\n\t\"admin_plugins.available\": \"Mevcut eklentiler\",\n\t\"admin_plugins.available_not-found\": \"Eklenti bulunamadı.\",\n\t\"admin_plugins.available_fetching\": \"Getiriliyor…\",\n\t\"admin_plugins.available_install.value\": \"Yükle\",\n\t\"admin_plugins.available_search.placeholder\": \"Yüklenecek eklentileri arayın\",\n\t\"admin_plugins.description\": \"Açıklama\",\n\t\"admin_plugins.installed\": \"Yüklü eklentiler\",\n\t\"admin_plugins.installed_fetching\": \"Yüklü eklentiler alınıyor…\",\n\t\"admin_plugins.installed_nothing\": \"Henüz herhangi bir eklenti yüklemediniz.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Kaldır\",\n\t\"admin_plugins.last-update\": \"Son güncelleme\",\n\t\"admin_plugins.name\": \"Ad\",\n\t\"admin_plugins.page-title\": \"Eklenti yöneticisi - Etherpad\",\n\t\"admin_plugins.version\": \"Sürüm\",\n\t\"admin_plugins_info\": \"Sorun giderme bilgisi\",\n\t\"admin_plugins_info.hooks\": \"Yüklü kancalar\",\n\t\"admin_plugins_info.hooks_client\": \"İstemci taraf kancaları\",\n\t\"admin_plugins_info.hooks_server\": \"Sunucu taraf kancaları\",\n\t\"admin_plugins_info.parts\": \"Yüklü parçalar\",\n\t\"admin_plugins_info.plugins\": \"Yüklü eklentiler\",\n\t\"admin_plugins_info.page-title\": \"Eklenti bilgisi - Etherpad\",\n\t\"admin_plugins_info.version\": \"Etherpad sürümü\",\n\t\"admin_plugins_info.version_latest\": \"Mevcut en son sürümü\",\n\t\"admin_plugins_info.version_number\": \"Sürüm numarası\",\n\t\"admin_settings\": \"Ayarlar\",\n\t\"admin_settings.current\": \"Geçerli yapılandırma\",\n\t\"admin_settings.current_example-devel\": \"Örnek geliştirme ayarları şablonu\",\n\t\"admin_settings.current_example-prod\": \"Örnek üretim ayarları şablonu\",\n\t\"admin_settings.current_restart.value\": \"Etherpad'i yeniden başlatın\",\n\t\"admin_settings.current_save.value\": \"Ayarları Kaydet\",\n\t\"admin_settings.page-title\": \"Ayarlar - Etherpad\",\n\t\"index.newPad\": \"Yeni Bloknot\",\n\t\"index.createOpenPad\": \"İsme göre açık bloknot\",\n\t\"index.openPad\": \"şu adla varolan bir Bloknot'u açın:\",\n\t\"pad.toolbar.bold.title\": \"Kalın (Ctrl+B)\",\n\t\"pad.toolbar.italic.title\": \"Eğik (Ctrl+I)\",\n\t\"pad.toolbar.underline.title\": \"Altı Çizili (Ctrl+U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Üstü Çizili (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Sıralı liste (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Sırasız Liste (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Girintiyi arttır (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Çıkıntı (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Geri Al (Ctrl+Z)\",\n\t\"pad.toolbar.redo.title\": \"Yinele (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Yazarlık Renklerini Temizle (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Farklı dosya biçimlerini içe/dışa aktar\",\n\t\"pad.toolbar.timeslider.title\": \"Zaman Çizelgesi\",\n\t\"pad.toolbar.savedRevision.title\": \"Düzeltmeyi Kaydet\",\n\t\"pad.toolbar.settings.title\": \"Ayarlar\",\n\t\"pad.toolbar.embed.title\": \"Bu Bloknot'u Paylaş ve Göm\",\n\t\"pad.toolbar.showusers.title\": \"Kullanıcıları bu bloknotta göster\",\n\t\"pad.colorpicker.save\": \"Kaydet\",\n\t\"pad.colorpicker.cancel\": \"İptal\",\n\t\"pad.loading\": \"Yükleniyor...\",\n\t\"pad.noCookie\": \"Çerez bulunamadı. Lütfen tarayıcınızda çerezlere izin verin! Oturumunuz ve ayarlarınız ziyaretler arasında kaydedilmez. Bunun nedeni, bazı Tarayıcılarda Etherpad'in bir iFrame'e dahil edilmesi olabilir. Lütfen Etherpad'in üst iFrame ile aynı alt etki alanında/etki alanında olduğundan emin olun.\",\n\t\"pad.permissionDenied\": \"Bu bloknota erişmeye izniniz yok\",\n\t\"pad.settings.padSettings\": \"Bloknot Ayarları\",\n\t\"pad.settings.myView\": \"Görünümüm\",\n\t\"pad.settings.stickychat\": \"Sohbeti her zaman ekranda yap\",\n\t\"pad.settings.chatandusers\": \"Sohbeti ve Kullanıcıları Göster\",\n\t\"pad.settings.colorcheck\": \"Yazarlık renkleri\",\n\t\"pad.settings.linenocheck\": \"Satır numaraları\",\n\t\"pad.settings.rtlcheck\": \"İçerik sağdan sola doğru okunsun mu?\",\n\t\"pad.settings.fontType\": \"Yazı tipi:\",\n\t\"pad.settings.fontType.normal\": \"Olağan\",\n\t\"pad.settings.language\": \"Dil:\",\n\t\"pad.settings.deletePad\": \"Silme Defteri\",\n\t\"pad.delete.confirm\": \"Bu defteri gerçekten silmek istiyor musunuz?\",\n\t\"pad.settings.about\": \"Hakkında\",\n\t\"pad.settings.poweredBy\": \"Destekleyen:\",\n\t\"pad.importExport.import_export\": \"İçe/Dışa aktar\",\n\t\"pad.importExport.import\": \"Herhangi bir metin dosyası ya da belgesi yükle\",\n\t\"pad.importExport.importSuccessful\": \"Başarılı!\",\n\t\"pad.importExport.export\": \"Mevcut bloknotu şuraya dışa aktar:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Düz metin\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (Açık Doküman Biçimi)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Yalnızca düz metin veya HTML biçimlerinden içe aktarabilirsiniz. Daha gelişmiş içe aktarma özellikleri için lütfen <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">AbiWord veya LibreOffice yükleyin</a> .\",\n\t\"pad.modals.connected\": \"Bağlandı.\",\n\t\"pad.modals.reconnecting\": \"Bloknotuza tekrar bağlanılıyor…\",\n\t\"pad.modals.forcereconnect\": \"Yeniden bağlanmaya zorla\",\n\t\"pad.modals.reconnecttimer\": \"Yeniden bağlanmaya çalışılıyor\",\n\t\"pad.modals.cancel\": \"İptal\",\n\t\"pad.modals.userdup\": \"Başka pencerede açıldı\",\n\t\"pad.modals.userdup.explanation\": \"Bu bloknot bu bilgisayarda birden fazla tarayıcı penceresinde açılmış gibi görünüyor.\",\n\t\"pad.modals.userdup.advice\": \"Bu pencereden kullanmak için yeniden bağlanın.\",\n\t\"pad.modals.unauth\": \"Yetkilendirilmemiş\",\n\t\"pad.modals.unauth.explanation\": \"Bu sayfayı görüntülerken izinleriniz değiştirildi. Tekrar bağlanmayı deneyin.\",\n\t\"pad.modals.looping.explanation\": \"Senkronizasyon sunucusuyla iletişim sorunları yaşanıyor.\",\n\t\"pad.modals.looping.cause\": \"Belki de uygun olmayan güvenlik duvarı ya da vekil sunucu (proxy) ile bağlanmaya çalışıyorsunuz.\",\n\t\"pad.modals.initsocketfail\": \"Sunucuya ulaşılamıyor.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Senkronizasyon sunucusuna bağlanılamadı.\",\n\t\"pad.modals.initsocketfail.cause\": \"Bu sorun muhtemelen, tarayıcınızdan ya da internet bağlantınızdan kaynaklanıyor.\",\n\t\"pad.modals.slowcommit.explanation\": \"Sunucu yanıt vermiyor.\",\n\t\"pad.modals.slowcommit.cause\": \"Bu durum, ağ bağlantısıyla ilgili sorunlardan kaynaklanıyor olabilir.\",\n\t\"pad.modals.badChangeset.explanation\": \"Yaptığınız bir düzenleme, senkronizasyon sunucusu tarafından yasa dışı olarak sınıflandırıldı.\",\n\t\"pad.modals.badChangeset.cause\": \"Bunun nedeni yanlış bir sunucu yapılandırması veya başka bir beklenmeyen davranış olabilir. Bunun bir hata olduğunu düşünüyorsanız lütfen servis yöneticisi ile iletişime geçin. Düzenlemeye devam etmek için yeniden bağlanmayı deneyin.\",\n\t\"pad.modals.corruptPad.explanation\": \"Erişmeye çalıştığınız bloknot bozuk.\",\n\t\"pad.modals.corruptPad.cause\": \"Bunun nedeni yanlış bir sunucu yapılandırması veya başka bir beklenmeyen davranış olabilir. Lütfen servis yöneticisine başvurun.\",\n\t\"pad.modals.deleted\": \"Silindi.\",\n\t\"pad.modals.deleted.explanation\": \"Bu bloknot kaldırılmış.\",\n\t\"pad.modals.rateLimited\": \"Oran Sınırlı.\",\n\t\"pad.modals.rateLimited.explanation\": \"Bu bloknota çok fazla mesaj gönderdiğiniz için bağlantı kesildi.\",\n\t\"pad.modals.rejected.explanation\": \"Sunucu, tarayıcınız tarafından gönderilen bir mesajı reddetti.\",\n\t\"pad.modals.rejected.cause\": \"Bloknotu görüntülerken sunucu güncellenmiş olabilir veya Etherpad'de bir hata olabilir. Sayfayı yeniden yüklemeyi deneyin.\",\n\t\"pad.modals.disconnected\": \"Bağlantınız kesildi.\",\n\t\"pad.modals.disconnected.explanation\": \"Sunucuyla bağlantı kesildi\",\n\t\"pad.modals.disconnected.cause\": \"Sunucu kullanılamıyor olabilir. Böyle devam ederse lütfen hizmet yöneticisine bildirin.\",\n\t\"pad.share\": \"Bu bloknotu paylaş\",\n\t\"pad.share.readonly\": \"Yalnızca oku\",\n\t\"pad.share.link\": \"Bağlantı\",\n\t\"pad.share.emebdcode\": \"URL'yi göm\",\n\t\"pad.chat\": \"Sohbet\",\n\t\"pad.chat.title\": \"Bu bloknot için sohbeti açın.\",\n\t\"pad.chat.loadmessages\": \"Daha fazla mesaj yükle\",\n\t\"pad.chat.stick.title\": \"Sohbeti ekrana yapıştır\",\n\t\"pad.chat.writeMessage.placeholder\": \"Mesajınızı buraya yazın\",\n\t\"timeslider.followContents\": \"Bloknot içerik güncellemelerini takip edin\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} Zaman Çizelgesi\",\n\t\"timeslider.toolbar.returnbutton\": \"Bloknota geri dön\",\n\t\"timeslider.toolbar.authors\": \"Yazarlar:\",\n\t\"timeslider.toolbar.authorsList\": \"Yazar Yok\",\n\t\"timeslider.toolbar.exportlink.title\": \"Dışa aktar\",\n\t\"timeslider.exportCurrent\": \"Geçerli sürümü şu şekilde dışa aktar:\",\n\t\"timeslider.version\": \"Sürüm {{version}}\",\n\t\"timeslider.saved\": \"{{day}} {{month}} {{year}} tarihinde kaydedildi\",\n\t\"timeslider.playPause\": \"Bloknot İçeriğini Oynat / Durdur\",\n\t\"timeslider.backRevision\": \"Bu bloknottaki bir sürüme geri dön\",\n\t\"timeslider.forwardRevision\": \"Bu bloknatta sonraki sürüme git\",\n\t\"timeslider.dateformat\": \"{{day}}.{{month}}.{{year}} {{hours}}.{{minutes}}.{{seconds}}\",\n\t\"timeslider.month.january\": \"Ocak\",\n\t\"timeslider.month.february\": \"Şubat\",\n\t\"timeslider.month.march\": \"Mart\",\n\t\"timeslider.month.april\": \"Nisan\",\n\t\"timeslider.month.may\": \"Mayıs\",\n\t\"timeslider.month.june\": \"Haziran\",\n\t\"timeslider.month.july\": \"Temmuz\",\n\t\"timeslider.month.august\": \"Ağustos\",\n\t\"timeslider.month.september\": \"Eylül\",\n\t\"timeslider.month.october\": \"Ekim\",\n\t\"timeslider.month.november\": \"Kasım\",\n\t\"timeslider.month.december\": \"Aralık\",\n\t\"timeslider.unnamedauthors\": \"{{num}} isimsiz {[plural(num) one: yazar, other: yazar ]}\",\n\t\"pad.savedrevs.marked\": \"Bu sürüm, artık kaydedilmiş bir sürüm olarak işaretlendi.\",\n\t\"pad.savedrevs.timeslider\": \"Kaydedilmiş sürümleri, zaman kaydırıcısını ziyaret ederek görebilirsiniz.\",\n\t\"pad.userlist.entername\": \"Adınızı girin\",\n\t\"pad.userlist.unnamed\": \"isimsiz\",\n\t\"pad.editbar.clearcolors\": \"Bütün belgedeki yazarlık renkleri silinsin mi? Bu işlem geri alınamaz.\",\n\t\"pad.impexp.importbutton\": \"Şimdi İçe Aktar\",\n\t\"pad.impexp.importing\": \"İçe aktarılıyor...\",\n\t\"pad.impexp.confirmimport\": \"Bir dosyanın içe aktarılması, bloknotun mevcut metninin üzerine yazacaktır. Devam etmek istediğinizden emin misiniz?\",\n\t\"pad.impexp.convertFailed\": \"Bu dosyayı içe aktaramadık. Lütfen farklı bir belge biçimi kullanın veya elle kopyalayıp yapıştırın\",\n\t\"pad.impexp.padHasData\": \"Bu bloknotta zaten değişiklikler olduğu için bu dosyayı içe aktaramadık, lütfen yeni bir bloknota aktarın.\",\n\t\"pad.impexp.uploadFailed\": \"Yükleme başarısız oldu, lütfen tekrar deneyin.\",\n\t\"pad.impexp.importfailed\": \"İçe aktarılamadı\",\n\t\"pad.impexp.copypaste\": \"Lütfen kopyala yapıştır yapın\",\n\t\"pad.impexp.exportdisabled\": \"{{type}} biçiminde dışa aktarma devre dışı bırakıldı. Ayrıntılar için lütfen sistem yöneticinize başvurun.\",\n\t\"pad.impexp.maxFileSize\": \"Dosya çok büyük. İçe aktarma için izin verilen dosya boyutunu artırmak için site yöneticinize başvurun\"\n}\n"
  },
  {
    "path": "src/locales/uk.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Andriykopanytsia\",\n\t\t\t\"Base\",\n\t\t\t\"Bunyk\",\n\t\t\t\"DDPAT\",\n\t\t\t\"Ice bulldog\",\n\t\t\t\"Lxlalexlxl\",\n\t\t\t\"Movses\",\n\t\t\t\"Olvin\",\n\t\t\t\"Piramidion\",\n\t\t\t\"Steve.rusyn\",\n\t\t\t\"SteveR\",\n\t\t\t\"Ата\",\n\t\t\t\"Григорій Пугач\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"Адміністративна панель — Etherpad\",\n\t\"admin_plugins\": \"Менеджер плагінів\",\n\t\"admin_plugins.available\": \"Доступні плагіни\",\n\t\"admin_plugins.available_not-found\": \"Плагінів не знайдено.\",\n\t\"admin_plugins.available_fetching\": \"Отримується…\",\n\t\"admin_plugins.available_install.value\": \"Встановити\",\n\t\"admin_plugins.available_search.placeholder\": \"Шукати плагіни для встановлення\",\n\t\"admin_plugins.description\": \"Опис\",\n\t\"admin_plugins.installed\": \"Встановлені плагіни\",\n\t\"admin_plugins.installed_fetching\": \"Отримуються встановлені плагіни…\",\n\t\"admin_plugins.installed_nothing\": \"Ви ще не встановили жодних плагінів.\",\n\t\"admin_plugins.installed_uninstall.value\": \"Видалити\",\n\t\"admin_plugins.last-update\": \"Останнє оновлення\",\n\t\"admin_plugins.name\": \"Назва\",\n\t\"admin_plugins.page-title\": \"Менеджер плагінів — Etherpad\",\n\t\"admin_plugins.version\": \"Версія\",\n\t\"admin_plugins_info\": \"Інформація щодо виправлення неполадок\",\n\t\"admin_plugins_info.hooks\": \"Встановлені гачки\",\n\t\"admin_plugins_info.hooks_client\": \"Гачки на стороні клієнта\",\n\t\"admin_plugins_info.hooks_server\": \"Серверні гачки\",\n\t\"admin_plugins_info.parts\": \"Встановлені деталі\",\n\t\"admin_plugins_info.plugins\": \"Встановлені плагіни\",\n\t\"admin_plugins_info.page-title\": \"Інформація про плагіни — Etherpad\",\n\t\"admin_plugins_info.version\": \"Версія Etherpad\",\n\t\"admin_plugins_info.version_latest\": \"Найсвіжіша доступна версія\",\n\t\"admin_plugins_info.version_number\": \"Номер версії\",\n\t\"admin_settings\": \"Налаштування\",\n\t\"admin_settings.current\": \"Поточна конфігурація\",\n\t\"admin_settings.current_example-devel\": \"Приклад шаблону налаштувань розробки\",\n\t\"admin_settings.current_example-prod\": \"Приклад шаблону налаштувань виробництва\",\n\t\"admin_settings.current_restart.value\": \"Перезапустити Etherpad\",\n\t\"admin_settings.current_save.value\": \"Зберегти налаштування\",\n\t\"admin_settings.page-title\": \"Налаштування — Etherpad\",\n\t\"index.newPad\": \"Створити\",\n\t\"index.createOpenPad\": \"Відкрити документ з іменем\",\n\t\"index.openPad\": \"відкрити наявний документ з назвою:\",\n\t\"pad.toolbar.bold.title\": \"Напівжирний (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Курсив (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Підкреслення (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Закреслення (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Упорядкований список (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Неупорядкований список (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Відступ (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Відступ (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Скасувати (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Повторити (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Очистити кольори авторства (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Імпорт/Експорт з використанням різних форматів файлів\",\n\t\"pad.toolbar.timeslider.title\": \"Шкала часу\",\n\t\"pad.toolbar.savedRevision.title\": \"Зберегти версію\",\n\t\"pad.toolbar.settings.title\": \"Налаштування\",\n\t\"pad.toolbar.embed.title\": \"Поділитись та вбудувати цей документ\",\n\t\"pad.toolbar.showusers.title\": \"Показати користувачів цього документа\",\n\t\"pad.colorpicker.save\": \"Зберегти\",\n\t\"pad.colorpicker.cancel\": \"Скасувати\",\n\t\"pad.loading\": \"Завантаження…\",\n\t\"pad.noCookie\": \"Cookie не знайдено. Будь ласка, увімкніть cookie у вашому браузері! Ваша сесія та налаштування не зберігатимуться між візитами. Це може спричинятися тим, що Etherpad у деяких браузерах включений через iFrame. Будь ласка, переконайтеся, що iFrame міститься на тому ж піддомені/домені, що й батьківський iFrame\",\n\t\"pad.permissionDenied\": \"У Вас немає дозволу для доступу до цього документа\",\n\t\"pad.settings.padSettings\": \"Налаштування документа\",\n\t\"pad.settings.myView\": \"Мій погляд\",\n\t\"pad.settings.stickychat\": \"Завжди відображувати чат\",\n\t\"pad.settings.chatandusers\": \"Показати чат і користувачів\",\n\t\"pad.settings.colorcheck\": \"Кольори авторства\",\n\t\"pad.settings.linenocheck\": \"Номери рядків\",\n\t\"pad.settings.rtlcheck\": \"Читати вміст з права на ліво?\",\n\t\"pad.settings.fontType\": \"Тип шрифту:\",\n\t\"pad.settings.fontType.normal\": \"Звичайний\",\n\t\"pad.settings.language\": \"Мова:\",\n\t\"pad.settings.deletePad\": \"Вилучити документ\",\n\t\"pad.delete.confirm\": \"Ви дійсно хочете вилучити цей документ?\",\n\t\"pad.settings.about\": \"Про програму\",\n\t\"pad.settings.poweredBy\": \"Працює на\",\n\t\"pad.importExport.import_export\": \"Імпорт/Експорт\",\n\t\"pad.importExport.import\": \"Завантажити будь-який текстовий файл або документ\",\n\t\"pad.importExport.importSuccessful\": \"Успішно!\",\n\t\"pad.importExport.export\": \"Експортувати поточний документ як:\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Звичайний текст\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF (документ OpenOffice)\",\n\t\"pad.importExport.abiword.innerHTML\": \"Ви можете імпортувати лише у форматі простого тексту або HTML. Для більш просунутих способів імпорту <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">встановіть AbiWord або LibreOffice</a>.\",\n\t\"pad.modals.connected\": \"З'єднано.\",\n\t\"pad.modals.reconnecting\": \"Перепідключення до Вашого документа…\",\n\t\"pad.modals.forcereconnect\": \"Примусове перепідключення\",\n\t\"pad.modals.reconnecttimer\": \"Триває спроба відновлення з'єднання\",\n\t\"pad.modals.cancel\": \"Скасувати\",\n\t\"pad.modals.userdup\": \"Відкрито в іншому вікні\",\n\t\"pad.modals.userdup.explanation\": \"Документ, можливо, відкрито більш ніж в одному вікні браузера на цьому комп'ютері.\",\n\t\"pad.modals.userdup.advice\": \"Перепідключитись використовуючи це вікно.\",\n\t\"pad.modals.unauth\": \"Не авторизовано\",\n\t\"pad.modals.unauth.explanation\": \"Ваші права було змінено під час перегляду цієї сторінки. Спробуйте відновити зв’язок.\",\n\t\"pad.modals.looping.explanation\": \"Проблеми зв'єзку з сервером синхронізації.\",\n\t\"pad.modals.looping.cause\": \"Можливо, підключились через несумісний брандмауер або проксі-сервер.\",\n\t\"pad.modals.initsocketfail\": \"Сервер недоступний.\",\n\t\"pad.modals.initsocketfail.explanation\": \"Не вдалося підключитися до сервера синхронізації.\",\n\t\"pad.modals.initsocketfail.cause\": \"Ймовірно, це пов'язано з Вашим браузером або інтернет-з'єднанням.\",\n\t\"pad.modals.slowcommit.explanation\": \"Сервер не відповідає.\",\n\t\"pad.modals.slowcommit.cause\": \"Це може бути через проблем з підключенням до мережі.\",\n\t\"pad.modals.badChangeset.explanation\": \"Редагування, яке ви зробили, було класифіковане як незаконний шлях доступу до сервера синхронізації.\",\n\t\"pad.modals.badChangeset.cause\": \"Причиною може бути неправильна конфігурація сервера або інші непередбачувані поведінки. Зверніться до адміністратора служби, якщо ви відчуваєте, що це помилка. Спробуйте підключитися повторно для того, щоб продовжити редагування.\",\n\t\"pad.modals.corruptPad.explanation\": \"Пошкоджено документ, до якого ви хочете одержати доступ.\",\n\t\"pad.modals.corruptPad.cause\": \"Це може бути через неправильну конфігурацію сервера або іншу непередбачувану поведінку. Зверніться до адміністратора служби.\",\n\t\"pad.modals.deleted\": \"Вилучено.\",\n\t\"pad.modals.deleted.explanation\": \"Цей документ було вилучено.\",\n\t\"pad.modals.rateLimited\": \"Швидкість обмежено.\",\n\t\"pad.modals.rateLimited.explanation\": \"Ви надіслали надто багато повідомлень у цей документ, тому він вас від'єднав.\",\n\t\"pad.modals.rejected.explanation\": \"Сервер відхилив повідомлення, надіслане вашим браузером.\",\n\t\"pad.modals.rejected.cause\": \"Сервер міг оновитися, поки ви переглядали документ, а може це помилка у Etherpad'і. Спробуйте перезавантажити сторінку.\",\n\t\"pad.modals.disconnected\": \"Вас було від'єднано.\",\n\t\"pad.modals.disconnected.explanation\": \"З'єднання з сервером втрачено\",\n\t\"pad.modals.disconnected.cause\": \"Сервер, можливо, недоступний. Будь ласка, повідомте адміністратора служби, якщо це повторюватиметься.\",\n\t\"pad.share\": \"Поділитись\",\n\t\"pad.share.readonly\": \"Тільки читання\",\n\t\"pad.share.link\": \"Посилання\",\n\t\"pad.share.emebdcode\": \"Вставити URL\",\n\t\"pad.chat\": \"Чат\",\n\t\"pad.chat.title\": \"Відкрити чат для цього документа.\",\n\t\"pad.chat.loadmessages\": \"Завантажити більше повідомлень\",\n\t\"pad.chat.stick.title\": \"Закріпити чат на екрані\",\n\t\"pad.chat.writeMessage.placeholder\": \"Напишіть своє повідомлення сюди\",\n\t\"timeslider.followContents\": \"Слідкувати за оновленнями вмісту документа\",\n\t\"timeslider.pageTitle\": \"Часова шкала {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Повернутись до документа\",\n\t\"timeslider.toolbar.authors\": \"Автори:\",\n\t\"timeslider.toolbar.authorsList\": \"Немає авторів\",\n\t\"timeslider.toolbar.exportlink.title\": \"Експорт\",\n\t\"timeslider.exportCurrent\": \"Експортувати поточну версію як:\",\n\t\"timeslider.version\": \"Версія {{version}}\",\n\t\"timeslider.saved\": \"Збережено {{month}} {{day}}, {{year}}\",\n\t\"timeslider.playPause\": \"Вміст панелі відтворення/паузи\",\n\t\"timeslider.backRevision\": \"Переглянути попередню ревізію цієї панелі\",\n\t\"timeslider.forwardRevision\": \"Переглянути наступну ревізію цієї панелі\",\n\t\"timeslider.dateformat\": \"{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Січень\",\n\t\"timeslider.month.february\": \"Лютий\",\n\t\"timeslider.month.march\": \"Березень\",\n\t\"timeslider.month.april\": \"Квітень\",\n\t\"timeslider.month.may\": \"Травень\",\n\t\"timeslider.month.june\": \"Червень\",\n\t\"timeslider.month.july\": \"Липень\",\n\t\"timeslider.month.august\": \"Серпень\",\n\t\"timeslider.month.september\": \"Вересень\",\n\t\"timeslider.month.october\": \"Жовтень\",\n\t\"timeslider.month.november\": \"Листопад\",\n\t\"timeslider.month.december\": \"Грудень\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: безіменний автор, few: безіменні автори, many: безіменних авторів, other: безіменних авторів]}\",\n\t\"pad.savedrevs.marked\": \"Цю версію помічено збереженою версією\",\n\t\"pad.savedrevs.timeslider\": \"Ви можете побачити збережені ревізії, відвідавши «Слайдер Змін Ревізій»\",\n\t\"pad.userlist.entername\": \"Введіть ваше ім'я\",\n\t\"pad.userlist.unnamed\": \"безіменний\",\n\t\"pad.editbar.clearcolors\": \"Очистити кольори у всьому документі? Це не можна буде відкотити\",\n\t\"pad.impexp.importbutton\": \"Імпортувати зараз\",\n\t\"pad.impexp.importing\": \"Імпорт...\",\n\t\"pad.impexp.confirmimport\": \"Імпортування файлу перезапише поточний текст документа. Ви дійсно хочете продовжити?\",\n\t\"pad.impexp.convertFailed\": \"Ми не можемо імпортувати цей файл. Будь ласка, використайте інший формат документа, або прямо скопіюйте та вставте\",\n\t\"pad.impexp.padHasData\": \"Ми були не в стані імпортувати цей файл, тому що ця панель, вже відредактована, будь ласка, імпортуйте на нову панель\",\n\t\"pad.impexp.uploadFailed\": \"Завантаження не вдалось, будь ласка, спробуйте знову\",\n\t\"pad.impexp.importfailed\": \"Помилка при імпортуванні\",\n\t\"pad.impexp.copypaste\": \"Будь ласка, скопіюйте та вставте\",\n\t\"pad.impexp.exportdisabled\": \"Експорт у формат {{type}} вимкнено. Будь ласка, зв'яжіться із Вашим системним адміністратором за деталями.\",\n\t\"pad.impexp.maxFileSize\": \"Файл завеликий. Зверніться до адміністратора сайту для збільшення максимально дозволеного розміру файлів для імпорту\"\n}\n"
  },
  {
    "path": "src/locales/vec.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Candalua\",\n\t\t\t\"Fierodelveneto\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Novo Pad\",\n\t\"index.createOpenPad\": \"O creare o verxare on Pad co'l nome:\",\n\t\"pad.toolbar.bold.title\": \"Groseto (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"Corsivo (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Sotolineà (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Barà (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Ełenco numarà (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Ełenco pontà (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Indentasion (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Scursa indentasion (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Dasa stare (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Ripeti (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Cava i cołori che i indega i autori (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Inporta/esporta da/a fidarenti formati de file\",\n\t\"pad.toolbar.timeslider.title\": \"Prexentasion storego\",\n\t\"pad.toolbar.savedRevision.title\": \"Version salvada\",\n\t\"pad.toolbar.settings.title\": \"Inpostasion\",\n\t\"pad.toolbar.embed.title\": \"Spartisi o incastra sto Pad\",\n\t\"pad.toolbar.showusers.title\": \"Varda i utenti so sto Pad\",\n\t\"pad.colorpicker.save\": \"Salva\",\n\t\"pad.colorpicker.cancel\": \"Descançełare\",\n\t\"pad.loading\": \"Drio cargar...\",\n\t\"pad.noCookie\": \"El cookie no el xé sta catà. Cosenti i cookie n'tel to navegadore web.\",\n\t\"timeslider.month.january\": \"Zenaro\",\n\t\"timeslider.month.march\": \"Marso\",\n\t\"timeslider.month.april\": \"Apriłe\",\n\t\"timeslider.month.may\": \"Majo\",\n\t\"timeslider.month.june\": \"Zugno\",\n\t\"timeslider.month.july\": \"Lujo\",\n\t\"timeslider.month.august\": \"Agosto\",\n\t\"timeslider.month.september\": \"Setenbre\",\n\t\"timeslider.month.october\": \"Otobre\",\n\t\"timeslider.month.november\": \"Novenbre\",\n\t\"timeslider.month.december\": \"Disenbre\",\n\t\"timeslider.unnamedauthors\": \"{{num}} {[plural(num) one: autore, other: autori ]} sensa nome\"\n}\n"
  },
  {
    "path": "src/locales/vi.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"Baonguyen21022003\",\n\t\t\t\"Max20091\",\n\t\t\t\"Minh Nguyen\",\n\t\t\t\"NguoiDungKhongDinhDanh\",\n\t\t\t\"Tuankiet65\"\n\t\t]\n\t},\n\t\"index.newPad\": \"Tạo một Pad mới\",\n\t\"index.createOpenPad\": \"hay tạo/mở một Pad với tên:\",\n\t\"pad.toolbar.bold.title\": \"In đậm (Ctrl-B)\",\n\t\"pad.toolbar.italic.title\": \"In nghiêng (Ctrl-I)\",\n\t\"pad.toolbar.underline.title\": \"Gạch chân (Ctrl-U)\",\n\t\"pad.toolbar.strikethrough.title\": \"Gạch ngang (Ctrl+5)\",\n\t\"pad.toolbar.ol.title\": \"Danh sách Có Đánh số (Ctrl+Shift+N)\",\n\t\"pad.toolbar.ul.title\": \"Danh sách Không Đánh số (Ctrl+Shift+L)\",\n\t\"pad.toolbar.indent.title\": \"Tăng lề (TAB)\",\n\t\"pad.toolbar.unindent.title\": \"Giảm lề (Shift+TAB)\",\n\t\"pad.toolbar.undo.title\": \"Hoàn tác (Ctrl-Z)\",\n\t\"pad.toolbar.redo.title\": \"Làm lại (Ctrl-Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"Xóa Màu chỉ Tác giả (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"Xuất/Nhập từ/đến các định dạng file khác nhau\",\n\t\"pad.toolbar.timeslider.title\": \"Thanh thời gian\",\n\t\"pad.toolbar.savedRevision.title\": \"Lưu Phiên bản\",\n\t\"pad.toolbar.settings.title\": \"Thiết lập\",\n\t\"pad.toolbar.embed.title\": \"Chia sẻ và Nhúng pad này\",\n\t\"pad.toolbar.showusers.title\": \"Hiện các người dùng trên pad này\",\n\t\"pad.colorpicker.save\": \"Lưu\",\n\t\"pad.colorpicker.cancel\": \"Hủy bỏ\",\n\t\"pad.loading\": \"Đang tải…\",\n\t\"pad.permissionDenied\": \"Bạn không có quyền truy cập pad này.\",\n\t\"pad.settings.padSettings\": \"Tùy chọn Pad\",\n\t\"pad.settings.myView\": \"Chỉ có tôi\",\n\t\"pad.settings.stickychat\": \"Luôn hiện cửa sổ trò chuyện trên màn hình\",\n\t\"pad.settings.colorcheck\": \"Màu chỉ tác giả\",\n\t\"pad.settings.linenocheck\": \"Số dòng\",\n\t\"pad.settings.rtlcheck\": \"Đọc nội dung từ phải sang trái?\",\n\t\"pad.settings.fontType\": \"Kiểu phông chữ:\",\n\t\"pad.settings.fontType.normal\": \"Thường\",\n\t\"pad.settings.language\": \"Ngôn ngữ:\",\n\t\"pad.importExport.import_export\": \"Xuất/Nhập\",\n\t\"pad.importExport.import\": \"Tải lên bất kỳ tập tin văn bản hoặc tài liệu\",\n\t\"pad.importExport.importSuccessful\": \"Thành công!\",\n\t\"pad.importExport.export\": \"Xuất pad hiện tại ra định dạng:\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"Văn bản thuần túy\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF\",\n\t\"pad.importExport.abiword.innerHTML\": \"Bạn chỉ có thể nhập vào từ văn bản thuần túy hay định dạng HTML. Nếu muốn có nhiều chức năng nhập hơn xin hãy <a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\\\">cài đặt abiword</a>.\",\n\t\"pad.modals.connected\": \"Đã kết nối lại.\",\n\t\"pad.modals.reconnecting\": \"Kết nối lại tới pad của bạn\",\n\t\"pad.modals.forcereconnect\": \"Ép kết nối lại\",\n\t\"pad.modals.userdup\": \"Mở trong cửa sổ khác\",\n\t\"pad.modals.userdup.explanation\": \"Pad này dường như được mở trên hơn một cửa sổ trình duyệt trên máy tính này.\",\n\t\"pad.modals.userdup.advice\": \"Kết nối lại để sử dụng cửa sổ này.\",\n\t\"pad.modals.unauth\": \"Không có quyền\",\n\t\"pad.modals.unauth.explanation\": \"Quyền của bạn đã thay đổi trong khi bạn đang xem trang này. Hãy thử kết nối lại.\",\n\t\"pad.modals.looping.explanation\": \"Có vấn đề khi giao tiếp với máy chủ đồng bộ\",\n\t\"pad.modals.looping.cause\": \"Có thể bạn đã kết nối thông qua một tường lửa hay proxy không thích hợp\",\n\t\"pad.modals.initsocketfail\": \"Không thể tiếp cận máy chủ\",\n\t\"pad.modals.initsocketfail.explanation\": \"Không thể kết nối đến máy chủ đồng bộ.\",\n\t\"pad.modals.initsocketfail.cause\": \"Điều này có thể là do một vấn đề với trình duyệt của bạn hay đường truyền internet của bạn.\",\n\t\"pad.modals.slowcommit.explanation\": \"Máy chủ không phản hồi.\",\n\t\"pad.modals.slowcommit.cause\": \"Điều này có thể là do vấn đề về kết nối mạng.\",\n\t\"pad.modals.badChangeset.explanation\": \"Chỉnh sửa bạn đã thực hiện là bất hợp pháp phân loại bởi máy chủ đồng bộ hóa.\",\n\t\"pad.modals.badChangeset.cause\": \"Điều này có thể là do một cấu hình máy chủ sai hoặc một số hành vi không mong muốn khác. Xin vui lòng liên hệ với quản trị viên dịch vụ, nếu bạn cảm thấy đây là một lỗi. Cố gắng kết nối lại để tiếp tục chỉnh sửa.\",\n\t\"pad.modals.corruptPad.explanation\": \"Các phím bạn đang cố truy cập bị hỏng.\",\n\t\"pad.modals.corruptPad.cause\": \"Điều này có thể là do một cấu hình máy chủ sai hoặc một số hành vi không mong muốn khác. Xin vui lòng liên hệ với người quản trị dịch vụ.\",\n\t\"pad.modals.deleted\": \"Đã xóa\",\n\t\"pad.modals.deleted.explanation\": \"Pad này đã được gỡ\",\n\t\"pad.modals.disconnected\": \"Bạn đã ngắt kết nối\",\n\t\"pad.modals.disconnected.explanation\": \"Kết nối tới máy chủ đã bị mất\",\n\t\"pad.modals.disconnected.cause\": \"Hệ phục vụ có thể không sẵn dùng. Xin vui lòng thông báo cho người quản trị dịch vụ nếu điều này tiếp tục xảy ra.\",\n\t\"pad.share\": \"Chia sẻ pad này\",\n\t\"pad.share.readonly\": \"Chỉ đọc\",\n\t\"pad.share.link\": \"Liên kết\",\n\t\"pad.share.emebdcode\": \"URL nhúng\",\n\t\"pad.chat\": \"Trò chuyện\",\n\t\"pad.chat.title\": \"Mở trò chuyện cho pad này.\",\n\t\"pad.chat.loadmessages\": \"Tải thêm tin nhắn\",\n\t\"timeslider.pageTitle\": \"Thanh thời gian của {{appTitle}}\",\n\t\"timeslider.toolbar.returnbutton\": \"Trở về pad\",\n\t\"timeslider.toolbar.authors\": \"Tác giả:\",\n\t\"timeslider.toolbar.authorsList\": \"Không có tác giả\",\n\t\"timeslider.toolbar.exportlink.title\": \"Xuất\",\n\t\"timeslider.exportCurrent\": \"Xuất phiên bản hiện tại thành:\",\n\t\"timeslider.version\": \"Phiên bản {{version}}\",\n\t\"timeslider.saved\": \"Đã lưu vào ngày {{day}} {{month}} năm {{year}}\",\n\t\"timeslider.dateformat\": \"{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"Tháng Giêng\",\n\t\"timeslider.month.february\": \"Tháng Hai\",\n\t\"timeslider.month.march\": \"Tháng Ba\",\n\t\"timeslider.month.april\": \"Tháng Tư\",\n\t\"timeslider.month.may\": \"Tháng Năm\",\n\t\"timeslider.month.june\": \"Tháng Sáu\",\n\t\"timeslider.month.july\": \"Tháng Bảy\",\n\t\"timeslider.month.august\": \"Tháng Tám\",\n\t\"timeslider.month.september\": \"Tháng Chín\",\n\t\"timeslider.month.october\": \"Tháng Mười\",\n\t\"timeslider.month.november\": \"Tháng Mười Một\",\n\t\"timeslider.month.december\": \"Tháng Mười Hai\",\n\t\"timeslider.unnamedauthors\": \"Không tên {{in a}} {[plural(num) một: tác giả, khác: tác giả]}\",\n\t\"pad.savedrevs.marked\": \"Phiên bản này đã được đánh dấu là một phiên bản đã lưu\",\n\t\"pad.userlist.entername\": \"Nhập tên của bạn\",\n\t\"pad.userlist.unnamed\": \"Không tên\",\n\t\"pad.editbar.clearcolors\": \"Xóa màu chỉ tác giả trên toàn bộ tài liệu?\",\n\t\"pad.impexp.importbutton\": \"Nhập ngay bây giờ\",\n\t\"pad.impexp.importing\": \"Đang nhập…\",\n\t\"pad.impexp.confirmimport\": \"Nhập một tập tin sẽ ghi đè nội dung hiện tại của pad. Bạn có muốn làm như vậy không?\",\n\t\"pad.impexp.convertFailed\": \"Chúng tôi không thể nhập tập tin này. Hãy sử dụng định dạng tập tin khác hay sao chéo và dán một cách thủ công.\",\n\t\"pad.impexp.uploadFailed\": \"Tải lên không thành công, vui lòng thử lại\",\n\t\"pad.impexp.importfailed\": \"Nhập thất bại\",\n\t\"pad.impexp.copypaste\": \"Xin vui lòng sao chép và dán\",\n\t\"pad.impexp.exportdisabled\": \"Xuất ra định dạng {{type}} đã bị vô hiệu hóa. Xin hãy liên hệ với quản trị viên hệ thống để biết thêm thông tin chi tiết.\"\n}\n"
  },
  {
    "path": "src/locales/zh-hans.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"94rain\",\n\t\t\t\"Cosing\",\n\t\t\t\"Dimension\",\n\t\t\t\"GuoPC\",\n\t\t\t\"HellojoeAoPS\",\n\t\t\t\"Hydra\",\n\t\t\t\"Hzy980512\",\n\t\t\t\"JuneAugust\",\n\t\t\t\"Lakejason0\",\n\t\t\t\"LittlePaw365\",\n\t\t\t\"Liuxinyu970226\",\n\t\t\t\"Qiyue2001\",\n\t\t\t\"Shangkuanlc\",\n\t\t\t\"Shizhao\",\n\t\t\t\"Stang\",\n\t\t\t\"TFX202X\",\n\t\t\t\"VulpesVulpes825\",\n\t\t\t\"Yfdyh000\",\n\t\t\t\"乌拉跨氪\",\n\t\t\t\"列维劳德\",\n\t\t\t\"沈澄心\",\n\t\t\t\"燃玉\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"管理员面板 - Etherpad\",\n\t\"admin_plugins\": \"插件管理器\",\n\t\"admin_plugins.available\": \"可用插件\",\n\t\"admin_plugins.available_not-found\": \"找不到插件。\",\n\t\"admin_plugins.available_fetching\": \"获取中…\",\n\t\"admin_plugins.available_install.value\": \"安装\",\n\t\"admin_plugins.available_search.placeholder\": \"搜索要安装的插件\",\n\t\"admin_plugins.description\": \"描述\",\n\t\"admin_plugins.installed\": \"已装插件\",\n\t\"admin_plugins.installed_fetching\": \"正在获取已安装的插件…\",\n\t\"admin_plugins.installed_nothing\": \"您尚未安装任何插件。\",\n\t\"admin_plugins.installed_uninstall.value\": \"卸载\",\n\t\"admin_plugins.last-update\": \"最近更新\",\n\t\"admin_plugins.name\": \"名称\",\n\t\"admin_plugins.page-title\": \"插件管理器 - Etherpad\",\n\t\"admin_plugins.version\": \"版本\",\n\t\"admin_plugins_info\": \"故障排除信息\",\n\t\"admin_plugins_info.hooks\": \"已安装的挂钩\",\n\t\"admin_plugins_info.hooks_client\": \"客户端挂钩\",\n\t\"admin_plugins_info.hooks_server\": \"服务器端挂钩\",\n\t\"admin_plugins_info.parts\": \"已安装部分\",\n\t\"admin_plugins_info.plugins\": \"已安装插件\",\n\t\"admin_plugins_info.page-title\": \"插件信息 - Etherpad\",\n\t\"admin_plugins_info.version\": \"Etherpad版本\",\n\t\"admin_plugins_info.version_latest\": \"最新可用版本\",\n\t\"admin_plugins_info.version_number\": \"版本号\",\n\t\"admin_settings\": \"设置\",\n\t\"admin_settings.current\": \"当前配置\",\n\t\"admin_settings.current_example-devel\": \"开发设置模板示例\",\n\t\"admin_settings.current_example-prod\": \"生产设置模板示例\",\n\t\"admin_settings.current_restart.value\": \"重启Etherpad\",\n\t\"admin_settings.current_save.value\": \"保存设置\",\n\t\"admin_settings.page-title\": \"设置 - Etherpad\",\n\t\"index.newPad\": \"新记事本\",\n\t\"index.settings\": \"设置\",\n\t\"index.transferSessionTitle\": \"转移会话\",\n\t\"index.receiveSessionTitle\": \"接收会话\",\n\t\"index.receiveSessionDescription\": \"您可以在此处从其他浏览器或设备接收Etherpad会话。但请注意，这会删除您目前的会话（如有）。\",\n\t\"index.transferSession\": \"1. 转移会话\",\n\t\"index.transferSessionNow\": \"现在进行转移会话\",\n\t\"index.copyLink\": \"2. 复制链接\",\n\t\"index.copyLinkDescription\": \"点击下方按钮，将链接复制到剪贴板。\",\n\t\"index.copyLinkButton\": \"复制链接到剪贴板\",\n\t\"index.transferToSystem\": \"3. 将会话复制到新系统\",\n\t\"index.transferToSystemDescription\": \"在目标浏览器或设备上打开复制的链接，即可转移您的会话。\",\n\t\"index.transferSessionDescription\": \"点击下方按钮，即可将目前的会话转移到浏览器或设备。这将复制一个指向某页面的链接，当该页面在目标浏览器或设备上打开时，将会转移您的会话。\",\n\t\"index.createOpenPad\": \"按名称打开记事本\",\n\t\"index.openPad\": \"打开一个现有的记事本，名称为：\",\n\t\"index.recentPads\": \"最近的记事本\",\n\t\"index.recentPadsEmpty\": \"未找到最近的记事本。\",\n\t\"index.generateNewPad\": \"生成随机记事本名称\",\n\t\"index.labelPad\": \"记事本名称（可选）\",\n\t\"index.placeholderPadEnter\": \"输入记事本名称…\",\n\t\"index.createAndShareDocuments\": \"实时创建和共享文档\",\n\t\"index.createAndShareDocumentsDescription\": \"Etherpad允许您实时协作编辑文档，就像在浏览器中运行的实时多人编辑器一样。\",\n\t\"pad.toolbar.bold.title\": \"粗体（Ctrl-B）\",\n\t\"pad.toolbar.italic.title\": \"斜体（Ctrl-I）\",\n\t\"pad.toolbar.underline.title\": \"下划线（Ctrl-U）\",\n\t\"pad.toolbar.strikethrough.title\": \"删除线（Ctrl+5）\",\n\t\"pad.toolbar.ol.title\": \"有序列表（Ctrl+Shift+N）\",\n\t\"pad.toolbar.ul.title\": \"无序列表（Ctrl+Shift+L）\",\n\t\"pad.toolbar.indent.title\": \"缩进（TAB）\",\n\t\"pad.toolbar.unindent.title\": \"减少缩进（Shift+TAB）\",\n\t\"pad.toolbar.undo.title\": \"撤消（Ctrl-Z）\",\n\t\"pad.toolbar.redo.title\": \"重做（Ctrl+Y）\",\n\t\"pad.toolbar.clearAuthorship.title\": \"清除作者颜色（Ctrl+Shift+C）\",\n\t\"pad.toolbar.import_export.title\": \"从不同的文件格式导入/导出\",\n\t\"pad.toolbar.timeslider.title\": \"时间轴\",\n\t\"pad.toolbar.savedRevision.title\": \"保存修订\",\n\t\"pad.toolbar.settings.title\": \"设置\",\n\t\"pad.toolbar.embed.title\": \"共享并嵌入此记事本\",\n\t\"pad.toolbar.home.title\": \"返回首页\",\n\t\"pad.toolbar.showusers.title\": \"显示此记事本上的用户\",\n\t\"pad.colorpicker.save\": \"保存\",\n\t\"pad.colorpicker.cancel\": \"取消\",\n\t\"pad.loading\": \"加载中...\",\n\t\"pad.noCookie\": \"无法找到 Cookie。请在您的浏览器中允许cookie！您的会话和设置不会在两次访问之间保存。这可能是由于 Etherpad 包含在某些浏览器的 iFrame 中。请确保 Etherpad 与父 iFrame 位于同一子域/域中\",\n\t\"pad.permissionDenied\": \"您没有访问这个记事本的权限\",\n\t\"pad.settings.padSettings\": \"记事本设置\",\n\t\"pad.settings.myView\": \"我的视窗\",\n\t\"pad.settings.stickychat\": \"总是显示聊天屏幕\",\n\t\"pad.settings.chatandusers\": \"显示聊天和用户\",\n\t\"pad.settings.colorcheck\": \"作者颜色\",\n\t\"pad.settings.linenocheck\": \"行号\",\n\t\"pad.settings.rtlcheck\": \"从右到左阅读内容吗？\",\n\t\"pad.settings.fontType\": \"字体类型：\",\n\t\"pad.settings.fontType.normal\": \"正常\",\n\t\"pad.settings.language\": \"语言：\",\n\t\"pad.settings.deletePad\": \"删除记事本\",\n\t\"pad.delete.confirm\": \"您确定要删除此记事本吗？\",\n\t\"pad.settings.about\": \"关于\",\n\t\"pad.settings.poweredBy\": \"技术支持来自\",\n\t\"pad.importExport.import_export\": \"导入/导出\",\n\t\"pad.importExport.import\": \"上载任何文本文件或档案\",\n\t\"pad.importExport.importSuccessful\": \"成功！\",\n\t\"pad.importExport.export\": \"当前记事本导出为：\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"纯文本\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF（开放文档格式）\",\n\t\"pad.importExport.abiword.innerHTML\": \"您只可以导入纯文本或HTML格式。要获取更高级的导入功能，请<a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\\\">安装 AbiWord 或是 LibreOffice</a>。\",\n\t\"pad.modals.connected\": \"已连接。\",\n\t\"pad.modals.reconnecting\": \"重新连接到您的记事本…\",\n\t\"pad.modals.forcereconnect\": \"强制重新连接\",\n\t\"pad.modals.reconnecttimer\": \"尝试重新连入\",\n\t\"pad.modals.cancel\": \"取消\",\n\t\"pad.modals.userdup\": \"在另一个窗口中打开\",\n\t\"pad.modals.userdup.explanation\": \"此记事本似乎在本电脑上的多个浏览器窗口中打开。\",\n\t\"pad.modals.userdup.advice\": \"重新连接以使用此窗口替代。\",\n\t\"pad.modals.unauth\": \"未授权\",\n\t\"pad.modals.unauth.explanation\": \"您的权限在查看此页面时已改变。尝试重新连接。\",\n\t\"pad.modals.looping.explanation\": \"与同步服务器的通信出现问题。\",\n\t\"pad.modals.looping.cause\": \"也许您是通过不兼容的防火墙或代理服务器连接。\",\n\t\"pad.modals.initsocketfail\": \"无法访问服务器。\",\n\t\"pad.modals.initsocketfail.explanation\": \"无法连接到同步服务器。\",\n\t\"pad.modals.initsocketfail.cause\": \"这可能是由于您的浏览器或您的互联网连接的问题。\",\n\t\"pad.modals.slowcommit.explanation\": \"服务器没有响应。\",\n\t\"pad.modals.slowcommit.cause\": \"这可能是由于网络连接问题。\",\n\t\"pad.modals.badChangeset.explanation\": \"您的一个编辑被同步服务器分类为非法。\",\n\t\"pad.modals.badChangeset.cause\": \"这可能是由于服务器配置错误或其他一些意外行为造成的。如果您认为这是一个错误，请联系服务管理员。尝试重新连接以继续编辑。\",\n\t\"pad.modals.corruptPad.explanation\": \"您试图连接的记事本已损坏。\",\n\t\"pad.modals.corruptPad.cause\": \"这可能是由于服务器配置错误或其他一些意外行为造成的。请联系服务管理员。\",\n\t\"pad.modals.deleted\": \"已删除。\",\n\t\"pad.modals.deleted.explanation\": \"此记事本已被移除。\",\n\t\"pad.modals.rateLimited\": \"费率有限。\",\n\t\"pad.modals.rateLimited.explanation\": \"您向此平板发送了太多消息，因此它断开了您的连接。\",\n\t\"pad.modals.rejected.explanation\": \"服务器拒绝了您的浏览器发送的信息。\",\n\t\"pad.modals.rejected.cause\": \"服务器可能在你查看记事本时更新了，也可能是Etherpad出现了错误。请尝试重新加载页面。\",\n\t\"pad.modals.disconnected\": \"您已断开连接。\",\n\t\"pad.modals.disconnected.explanation\": \"与服务器的连接丢失\",\n\t\"pad.modals.disconnected.cause\": \"服务器可能无法使用。若此情况持续发生，请通知服务器管理员。\",\n\t\"pad.share\": \"分享此记事本\",\n\t\"pad.share.readonly\": \"只读\",\n\t\"pad.share.link\": \"链接\",\n\t\"pad.share.emebdcode\": \"嵌入网址\",\n\t\"pad.chat\": \"聊天\",\n\t\"pad.chat.title\": \"打开此记事本的聊天窗口。\",\n\t\"pad.chat.loadmessages\": \"加载更多信息\",\n\t\"pad.chat.stick.title\": \"在屏幕上固定聊天界面\",\n\t\"pad.chat.writeMessage.placeholder\": \"在这里写下您的留言\",\n\t\"timeslider.followContents\": \"关注记事本内容更新\",\n\t\"timeslider.pageTitle\": \"{{appTitle}} 时间轴\",\n\t\"timeslider.toolbar.returnbutton\": \"返回记事本\",\n\t\"timeslider.toolbar.authors\": \"作者：\",\n\t\"timeslider.toolbar.authorsList\": \"没有作者\",\n\t\"timeslider.toolbar.exportlink.title\": \"导出\",\n\t\"timeslider.exportCurrent\": \"当前版本导出为：\",\n\t\"timeslider.version\": \"版本 {{version}}\",\n\t\"timeslider.saved\": \"在{{year}}年{{month}}月{{day}}日保存\",\n\t\"timeslider.playPause\": \"回放 / 暂停记事本内容\",\n\t\"timeslider.backRevision\": \"返回此记事本的一次修订\",\n\t\"timeslider.forwardRevision\": \"前往此记事本的下一次修订\",\n\t\"timeslider.dateformat\": \"{{year}}年{{month}}月{{day}}日{{hours}}时{{minutes}}分{{seconds}}秒\",\n\t\"timeslider.month.january\": \"1月\",\n\t\"timeslider.month.february\": \"2月\",\n\t\"timeslider.month.march\": \"3月\",\n\t\"timeslider.month.april\": \"4月\",\n\t\"timeslider.month.may\": \"5月\",\n\t\"timeslider.month.june\": \"6月\",\n\t\"timeslider.month.july\": \"7月\",\n\t\"timeslider.month.august\": \"8月\",\n\t\"timeslider.month.september\": \"9月\",\n\t\"timeslider.month.october\": \"10月\",\n\t\"timeslider.month.november\": \"11月\",\n\t\"timeslider.month.december\": \"12月\",\n\t\"timeslider.unnamedauthors\": \"{{num}} 未命名 {[plural(num) one: author, other: authors ]}\",\n\t\"pad.savedrevs.marked\": \"这一修订现在被标记为已保存的修订版本\",\n\t\"pad.savedrevs.timeslider\": \"您可以使用时间轴查阅已保存的版本\",\n\t\"pad.userlist.entername\": \"输入您的姓名\",\n\t\"pad.userlist.unnamed\": \"未命名的\",\n\t\"pad.editbar.clearcolors\": \"清除整个文档的作者颜色？这不能被撤消\",\n\t\"pad.impexp.importbutton\": \"立即导入\",\n\t\"pad.impexp.importing\": \"正在导入...\",\n\t\"pad.impexp.confirmimport\": \"导入的文件将覆盖记事本的当前文本。你确定要继续吗？\",\n\t\"pad.impexp.convertFailed\": \"我们无法导入此文档。请使用其他文档格式或手动复制贴上。\",\n\t\"pad.impexp.padHasData\": \"我们无法导入此文件，因为此记事本已经变更，请导入到一个新的记事本中\",\n\t\"pad.impexp.uploadFailed\": \"上载失败，请重试\",\n\t\"pad.impexp.importfailed\": \"导入失败\",\n\t\"pad.impexp.copypaste\": \"请复制粘贴\",\n\t\"pad.impexp.exportdisabled\": \"{{type}} 格式的导出被禁用。有关详情，请与您的系统管理员联系。\",\n\t\"pad.impexp.maxFileSize\": \"文件太大。 请与您的站点管理员联系以增加允许导入的文件大小\"\n}\n"
  },
  {
    "path": "src/locales/zh-hant.json",
    "content": "{\n\t\"@metadata\": {\n\t\t\"authors\": [\n\t\t\t\"HellojoeAoPS\",\n\t\t\t\"Justincheng12345\",\n\t\t\t\"Kly\",\n\t\t\t\"LNDDYL\",\n\t\t\t\"Liuxinyu970226\",\n\t\t\t\"Pan93412\",\n\t\t\t\"Shangkuanlc\",\n\t\t\t\"Shirayuki\",\n\t\t\t\"Simon Shek\",\n\t\t\t\"Wehwei\",\n\t\t\t\"Winston Sung\"\n\t\t]\n\t},\n\t\"admin.page-title\": \"管理員面板 - Etherpad\",\n\t\"admin_plugins\": \"外掛程式管理器\",\n\t\"admin_plugins.available\": \"可用套件\",\n\t\"admin_plugins.available_not-found\": \"沒有找到套件。\",\n\t\"admin_plugins.available_fetching\": \"正在取得…\",\n\t\"admin_plugins.available_install.value\": \"安裝\",\n\t\"admin_plugins.available_search.placeholder\": \"搜尋套件來安裝\",\n\t\"admin_plugins.description\": \"描述\",\n\t\"admin_plugins.installed\": \"已安裝的套件\",\n\t\"admin_plugins.installed_fetching\": \"正在取得已安裝的套件…\",\n\t\"admin_plugins.installed_nothing\": \"您還沒有安裝任何套件。\",\n\t\"admin_plugins.installed_uninstall.value\": \"解除安裝\",\n\t\"admin_plugins.last-update\": \"最後更新\",\n\t\"admin_plugins.name\": \"名稱\",\n\t\"admin_plugins.page-title\": \"套件管理 - Etherpad\",\n\t\"admin_plugins.version\": \"版本\",\n\t\"admin_plugins_info\": \"問題排除資訊\",\n\t\"admin_plugins_info.hooks\": \"已安裝的掛勾\",\n\t\"admin_plugins_info.hooks_client\": \"客戶端掛勾\",\n\t\"admin_plugins_info.hooks_server\": \"伺服器端掛勾\",\n\t\"admin_plugins_info.parts\": \"已安裝部分\",\n\t\"admin_plugins_info.plugins\": \"已安裝的套件\",\n\t\"admin_plugins_info.page-title\": \"套件資訊 - Etherpad\",\n\t\"admin_plugins_info.version\": \"Etherpad 版本\",\n\t\"admin_plugins_info.version_latest\": \"最新可用版本\",\n\t\"admin_plugins_info.version_number\": \"版本號\",\n\t\"admin_settings\": \"設定\",\n\t\"admin_settings.current\": \"目前配置\",\n\t\"admin_settings.current_example-devel\": \"範例開發設定模板\",\n\t\"admin_settings.current_example-prod\": \"生產設定模板範例\",\n\t\"admin_settings.current_restart.value\": \"重新啟動 Etherpad\",\n\t\"admin_settings.current_save.value\": \"儲存設定\",\n\t\"admin_settings.page-title\": \"設定 - Etherpad\",\n\t\"index.newPad\": \"新記事本\",\n\t\"index.settings\": \"設定\",\n\t\"index.transferSessionTitle\": \"轉移連線階段\",\n\t\"index.receiveSessionTitle\": \"接收連線階段\",\n\t\"index.receiveSessionDescription\": \"您可以在此處從其他瀏覽器或裝置接收 Etherpad 的連線階段。但請留意，這會刪除您目前的連線階段（如有）。\",\n\t\"index.transferSession\": \"1. 轉移連線階段\",\n\t\"index.transferSessionNow\": \"現在進行轉移連線階段\",\n\t\"index.copyLink\": \"2. 複製連結\",\n\t\"index.copyLinkDescription\": \"點擊下方按鈕，將連結複製到您的剪貼簿。\",\n\t\"index.copyLinkButton\": \"複製連結到剪貼簿\",\n\t\"index.transferToSystem\": \"3. 將連線階段複製到新系統\",\n\t\"index.transferToSystemDescription\": \"在目標瀏覽器或裝置上開啟複製的連結，即可轉移您的連線階段。\",\n\t\"index.transferSessionDescription\": \"點擊下方按鈕，即可將您目前的連線階段轉移到瀏覽器或裝置。這將複製一個指向到一個頁面的連結，當該頁面在目標瀏覽器或設備上打開時，會轉移您的連線階段。\",\n\t\"index.createOpenPad\": \"依照名稱開啟記事本\",\n\t\"index.openPad\": \"開啟一個現有的記事本，名稱為：\",\n\t\"index.recentPads\": \"近期記事本\",\n\t\"index.recentPadsEmpty\": \"找不到近期的記事本。\",\n\t\"index.generateNewPad\": \"產生隨機記事本名稱\",\n\t\"index.labelPad\": \"記事本名稱（可選）\",\n\t\"index.placeholderPadEnter\": \"請輸入記事本名稱…\",\n\t\"index.createAndShareDocuments\": \"即時建立和共享文件\",\n\t\"index.createAndShareDocumentsDescription\": \"Etherpad 允許您即時協作編輯文件，就像在瀏覽器中運作的即時多人編輯器一樣。\",\n\t\"pad.toolbar.bold.title\": \"粗體（Ctrl+B）\",\n\t\"pad.toolbar.italic.title\": \"斜體（Ctrl+I）\",\n\t\"pad.toolbar.underline.title\": \"底線（Ctrl+U）\",\n\t\"pad.toolbar.strikethrough.title\": \"刪除線（Ctrl+5）\",\n\t\"pad.toolbar.ol.title\": \"有序清單（Ctrl+Shift+N）\",\n\t\"pad.toolbar.ul.title\": \"無序清單（Ctrl+Shift+L）\",\n\t\"pad.toolbar.indent.title\": \"縮排（TAB）\",\n\t\"pad.toolbar.unindent.title\": \"減少縮排（Shift+TAB）\",\n\t\"pad.toolbar.undo.title\": \"復原（Ctrl+Z）\",\n\t\"pad.toolbar.redo.title\": \"重做 (Ctrl+Y)\",\n\t\"pad.toolbar.clearAuthorship.title\": \"清除作者顏色 (Ctrl+Shift+C)\",\n\t\"pad.toolbar.import_export.title\": \"從不同的檔案格式匯入/匯出\",\n\t\"pad.toolbar.timeslider.title\": \"時間軸\",\n\t\"pad.toolbar.savedRevision.title\": \"儲存修訂版\",\n\t\"pad.toolbar.settings.title\": \"設定\",\n\t\"pad.toolbar.embed.title\": \"分享和嵌入此記事本\",\n\t\"pad.toolbar.home.title\": \"返回首頁\",\n\t\"pad.toolbar.showusers.title\": \"顯示此記事本的使用者\",\n\t\"pad.colorpicker.save\": \"儲存\",\n\t\"pad.colorpicker.cancel\": \"取消\",\n\t\"pad.loading\": \"載入中...\",\n\t\"pad.noCookie\": \"無法找到 Cookie。請在您的瀏覽器中允許 cookie！您的連線階段和設定未在訪問期間保存下來。這可能是由於 Etherpad 包含在某些瀏覽器的 iFrame 中。請確保 Etherpad 與父層級 iFrame 位於同一子網域/網域中\",\n\t\"pad.permissionDenied\": \"你沒有存取這個記事本的權限\",\n\t\"pad.settings.padSettings\": \"記事本設定\",\n\t\"pad.settings.myView\": \"我的視窗\",\n\t\"pad.settings.stickychat\": \"永遠在螢幕上顯示聊天\",\n\t\"pad.settings.chatandusers\": \"顯示聊天和使用者\",\n\t\"pad.settings.colorcheck\": \"作者顏色\",\n\t\"pad.settings.linenocheck\": \"行號\",\n\t\"pad.settings.rtlcheck\": \"從右至左讀取內容？\",\n\t\"pad.settings.fontType\": \"字型類型：\",\n\t\"pad.settings.fontType.normal\": \"正常\",\n\t\"pad.settings.language\": \"語言：\",\n\t\"pad.settings.deletePad\": \"刪除記事本\",\n\t\"pad.delete.confirm\": \"您確定要刪除此記事本？\",\n\t\"pad.settings.about\": \"關於\",\n\t\"pad.settings.poweredBy\": \"技術支援來自\",\n\t\"pad.importExport.import_export\": \"導入/匯出\",\n\t\"pad.importExport.import\": \"上傳任何文字檔案或文件\",\n\t\"pad.importExport.importSuccessful\": \"完成！\",\n\t\"pad.importExport.export\": \"匯出目前的記事本為：\",\n\t\"pad.importExport.exportetherpad\": \"Etherpad\",\n\t\"pad.importExport.exporthtml\": \"HTML\",\n\t\"pad.importExport.exportplain\": \"純文字\",\n\t\"pad.importExport.exportword\": \"Microsoft Word\",\n\t\"pad.importExport.exportpdf\": \"PDF\",\n\t\"pad.importExport.exportopen\": \"ODF（開放文件格式）\",\n\t\"pad.importExport.abiword.innerHTML\": \"您只可以匯入純文字或HTML格式。若要取得更進階的導入功能，請<a href=\\\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with- AbiWord\\\">安裝AbiWord 或是LibreOffice</a>。\",\n\t\"pad.modals.connected\": \"已連線。\",\n\t\"pad.modals.reconnecting\": \"重新連線到您的記事本…\",\n\t\"pad.modals.forcereconnect\": \"強制重新連線\",\n\t\"pad.modals.reconnecttimer\": \"嘗試重新連接\",\n\t\"pad.modals.cancel\": \"取消\",\n\t\"pad.modals.userdup\": \"在另一個視窗中開啟\",\n\t\"pad.modals.userdup.explanation\": \"此記事本似乎在此電腦上的多個瀏覽器視窗中開啟。\",\n\t\"pad.modals.userdup.advice\": \"重新連線以使用此視窗替代。\",\n\t\"pad.modals.unauth\": \"未授權\",\n\t\"pad.modals.unauth.explanation\": \"您的權限在查看此頁面時已改變。嘗試重新連線。\",\n\t\"pad.modals.looping.explanation\": \"與同步伺服器間有通訊問題。\",\n\t\"pad.modals.looping.cause\": \"也許您是透過不相容的防火牆或代理伺服器連線。\",\n\t\"pad.modals.initsocketfail\": \"無法存取伺服器。\",\n\t\"pad.modals.initsocketfail.explanation\": \"無法連線到同步伺服器。\",\n\t\"pad.modals.initsocketfail.cause\": \"這可能是因為瀏覽器或網際網路連線問題所造成。\",\n\t\"pad.modals.slowcommit.explanation\": \"伺服器沒有回應。\",\n\t\"pad.modals.slowcommit.cause\": \"這可能是因為網路連線問題所造成。\",\n\t\"pad.modals.badChangeset.explanation\": \"您所做的一個編輯被同步伺服器歸類為非法。\",\n\t\"pad.modals.badChangeset.cause\": \"這可能是由於伺服器配置錯誤或其他一些意外行為造成的。如果您認為這是一個錯誤，請聯絡服務管理員。嘗試重新連線以繼續編輯。\",\n\t\"pad.modals.corruptPad.explanation\": \"您試圖存取的記事本已損壞。\",\n\t\"pad.modals.corruptPad.cause\": \"這可能是由於伺服器配置錯誤或其他一些意外行為造成的。請聯絡服務管理員。\",\n\t\"pad.modals.deleted\": \"已刪除。\",\n\t\"pad.modals.deleted.explanation\": \"此記事本已被移除。\",\n\t\"pad.modals.rateLimited\": \"速率限制。\",\n\t\"pad.modals.rateLimited.explanation\": \"您發送太多訊息到此記事本，因此中斷了您的連結。\",\n\t\"pad.modals.rejected.explanation\": \"伺服器拒絕了由您的瀏覽器發送的訊息。\",\n\t\"pad.modals.rejected.cause\": \"伺服器可能在你查看記事本時更新了，也可能是Etherpad出現了錯誤。請嘗試重新載入頁面。\",\n\t\"pad.modals.disconnected\": \"您已中斷連線。\",\n\t\"pad.modals.disconnected.explanation\": \"與伺服器的連線遺失\",\n\t\"pad.modals.disconnected.cause\": \"伺服器可能無法使用。若此情況持續發生，請通知伺服器管理員。\",\n\t\"pad.share\": \"分享此記事本\",\n\t\"pad.share.readonly\": \"唯讀\",\n\t\"pad.share.link\": \"連結\",\n\t\"pad.share.emebdcode\": \"嵌入網址\",\n\t\"pad.chat\": \"聊天功能\",\n\t\"pad.chat.title\": \"打開記事本聊天功能\",\n\t\"pad.chat.loadmessages\": \"載入更多訊息\",\n\t\"pad.chat.stick.title\": \"在螢幕上固定聊天介面\",\n\t\"pad.chat.writeMessage.placeholder\": \"在這裡寫下您的留言\",\n\t\"timeslider.followContents\": \"關注記事本內容更新\",\n\t\"timeslider.pageTitle\": \"{{appTitle}}時間軸\",\n\t\"timeslider.toolbar.returnbutton\": \"返回記事本\",\n\t\"timeslider.toolbar.authors\": \"作者：\",\n\t\"timeslider.toolbar.authorsList\": \"無作者\",\n\t\"timeslider.toolbar.exportlink.title\": \"匯出\",\n\t\"timeslider.exportCurrent\": \"匯出當前版本為：\",\n\t\"timeslider.version\": \"版本{{version}}\",\n\t\"timeslider.saved\": \"儲存於 {{year}} {{month}} {{day}}\",\n\t\"timeslider.playPause\": \"重播 / 暫停記事本內容\",\n\t\"timeslider.backRevision\": \"返回此記事本的前一次修訂\",\n\t\"timeslider.forwardRevision\": \"前往此記事本的下一次修訂\",\n\t\"timeslider.dateformat\": \"{{year}}年{{month}}月{{day}}日 {{hours}}:{{minutes}}:{{seconds}}\",\n\t\"timeslider.month.january\": \"1月\",\n\t\"timeslider.month.february\": \"2月\",\n\t\"timeslider.month.march\": \"3月\",\n\t\"timeslider.month.april\": \"4月\",\n\t\"timeslider.month.may\": \"5月\",\n\t\"timeslider.month.june\": \"6月\",\n\t\"timeslider.month.july\": \"7月\",\n\t\"timeslider.month.august\": \"8月\",\n\t\"timeslider.month.september\": \"9月\",\n\t\"timeslider.month.october\": \"10月\",\n\t\"timeslider.month.november\": \"11月\",\n\t\"timeslider.month.december\": \"12月\",\n\t\"timeslider.unnamedauthors\": \"{{num}} 個匿名{[plural(num) one: author, other: authors]}\",\n\t\"pad.savedrevs.marked\": \"標記此修訂版本為已儲存修訂版本。\",\n\t\"pad.savedrevs.timeslider\": \"您可以透過造訪時間滑桿查看已儲存的修訂\",\n\t\"pad.userlist.entername\": \"輸入您的姓名\",\n\t\"pad.userlist.unnamed\": \"未命名\",\n\t\"pad.editbar.clearcolors\": \"清除整個文件的作者顏色？這不能被撤銷\",\n\t\"pad.impexp.importbutton\": \"現在匯入\",\n\t\"pad.impexp.importing\": \"匯入中...\",\n\t\"pad.impexp.confirmimport\": \"匯入的檔案將會覆蓋記事本內目前的文字。您確定要繼續嗎？\",\n\t\"pad.impexp.convertFailed\": \"未能匯入此檔案。請以其他檔案格式或手動複製貼上匯入。\",\n\t\"pad.impexp.padHasData\": \"因為此記事本已異動過我們無法匯入此檔案，請匯入至新的記事本\",\n\t\"pad.impexp.uploadFailed\": \"上載失敗，請重試\",\n\t\"pad.impexp.importfailed\": \"匯入失敗\",\n\t\"pad.impexp.copypaste\": \"請複製貼上\",\n\t\"pad.impexp.exportdisabled\": \"{{type}}格式的匯出被禁用。有關詳情，請與您的系統管理員聯繫。\",\n\t\"pad.impexp.maxFileSize\": \"檔案太大。請聯絡您的網站管理員以增加允許匯入的檔案大小\"\n}\n"
  },
  {
    "path": "src/node/README.md",
    "content": "# About the folder structure\n\n* **db** - all modules that are accessing the data structure and are communicating directly to the database\n* **handler** - all modules that respond directly to requests/messages of the browser\n* **utils** - helper modules\n\n# Module name conventions\n\nModule file names start with a capital letter and uses camelCase\n\n# Where does it start?\n\nserver.ts is started directly\n"
  },
  {
    "path": "src/node/db/API.ts",
    "content": "'use strict';\n/**\n * This module provides all API functions\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {deserializeOps} from '../../static/js/Changeset';\nimport ChatMessage from '../../static/js/ChatMessage';\nimport {Builder} from \"../../static/js/Builder\";\nimport {Attribute} from \"../../static/js/types/Attribute\";\nconst CustomError = require('../utils/customError');\nconst padManager = require('./PadManager');\nconst padMessageHandler = require('../handler/PadMessageHandler');\nimport readOnlyManager from './ReadOnlyManager';\nconst groupManager = require('./GroupManager');\nconst authorManager = require('./AuthorManager');\nconst sessionManager = require('./SessionManager');\nconst exportHtml = require('../utils/ExportHtml');\nconst exportTxt = require('../utils/ExportTxt');\nconst importHtml = require('../utils/ImportHtml');\nconst cleanText = require('./Pad').cleanText;\nconst PadDiff = require('../utils/padDiff');\nconst {checkValidRev, isInt} = require('../utils/checkValidRev');\n\n/* ********************\n * GROUP FUNCTIONS ****\n ******************** */\n\nexports.listAllGroups = groupManager.listAllGroups;\nexports.createGroup = groupManager.createGroup;\nexports.createGroupIfNotExistsFor = groupManager.createGroupIfNotExistsFor;\nexports.deleteGroup = groupManager.deleteGroup;\nexports.listPads = groupManager.listPads;\nexports.createGroupPad = groupManager.createGroupPad;\n\n/* ********************\n * PADLIST FUNCTION ***\n ******************** */\n\nexports.listAllPads = padManager.listAllPads;\n\n/* ********************\n * AUTHOR FUNCTIONS ***\n ******************** */\n\nexports.createAuthor = authorManager.createAuthor;\nexports.createAuthorIfNotExistsFor = authorManager.createAuthorIfNotExistsFor;\nexports.getAuthorName = authorManager.getAuthorName;\nexports.listPadsOfAuthor = authorManager.listPadsOfAuthor;\nexports.padUsers = padMessageHandler.padUsers;\nexports.padUsersCount = padMessageHandler.padUsersCount;\n\n/* ********************\n * SESSION FUNCTIONS **\n ******************** */\n\nexports.createSession = sessionManager.createSession;\nexports.deleteSession = sessionManager.deleteSession;\nexports.getSessionInfo = sessionManager.getSessionInfo;\nexports.listSessionsOfGroup = sessionManager.listSessionsOfGroup;\nexports.listSessionsOfAuthor = sessionManager.listSessionsOfAuthor;\n\n/* ***********************\n * PAD CONTENT FUNCTIONS *\n *********************** */\n\n/**\ngetAttributePool(padID) returns the attribute pool of a pad\n\nExample returns:\n{\n \"code\":0,\n \"message\":\"ok\",\n \"data\": {\n    \"pool\":{\n        \"numToAttrib\":{\n            \"0\":[\"author\",\"a.X4m8bBWJBZJnWGSh\"],\n            \"1\":[\"author\",\"a.TotfBPzov54ihMdH\"],\n            \"2\":[\"author\",\"a.StiblqrzgeNTbK05\"],\n            \"3\":[\"bold\",\"true\"]\n        },\n        \"attribToNum\":{\n            \"author,a.X4m8bBWJBZJnWGSh\":0,\n            \"author,a.TotfBPzov54ihMdH\":1,\n            \"author,a.StiblqrzgeNTbK05\":2,\n            \"bold,true\":3\n        },\n        \"nextNum\":4\n    }\n }\n}\n\n*/\nexports.getAttributePool = async (padID: string) => {\n  const pad = await getPadSafe(padID, true);\n  return {pool: pad.pool};\n};\n\n/**\ngetRevisionChangeset (padID, [rev])\n\nget the changeset at a given revision, or last revision if 'rev' is not defined.\n\nExample returns:\n{\n    \"code\" : 0,\n    \"message\" : \"ok\",\n    \"data\" : \"Z:1>6b|5+6b$Welcome to Etherpad!\\n\\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\\n\\nGet involved with Etherpad at http://etherpad.org\\n\"\n}\n\n*/\nexports.getRevisionChangeset = async (padID: string, rev: string) => {\n  // try to parse the revision number\n  if (rev !== undefined) {\n    rev = checkValidRev(rev);\n  }\n\n  // get the pad\n  const pad = await getPadSafe(padID, true);\n  const head = pad.getHeadRevisionNumber();\n\n  // the client asked for a special revision\n  if (rev !== undefined) {\n    // check if this is a valid revision\n    if (rev > head) {\n      throw new CustomError('rev is higher than the head revision of the pad', 'apierror');\n    }\n\n    // get the changeset for this revision\n    return await pad.getRevisionChangeset(rev);\n  }\n\n  // the client wants the latest changeset, lets return it to him\n  return await pad.getRevisionChangeset(head);\n};\n\n/**\ngetText(padID, [rev]) returns the text of a pad\n\nExample returns:\n\n{code: 0, message:\"ok\", data: {text:\"Welcome Text\"}}\n{code: 1, message:\"padID does not exist\", data: null}\n*/\nexports.getText = async (padID: string, rev: string) => {\n  // try to parse the revision number\n  if (rev !== undefined) {\n    rev = checkValidRev(rev);\n  }\n\n  // get the pad\n  const pad = await getPadSafe(padID, true);\n  const head = pad.getHeadRevisionNumber();\n\n  // the client asked for a special revision\n  if (rev !== undefined) {\n    // check if this is a valid revision\n    if (rev > head) {\n      throw new CustomError('rev is higher than the head revision of the pad', 'apierror');\n    }\n\n    // get the text of this revision\n    // getInternalRevisionAText() returns an atext object, but we only want the .text inside it.\n    // Details at https://github.com/ether/etherpad-lite/issues/5073\n    const {text} = await pad.getInternalRevisionAText(rev);\n    return {text};\n  }\n\n  // the client wants the latest text, lets return it to him\n  const text = exportTxt.getTXTFromAtext(pad, pad.atext);\n  return {text};\n};\n\n/**\nsetText(padID, text, [authorId]) sets the text of a pad\n\nExample returns:\n\n{code: 0, message:\"ok\", data: null}\n{code: 1, message:\"padID does not exist\", data: null}\n{code: 1, message:\"text too long\", data: null}\n*/\n/**\n *\n * @param {String} padID the id of the pad\n * @param {String} text the text of the pad\n * @param {String} authorId the id of the author, defaulting to empty string\n * @returns {Promise<void>}\n */\nexports.setText = async (padID: string, text?: string, authorId: string = ''): Promise<void> => {\n  // text is required\n  if (typeof text !== 'string') {\n    throw new CustomError('text is not a string', 'apierror');\n  }\n\n  // get the pad\n  const pad = await getPadSafe(padID, true);\n\n  await pad.setText(text, authorId);\n  await padMessageHandler.updatePadClients(pad);\n};\n\n/**\nappendText(padID, text, [authorId]) appends text to a pad\n\nExample returns:\n\n{code: 0, message:\"ok\", data: null}\n{code: 1, message:\"padID does not exist\", data: null}\n{code: 1, message:\"text too long\", data: null}\n @param {String} padID the id of the pad\n @param {String} text the text of the pad\n @param {String} authorId the id of the author, defaulting to empty string\n */\nexports.appendText = async (padID:string, text?: string, authorId:string = '') => {\n  // text is required\n  if (typeof text !== 'string') {\n    throw new CustomError('text is not a string', 'apierror');\n  }\n\n  const pad = await getPadSafe(padID, true);\n  await pad.appendText(text, authorId);\n  await padMessageHandler.updatePadClients(pad);\n};\n\n/**\ngetHTML(padID, [rev]) returns the html of a pad\n\nExample returns:\n\n{code: 0, message:\"ok\", data: {text:\"Welcome <strong>Text</strong>\"}}\n{code: 1, message:\"padID does not exist\", data: null}\n @param {String} padID the id of the pad\n @param {String} rev the revision number, defaulting to the latest revision\n @return {Promise<{html: string}>} the html of the pad\n*/\nexports.getHTML = async (padID: string, rev: string): Promise<{ html: string; }> => {\n  if (rev !== undefined) {\n    rev = checkValidRev(rev);\n  }\n\n  const pad = await getPadSafe(padID, true);\n\n  // the client asked for a special revision\n  if (rev !== undefined) {\n    // check if this is a valid revision\n    const head = pad.getHeadRevisionNumber();\n    if (rev > head) {\n      throw new CustomError('rev is higher than the head revision of the pad', 'apierror');\n    }\n  }\n\n  // get the html of this revision\n  let html = await exportHtml.getPadHTML(pad, rev);\n\n  // wrap the HTML\n  html = `<!DOCTYPE HTML><html><body>${html}</body></html>`;\n  return {html};\n};\n\n/**\nsetHTML(padID, html, [authorId]) sets the text of a pad based on HTML\n\nExample returns:\n\n{code: 0, message:\"ok\", data: null}\n{code: 1, message:\"padID does not exist\", data: null}\n\n @param {String} padID the id of the pad\n @param {String} html the html of the pad\n @param {String} authorId the id of the author, defaulting to empty string\n*/\nexports.setHTML = async (padID: string, html:string|object, authorId = '') => {\n  // html string is required\n  if (typeof html !== 'string') {\n    throw new CustomError('html is not a string', 'apierror');\n  }\n\n  // get the pad\n  const pad = await getPadSafe(padID, true);\n\n  // add a new changeset with the new html to the pad\n  try {\n    await importHtml.setPadHTML(pad, cleanText(html), authorId);\n  } catch (e) {\n    throw new CustomError('HTML is malformed', 'apierror');\n  }\n\n  // update the clients on the pad\n  padMessageHandler.updatePadClients(pad);\n};\n\n/* ****************\n * CHAT FUNCTIONS *\n **************** */\n\n/**\ngetChatHistory(padId, start, end), returns a part of or the whole chat-history of this pad\n\nExample returns:\n\n{\"code\":0,\"message\":\"ok\",\"data\":{\"messages\":[\n  {\"text\":\"foo\",\"authorID\":\"a.foo\",\"time\":1359199533759,\"userName\":\"test\"},\n  {\"text\":\"bar\",\"authorID\":\"a.foo\",\"time\":1359199534622,\"userName\":\"test\"}\n]}}\n\n{code: 1, message:\"start is higher or equal to the current chatHead\", data: null}\n\n{code: 1, message:\"padID does not exist\", data: null}\n @param {String} padID the id of the pad\n @param {Number} start the start point of the chat-history\n @param {Number} end the end point of the chat-history\n*/\nexports.getChatHistory = async (padID: string, start:number, end:number) => {\n  if (start && end) {\n    if (start < 0) {\n      throw new CustomError('start is below zero', 'apierror');\n    }\n    if (end < 0) {\n      throw new CustomError('end is below zero', 'apierror');\n    }\n    if (start > end) {\n      throw new CustomError('start is higher than end', 'apierror');\n    }\n  }\n\n  // get the pad\n  const pad = await getPadSafe(padID, true);\n\n  const chatHead = pad.chatHead;\n\n  // fall back to getting the whole chat-history if a parameter is missing\n  if (!start || !end) {\n    start = 0;\n    end = pad.chatHead;\n  }\n\n  if (start > chatHead) {\n    throw new CustomError('start is higher than the current chatHead', 'apierror');\n  }\n  if (end > chatHead) {\n    throw new CustomError('end is higher than the current chatHead', 'apierror');\n  }\n\n  // the whole message-log and return it to the client\n  const messages = await pad.getChatMessages(start, end);\n\n  return {messages};\n};\n\n/**\nappendChatMessage(padID, text, authorID, time), creates a chat message for the pad id,\ntime is a timestamp\n\nExample returns:\n\n{code: 0, message:\"ok\", data: null}\n{code: 1, message:\"padID does not exist\", data: null}\n @param {String} padID the id of the pad\n @param {String} text the text of the chat-message\n @param {String} authorID the id of the author\n @param {Number} time the timestamp of the chat-message\n*/\nexports.appendChatMessage = async (padID: string, text: string|object, authorID: string, time: number) => {\n  // text is required\n  if (typeof text !== 'string') {\n    throw new CustomError('text is not a string', 'apierror');\n  }\n\n  // if time is not an integer value set time to current timestamp\n  if (time === undefined || !isInt(time)) {\n    time = Date.now();\n  }\n\n  // @TODO - missing getPadSafe() call ?\n\n  // save chat message to database and send message to all connected clients\n  await padMessageHandler.sendChatMessageToPadClients(new ChatMessage(text, authorID, time), padID);\n};\n\n/* ***************\n * PAD FUNCTIONS *\n *************** */\n\n/**\ngetRevisionsCount(padID) returns the number of revisions of this pad\n\nExample returns:\n\n{code: 0, message:\"ok\", data: {revisions: 56}}\n{code: 1, message:\"padID does not exist\", data: null}\n @param {String} padID the id of the pad\n*/\nexports.getRevisionsCount = async (padID: string) => {\n  // get the pad\n  const pad = await getPadSafe(padID, true);\n  return {revisions: pad.getHeadRevisionNumber()};\n};\n\n/**\ngetSavedRevisionsCount(padID) returns the number of saved revisions of this pad\n\nExample returns:\n\n{code: 0, message:\"ok\", data: {savedRevisions: 42}}\n{code: 1, message:\"padID does not exist\", data: null}\n @param {String} padID the id of the pad\n*/\nexports.getSavedRevisionsCount = async (padID: string) => {\n  // get the pad\n  const pad = await getPadSafe(padID, true);\n  return {savedRevisions: pad.getSavedRevisionsNumber()};\n};\n\n/**\nlistSavedRevisions(padID) returns the list of saved revisions of this pad\n\nExample returns:\n\n{code: 0, message:\"ok\", data: {savedRevisions: [2, 42, 1337]}}\n{code: 1, message:\"padID does not exist\", data: null}\n @param {String} padID the id of the pad\n*/\nexports.listSavedRevisions = async (padID: string) => {\n  // get the pad\n  const pad = await getPadSafe(padID, true);\n  return {savedRevisions: pad.getSavedRevisionsList()};\n};\n\n/**\nsaveRevision(padID) returns the list of saved revisions of this pad\n\nExample returns:\n\n{code: 0, message:\"ok\", data: null}\n{code: 1, message:\"padID does not exist\", data: null}\n    @param {String} padID the id of the pad\n     @param {Number} rev the revision number, defaulting to the latest revision\n*/\nexports.saveRevision = async (padID: string, rev: number) => {\n  // check if rev is a number\n  if (rev !== undefined) {\n    rev = checkValidRev(rev);\n  }\n\n  // get the pad\n  const pad = await getPadSafe(padID, true);\n  const head = pad.getHeadRevisionNumber();\n\n  // the client asked for a special revision\n  if (rev !== undefined) {\n    if (rev > head) {\n      throw new CustomError('rev is higher than the head revision of the pad', 'apierror');\n    }\n  } else {\n    rev = pad.getHeadRevisionNumber();\n  }\n\n  const author = await authorManager.createAuthor('API');\n  await pad.addSavedRevision(rev, author.authorID, 'Saved through API call');\n};\n\n/**\ngetLastEdited(padID) returns the timestamp of the last revision of the pad\n\nExample returns:\n\n{code: 0, message:\"ok\", data: {lastEdited: 1340815946602}}\n{code: 1, message:\"padID does not exist\", data: null}\n    @param {String} padID the id of the pad\n @return {Promise<{lastEdited: number}>} the timestamp of the last revision of the pad\n*/\nexports.getLastEdited = async (padID: string): Promise<{ lastEdited: number; }> => {\n  // get the pad\n  const pad = await getPadSafe(padID, true);\n  const lastEdited = await pad.getLastEdit();\n  return {lastEdited};\n};\n\n/**\ncreatePad(padName, [text], [authorId]) creates a new pad in this group\n\nExample returns:\n\n{code: 0, message:\"ok\", data: null}\n{code: 1, message:\"pad does already exist\", data: null}\n @param {String} padID the name of the new pad\n    @param {String} text the initial text of the pad\n     @param {String} authorId the id of the author, defaulting to empty string\n*/\nexports.createPad = async (padID: string, text: string, authorId = '') => {\n  if (padID) {\n    // ensure there is no $ in the padID\n    if (padID.indexOf('$') !== -1) {\n      throw new CustomError(\"createPad can't create group pads\", 'apierror');\n    }\n\n    // check for url special characters\n    if (padID.match(/(\\/|\\?|&|#)/)) {\n      throw new CustomError('malformed padID: Remove special characters', 'apierror');\n    }\n  }\n\n  // create pad\n  await getPadSafe(padID, false, text, authorId);\n};\n\n/**\ndeletePad(padID) deletes a pad\n\nExample returns:\n\n{code: 0, message:\"ok\", data: null}\n{code: 1, message:\"padID does not exist\", data: null}\n @param {String} padID the id of the pad\n*/\nexports.deletePad = async (padID: string) => {\n  const pad = await getPadSafe(padID, true);\n  await pad.remove();\n};\n\n/**\n restoreRevision(padID, rev, [authorId]) Restores revision from past as new changeset\n\n Example returns:\n\n {code:0, message:\"ok\", data:null}\n {code: 1, message:\"padID does not exist\", data: null}\n @param {String} padID the id of the pad\n @param {Number} rev the revision number, defaulting to the latest revision\n @param {String} authorId the id of the author, defaulting to empty string\n */\nexports.restoreRevision = async (padID: string, rev: number, authorId = '') => {\n  // check if rev is a number\n  if (rev === undefined) {\n    throw new CustomError('rev is not defined', 'apierror');\n  }\n  rev = checkValidRev(rev);\n\n  // get the pad\n  const pad = await getPadSafe(padID, true);\n\n  // check if this is a valid revision\n  if (rev > pad.getHeadRevisionNumber()) {\n    throw new CustomError('rev is higher than the head revision of the pad', 'apierror');\n  }\n\n  const atext = await pad.getInternalRevisionAText(rev);\n\n  const oldText = pad.text();\n  atext.text += '\\n';\n\n  const eachAttribRun = (attribs: string, func:Function) => {\n    let textIndex = 0;\n    const newTextStart = 0;\n    const newTextEnd = atext.text.length;\n    for (const op of deserializeOps(attribs)) {\n      const nextIndex = textIndex + op.chars;\n      if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) {\n        func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs);\n      }\n      textIndex = nextIndex;\n    }\n  };\n\n  // create a new changeset with a helper builder object\n  const builder = new Builder(oldText.length);\n\n  // assemble each line into the builder\n  eachAttribRun(atext.attribs, (start: number, end: number, attribs:Attribute[]) => {\n    builder.insert(atext.text.substring(start, end), attribs);\n  });\n\n  const lastNewlinePos = oldText.lastIndexOf('\\n');\n  if (lastNewlinePos < 0) {\n    builder.remove(oldText.length - 1, 0);\n  } else {\n    builder.remove(lastNewlinePos, oldText.match(/\\n/g).length - 1);\n    builder.remove(oldText.length - lastNewlinePos - 1, 0);\n  }\n\n  const changeset = builder.toString();\n\n  await pad.appendRevision(changeset, authorId);\n  await padMessageHandler.updatePadClients(pad);\n};\n\n/**\ncopyPad(sourceID, destinationID[, force=false]) copies a pad. If force is true,\n  the destination will be overwritten if it exists.\n\nExample returns:\n\n{code: 0, message:\"ok\", data: {padID: destinationID}}\n{code: 1, message:\"padID does not exist\", data: null}\n @param {String} sourceID the id of the source pad\n @param {String} destinationID the id of the destination pad\n @param {Boolean} force whether to overwrite the destination pad if it exists\n*/\nexports.copyPad = async (sourceID: string, destinationID: string, force: boolean) => {\n  const pad = await getPadSafe(sourceID, true);\n  await pad.copy(destinationID, force);\n};\n\n/**\ncopyPadWithoutHistory(sourceID, destinationID[, force=false], [authorId]) copies a pad. If force is\ntrue, the destination will be overwritten if it exists.\n\nExample returns:\n\n{code: 0, message:\"ok\", data: {padID: destinationID}}\n{code: 1, message:\"padID does not exist\", data: null}\n @param {String} sourceID the id of the source pad\n @param {String} destinationID the id of the destination pad\n @param {Boolean} force whether to overwrite the destination pad if it exists\n @param {String} authorId the id of the author, defaulting to empty string\n*/\nexports.copyPadWithoutHistory = async (sourceID: string, destinationID: string, force:boolean, authorId = '') => {\n  const pad = await getPadSafe(sourceID, true);\n  await pad.copyPadWithoutHistory(destinationID, force, authorId);\n};\n\n/**\nmovePad(sourceID, destinationID[, force=false]) moves a pad. If force is true,\n  the destination will be overwritten if it exists.\n\nExample returns:\n\n{code: 0, message:\"ok\", data: {padID: destinationID}}\n{code: 1, message:\"padID does not exist\", data: null}\n @param {String} sourceID the id of the source pad\n @param {String} destinationID the id of the destination pad\n @param {Boolean} force whether to overwrite the destination pad if it exists\n*/\nexports.movePad = async (sourceID: string, destinationID: string, force:boolean) => {\n  const pad = await getPadSafe(sourceID, true);\n  await pad.copy(destinationID, force);\n  await pad.remove();\n};\n\n/**\ngetReadOnlyLink(padID) returns the read only link of a pad\n\nExample returns:\n\n{code: 0, message:\"ok\", data: null}\n{code: 1, message:\"padID does not exist\", data: null}\n @param {String} padID the id of the pad\n*/\nexports.getReadOnlyID = async (padID: string) => {\n  // we don't need the pad object, but this function does all the security stuff for us\n  await getPadSafe(padID, true);\n\n  // get the readonlyId\n  const readOnlyID = await readOnlyManager.getReadOnlyId(padID);\n\n  return {readOnlyID};\n};\n\n/**\ngetPadID(roID) returns the padID of a pad based on the readonlyID(roID)\n\nExample returns:\n\n{code: 0, message:\"ok\", data: {padID: padID}}\n{code: 1, message:\"padID does not exist\", data: null}\n    @param {String} roID the readonly id of the pad\n*/\nexports.getPadID = async (roID: string) => {\n  // get the PadId\n  const padID = await readOnlyManager.getPadId(roID);\n  if (padID == null) {\n    throw new CustomError('padID does not exist', 'apierror');\n  }\n\n  return {padID};\n};\n\n/**\nsetPublicStatus(padID, publicStatus) sets a boolean for the public status of a pad\n\nExample returns:\n\n{code: 0, message:\"ok\", data: null}\n{code: 1, message:\"padID does not exist\", data: null}\n    @param {String} padID the id of the pad\n     @param {Boolean} publicStatus the public status of the pad\n*/\nexports.setPublicStatus = async (padID: string, publicStatus: boolean|string) => {\n  // ensure this is a group pad\n  checkGroupPad(padID, 'publicStatus');\n\n  // get the pad\n  const pad = await getPadSafe(padID, true);\n\n  // convert string to boolean\n  if (typeof publicStatus === 'string') {\n    publicStatus = (publicStatus.toLowerCase() === 'true');\n  }\n\n  await pad.setPublicStatus(publicStatus);\n};\n\n/**\ngetPublicStatus(padID) return true of false\n\nExample returns:\n\n{code: 0, message:\"ok\", data: {publicStatus: true}}\n{code: 1, message:\"padID does not exist\", data: null}\n     @param {String} padID the id of the pad\n*/\nexports.getPublicStatus = async (padID: string) => {\n  // ensure this is a group pad\n  checkGroupPad(padID, 'publicStatus');\n\n  // get the pad\n  const pad = await getPadSafe(padID, true);\n  return {publicStatus: pad.getPublicStatus()};\n};\n\n/**\nlistAuthorsOfPad(padID) returns an array of authors who contributed to this pad\n\nExample returns:\n\n{code: 0, message:\"ok\", data: {authorIDs : [\"a.s8oes9dhwrvt0zif\", \"a.akf8finncvomlqva\"]}\n{code: 1, message:\"padID does not exist\", data: null}\n     @param {String} padID the id of the pad\n*/\nexports.listAuthorsOfPad = async (padID: string) => {\n  // get the pad\n  const pad = await getPadSafe(padID, true);\n  const authorIDs = pad.getAllAuthors();\n  return {authorIDs};\n};\n\n/**\nsendClientsMessage(padID, msg) sends a message to all clients connected to the\npad, possibly for the purpose of signalling a plugin.\n\nNote, this will only accept strings from the HTTP API, so sending bogus changes\nor chat messages will probably not be possible.\n\nThe resulting message will be structured like so:\n\n{\n  type: 'COLLABROOM',\n  data: {\n    type: <msg>,\n    time: <time the message was sent>\n  }\n}\n\nExample returns:\n\n{code: 0, message:\"ok\"}\n{code: 1, message:\"padID does not exist\"}\n     @param {String} padID the id of the pad\n     @param {String} msg the message to send\n*/\n\nexports.sendClientsMessage = async (padID: string, msg: string) => {\n  await getPadSafe(padID, true); // Throw if the padID is invalid or if the pad does not exist.\n  padMessageHandler.handleCustomMessage(padID, msg);\n};\n\n/**\ncheckToken() returns ok when the current api token is valid\n\nExample returns:\n\n{\"code\":0,\"message\":\"ok\",\"data\":null}\n{\"code\":4,\"message\":\"no or wrong API Key\",\"data\":null}\n*/\nexports.checkToken = async () => {\n};\n\n/**\ngetChatHead(padID) returns the chatHead (last number of the last chat-message) of the pad\n\nExample returns:\n\n{code: 0, message:\"ok\", data: {chatHead: 42}}\n{code: 1, message:\"padID does not exist\", data: null}\n     @param {String} padID the id of the pad\n     @return {Promise<{chatHead: number}>} the chatHead of the pad\n*/\nexports.getChatHead = async (padID:string): Promise<{ chatHead: number; }> => {\n  // get the pad\n  const pad = await getPadSafe(padID, true);\n  return {chatHead: pad.chatHead};\n};\n\n/**\ncreateDiffHTML(padID, startRev, endRev) returns an object of diffs from 2 points in a pad\n\nExample returns:\n{\n  \"code\": 0,\n  \"message\": \"ok\",\n  \"data\": {\n    \"html\": \"...\",\n    \"authors\": [\n      \"a.HKIv23mEbachFYfH\",\n      \"\"\n    ]\n  }\n}\n{\"code\":4,\"message\":\"no or wrong API Key\",\"data\":null}\n  @param {String} padID the id of the pad\n @param {Number} startRev the start revision number\n @param {Number} endRev the end revision number\n*/\nexports.createDiffHTML = async (padID: string, startRev: number, endRev: number) => {\n  // check if startRev is a number\n  if (startRev !== undefined) {\n    startRev = checkValidRev(startRev);\n  }\n\n  // check if endRev is a number\n  if (endRev !== undefined) {\n    endRev = checkValidRev(endRev);\n  }\n\n  // get the pad\n  const pad = await getPadSafe(padID, true);\n  const headRev = pad.getHeadRevisionNumber();\n  if (startRev > headRev) startRev = headRev;\n\n  if (endRev > headRev) endRev = headRev;\n\n  let padDiff;\n  try {\n    padDiff = new PadDiff(pad, startRev, endRev);\n  } catch (e:any) {\n    throw {stop: e.message};\n  }\n\n  const html = await padDiff.getHtml();\n  const authors = await padDiff.getAuthors();\n\n  return {html, authors};\n};\n\n/* ********************\n ** GLOBAL FUNCTIONS **\n ******************** */\n\n/**\n getStats() returns an json object with some instance stats\n\n Example returns:\n\n {\"code\":0,\"message\":\"ok\",\"data\":{\"totalPads\":3,\"totalSessions\": 2,\"totalActivePads\": 1}}\n {\"code\":4,\"message\":\"no or wrong API Key\",\"data\":null}\n */\nexports.getStats = async () => {\n  const sessionInfos = padMessageHandler.sessioninfos;\n\n  const sessionKeys = Object.keys(sessionInfos);\n  // @ts-ignore\n  const activePads = new Set(Object.entries(sessionInfos).map((k) => k[1].padId));\n\n  const {padIDs} = await padManager.listAllPads();\n\n  return {\n    totalPads: padIDs.length,\n    totalSessions: sessionKeys.length,\n    totalActivePads: activePads.size,\n  };\n};\n\n/* ****************************\n ** INTERNAL HELPER FUNCTIONS *\n **************************** */\n\n// gets a pad safe\nconst getPadSafe = async (padID: string|object, shouldExist: boolean, text?:string, authorId:string = '') => {\n  // check if padID is a string\n  if (typeof padID !== 'string') {\n    throw new CustomError('padID is not a string', 'apierror');\n  }\n\n  // check if the padID maches the requirements\n  if (!padManager.isValidPadId(padID)) {\n    throw new CustomError('padID did not match requirements', 'apierror');\n  }\n\n  // check if the pad exists\n  const exists = await padManager.doesPadExists(padID);\n\n  if (!exists && shouldExist) {\n    // does not exist, but should\n    throw new CustomError('padID does not exist', 'apierror');\n  }\n\n  if (exists && !shouldExist) {\n    // does exist, but shouldn't\n    throw new CustomError('padID does already exist', 'apierror');\n  }\n\n  // pad exists, let's get it\n  return padManager.getPad(padID, text, authorId);\n};\n\n// checks if a padID is part of a group\nconst checkGroupPad = (padID: string, field: string) => {\n  // ensure this is a group pad\n  if (padID && padID.indexOf('$') === -1) {\n    throw new CustomError(\n        `You can only get/set the ${field} of pads that belong to a group`, 'apierror');\n  }\n};\n"
  },
  {
    "path": "src/node/db/AuthorManager.ts",
    "content": "'use strict';\n/**\n * The AuthorManager controlls all information about the Pad authors\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst db = require('./DB');\nconst CustomError = require('../utils/customError');\nconst hooks = require('../../static/js/pluginfw/hooks');\nimport padutils, {randomString} from \"../../static/js/pad_utils\";\n\nexports.getColorPalette = () => [\n  '#ffc7c7',\n  '#fff1c7',\n  '#e3ffc7',\n  '#c7ffd5',\n  '#c7ffff',\n  '#c7d5ff',\n  '#e3c7ff',\n  '#ffc7f1',\n  '#ffa8a8',\n  '#ffe699',\n  '#cfff9e',\n  '#99ffb3',\n  '#a3ffff',\n  '#99b3ff',\n  '#cc99ff',\n  '#ff99e5',\n  '#e7b1b1',\n  '#e9dcAf',\n  '#cde9af',\n  '#bfedcc',\n  '#b1e7e7',\n  '#c3cdee',\n  '#d2b8ea',\n  '#eec3e6',\n  '#e9cece',\n  '#e7e0ca',\n  '#d3e5c7',\n  '#bce1c5',\n  '#c1e2e2',\n  '#c1c9e2',\n  '#cfc1e2',\n  '#e0bdd9',\n  '#baded3',\n  '#a0f8eb',\n  '#b1e7e0',\n  '#c3c8e4',\n  '#cec5e2',\n  '#b1d5e7',\n  '#cda8f0',\n  '#f0f0a8',\n  '#f2f2a6',\n  '#f5a8eb',\n  '#c5f9a9',\n  '#ececbb',\n  '#e7c4bc',\n  '#daf0b2',\n  '#b0a0fd',\n  '#bce2e7',\n  '#cce2bb',\n  '#ec9afe',\n  '#edabbd',\n  '#aeaeea',\n  '#c4e7b1',\n  '#d722bb',\n  '#f3a5e7',\n  '#ffa8a8',\n  '#d8c0c5',\n  '#eaaedd',\n  '#adc6eb',\n  '#bedad1',\n  '#dee9af',\n  '#e9afc2',\n  '#f8d2a0',\n  '#b3b3e6',\n];\n\n/**\n * Checks if the author exists\n * @param {String} authorID The id of the author\n */\nexports.doesAuthorExist = async (authorID: string) => {\n  const author = await db.get(`globalAuthor:${authorID}`);\n\n  return author != null;\n};\n\n/**\n exported for backwards compatibility\n @param {String} authorID The id of the author\n  */\nexports.doesAuthorExists = exports.doesAuthorExist;\n\n\n/**\n * Returns the AuthorID for a mapper. We can map using a mapperkey,\n * so far this is token2author and mapper2author\n * @param {String} mapperkey The database key name for this mapper\n * @param {String} mapper The mapper\n */\nconst mapAuthorWithDBKey = async (mapperkey: string, mapper:string) => {\n  // try to map to an author\n  const author = await db.get(`${mapperkey}:${mapper}`);\n\n  if (author == null) {\n    // there is no author with this mapper, so create one\n    const author = await exports.createAuthor(null);\n\n    // create the token2author relation\n    await db.set(`${mapperkey}:${mapper}`, author.authorID);\n\n    // return the author\n    return author;\n  }\n\n  // there is an author with this mapper\n  // update the timestamp of this author\n  await db.setSub(`globalAuthor:${author}`, ['timestamp'], Date.now());\n\n  // return the author\n  return {authorID: author};\n};\n\n/**\n * Returns the AuthorID for a token.\n * @param {String} token The token of the author\n * @return {Promise<string|*|{authorID: string}|{authorID: *}>}\n */\nconst getAuthor4Token = async (token: string) => {\n  const author = await mapAuthorWithDBKey('token2author', token);\n\n  // return only the sub value authorID\n  return author ? author.authorID : author;\n};\n\n/**\n * Returns the AuthorID for a token.\n * @param {String} token\n * @param {Object} user\n * @return {Promise<*>}\n */\nexports.getAuthorId = async (token: string, user: object) => {\n  const context = {dbKey: token, token, user};\n  let [authorId] = await hooks.aCallFirst('getAuthorId', context);\n  if (!authorId) authorId = await getAuthor4Token(context.dbKey);\n  return authorId;\n};\n\n/**\n * Returns the AuthorID for a token.\n *\n * @deprecated Use `getAuthorId` instead.\n * @param {String} token The token\n */\nexports.getAuthor4Token = async (token: string) => {\n  padutils.warnDeprecated(\n      'AuthorManager.getAuthor4Token() is deprecated; use AuthorManager.getAuthorId() instead');\n  return await getAuthor4Token(token);\n};\n\n/**\n * Returns the AuthorID for a mapper.\n * @param {String} authorMapper The mapper\n * @param {String} name The name of the author (optional)\n */\nexports.createAuthorIfNotExistsFor = async (authorMapper: string, name: string) => {\n  const author = await mapAuthorWithDBKey('mapper2author', authorMapper);\n\n  if (name) {\n    // set the name of this author\n    await exports.setAuthorName(author.authorID, name);\n  }\n\n  return author;\n};\n\n\n/**\n * Internal function that creates the database entry for an author\n * @param {String} name The name of the author\n */\nexports.createAuthor = async (name: string) => {\n  // create the new author name\n  const author = `a.${randomString(16)}`;\n\n  // create the globalAuthors db entry\n  const authorObj = {\n    colorId: Math.floor(Math.random() * (exports.getColorPalette().length)),\n    name,\n    timestamp: Date.now(),\n  };\n\n  // set the global author db entry\n  await db.set(`globalAuthor:${author}`, authorObj);\n\n  return {authorID: author};\n};\n\n/**\n * Returns the Author Obj of the author\n * @param {String} author The id of the author\n */\nexports.getAuthor = async (author: string) => await db.get(`globalAuthor:${author}`);\n\n/**\n * Returns the color Id of the author\n * @param {String} author The id of the author\n */\nexports.getAuthorColorId = async (author: string) => await db.getSub(`globalAuthor:${author}`, ['colorId']);\n\n/**\n * Sets the color Id of the author\n * @param {String} author The id of the author\n * @param {String} colorId The color id of the author\n */\nexports.setAuthorColorId = async (author: string, colorId: string) => await db.setSub(\n    `globalAuthor:${author}`, ['colorId'], colorId);\n\n/**\n * Returns the name of the author\n * @param {String} author The id of the author\n */\nexports.getAuthorName = async (author: string) => await db.getSub(`globalAuthor:${author}`, ['name']);\n\n/**\n * Sets the name of the author\n * @param {String} author The id of the author\n * @param {String} name The name of the author\n */\nexports.setAuthorName = async (author: string, name: string) => await db.setSub(\n    `globalAuthor:${author}`, ['name'], name);\n\n/**\n * Returns an array of all pads this author contributed to\n * @param {String} authorID The id of the author\n */\nexports.listPadsOfAuthor = async (authorID: string) => {\n  /* There are two other places where this array is manipulated:\n   * (1) When the author is added to a pad, the author object is also updated\n   * (2) When a pad is deleted, each author of that pad is also updated\n   */\n\n  // get the globalAuthor\n  const author = await db.get(`globalAuthor:${authorID}`);\n\n  if (author == null) {\n    // author does not exist\n    throw new CustomError('authorID does not exist', 'apierror');\n  }\n\n  // everything is fine, return the pad IDs\n  const padIDs = Object.keys(author.padIDs || {});\n\n  return {padIDs};\n};\n\n/**\n * Adds a new pad to the list of contributions\n * @param {String} authorID The id of the author\n * @param {String} padID The id of the pad the author contributes to\n */\nexports.addPad = async (authorID: string, padID: string) => {\n  // get the entry\n  const author = await db.get(`globalAuthor:${authorID}`);\n\n  if (author == null) return;\n\n  /*\n   * ACHTUNG: padIDs can also be undefined, not just null, so it is not possible\n   * to perform a strict check here\n   */\n  if (!author.padIDs) {\n    // the entry doesn't exist so far, let's create it\n    author.padIDs = {};\n  }\n\n  // add the entry for this pad\n  author.padIDs[padID] = 1; // anything, because value is not used\n\n  // save the new element back\n  await db.set(`globalAuthor:${authorID}`, author);\n};\n\n/**\n * Removes a pad from the list of contributions\n * @param {String} authorID The id of the author\n * @param {String} padID The id of the pad the author contributes to\n */\nexports.removePad = async (authorID: string, padID: string) => {\n  const author = await db.get(`globalAuthor:${authorID}`);\n\n  if (author == null) return;\n\n  if (author.padIDs != null) {\n    // remove pad from author\n    delete author.padIDs[padID];\n    await db.set(`globalAuthor:${authorID}`, author);\n  }\n};\n"
  },
  {
    "path": "src/node/db/DB.ts",
    "content": "'use strict';\n\n/**\n * The DB Module provides a database initialized with the settings\n * provided by the settings module\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {Database, DatabaseType} from 'ueberdb2';\nimport settings from '../utils/Settings';\nimport log4js from 'log4js';\nconst stats = require('../stats')\n\nconst logger = log4js.getLogger('ueberDB');\n\n/**\n * The UeberDB Object that provides the database functions\n */\nexports.db = null;\n\n/**\n * Initializes the database with the settings provided by the settings module\n */\nexports.init = async () => {\n  exports.db = new Database(settings.dbType as DatabaseType, settings.dbSettings, null, logger);\n  await exports.db.init();\n  if (exports.db.metrics != null) {\n    for (const [metric, value] of Object.entries(exports.db.metrics)) {\n      if (typeof value !== 'number') continue;\n      stats.gauge(`ueberdb_${metric}`, () => exports.db.metrics[metric]);\n    }\n  }\n  for (const fn of ['get', 'set', 'findKeys', 'getSub', 'setSub', 'remove']) {\n    const f = exports.db[fn];\n    exports[fn] = async (...args:string[]) => await f.call(exports.db, ...args);\n    Object.setPrototypeOf(exports[fn], Object.getPrototypeOf(f));\n    Object.defineProperties(exports[fn], Object.getOwnPropertyDescriptors(f));\n  }\n};\n\nexports.shutdown = async (hookName: string, context:any) => {\n  if (exports.db != null) await exports.db.close();\n  exports.db = null;\n  logger.log('Database closed');\n};\n"
  },
  {
    "path": "src/node/db/GroupManager.ts",
    "content": "'use strict';\n/**\n * The Group Manager provides functions to manage groups in the database\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst CustomError = require('../utils/customError');\nimport {randomString} from \"../../static/js/pad_utils\";\nconst db = require('./DB');\nconst padManager = require('./PadManager');\nconst sessionManager = require('./SessionManager');\n\n/**\n * Lists all groups\n * @return {Promise<{groupIDs: string[]}>} The ids of all groups\n */\nexports.listAllGroups = async () => {\n  let groups = await db.get('groups');\n  groups = groups || {};\n\n  const groupIDs = Object.keys(groups);\n  return {groupIDs};\n};\n\n/**\n * Deletes a group and all associated pads\n * @param {String} groupID The id of the group\n * @return {Promise<void>} Resolves when the group is deleted\n */\nexports.deleteGroup = async (groupID: string): Promise<void> => {\n  const group = await db.get(`group:${groupID}`);\n\n  // ensure group exists\n  if (group == null) {\n    // group does not exist\n    throw new CustomError('groupID does not exist', 'apierror');\n  }\n\n  // iterate through all pads of this group and delete them (in parallel)\n  await Promise.all(Object.keys(group.pads).map(async (padId) => {\n    const pad = await padManager.getPad(padId);\n    await pad.remove();\n  }));\n\n  // Delete associated sessions in parallel. This should be done before deleting the group2sessions\n  // record because deleting a session updates the group2sessions record.\n  const {sessionIDs = {}} = await db.get(`group2sessions:${groupID}`) || {};\n  await Promise.all(Object.keys(sessionIDs).map(async (sessionId) => {\n    await sessionManager.deleteSession(sessionId);\n  }));\n\n  await Promise.all([\n    db.remove(`group2sessions:${groupID}`),\n    // UeberDB's setSub() method atomically reads the record, updates the appropriate property, and\n    // writes the result. Setting a property to `undefined` deletes that property (JSON.stringify()\n    // ignores such properties).\n    db.setSub('groups', [groupID], undefined),\n    ...Object.keys(group.mappings || {}).map(async (m) => await db.remove(`mapper2group:${m}`)),\n  ]);\n\n  // Remove the group record after updating the `groups` record so that the state is consistent.\n  await db.remove(`group:${groupID}`);\n};\n\n/**\n * Checks if a group exists\n * @param {String} groupID the id of the group to delete\n * @return {Promise<boolean>} Resolves to true if the group exists\n */\nexports.doesGroupExist = async (groupID: string) => {\n  // try to get the group entry\n  const group = await db.get(`group:${groupID}`);\n\n  return (group != null);\n};\n\n/**\n * Creates a new group\n * @return {Promise<{groupID: string}>} the id of the new group\n */\nexports.createGroup = async () => {\n  const groupID = `g.${randomString(16)}`;\n  await db.set(`group:${groupID}`, {pads: {}, mappings: {}});\n  // Add the group to the `groups` record after the group's individual record is created so that\n  // the state is consistent. Note: UeberDB's setSub() method atomically reads the record, updates\n  // the appropriate property, and writes the result.\n  await db.setSub('groups', [groupID], 1);\n  return {groupID};\n};\n\n/**\n * Creates a new group if it does not exist already and returns the group ID\n * @param groupMapper the mapper of the group\n * @return {Promise<{groupID: string}|{groupID: *}>} a promise that resolves to the group ID\n */\nexports.createGroupIfNotExistsFor = async (groupMapper: string|object) => {\n  if (typeof groupMapper !== 'string') {\n    throw new CustomError('groupMapper is not a string', 'apierror');\n  }\n  const groupID = await db.get(`mapper2group:${groupMapper}`);\n  if (groupID && await exports.doesGroupExist(groupID)) return {groupID};\n  const result = await exports.createGroup();\n  await Promise.all([\n    db.set(`mapper2group:${groupMapper}`, result.groupID),\n    // Remember the mapping in the group record so that it can be cleaned up when the group is\n    // deleted. Although the core Etherpad API does not support multiple mappings for the same\n    // group, the database record does support multiple mappings in case a plugin decides to extend\n    // the core Etherpad functionality. (It's also easy to implement it this way.)\n    db.setSub(`group:${result.groupID}`, ['mappings', groupMapper], 1),\n  ]);\n  return result;\n};\n\n/**\n * Creates a group pad\n * @param {String} groupID The id of the group\n * @param {String} padName The name of the pad\n * @param {String} text The text of the pad\n * @param {String} authorId The id of the author\n * @return {Promise<{padID: string}>} a promise that resolves to the id of the new pad\n */\nexports.createGroupPad = async (groupID: string, padName: string, text: string, authorId: string = ''): Promise<{ padID: string; }> => {\n  // create the padID\n  const padID = `${groupID}$${padName}`;\n\n  // ensure group exists\n  const groupExists = await exports.doesGroupExist(groupID);\n\n  if (!groupExists) {\n    throw new CustomError('groupID does not exist', 'apierror');\n  }\n\n  // ensure pad doesn't exist already\n  const padExists = await padManager.doesPadExists(padID);\n\n  if (padExists) {\n    // pad exists already\n    throw new CustomError('padName does already exist', 'apierror');\n  }\n\n  // create the pad\n  await padManager.getPad(padID, text, authorId);\n\n  // create an entry in the group for this pad\n  await db.setSub(`group:${groupID}`, ['pads', padID], 1);\n\n  return {padID};\n};\n\n/**\n * Lists all pads of a group\n * @param {String} groupID The id of the group\n * @return {Promise<{padIDs: string[]}>} a promise that resolves to the ids of all pads of the group\n */\nexports.listPads = async (groupID: string): Promise<{ padIDs: string[]; }> => {\n  const exists = await exports.doesGroupExist(groupID);\n\n  // ensure the group exists\n  if (!exists) {\n    throw new CustomError('groupID does not exist', 'apierror');\n  }\n\n  // group exists, let's get the pads\n  const result = await db.getSub(`group:${groupID}`, ['pads']);\n  const padIDs = Object.keys(result);\n\n  return {padIDs};\n};\n"
  },
  {
    "path": "src/node/db/Pad.ts",
    "content": "'use strict';\nimport {Database} from \"ueberdb2\";\nimport {AChangeSet, APool, AText} from \"../types/PadType\";\nimport {MapArrayType} from \"../types/MapType\";\n\n/**\n * The pad object, defined with joose\n */\n\nimport AttributeMap from '../../static/js/AttributeMap';\nimport {applyToAText, checkRep, copyAText, deserializeOps, makeAText, makeSplice, opsFromAText, pack, unpack} from '../../static/js/Changeset';\nimport ChatMessage from '../../static/js/ChatMessage';\nimport AttributePool from '../../static/js/AttributePool';\nconst Stream = require('../utils/Stream');\nconst assert = require('assert').strict;\nconst db = require('./DB');\nimport settings from '../utils/Settings';\nconst authorManager = require('./AuthorManager');\nconst padManager = require('./PadManager');\nconst padMessageHandler = require('../handler/PadMessageHandler');\nconst groupManager = require('./GroupManager');\nconst CustomError = require('../utils/customError');\nimport readOnlyManager from './ReadOnlyManager';\nimport randomString from '../utils/randomstring';\nconst hooks = require('../../static/js/pluginfw/hooks');\nimport pad_utils from \"../../static/js/pad_utils\";\nimport {SmartOpAssembler} from \"../../static/js/SmartOpAssembler\";\nimport {timesLimit} from \"async\";\n\n/**\n * Copied from the Etherpad source code. It converts Windows line breaks to Unix\n * line breaks and convert Tabs to spaces\n * @param {String} txt The text to clean\n * @returns {String} The cleaned text\n */\nexports.cleanText = (txt:string): string => txt.replace(/\\r\\n/g, '\\n')\n    .replace(/\\r/g, '\\n')\n    .replace(/\\t/g, '        ')\n    .replace(/\\xa0/g, ' ');\n\nclass Pad {\n  private db: Database;\n  private atext: AText;\n  private pool: AttributePool;\n  private head: number;\n    private chatHead: number;\n    private publicStatus: boolean;\n    private id: string;\n    private savedRevisions: any[];\n  /**\n   * @param id\n   * @param [database] - Database object to access this pad's records (and only this pad's records;\n   *     the shared global Etherpad database object is still used for all other pad accesses, such\n   *     as copying the pad). Defaults to the shared global Etherpad database object. This parameter\n   *     can be used to shard pad storage across multiple database backends, to put each pad in its\n   *     own database table, or to validate imported pad data before it is written to the database.\n   */\n  constructor(id:string, database = db) {\n    this.db = database;\n    this.atext = makeAText('\\n');\n    this.pool = new AttributePool();\n    this.head = -1;\n    this.chatHead = -1;\n    this.publicStatus = false;\n    this.id = id;\n    this.savedRevisions = [];\n  }\n\n  apool() {\n    return this.pool;\n  }\n\n  getHeadRevisionNumber() {\n    return this.head;\n  }\n\n  getSavedRevisionsNumber() {\n    return this.savedRevisions.length;\n  }\n\n  getSavedRevisionsList() {\n    const savedRev = this.savedRevisions.map((rev) => rev.revNum);\n    savedRev.sort((a, b) => a - b);\n    return savedRev;\n  }\n\n  getPublicStatus() {\n    return this.publicStatus;\n  }\n\n  /**\n   * Appends a new revision\n   * @param {Object} aChangeset The changeset to append to the pad\n   * @param {String} authorId The id of the author\n   * @return {Promise<number|string>}\n   */\n  async appendRevision(aChangeset:string, authorId = '') {\n    const newAText = applyToAText(aChangeset, this.atext, this.pool);\n    if (newAText.text === this.atext.text && newAText.attribs === this.atext.attribs &&\n        this.head !== -1) {\n      return this.head;\n    }\n    copyAText(newAText, this.atext);\n\n    const newRev = ++this.head;\n\n    // ex. getNumForAuthor\n    if (authorId !== '') this.pool.putAttrib(['author', authorId]);\n\n    const hook = this.head === 0 ? 'padCreate' : 'padUpdate';\n    await Promise.all([\n      // @ts-ignore\n      this.db.set(`pad:${this.id}:revs:${newRev}`, {\n        changeset: aChangeset,\n        meta: {\n          author: authorId,\n          timestamp: Date.now(),\n          ...newRev === this.getKeyRevisionNumber(newRev) ? {\n            pool: this.pool,\n            atext: this.atext,\n          } : {},\n        },\n      }),\n      this.saveToDatabase(),\n      authorId && authorManager.addPad(authorId, this.id),\n      hooks.aCallAll(hook, {\n        pad: this,\n        authorId,\n        get author() {\n          pad_utils.warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`);\n          return this.authorId;\n        },\n        set author(authorId) {\n          pad_utils.warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`);\n          this.authorId = authorId;\n        },\n        ...this.head === 0 ? {} : {\n          revs: newRev,\n          changeset: aChangeset,\n        },\n      }),\n    ]);\n    return newRev;\n  }\n\n  toJSON() {\n    const o:Pad = {...this, pool: this.pool.toJsonable()};\n    // @ts-ignore\n    delete o.db;\n    // @ts-ignore\n    delete o.id;\n    return o;\n  }\n\n  // save all attributes to the database\n  async saveToDatabase() {\n    // @ts-ignore\n    await this.db.set(`pad:${this.id}`, this);\n  }\n\n  // get time of last edit (changeset application)\n  async getLastEdit() {\n    const revNum = this.getHeadRevisionNumber();\n    // @ts-ignore\n    return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'timestamp']);\n  }\n\n  async getRevisionChangeset(revNum: number) {\n    // @ts-ignore\n    return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['changeset']);\n  }\n\n  async getRevisionAuthor(revNum: number) {\n    // @ts-ignore\n    return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'author']);\n  }\n\n  async getRevisionDate(revNum: number) {\n    // @ts-ignore\n    return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'timestamp']);\n  }\n\n  /**\n   * @param {number} revNum - Must be a key revision number (see `getKeyRevisionNumber`).\n   * @returns The attribute text stored at `revNum`.\n   */\n  async _getKeyRevisionAText(revNum: number) {\n    // @ts-ignore\n    return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'atext']);\n  }\n\n  /**\n   * Returns all authors that worked on this pad\n   * @return {[String]} The id of authors who contributed to this pad\n   */\n  getAllAuthors() {\n    const authorIds = [];\n\n    for (const key in this.pool.numToAttrib) {\n      if (this.pool.numToAttrib[key][0] === 'author' && this.pool.numToAttrib[key][1] !== '') {\n        authorIds.push(this.pool.numToAttrib[key][1]);\n      }\n    }\n\n    return authorIds;\n  }\n\n  async getInternalRevisionAText(targetRev: number) {\n    const keyRev = this.getKeyRevisionNumber(targetRev);\n    const headRev = this.getHeadRevisionNumber();\n    if (targetRev > headRev) targetRev = headRev;\n    const [keyAText, changesets] = await Promise.all([\n      this._getKeyRevisionAText(keyRev),\n      Promise.all(\n          Stream.range(keyRev + 1, targetRev + 1).map(this.getRevisionChangeset.bind(this))),\n    ]);\n    const apool = this.apool();\n    let atext = keyAText;\n    for (const cs of changesets) atext = applyToAText(cs, atext, apool);\n    return atext;\n  }\n\n  async getRevision(revNum: number) {\n    return await this.db.get(`pad:${this.id}:revs:${revNum}`);\n  }\n\n  async getAllAuthorColors() {\n    const authorIds = this.getAllAuthors();\n    const returnTable:MapArrayType<string> = {};\n    const colorPalette = authorManager.getColorPalette();\n\n    await Promise.all(\n        authorIds.map((authorId) => authorManager.getAuthorColorId(authorId).then((colorId:string) => {\n          // colorId might be a hex color or an number out of the palette\n          returnTable[authorId] = colorPalette[colorId] || colorId;\n        })));\n\n    return returnTable;\n  }\n\n  getValidRevisionRange(startRev: any, endRev:any) {\n    startRev = parseInt(startRev, 10);\n    const head = this.getHeadRevisionNumber();\n    endRev = endRev ? parseInt(endRev, 10) : head;\n\n    if (isNaN(startRev) || startRev < 0 || startRev > head) {\n      startRev = null;\n    }\n\n    if (isNaN(endRev) || endRev < startRev) {\n      endRev = null;\n    } else if (endRev > head) {\n      endRev = head;\n    }\n\n    if (startRev != null && endRev != null) {\n      return {startRev, endRev};\n    }\n    return null;\n  }\n\n  getKeyRevisionNumber(revNum: number) {\n    return Math.floor(revNum / 100) * 100;\n  }\n\n  /**\n   * @returns {string} The pad's text.\n   */\n  text(): string {\n    return this.atext.text;\n  }\n\n  /**\n   * Splices text into the pad. If the result of the splice does not end with a newline, one will be\n   * automatically appended.\n   *\n   * @param {number} start - Location in pad text to start removing and inserting characters. Must\n   *     be a non-negative integer less than or equal to `this.text().length`.\n   * @param {number} ndel - Number of characters to remove starting at `start`. Must be a\n   *     non-negative integer less than or equal to `this.text().length - start`.\n   * @param {string} ins - New text to insert at `start` (after the `ndel` characters are deleted).\n   * @param {string} [authorId] - Author ID of the user making the change (if applicable).\n   */\n  async spliceText(start:number, ndel:number, ins: string, authorId: string = '') {\n    if (start < 0) throw new RangeError(`start index must be non-negative (is ${start})`);\n    if (ndel < 0) throw new RangeError(`characters to delete must be non-negative (is ${ndel})`);\n    const orig = this.text();\n    assert(orig.endsWith('\\n'));\n    if (start + ndel > orig.length) throw new RangeError('start/delete past the end of the text');\n    ins = exports.cleanText(ins);\n    const willEndWithNewline =\n        start + ndel < orig.length || // Keeping last char (which is guaranteed to be a newline).\n        ins.endsWith('\\n') ||\n        (!ins && start > 0 && orig[start - 1] === '\\n');\n    if (!willEndWithNewline) ins += '\\n';\n    if (ndel === 0 && ins.length === 0) return;\n    const changeset = makeSplice(orig, start, ndel, ins);\n    await this.appendRevision(changeset, authorId);\n  }\n\n  /**\n   * Replaces the pad's text with new text.\n   *\n   * @param {string} newText - The pad's new text. If this string does not end with a newline, one\n   *     will be automatically appended.\n   * @param {string} [authorId] - The author ID of the user that initiated the change, if\n   *     applicable.\n   */\n  async setText(newText: string, authorId = '') {\n    await this.spliceText(0, this.text().length, newText, authorId);\n  }\n\n  /**\n   * Appends text to the pad.\n   *\n   * @param {string} newText - Text to insert just BEFORE the pad's existing terminating newline.\n   * @param {string} [authorId] - The author ID of the user that initiated the change, if\n   *     applicable.\n   */\n  async appendText(newText:string, authorId = '') {\n    await this.spliceText(this.text().length - 1, 0, newText, authorId);\n  }\n\n  /**\n   * Adds a chat message to the pad, including saving it to the database.\n   *\n   * @param {(ChatMessage|string)} msgOrText - Either a chat message object (recommended) or a\n   *     string containing the raw text of the user's chat message (deprecated).\n   * @param {?string} [authorId] - The user's author ID. Deprecated; use `msgOrText.authorId`\n   *     instead.\n   * @param {?number} [time] - Message timestamp (milliseconds since epoch). Deprecated; use\n   *     `msgOrText.time` instead.\n   */\n  async appendChatMessage(msgOrText: string| ChatMessage, authorId = null, time = null) {\n    const msg =\n          msgOrText instanceof ChatMessage ? msgOrText : new ChatMessage(msgOrText, authorId, time);\n    this.chatHead++;\n    await Promise.all([\n      // Don't save the display name in the database because the user can change it at any time. The\n      // `displayName` property will be populated with the current value when the message is read\n      // from the database.\n      this.db.set(`pad:${this.id}:chat:${this.chatHead}`, {...msg, displayName: undefined}),\n      this.saveToDatabase(),\n    ]);\n  }\n\n  /**\n   * @param {number} entryNum - ID of the desired chat message.\n   * @returns {?ChatMessage}\n   */\n  async getChatMessage(entryNum: number) {\n    const entry = await this.db.get(`pad:${this.id}:chat:${entryNum}`);\n    if (entry == null) return null;\n    const message = ChatMessage.fromObject(entry);\n    message.displayName = await authorManager.getAuthorName(message.authorId);\n    return message;\n  }\n\n  /**\n   * @param {number} start - ID of the first desired chat message.\n   * @param {number} end - ID of the last desired chat message.\n   * @returns {ChatMessage[]} Any existing messages with IDs between `start` (inclusive) and `end`\n   *     (inclusive), in order. Note: `start` and `end` form a closed interval, not a half-open\n   *     interval as is typical in code.\n   */\n  async getChatMessages(start: string, end: number) {\n    const entries =\n        await Promise.all(Stream.range(start, end + 1).map(this.getChatMessage.bind(this)));\n\n    // sort out broken chat entries\n    // it looks like in happened in the past that the chat head was\n    // incremented, but the chat message wasn't added\n    return entries.filter((entry) => {\n      const pass = (entry != null);\n      if (!pass) {\n        console.warn(`WARNING: Found broken chat entry in pad ${this.id}`);\n      }\n      return pass;\n    });\n  }\n\n  async init(text:string, authorId = '') {\n    // try to load the pad\n    const value = await this.db.get(`pad:${this.id}`);\n\n    // if this pad exists, load it\n    if (value != null) {\n      Object.assign(this, value);\n      if ('pool' in value) this.pool = new AttributePool().fromJsonable(value.pool);\n    } else {\n      if (text == null) {\n        const context = {pad: this, authorId, type: 'text', content: settings.defaultPadText};\n        await hooks.aCallAll('padDefaultContent', context);\n        if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`);\n        text = exports.cleanText(context.content);\n      }\n      const firstChangeset = makeSplice('\\n', 0, 0, text);\n      await this.appendRevision(firstChangeset, authorId);\n    }\n    await hooks.aCallAll('padLoad', {pad: this});\n  }\n\n  async copy(destinationID: string, force: boolean) {\n    // Kick everyone from this pad.\n    // This was commented due to https://github.com/ether/etherpad-lite/issues/3183.\n    // Do we really need to kick everyone out?\n    // padMessageHandler.kickSessionsFromPad(sourceID);\n\n    // flush the source pad:\n    await this.saveToDatabase();\n\n    // if it's a group pad, let's make sure the group exists.\n    const destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID);\n\n    // if force is true and already exists a Pad with the same id, remove that Pad\n    await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force);\n\n    const copyRecord = async (keySuffix: string) => {\n      const val = await this.db.get(`pad:${this.id}${keySuffix}`);\n      await db.set(`pad:${destinationID}${keySuffix}`, val);\n    };\n\n    const promises = (function* () {\n      yield copyRecord('');\n      // @ts-ignore\n      yield* Stream.range(0, this.head + 1).map((i) => copyRecord(`:revs:${i}`));\n      // @ts-ignore\n      yield* Stream.range(0, this.chatHead + 1).map((i) => copyRecord(`:chat:${i}`));\n      // @ts-ignore\n      yield this.copyAuthorInfoToDestinationPad(destinationID);\n      if (destGroupID) yield db.setSub(`group:${destGroupID}`, ['pads', destinationID], 1);\n    }).call(this);\n    for (const p of new Stream(promises).batch(100).buffer(99)) await p;\n\n    // Initialize the new pad (will update the listAllPads cache)\n    const dstPad = await padManager.getPad(destinationID, null);\n\n    // let the plugins know the pad was copied\n    await hooks.aCallAll('padCopy', {\n      get originalPad() {\n        pad_utils.warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead');\n        return this.srcPad;\n      },\n      get destinationID() {\n        pad_utils.warnDeprecated(\n            'padCopy destinationID context property is deprecated; use dstPad.id instead');\n        return this.dstPad.id;\n      },\n      srcPad: this,\n      dstPad,\n    });\n\n    return {padID: destinationID};\n  }\n\n  async checkIfGroupExistAndReturnIt(destinationID: string) {\n    let destGroupID:false|string = false;\n\n    if (destinationID.indexOf('$') >= 0) {\n      destGroupID = destinationID.split('$')[0];\n      const groupExists = await groupManager.doesGroupExist(destGroupID);\n\n      // group does not exist\n      if (!groupExists) {\n        throw new CustomError('groupID does not exist for destinationID', 'apierror');\n      }\n    }\n    return destGroupID;\n  }\n\n  async removePadIfForceIsTrueAndAlreadyExist(destinationID: string, force: boolean|string) {\n    // if the pad exists, we should abort, unless forced.\n    const exists = await padManager.doesPadExist(destinationID);\n\n    // allow force to be a string\n    if (typeof force === 'string') {\n      force = (force.toLowerCase() === 'true');\n    } else {\n      force = !!force;\n    }\n\n    if (exists) {\n      if (!force) {\n        console.error('erroring out without force');\n        throw new CustomError('destinationID already exists', 'apierror');\n      }\n\n      // exists and forcing\n      const pad = await padManager.getPad(destinationID);\n      await pad.remove();\n    }\n  }\n\n  async copyAuthorInfoToDestinationPad(destinationID: string) {\n    // add the new sourcePad to all authors who contributed to the old one\n    await Promise.all(this.getAllAuthors().map(\n        (authorID) => authorManager.addPad(authorID, destinationID)));\n  }\n\n  async copyPadWithoutHistory(destinationID: string, force: string|boolean, authorId = '') {\n    // flush the source pad\n    this.saveToDatabase();\n\n    // if it's a group pad, let's make sure the group exists.\n    const destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID);\n\n    // if force is true and already exists a Pad with the same id, remove that Pad\n    await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force);\n\n    await this.copyAuthorInfoToDestinationPad(destinationID);\n\n    // Group pad? Add it to the group's list\n    if (destGroupID) {\n      await db.setSub(`group:${destGroupID}`, ['pads', destinationID], 1);\n    }\n\n    // initialize the pad with a new line to avoid getting the defaultText\n    const dstPad = await padManager.getPad(destinationID, '\\n', authorId);\n    dstPad.pool = this.pool.clone();\n\n    const oldAText = this.atext;\n\n    // based on Changeset.makeSplice\n    const assem = new SmartOpAssembler();\n    for (const op of opsFromAText(oldAText)) assem.append(op);\n    assem.endDocument();\n\n    // although we have instantiated the dstPad with '\\n', an additional '\\n' is\n    // added internally, so the pad text on the revision 0 is \"\\n\\n\"\n    const oldLength = 2;\n\n    const newLength = assem.getLengthChange();\n    const newText = oldAText.text;\n\n    // create a changeset that removes the previous text and add the newText with\n    // all atributes present on the source pad\n    const changeset = pack(oldLength, newLength, assem.toString(), newText);\n    dstPad.appendRevision(changeset, authorId);\n\n    await hooks.aCallAll('padCopy', {\n      get originalPad() {\n        pad_utils.warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead');\n        return this.srcPad;\n      },\n      get destinationID() {\n        pad_utils.warnDeprecated(\n            'padCopy destinationID context property is deprecated; use dstPad.id instead');\n        return this.dstPad.id;\n      },\n      srcPad: this,\n      dstPad,\n    });\n\n    return {padID: destinationID};\n  }\n\n  async remove() {\n    const padID = this.id;\n    const p = [];\n\n    // kick everyone from this pad\n    padMessageHandler.kickSessionsFromPad(padID);\n\n    // delete all relations - the original code used async.parallel but\n    // none of the operations except getting the group depended on callbacks\n    // so the database operations here are just started and then left to\n    // run to completion\n\n    // is it a group pad? -> delete the entry of this pad in the group\n    if (padID.indexOf('$') >= 0) {\n      // it is a group pad\n      const groupID = padID.substring(0, padID.indexOf('$'));\n      const group = await db.get(`group:${groupID}`);\n\n      // remove the pad entry\n      delete group.pads[padID];\n\n      // set the new value\n      p.push(db.set(`group:${groupID}`, group));\n    }\n\n    // remove the readonly entries\n    p.push(readOnlyManager.getReadOnlyId(padID).then(async (readonlyID: string) => {\n      await db.remove(`readonly2pad:${readonlyID}`);\n    }));\n    p.push(db.remove(`pad2readonly:${padID}`));\n\n    // delete all chat messages\n    // @ts-ignore\n    p.push(timesLimit(this.chatHead + 1, 500, async (i: string) => {\n      await this.db.remove(`pad:${this.id}:chat:${i}`, null);\n    }));\n\n    // delete all revisions\n    // @ts-ignore\n    p.push(timesLimit(this.head + 1, 500, async (i: string) => {\n      await this.db.remove(`pad:${this.id}:revs:${i}`, null);\n    }));\n\n    // remove pad from all authors who contributed\n    this.getAllAuthors().forEach((authorId) => {\n      p.push(authorManager.removePad(authorId, padID));\n    });\n\n    // delete the pad entry and delete pad from padManager\n    p.push(padManager.removePad(padID));\n    p.push(hooks.aCallAll('padRemove', {\n      get padID() {\n        pad_utils.warnDeprecated('padRemove padID context property is deprecated; use pad.id instead');\n        return this.pad.id;\n      },\n      pad: this,\n    }));\n    await Promise.all(p);\n  }\n\n  // set in db\n  async setPublicStatus(publicStatus: boolean) {\n    this.publicStatus = publicStatus;\n    await this.saveToDatabase();\n  }\n\n  async addSavedRevision(revNum: string, savedById: string, label: string) {\n    // if this revision is already saved, return silently\n    for (const i in this.savedRevisions) {\n      if (this.savedRevisions[i] && this.savedRevisions[i].revNum === revNum) {\n        return;\n      }\n    }\n\n    // build the saved revision object\n    const savedRevision:MapArrayType<any> = {};\n    savedRevision.revNum = revNum;\n    savedRevision.savedById = savedById;\n    savedRevision.label = label || `Revision ${revNum}`;\n    savedRevision.timestamp = Date.now();\n    savedRevision.id = randomString(10);\n\n    // save this new saved revision\n    this.savedRevisions.push(savedRevision);\n    await this.saveToDatabase();\n  }\n\n  getSavedRevisions() {\n    return this.savedRevisions;\n  }\n\n  /**\n   * Asserts that all pad data is consistent. Throws if inconsistent.\n   */\n  async check() {\n    assert(this.id != null);\n    assert.equal(typeof this.id, 'string');\n\n    const head = this.getHeadRevisionNumber();\n    assert(head != null);\n    assert(Number.isInteger(head));\n    assert(head >= -1);\n\n    const savedRevisionsList = this.getSavedRevisionsList();\n    assert(Array.isArray(savedRevisionsList));\n    assert.equal(this.getSavedRevisionsNumber(), savedRevisionsList.length);\n    let prevSavedRev = null;\n    for (const rev of savedRevisionsList) {\n      assert(rev != null);\n      assert(Number.isInteger(rev));\n      assert(rev >= 0);\n      assert(rev <= head);\n      assert(prevSavedRev == null || rev > prevSavedRev);\n      prevSavedRev = rev;\n    }\n    const savedRevisions = this.getSavedRevisions();\n    assert(Array.isArray(savedRevisions));\n    assert.equal(savedRevisions.length, savedRevisionsList.length);\n    const savedRevisionsIds = new Set();\n    for (const savedRev of savedRevisions) {\n      assert(savedRev != null);\n      assert.equal(typeof savedRev, 'object');\n      assert(savedRevisionsList.includes(savedRev.revNum));\n      assert(savedRev.id != null);\n      assert.equal(typeof savedRev.id, 'string');\n      assert(!savedRevisionsIds.has(savedRev.id));\n      savedRevisionsIds.add(savedRev.id);\n    }\n\n    const pool = this.apool();\n    assert(pool instanceof AttributePool);\n    await pool.check();\n\n    const authorIds = new Set();\n    pool.eachAttrib((k, v) => {\n      if (k === 'author' && v) authorIds.add(v);\n    });\n    const revs = Stream.range(0, head + 1)\n        .map(async (r: number) => {\n          const isKeyRev = r === this.getKeyRevisionNumber(r);\n          try {\n            return await Promise.all([\n              r,\n              this.getRevisionChangeset(r),\n              this.getRevisionAuthor(r),\n              this.getRevisionDate(r),\n              isKeyRev,\n              isKeyRev ? this._getKeyRevisionAText(r) : null,\n            ]);\n          } catch (err:any) {\n            err.message = `(pad ${this.id} revision ${r}) ${err.message}`;\n            throw err;\n          }\n        })\n        .batch(100).buffer(99);\n    let atext = makeAText('\\n');\n    for await (const [r, changeset, authorId, timestamp, isKeyRev, keyAText] of revs) {\n      try {\n        assert(authorId != null);\n        assert.equal(typeof authorId, 'string');\n        if (authorId) authorIds.add(authorId);\n        assert(timestamp != null);\n        assert.equal(typeof timestamp, 'number');\n        assert(timestamp > 0);\n        assert(changeset != null);\n        assert.equal(typeof changeset, 'string');\n        checkRep(changeset);\n        const unpacked = unpack(changeset);\n        let text = atext.text;\n        for (const op of deserializeOps(unpacked.ops)) {\n          if (['=', '-'].includes(op.opcode)) {\n            assert(text.length >= op.chars);\n            const consumed = text.slice(0, op.chars);\n            const nlines = (consumed.match(/\\n/g) || []).length;\n            assert.equal(op.lines, nlines);\n            if (op.lines > 0) assert(consumed.endsWith('\\n'));\n            text = text.slice(op.chars);\n          }\n          assert.equal(op.attribs, AttributeMap.fromString(op.attribs, pool).toString());\n        }\n        atext = applyToAText(changeset, atext, pool);\n        if (isKeyRev) assert.deepEqual(keyAText, atext);\n      } catch (err:any) {\n        err.message = `(pad ${this.id} revision ${r}) ${err.message}`;\n        throw err;\n      }\n    }\n    assert.equal(this.text(), atext.text);\n    assert.deepEqual(this.atext, atext);\n    assert.deepEqual(this.getAllAuthors().sort(), [...authorIds].sort());\n\n    assert(this.chatHead != null);\n    assert(Number.isInteger(this.chatHead));\n    assert(this.chatHead >= -1);\n    const chats = Stream.range(0, this.chatHead + 1)\n        .map(async (c: number) => {\n          try {\n            const msg = await this.getChatMessage(c);\n            assert(msg != null);\n            assert(msg instanceof ChatMessage);\n          } catch (err:any) {\n            err.message = `(pad ${this.id} chat message ${c}) ${err.message}`;\n            throw err;\n          }\n        })\n        .batch(100).buffer(99);\n    for (const p of chats) await p;\n\n    await hooks.aCallAll('padCheck', {pad: this});\n  }\n}\nexports.Pad = Pad;\n"
  },
  {
    "path": "src/node/db/PadManager.ts",
    "content": "'use strict';\n/**\n * The Pad Manager is a Factory for pad Objects\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {MapArrayType} from \"../types/MapType\";\nimport {PadType} from \"../types/PadType\";\n\nconst CustomError = require('../utils/customError');\nconst Pad = require('../db/Pad');\nconst db = require('./DB');\nimport settings from '../utils/Settings';\n\n/**\n * A cache of all loaded Pads.\n *\n * Provides \"get\" and \"set\" functions,\n * which should be used instead of indexing with brackets. These prepend a\n * colon to the key, to avoid conflicting with built-in Object methods or with\n * these functions themselves.\n *\n * If this is needed in other places, it would be wise to make this a prototype\n * that's defined somewhere more sensible.\n */\nconst globalPads:MapArrayType<any> = {\n  get(name: string)\n  {\n    return this[`:${name}`];\n    },\n  set(name: string, value: any)\n  {\n    this[`:${name}`] = value;\n  },\n  remove(name: string) {\n    delete this[`:${name}`];\n  },\n};\n\n/**\n * A cache of the list of all pads.\n *\n * Updated without db access as new pads are created/old ones removed.\n */\nconst padList = new class {\n  private _cachedList: string[] | null;\n    private _list: Set<string>;\n    private _loaded: Promise<void> | null;\n  constructor() {\n    this._cachedList = null;\n    this._list = new Set();\n    this._loaded = null;\n  }\n\n  /**\n   * Returns all pads in alphabetical order as array.\n   * @returns {Promise<string[]>} A promise that resolves to an array of pad IDs.\n   */\n  async getPads() {\n    if (!this._loaded) {\n      this._loaded = (async () => {\n        const dbData = await db.findKeys('pad:*', '*:*:*');\n        if (dbData == null) return;\n        for (const val of dbData) this.addPad(val.replace(/^pad:/, ''));\n      })();\n    }\n    await this._loaded;\n    if (!this._cachedList) this._cachedList = [...this._list].sort();\n    return this._cachedList;\n  }\n\n  addPad(name: string) {\n    if (this._list.has(name)) return;\n    this._list.add(name);\n    this._cachedList = null;\n  }\n\n  removePad(name: string) {\n    if (!this._list.has(name)) return;\n    this._list.delete(name);\n    this._cachedList = null;\n  }\n}();\n\n// initialises the all-knowing data structure\n\n/**\n * Returns a Pad Object with the callback\n * @param id A String with the id of the pad\n * @param {string} [text] - Optional initial pad text if creating a new pad.\n * @param {string} [authorId] - Optional author ID of the user that initiated the pad creation (if\n *     applicable).\n */\nexports.getPad = async (id: string, text?: string|null, authorId:string|null = ''):Promise<PadType> => {\n  // check if this is a valid padId\n  if (!exports.isValidPadId(id)) {\n    throw new CustomError(`${id} is not a valid padId`, 'apierror');\n  }\n\n  // check if this is a valid text\n  if (text != null) {\n    // check if text is a string\n    if (typeof text !== 'string') {\n      throw new CustomError('text is not a string', 'apierror');\n    }\n\n    // check if text is less than 100k chars\n    if (text.length > 100000) {\n      throw new CustomError('text must be less than 100k chars', 'apierror');\n    }\n  }\n\n  let pad = globalPads.get(id);\n\n  // return pad if it's already loaded\n  if (pad != null) {\n    return pad;\n  }\n\n  // try to load pad\n  pad = new Pad.Pad(id);\n\n  // initialize the pad\n  await pad.init(text, authorId);\n  globalPads.set(id, pad);\n  padList.addPad(id);\n\n  return pad;\n};\n\nexports.listAllPads = async () => {\n  const padIDs = await padList.getPads();\n\n  return {padIDs};\n};\n\n\n\n\n// checks if a pad exists\nexports.doesPadExist = async (padId: string) => {\n  const value = await db.get(`pad:${padId}`);\n\n  return (value != null && value.atext);\n};\n\n// alias for backwards compatibility\nexports.doesPadExists = exports.doesPadExist;\n\n/**\n * An array of padId transformations. These represent changes in pad name policy over\n * time, and allow us to \"play back\" these changes so legacy padIds can be found.\n */\nconst padIdTransforms = [\n  [/\\s+/g, '_'],\n  [/:+/g, '_'],\n];\n\n// returns a sanitized padId, respecting legacy pad id formats\nexports.sanitizePadId = async (padId: string) => {\n  for (let i = 0, n = padIdTransforms.length; i < n; ++i) {\n    const exists = await exports.doesPadExist(padId);\n\n    if (exists) {\n      return padId;\n    }\n\n    const [from, to] = padIdTransforms[i];\n\n    // @ts-ignore\n    padId = padId.replace(from, to);\n  }\n\n  if (settings.lowerCasePadIds) padId = padId.toLowerCase();\n\n  // we're out of possible transformations, so just return it\n  return padId;\n};\n\nexports.isValidPadId = (padId: string) => /^(g.[a-zA-Z0-9]{16}\\$)?[^$]{1,50}$/.test(padId);\n\n/**\n * Removes the pad from database and unloads it.\n */\nexports.removePad = async (padId: string) => {\n  const p = db.remove(`pad:${padId}`);\n  exports.unloadPad(padId);\n  padList.removePad(padId);\n  await p;\n};\n\n// removes a pad from the cache\nexports.unloadPad = (padId: string) => {\n  globalPads.remove(padId);\n};\n"
  },
  {
    "path": "src/node/db/ReadOnlyManager.ts",
    "content": "'use strict';\n/**\n * The ReadOnlyManager manages the database and rendering releated to read only pads\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\nconst db = require('./DB');\nimport randomString from '../utils/randomstring';\n\n\n/**\n * checks if the id pattern matches a read-only pad id\n * @param {String} id the pad's id\n * @return {Boolean} true if the id is readonly\n */\nconst isReadOnlyId = (id:string) => id.startsWith('r.');\n\n/**\n * returns a read only id for a pad\n * @param {String} padId the id of the pad\n * @return {String} the read only id\n */\nconst getReadOnlyId = async (padId:string) => {\n  // check if there is a pad2readonly entry\n  let readOnlyId = await db.get(`pad2readonly:${padId}`);\n\n  // there is no readOnly Entry in the database, let's create one\n  if (readOnlyId == null) {\n    readOnlyId = `r.${randomString(16)}`;\n    await Promise.all([\n      db.set(`pad2readonly:${padId}`, readOnlyId),\n      db.set(`readonly2pad:${readOnlyId}`, padId),\n    ]);\n  }\n\n  return readOnlyId;\n};\n\n/**\n * returns the padId for a read only id\n * @param {String} readOnlyId read only id\n * @return {String} the padId\n */\nconst getPadId = async (readOnlyId:string) => await db.get(`readonly2pad:${readOnlyId}`);\n\n/**\n * returns the padId and readonlyPadId in an object for any id\n * @param {String} id read only id or real pad id\n * @return {Object} an object with the padId and readonlyPadId\n */\nconst getIds = async (id:string) => {\n  const readonly = isReadOnlyId(id);\n\n  // Might be null, if this is an unknown read-only id\n  const readOnlyPadId = readonly ? id : await getReadOnlyId(id);\n  const padId = readonly ? await getPadId(id) : id;\n\n  return {readOnlyPadId, padId, readonly};\n};\n\nexport default {\n  isReadOnlyId,\n  getReadOnlyId,\n  getPadId,\n  getIds,\n  // Export for testing purposes\n  __getReadOnlyId: getReadOnlyId, // eslint-disable-line no-underscore-dangle\n  __getPadId: getPadId, // eslint-disable-line no-underscore-dangle\n}\n"
  },
  {
    "path": "src/node/db/SecurityManager.ts",
    "content": "'use strict';\n/**\n * Controls the security of pad access\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {UserSettingsObject} from \"../types/UserSettingsObject\";\n\nconst authorManager = require('./AuthorManager');\nconst hooks = require('../../static/js/pluginfw/hooks');\nconst padManager = require('./PadManager');\nimport readOnlyManager from './ReadOnlyManager';\nconst sessionManager = require('./SessionManager');\nimport settings from '../utils/Settings';\nconst webaccess = require('../hooks/express/webaccess');\nconst log4js = require('log4js');\nconst authLogger = log4js.getLogger('auth');\nimport padutils from '../../static/js/pad_utils'\n\nconst DENY = Object.freeze({accessStatus: 'deny'});\n\n/**\n * Determines whether the user can access a pad.\n *\n * @param padID identifies the pad the user wants to access.\n * @param sessionCookie identifies the sessions the user created via the HTTP API, if any.\n *     Note: The term \"session\" used here is unrelated to express-session.\n * @param token is a random token of the form t.randomstring_of_length_20 generated by the client\n *     when using the web UI (not the HTTP API). This token is only used if settings.requireSession\n *     is false and the user is accessing a public pad. If there is not an author already associated\n *     with this token then a new author object is created (including generating an author ID) and\n *     associated with this token.\n * @param userSettings is the settings.users[username] object (or equivalent from an authn plugin).\n * @return {accessStatus: grant|deny, authorID: a.xxxxxx}. The caller must use the author ID\n *     returned in this object when making any changes associated with the author.\n *\n * WARNING: Tokens and session IDs MUST be kept secret, otherwise users will be able to impersonate\n * each other (which might allow them to gain privileges).\n * @param {String} padID\n * @param {String} sessionCookie\n * @param {String} token\n * @param {Object} userSettings\n * @return {DENY|{accessStatus: String, authorID: String}}\n */\nexports.checkAccess = async (padID:string, sessionCookie:string, token:string, userSettings:UserSettingsObject) => {\n  if (!padID) {\n    authLogger.debug('access denied: missing padID');\n    return DENY;\n  }\n\n  let canCreate = !settings.editOnly;\n\n  if (readOnlyManager.isReadOnlyId(padID)) {\n    canCreate = false;\n    padID = await readOnlyManager.getPadId(padID);\n    if (padID == null) {\n      authLogger.debug('access denied: read-only pad ID for a pad that does not exist');\n      return DENY;\n    }\n  }\n\n  // Authentication and authorization checks.\n  if (settings.loadTest) {\n    console.warn(\n        'bypassing socket.io authentication and authorization checks due to settings.loadTest');\n  } else if (settings.requireAuthentication) {\n    if (userSettings == null) {\n      authLogger.debug('access denied: authentication is required');\n      return DENY;\n    }\n    if (userSettings.canCreate != null && !userSettings.canCreate) canCreate = false;\n    if (userSettings.readOnly) canCreate = false;\n    // Note: userSettings.padAuthorizations should still be populated even if\n    // settings.requireAuthorization is false.\n    const padAuthzs = userSettings.padAuthorizations || {};\n    const level = webaccess.normalizeAuthzLevel(padAuthzs[padID]);\n    if (!level) {\n      authLogger.debug('access denied: unauthorized');\n      return DENY;\n    }\n    if (level !== 'create') canCreate = false;\n  }\n\n  // allow plugins to deny access\n  const isFalse = (x:boolean) => x === false;\n  if (hooks.callAll('onAccessCheck', {padID, token, sessionCookie}).some(isFalse)) {\n    authLogger.debug('access denied: an onAccessCheck hook function returned false');\n    return DENY;\n  }\n\n  const padExists = await padManager.doesPadExist(padID);\n  if (!padExists && !canCreate) {\n    authLogger.debug('access denied: user attempted to create a pad, which is prohibited');\n    return DENY;\n  }\n\n  const sessionAuthorID = await sessionManager.findAuthorID(padID.split('$')[0], sessionCookie);\n  if (settings.requireSession && !sessionAuthorID) {\n    authLogger.debug('access denied: HTTP API session is required');\n    return DENY;\n  }\n  if (!sessionAuthorID && token != null && !padutils.isValidAuthorToken(token)) {\n    // The author token should be kept secret, so do not log it.\n    authLogger.debug('access denied: invalid author token');\n    return DENY;\n  }\n\n  const grant = {\n    accessStatus: 'grant',\n    authorID: sessionAuthorID || await authorManager.getAuthorId(token, userSettings),\n  };\n\n  if (!padID.includes('$')) {\n    // Only group pads can be private, so there is nothing more to check for this non-group pad.\n    return grant;\n  }\n\n  if (!padExists) {\n    if (sessionAuthorID == null) {\n      authLogger.debug('access denied: must have an HTTP API session to create a group pad');\n      return DENY;\n    }\n    // Creating a group pad, so there is no public status to check.\n    return grant;\n  }\n\n  const pad = await padManager.getPad(padID);\n\n  if (!pad.getPublicStatus() && sessionAuthorID == null) {\n    authLogger.debug('access denied: must have an HTTP API session to access private group pads');\n    return DENY;\n  }\n\n  return grant;\n};\n"
  },
  {
    "path": "src/node/db/SessionManager.ts",
    "content": "'use strict';\n/**\n * The Session Manager provides functions to manage session in the database,\n * it only provides session management for sessions created by the API\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst CustomError = require('../utils/customError');\nimport {firstSatisfies} from '../utils/promises';\nimport randomString from '../utils/randomstring';\nconst db = require('./DB');\nconst groupManager = require('./GroupManager');\nconst authorManager = require('./AuthorManager');\n\n/**\n * Finds the author ID for a session with matching ID and group.\n *\n * @param groupID identifies the group the session is bound to.\n * @param sessionCookie contains a comma-separated list of IDs identifying the sessions to search.\n * @return If there is a session that is not expired, has an ID matching one of the session IDs in\n *     sessionCookie, and is bound to a group with the given ID, then this returns the author ID\n *     bound to the session. Otherwise, returns undefined.\n */\nexports.findAuthorID = async (groupID:string, sessionCookie: string) => {\n  if (!sessionCookie) return undefined;\n  /*\n   * Sometimes, RFC 6265-compliant web servers may send back a cookie whose\n   * value is enclosed in double quotes, such as:\n   *\n   *   Set-Cookie: sessionCookie=\"s.37cf5299fbf981e14121fba3a588c02b,\n   * s.2b21517bf50729d8130ab85736a11346\"; Version=1; Path=/; Domain=localhost; Discard\n   *\n   * Where the double quotes at the start and the end of the header value are\n   * just delimiters. This is perfectly legal: Etherpad parsing logic should\n   * cope with that, and remove the quotes early in the request phase.\n   *\n   * Somehow, this does not happen, and in such cases the actual value that\n   * sessionCookie ends up having is:\n   *\n   *     sessionCookie = '\"s.37cf5299fbf981e14121fba3a588c02b,s.2b21517bf50729d8130ab85736a11346\"'\n   *\n   * As quick measure, let's strip the double quotes (when present).\n   * Note that here we are being minimal, limiting ourselves to just removing\n   * quotes at the start and the end of the string.\n   *\n   * Fixes #3819.\n   * Also, see #3820.\n   */\n  const sessionIDs = sessionCookie.replace(/^\"|\"$/g, '').split(',');\n  const sessionInfoPromises = sessionIDs.map(async (id) => {\n    try {\n      return await exports.getSessionInfo(id);\n    } catch (err:any) {\n      if (err.message === 'sessionID does not exist') {\n        console.debug(`SessionManager getAuthorID: no session exists with ID ${id}`);\n      } else {\n        throw err;\n      }\n    }\n    return undefined;\n  });\n  const now = Math.floor(Date.now() / 1000);\n  const isMatch = (si: {\n    groupID: string;\n    validUntil: number;\n  }|null) => (si != null && si.groupID === groupID && now < si.validUntil);\n  const sessionInfo = await firstSatisfies(sessionInfoPromises, isMatch) as any;\n  if (sessionInfo == null) return undefined;\n  return sessionInfo.authorID;\n};\n\n/**\n * Checks if a session exists\n * @param {String} sessionID The id of the session\n * @return {Promise<boolean>} Resolves to true if the session exists\n */\nexports.doesSessionExist = async (sessionID: string) => {\n  // check if the database entry of this session exists\n  const session = await db.get(`session:${sessionID}`);\n  return (session != null);\n};\n\n/**\n * Creates a new session between an author and a group\n * @param {String} groupID The id of the group\n * @param {String} authorID The id of the author\n * @param {Number} validUntil The unix timestamp when the session should expire\n * @return {Promise<{sessionID: string}>} the id of the new session\n */\nexports.createSession = async (groupID: string, authorID: string, validUntil: number) => {\n  // check if the group exists\n  const groupExists = await groupManager.doesGroupExist(groupID);\n  if (!groupExists) {\n    throw new CustomError('groupID does not exist', 'apierror');\n  }\n\n  // check if the author exists\n  const authorExists = await authorManager.doesAuthorExist(authorID);\n  if (!authorExists) {\n    throw new CustomError('authorID does not exist', 'apierror');\n  }\n\n  // try to parse validUntil if it's not a number\n  if (typeof validUntil !== 'number') {\n    validUntil = parseInt(validUntil);\n  }\n\n  // check it's a valid number\n  if (isNaN(validUntil)) {\n    throw new CustomError('validUntil is not a number', 'apierror');\n  }\n\n  // ensure this is not a negative number\n  if (validUntil < 0) {\n    throw new CustomError('validUntil is a negative number', 'apierror');\n  }\n\n  // ensure this is not a float value\n  if (!isInt(validUntil)) {\n    throw new CustomError('validUntil is a float value', 'apierror');\n  }\n\n  // check if validUntil is in the future\n  if (validUntil < Math.floor(Date.now() / 1000)) {\n    throw new CustomError('validUntil is in the past', 'apierror');\n  }\n\n  // generate sessionID\n  const sessionID = `s.${randomString(16)}`;\n\n  // set the session into the database\n  await db.set(`session:${sessionID}`, {groupID, authorID, validUntil});\n\n  // Add the session ID to the group2sessions and author2sessions records after creating the session\n  // so that the state is consistent.\n  await Promise.all([\n    // UeberDB's setSub() method atomically reads the record, updates the appropriate (sub)object\n    // property, and writes the result.\n    db.setSub(`group2sessions:${groupID}`, ['sessionIDs', sessionID], 1),\n    db.setSub(`author2sessions:${authorID}`, ['sessionIDs', sessionID], 1),\n  ]);\n\n  return {sessionID};\n};\n\n/**\n * Returns the sessioninfos for a session\n * @param {String} sessionID The id of the session\n * @return {Promise<Object>} the sessioninfos\n */\nexports.getSessionInfo = async (sessionID:string) => {\n  // check if the database entry of this session exists\n  const session = await db.get(`session:${sessionID}`);\n\n  if (session == null) {\n    // session does not exist\n    throw new CustomError('sessionID does not exist', 'apierror');\n  }\n\n  // everything is fine, return the sessioninfos\n  return session;\n};\n\n/**\n * Deletes a session\n * @param {String} sessionID The id of the session\n * @return {Promise<void>} Resolves when the session is deleted\n */\nexports.deleteSession = async (sessionID:string) => {\n  // ensure that the session exists\n  const session = await db.get(`session:${sessionID}`);\n  if (session == null) {\n    throw new CustomError('sessionID does not exist', 'apierror');\n  }\n\n  // everything is fine, use the sessioninfos\n  const groupID = session.groupID;\n  const authorID = session.authorID;\n\n  await Promise.all([\n    // UeberDB's setSub() method atomically reads the record, updates the appropriate (sub)object\n    // property, and writes the result. Setting a property to `undefined` deletes that property\n    // (JSON.stringify() ignores such properties).\n    db.setSub(`group2sessions:${groupID}`, ['sessionIDs', sessionID], undefined),\n    db.setSub(`author2sessions:${authorID}`, ['sessionIDs', sessionID], undefined),\n  ]);\n\n  // Delete the session record after updating group2sessions and author2sessions so that the state\n  // is consistent.\n  await db.remove(`session:${sessionID}`);\n};\n\n/**\n * Returns an array of all sessions of a group\n * @param {String} groupID The id of the group\n * @return {Promise<Object>} The sessioninfos of all sessions of this group\n */\nexports.listSessionsOfGroup = async (groupID: string) => {\n  // check that the group exists\n  const exists = await groupManager.doesGroupExist(groupID);\n  if (!exists) {\n    throw new CustomError('groupID does not exist', 'apierror');\n  }\n\n  const sessions = await listSessionsWithDBKey(`group2sessions:${groupID}`);\n  return sessions;\n};\n\n/**\n * Returns an array of all sessions of an author\n * @param {String} authorID The id of the author\n * @return {Promise<Object>} The sessioninfos of all sessions of this author\n */\nexports.listSessionsOfAuthor = async (authorID: string) => {\n  // check that the author exists\n  const exists = await authorManager.doesAuthorExist(authorID);\n  if (!exists) {\n    throw new CustomError('authorID does not exist', 'apierror');\n  }\n\n  return await listSessionsWithDBKey(`author2sessions:${authorID}`);\n};\n\n// this function is basically the code listSessionsOfAuthor and listSessionsOfGroup has in common\n// required to return null rather than an empty object if there are none\n/**\n * Returns an array of all sessions of a group\n * @param {String} dbkey The db key to use to get the sessions\n * @return {Promise<*>}\n */\nconst listSessionsWithDBKey = async (dbkey: string) => {\n  // get the group2sessions entry\n  const sessionObject = await db.get(dbkey);\n  const sessions = sessionObject ? sessionObject.sessionIDs : null;\n\n  // iterate through the sessions and get the sessioninfos\n  for (const sessionID of Object.keys(sessions || {})) {\n    try {\n      sessions[sessionID] = await exports.getSessionInfo(sessionID);\n    } catch (err:any) {\n      if (err.name === 'apierror') {\n        console.warn(`Found bad session ${sessionID} in ${dbkey}`);\n        sessions[sessionID] = null;\n      } else {\n        throw err;\n      }\n    }\n  }\n\n  return sessions;\n};\n\n\n/**\n * checks if a number is an int\n * @param {number|string} value\n * @return {boolean} If the value is an integer\n */\n// @ts-ignore\nconst isInt = (value:number|string): boolean => (parseFloat(value) === parseInt(value)) && !isNaN(value);\n"
  },
  {
    "path": "src/node/db/SessionStore.ts",
    "content": "// @ts-nocheck\n\n\nconst DB = require('./DB');\nimport expressSession from 'express-session'\n\nconst log4js = require('log4js');\nconst util = require('util');\n\nconst logger = log4js.getLogger('SessionStore');\n\nclass SessionStore extends expressSession.Store {\n  /**\n   * @param {?number} [refresh] - How often (in milliseconds) `touch()` will update a session's\n   *     database record with the cookie's latest expiration time. If the difference between the\n   *     value saved in the database and the actual value is greater than this amount, the database\n   *     record will be updated to reflect the actual value. Use this to avoid continual database\n   *     writes caused by express-session's rolling=true feature (see\n   *     https://github.com/expressjs/session#rolling). A good value is high enough to keep query\n   *     rate low but low enough to avoid annoying premature logouts (session invalidation) if\n   *     Etherpad is restarted. Use `null` to prevent `touch()` from ever updating the record.\n   *     Ignored if the cookie does not expire.\n   */\n  constructor(refresh: number | null = null) {\n    super();\n    this._refresh = refresh;\n    // Maps session ID to an object with the following properties:\n    //   - `db`: Session expiration as recorded in the database (ms since epoch, not a Date).\n    //   - `real`: Actual session expiration (ms since epoch, not a Date). Always greater than or\n    //     equal to `db`.\n    //   - `timeout`: Timeout ID for a timeout that will clean up the database record.\n    this._expirations = new Map();\n  }\n\n  shutdown() {\n    for (const {timeout} of this._expirations.values()) clearTimeout(timeout);\n  }\n\n  async _updateExpirations(sid: string, sess: any, updateDbExp = true) {\n    const exp = this._expirations.get(sid) || {};\n    clearTimeout(exp.timeout);\n    // @ts-ignore\n    const {cookie: {expires} = {}} = sess || {};\n    if (expires) {\n      const sessExp = new Date(expires).getTime();\n      if (updateDbExp) exp.db = sessExp;\n      exp.real = Math.max(exp.real || 0, exp.db || 0, sessExp);\n      const now = Date.now();\n      if (exp.real <= now) return await this._destroy(sid);\n      // If reading from the database, update the expiration with the latest value from touch() so\n      // that touch() appears to write to the database every time even though it doesn't.\n      if (typeof expires === 'string') sess.cookie.expires = new Date(exp.real).toJSON();\n      // Use this._get(), not this._destroy(), to destroy the DB record for the expired session.\n      // This is done in case multiple Etherpad instances are sharing the same database and users\n      // are bouncing between the instances. By using this._get(), this instance will query the DB\n      // for the latest expiration time written by any of the instances, ensuring that the record\n      // isn't prematurely deleted if the expiration time was updated by a different Etherpad\n      // instance. (Important caveat: Client-side database caching, which ueberdb does by default,\n      // could still cause the record to be prematurely deleted because this instance might get a\n      // stale expiration time from cache.)\n      exp.timeout = setTimeout(() => this._get(sid), exp.real - now);\n      this._expirations.set(sid, exp);\n    } else {\n      this._expirations.delete(sid);\n    }\n    return sess;\n  }\n\n  async _write(sid: string, sess: any) {\n    await DB.set(`sessionstorage:${sid}`, sess);\n  }\n\n  async _get(sid: string) {\n    logger.debug(`GET ${sid}`);\n    const s = await DB.get(`sessionstorage:${sid}`);\n    return await this._updateExpirations(sid, s);\n  }\n\n  async _set(sid: string, sess:any) {\n    logger.debug(`SET ${sid}`);\n    sess = await this._updateExpirations(sid, sess);\n    if (sess != null) await this._write(sid, sess);\n  }\n\n  async _destroy(sid:string) {\n    logger.debug(`DESTROY ${sid}`);\n    clearTimeout((this._expirations.get(sid) || {}).timeout);\n    this._expirations.delete(sid);\n    await DB.remove(`sessionstorage:${sid}`);\n  }\n\n  // Note: express-session might call touch() before it calls set() for the first time. Ideally this\n  // would behave like set() in that case but it's OK if it doesn't -- express-session will call\n  // set() soon enough.\n  async _touch(sid: string, sess:any) {\n    logger.debug(`TOUCH ${sid}`);\n    sess = await this._updateExpirations(sid, sess, false);\n    if (sess == null) return; // Already expired.\n    const exp = this._expirations.get(sid);\n    // If the session doesn't expire, don't do anything. Ideally we would write the session to the\n    // database if it didn't already exist, but we have no way of knowing that without querying the\n    // database. The query overhead is not worth it because set() should be called soon anyway.\n    if (exp == null) return;\n    if (exp.db != null && (this._refresh == null || exp.real < exp.db + this._refresh)) return;\n    await this._write(sid, sess);\n    exp.db = new Date(sess.cookie.expires).getTime();\n  }\n}\n\n// express-session doesn't support Promise-based methods. This is where the callbackified versions\n// used by express-session are defined.\nfor (const m of ['get', 'set', 'destroy', 'touch']) {\n  SessionStore.prototype[m] = util.callbackify(SessionStore.prototype[`_${m}`]);\n}\n\nmodule.exports = SessionStore;\n"
  },
  {
    "path": "src/node/eejs/index.ts",
    "content": "'use strict';\n/*\n * Copyright (c) 2011 RedHog (Egil Möller) <egil.moller@freecode.no>\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/* Basic usage:\n *\n * require(\"./index\").require(\"./path/to/template.ejs\")\n */\n\nimport ejs from 'ejs';\nimport fs from 'fs';\nconst hooks = require('../../static/js/pluginfw/hooks');\nimport path from 'node:path';\n// @ts-ignore\nimport resolve from 'resolve';\nimport settings from '../utils/Settings';\nimport {pluginInstallPath} from '../../static/js/pluginfw/installer'\n\nconst templateCache = new Map();\n\nexports.info = {\n  __output_stack: [],\n  block_stack: [],\n  file_stack: [],\n  args: [],\n};\n\nconst getCurrentFile = () => exports.info.file_stack[exports.info.file_stack.length - 1];\n\nexports._init = (b: any, recursive: boolean) => {\n  exports.info.__output_stack.push(exports.info.__output);\n  exports.info.__output = b;\n};\n\nexports._exit = (b:any, recursive:boolean) => {\n  exports.info.__output = exports.info.__output_stack.pop();\n};\n\nexports.begin_block = (name:string) => {\n  exports.info.block_stack.push(name);\n  exports.info.__output_stack.push(exports.info.__output.get());\n  exports.info.__output.set('');\n};\n\nexports.end_block = () => {\n  const name = exports.info.block_stack.pop();\n  const renderContext = exports.info.args[exports.info.args.length - 1];\n  const content = exports.info.__output.get();\n  exports.info.__output.set(exports.info.__output_stack.pop());\n  const args = {content, renderContext};\n  hooks.callAll(`eejsBlock_${name}`, args);\n  exports.info.__output.set(exports.info.__output.get().concat(args.content));\n};\n\nexports.require = (name:string, args:{\n  e?: Function,\n    require?: Function,\n}, mod:{\n  filename:string,\n    paths:string[],\n}) => {\n  if (args == null) args = {};\n\n  let basedir = __dirname;\n  let paths:string[] = [];\n\n  if (exports.info.file_stack.length) {\n    basedir = path.dirname(getCurrentFile().path);\n  }\n  if (mod) {\n    basedir = path.dirname(mod.filename);\n    paths = mod.paths;\n  }\n\n  /**\n   * Add the plugin install path to the paths array\n   */\n  if (!paths.includes(pluginInstallPath)) {\n    paths.push(pluginInstallPath)\n  }\n\n  const ejspath = resolve.sync(name, {paths, basedir, extensions: ['.html', '.ejs']});\n\n  args.e = exports;\n  args.require = require;\n\n  const cache = settings.maxAge !== 0;\n  const template = cache && templateCache.get(ejspath) || ejs.compile(\n      '<% e._init({get: () => __output, set: (s) => { __output = s; }}); %>' +\n        `${fs.readFileSync(ejspath).toString()}<% e._exit(); %>`,\n      {filename: ejspath});\n  if (cache) templateCache.set(ejspath, template);\n\n  exports.info.args.push(args);\n  exports.info.file_stack.push({path: ejspath});\n  const res = template(args);\n  exports.info.file_stack.pop();\n  exports.info.args.pop();\n\n  return res;\n};\n"
  },
  {
    "path": "src/node/handler/APIHandler.ts",
    "content": "'use strict';\n/**\n * The API Handler handles all API http requests\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {MapArrayType} from \"../types/MapType\";\nimport { jwtDecode } from \"jwt-decode\";\nconst api = require('../db/API');\nconst padManager = require('../db/PadManager');\nimport settings from '../utils/Settings';\nimport createHTTPError from 'http-errors';\nimport {Http2ServerRequest} from \"node:http2\";\nimport {publicKeyExported} from \"../security/OAuth2Provider\";\nimport {jwtVerify} from \"jose\";\nimport {APIFields, apikey} from './APIKeyHandler'\n// a list of all functions\nconst version:MapArrayType<any> = {};\n\nversion['1'] = {\n  createGroup: [],\n  createGroupIfNotExistsFor: ['groupMapper'],\n  deleteGroup: ['groupID'],\n  listPads: ['groupID'],\n  createPad: ['padID', 'text'],\n  createGroupPad: ['groupID', 'padName', 'text'],\n  createAuthor: ['name'],\n  createAuthorIfNotExistsFor: ['authorMapper', 'name'],\n  listPadsOfAuthor: ['authorID'],\n  createSession: ['groupID', 'authorID', 'validUntil'],\n  deleteSession: ['sessionID'],\n  getSessionInfo: ['sessionID'],\n  listSessionsOfGroup: ['groupID'],\n  listSessionsOfAuthor: ['authorID'],\n  getText: ['padID', 'rev'],\n  setText: ['padID', 'text'],\n  getHTML: ['padID', 'rev'],\n  setHTML: ['padID', 'html'],\n  getRevisionsCount: ['padID'],\n  getLastEdited: ['padID'],\n  deletePad: ['padID'],\n  getReadOnlyID: ['padID'],\n  setPublicStatus: ['padID', 'publicStatus'],\n  getPublicStatus: ['padID'],\n  listAuthorsOfPad: ['padID'],\n  padUsersCount: ['padID'],\n};\n\nversion['1.1'] = {\n  ...version['1'],\n  getAuthorName: ['authorID'],\n  padUsers: ['padID'],\n  sendClientsMessage: ['padID', 'msg'],\n  listAllGroups: [],\n};\n\nversion['1.2'] = {\n  ...version['1.1'],\n  checkToken: [],\n};\n\nversion['1.2.1'] = {\n  ...version['1.2'],\n  listAllPads: [],\n};\n\nversion['1.2.7'] = {\n  ...version['1.2.1'],\n  createDiffHTML: ['padID', 'startRev', 'endRev'],\n  getChatHistory: ['padID', 'start', 'end'],\n  getChatHead: ['padID'],\n};\n\nversion['1.2.8'] = {\n  ...version['1.2.7'],\n  getAttributePool: ['padID'],\n  getRevisionChangeset: ['padID', 'rev'],\n};\n\nversion['1.2.9'] = {\n  ...version['1.2.8'],\n  copyPad: ['sourceID', 'destinationID', 'force'],\n  movePad: ['sourceID', 'destinationID', 'force'],\n};\n\nversion['1.2.10'] = {\n  ...version['1.2.9'],\n  getPadID: ['roID'],\n};\n\nversion['1.2.11'] = {\n  ...version['1.2.10'],\n  getSavedRevisionsCount: ['padID'],\n  listSavedRevisions: ['padID'],\n  saveRevision: ['padID', 'rev'],\n  restoreRevision: ['padID', 'rev'],\n};\n\nversion['1.2.12'] = {\n  ...version['1.2.11'],\n  appendChatMessage: ['padID', 'text', 'authorID', 'time'],\n};\n\nversion['1.2.13'] = {\n  ...version['1.2.12'],\n  appendText: ['padID', 'text'],\n};\n\nversion['1.2.14'] = {\n  ...version['1.2.13'],\n  getStats: [],\n};\n\nversion['1.2.15'] = {\n  ...version['1.2.14'],\n  copyPadWithoutHistory: ['sourceID', 'destinationID', 'force'],\n};\n\nversion['1.3.0'] = {\n  ...version['1.2.15'],\n  appendText: ['padID', 'text', 'authorId'],\n  copyPadWithoutHistory: ['sourceID', 'destinationID', 'force', 'authorId'],\n  createGroupPad: ['groupID', 'padName', 'text', 'authorId'],\n  createPad: ['padID', 'text', 'authorId'],\n  restoreRevision: ['padID', 'rev', 'authorId'],\n  setHTML: ['padID', 'html', 'authorId'],\n  setText: ['padID', 'text', 'authorId'],\n};\n\n\n// set the latest available API version here\nexports.latestApiVersion = '1.3.0';\n\n// exports the versions so it can be used by the new Swagger endpoint\nexports.version = version;\n\n\n\n/**\n * Handles an HTTP API call\n * @param {String} apiVersion the version of the api\n * @param {String} functionName the name of the called function\n * @param fields the params of the called function\n * @param req express request object\n */\nexports.handle = async function (apiVersion: string, functionName: string, fields: APIFields,\n                                 req: Http2ServerRequest) {\n  // say goodbye if this is an unknown API version\n  if (!(apiVersion in version)) {\n    throw new createHTTPError.NotFound('no such api version');\n  }\n\n  // say goodbye if this is an unknown function\n  if (!(functionName in version[apiVersion])) {\n    throw new createHTTPError.NotFound('no such function');\n  }\n\n\n\n  if (apikey !== null && apikey.trim().length > 0) {\n    fields.apikey = fields.apikey || fields.api_key || fields.authorization;\n    // API key is configured, check if it is valid\n    if (fields.apikey !== apikey!.trim()) {\n      throw new createHTTPError.Unauthorized('no or wrong API Key');\n    }\n  } else {\n    if(!req.headers.authorization) {\n      throw new createHTTPError.Unauthorized('no or wrong API Key');\n    }\n    try {\n      const clientIds: string[] = settings.sso.clients?.map((client: {client_id: string}) => client.client_id) ?? [];\n      const jwtToCheck = req.headers.authorization.replace(\"Bearer \", \"\")\n      const payload = jwtDecode(jwtToCheck)\n      // client_credentials\n      if (clientIds.includes(<string>payload.sub)) {\n        await jwtVerify(jwtToCheck, publicKeyExported!, {algorithms: ['RS256']})\n      } else {\n        // authorization_code\n        await jwtVerify(jwtToCheck, publicKeyExported!, {algorithms: ['RS256'],\n          requiredClaims: [\"admin\"]})\n      }\n    } catch (e) {\n      throw new createHTTPError.Unauthorized('no or wrong OAuth token');\n    }\n  }\n\n  // sanitize any padIDs before continuing\n  if (fields.padID) {\n    fields.padID = await padManager.sanitizePadId(fields.padID);\n  }\n  // there was an 'else' here before - removed it to ensure\n  // that this sanitize step can't be circumvented by forcing\n  // the first branch to be taken\n  if (fields.padName) {\n    fields.padName = await padManager.sanitizePadId(fields.padName);\n  }\n\n  // put the function parameters in an array\n  // @ts-ignore\n  const functionParams = version[apiVersion][functionName].map((field) => fields[field]);\n\n  // call the api function\n  return api[functionName].apply(this, functionParams);\n};\n"
  },
  {
    "path": "src/node/handler/APIKeyHandler.ts",
    "content": "import * as absolutePaths from '../utils/AbsolutePaths';\nimport fs from 'fs';\nimport log4js from 'log4js';\nimport randomString from '../utils/randomstring';\nimport {argv} from '../utils/Cli'\nimport settings from '../utils/Settings';\n\nconst apiHandlerLogger = log4js.getLogger('APIHandler');\n\n\n\nexport type APIFields = {\n  apikey: string;\n  api_key: string;\n  padID: string;\n  padName: string;\n  authorization: string;\n}\n\n// ensure we have an apikey\nexport let apikey:string|null = null;\nconst apikeyFilename = absolutePaths.makeAbsolute(argv.apikey || './APIKEY.txt');\n\n\nif(settings.authenticationMethod === 'apikey') {\n    try {\n      apikey = fs.readFileSync(apikeyFilename, 'utf8');\n      apiHandlerLogger.info(`Api key file read from: \"${apikeyFilename}\"`);\n    } catch (e) {\n      apiHandlerLogger.info(\n        `Api key file \"${apikeyFilename}\" not found.  Creating with random contents.`);\n      apikey = randomString(32);\n      fs.writeFileSync(apikeyFilename, apikey!, 'utf8');\n    }\n}\n"
  },
  {
    "path": "src/node/handler/ExportHandler.ts",
    "content": "'use strict';\n/**\n * Handles the export requests\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n * 2014 John McLear (Etherpad Foundation / McLear Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst exporthtml = require('../utils/ExportHtml');\nconst exporttxt = require('../utils/ExportTxt');\nconst exportEtherpad = require('../utils/ExportEtherpad');\nimport fs from 'fs';\nimport settings from '../utils/Settings';\nimport os from 'os';\nconst hooks = require('../../static/js/pluginfw/hooks');\nimport util from 'util';\nconst { checkValidRev } = require('../utils/checkValidRev');\n\nconst fsp_writeFile = util.promisify(fs.writeFile);\nconst fsp_unlink = util.promisify(fs.unlink);\n\nconst tempDirectory = os.tmpdir();\n\n/**\n * do a requested export\n * @param {Object} req the request object\n * @param {Object} res the response object\n * @param {String} padId the pad id to export\n * @param {String} readOnlyId the read only id of the pad to export\n * @param {String} type the type to export\n */\nexports.doExport = async (req: any, res: any, padId: string, readOnlyId: string, type:string) => {\n  // avoid naming the read-only file as the original pad's id\n  let fileName = readOnlyId ? readOnlyId : padId;\n\n  // allow fileName to be overwritten by a hook, the type type is kept static for security reasons\n  const hookFileName = await hooks.aCallFirst('exportFileName', padId);\n\n  // if fileName is set then set it to the padId, note that fileName is returned as an array.\n  if (hookFileName.length) {\n    fileName = hookFileName;\n  }\n\n  // tell the browser that this is a downloadable file\n  res.attachment(`${fileName}.${type}`);\n\n  if (req.params.rev !== undefined) {\n    // ensure revision is a number\n    // modify req, as we use it in a later call to exportConvert\n    req.params.rev = checkValidRev(req.params.rev);\n  }\n\n  // if this is a plain text export, we can do this directly\n  // We have to over engineer this because tabs are stored as attributes and not plain text\n  if (type === 'etherpad') {\n    const pad = await exportEtherpad.getPadRaw(padId, readOnlyId);\n    res.send(pad);\n  } else if (type === 'txt') {\n    const txt = await exporttxt.getPadTXTDocument(padId, req.params.rev);\n    res.send(txt);\n  } else {\n    // render the html document\n    let html = await exporthtml.getPadHTMLDocument(padId, req.params.rev, readOnlyId);\n\n    // decide what to do with the html export\n\n    // if this is a html export, we can send this from here directly\n    if (type === 'html') {\n      // do any final changes the plugin might want to make\n      const newHTML = await hooks.aCallFirst('exportHTMLSend', html);\n      if (newHTML.length) html = newHTML;\n      res.send(html);\n      return;\n    }\n\n    // else write the html export to a file\n    const randNum = Math.floor(Math.random() * 0xFFFFFFFF);\n    const srcFile = `${tempDirectory}/etherpad_export_${randNum}.html`;\n    await fsp_writeFile(srcFile, html);\n\n    // ensure html can be collected by the garbage collector\n    html = null;\n\n    // send the convert job to the converter (abiword, libreoffice, ..)\n    const destFile = `${tempDirectory}/etherpad_export_${randNum}.${type}`;\n\n    // Allow plugins to overwrite the convert in export process\n    const result = await hooks.aCallAll('exportConvert', {srcFile, destFile, req, res});\n    if (result.length > 0) {\n      // console.log(\"export handled by plugin\", destFile);\n    } else {\n      const converter =\n          settings.soffice != null ? require('../utils/LibreOffice')\n          : settings.abiword != null ? require('../utils/Abiword')\n          : null;\n      await converter.convertFile(srcFile, destFile, type);\n    }\n\n    // send the file\n    await res.sendFile(destFile, null);\n\n    // clean up temporary files\n    await fsp_unlink(srcFile);\n\n    // 100ms delay to accommodate for slow windows fs\n    if (os.type().indexOf('Windows') > -1) {\n      await new Promise((resolve) => setTimeout(resolve, 100));\n    }\n\n    await fsp_unlink(destFile);\n  }\n};\n"
  },
  {
    "path": "src/node/handler/ImportHandler.ts",
    "content": "'use strict';\n/**\n * Handles the import requests\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n * 2012 Iván Eixarch\n * 2014 John McLear (Etherpad Foundation / McLear Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst padManager = require('../db/PadManager');\nconst padMessageHandler = require('./PadMessageHandler');\nimport {promises as fs} from 'fs';\nimport path from 'path';\nimport settings from '../utils/Settings';\nconst {Formidable} = require('formidable');\nimport os from 'os';\nconst importHtml = require('../utils/ImportHtml');\nconst importEtherpad = require('../utils/ImportEtherpad');\nimport log4js from 'log4js';\nconst hooks = require('../../static/js/pluginfw/hooks');\n\nconst logger = log4js.getLogger('ImportHandler');\n\n// `status` must be a string supported by `importErrorMessage()` in `src/static/js/pad_impexp.js`.\nclass ImportError extends Error {\n  status: string;\n  constructor(status: string, ...args:any) {\n    super(...args);\n    if (Error.captureStackTrace) Error.captureStackTrace(this, ImportError);\n    this.name = 'ImportError';\n    this.status = status;\n    const msg = this.message == null ? '' : String(this.message);\n    if (status !== '') this.message = msg === '' ? status : `${status}: ${msg}`;\n  }\n}\n\nconst rm = async (path: string) => {\n  try {\n    await fs.unlink(path);\n  } catch (err:any) {\n    if (err.code !== 'ENOENT') throw err;\n  }\n};\n\nlet converter:any = null;\nlet exportExtension = 'htm';\n\n// load abiword only if it is enabled and if soffice is disabled\nif (settings.abiword != null && settings.soffice == null) {\n  converter = require('../utils/Abiword');\n}\n\n// load soffice only if it is enabled\nif (settings.soffice != null) {\n  converter = require('../utils/LibreOffice');\n  exportExtension = 'html';\n}\n\nconst tmpDirectory = os.tmpdir();\n\n/**\n * do a requested import\n * @param {Object} req the request object\n * @param {Object} res the response object\n * @param {String} padId the pad id to export\n * @param {String} authorId the author id to use for the import\n */\nconst doImport = async (req:any, res:any, padId:string, authorId:string) => {\n  // pipe to a file\n  // convert file to html via abiword or soffice\n  // set html in the pad\n  const randNum = Math.floor(Math.random() * 0xFFFFFFFF);\n\n  // setting flag for whether to use converter or not\n  let useConverter = (converter != null);\n\n  const form = new Formidable({\n    keepExtensions: true,\n    uploadDir: tmpDirectory,\n    maxFileSize: settings.importMaxFileSize,\n  });\n\n  let srcFile;\n  let files;\n  let fields;\n  try {\n    [fields, files] = await form.parse(req);\n  } catch (err:any) {\n    logger.warn(`Import failed due to form error: ${err.stack || err}`);\n    if (err.code === Formidable.formidableErrors.biggerThanMaxFileSize) {\n      throw new ImportError('maxFileSize');\n    }\n    throw new ImportError('uploadFailed');\n  }\n  if (!files.file) {\n    logger.warn('Import failed because form had no file');\n    throw new ImportError('uploadFailed');\n  } else {\n    srcFile = files.file[0].filepath;\n  }\n\n  // ensure this is a file ending we know, else we change the file ending to .txt\n  // this allows us to accept source code files like .c or .java\n  const fileEnding = path.extname(files.file[0].originalFilename).toLowerCase();\n  const knownFileEndings =\n    ['.txt', '.doc', '.docx', '.pdf', '.odt', '.html', '.htm', '.etherpad', '.rtf'];\n  const fileEndingUnknown = (knownFileEndings.indexOf(fileEnding) < 0);\n\n  if (fileEndingUnknown) {\n    // the file ending is not known\n\n    if (settings.allowUnknownFileEnds === true) {\n      // we need to rename this file with a .txt ending\n      const oldSrcFile = srcFile;\n\n      srcFile = path.join(path.dirname(srcFile), `${path.basename(srcFile, fileEnding)}.txt`);\n      await fs.rename(oldSrcFile, srcFile);\n    } else {\n      logger.warn(`Not allowing unknown file type to be imported: ${fileEnding}`);\n      throw new ImportError('uploadFailed');\n    }\n  }\n\n  const destFile = path.join(tmpDirectory, `etherpad_import_${randNum}.${exportExtension}`);\n  const context = {srcFile, destFile, fileEnding, padId, ImportError};\n  const importHandledByPlugin = (await hooks.aCallAll('import', context)).some((x:string) => x);\n  const fileIsEtherpad = (fileEnding === '.etherpad');\n  const fileIsHTML = (fileEnding === '.html' || fileEnding === '.htm');\n  const fileIsTXT = (fileEnding === '.txt');\n\n  let directDatabaseAccess = false;\n  if (fileIsEtherpad) {\n    // Use '\\n' to avoid the default pad text if the pad doesn't yet exist.\n    const pad = await padManager.getPad(padId, '\\n', authorId);\n    const headCount = pad.head;\n    if (headCount >= 10) {\n      logger.warn('Aborting direct database import attempt of a pad that already has content');\n      throw new ImportError('padHasData');\n    }\n    const text = await fs.readFile(srcFile, 'utf8');\n    directDatabaseAccess = true;\n    await importEtherpad.setPadRaw(padId, text, authorId);\n  }\n\n  // convert file to html if necessary\n  if (!importHandledByPlugin && !directDatabaseAccess) {\n    if (fileIsTXT) {\n      // Don't use converter for text files\n      useConverter = false;\n    }\n\n    // See https://github.com/ether/etherpad-lite/issues/2572\n    if (fileIsHTML || !useConverter) {\n      // if no converter only rename\n      await fs.rename(srcFile, destFile);\n    } else {\n      try {\n        await converter.convertFile(srcFile, destFile, exportExtension);\n      } catch (err:any) {\n        logger.warn(`Converting Error: ${err.stack || err}`);\n        throw new ImportError('convertFailed');\n      }\n    }\n  }\n\n  if (!useConverter && !directDatabaseAccess) {\n    // Read the file with no encoding for raw buffer access.\n    const buf = await fs.readFile(destFile);\n\n    // Check if there are only ascii chars in the uploaded file\n    const isAscii = !Array.prototype.some.call(buf, (c) => (c > 240));\n\n    if (!isAscii) {\n      logger.warn('Attempt to import non-ASCII file');\n      throw new ImportError('uploadFailed');\n    }\n  }\n\n  // Use '\\n' to avoid the default pad text if the pad doesn't yet exist.\n  let pad = await padManager.getPad(padId, '\\n', authorId);\n\n  // read the text\n  let text;\n\n  if (!directDatabaseAccess) {\n    text = await fs.readFile(destFile, 'utf8');\n\n    // node on windows has a delay on releasing of the file lock.\n    // We add a 100ms delay to work around this\n    if (os.type().indexOf('Windows') > -1) {\n      await new Promise((resolve) => setTimeout(resolve, 100));\n    }\n  }\n\n  // change text of the pad and broadcast the changeset\n  if (!directDatabaseAccess) {\n    if (importHandledByPlugin || useConverter || fileIsHTML) {\n      try {\n        await importHtml.setPadHTML(pad, text, authorId);\n      } catch (err:any) {\n        logger.warn(`Error importing, possibly caused by malformed HTML: ${err.stack || err}`);\n      }\n    } else {\n      await pad.setText(text, authorId);\n    }\n  }\n\n  // Load the Pad into memory then broadcast updates to all clients\n  padManager.unloadPad(padId);\n  pad = await padManager.getPad(padId, '\\n', authorId);\n  padManager.unloadPad(padId);\n\n  // Direct database access means a pad user should reload the pad and not attempt to receive\n  // updated pad data.\n  if (directDatabaseAccess) return true;\n\n  // tell clients to update\n  await padMessageHandler.updatePadClients(pad);\n\n  // clean up temporary files\n  rm(srcFile);\n  rm(destFile);\n\n  return false;\n};\n\n/**\n * Handles the request to import a file\n * @param {Request} req the request object\n * @param {Response} res the response object\n * @param {String} padId the pad id to export\n * @param {String} authorId the author id to use for the import\n * @return {Promise<void>} a promise\n */\nexports.doImport = async (req:any, res:any, padId:string, authorId:string = '') => {\n  let httpStatus = 200;\n  let code = 0;\n  let message = 'ok';\n  let directDatabaseAccess;\n  try {\n    directDatabaseAccess = await doImport(req, res, padId, authorId);\n  } catch (err:any) {\n    const known = err instanceof ImportError && err.status;\n    if (!known) logger.error(`Internal error during import: ${err.stack || err}`);\n    httpStatus = known ? 400 : 500;\n    code = known ? 1 : 2;\n    message = known ? err.status : 'internalError';\n  }\n  res.status(httpStatus).json({code, message, data: {directDatabaseAccess}});\n};\n"
  },
  {
    "path": "src/node/handler/PadMessageHandler.ts",
    "content": "'use strict';\n/**\n * The MessageHandler handles all Messages that comes from Socket.IO and controls the sessions\n */\n\n/*\n * Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {MapArrayType} from \"../types/MapType\";\n\nimport AttributeMap from '../../static/js/AttributeMap';\nconst padManager = require('../db/PadManager');\nimport {checkRep, cloneAText, compose, deserializeOps, follow, identity, inverse, makeAText, makeSplice, moveOpsToNewPool, mutateAttributionLines, mutateTextLines, oldLen, prepareForWire, splitAttributionLines, splitTextLines, unpack} from '../../static/js/Changeset';\nimport ChatMessage from '../../static/js/ChatMessage';\nimport AttributePool from '../../static/js/AttributePool';\nconst AttributeManager = require('../../static/js/AttributeManager');\nconst authorManager = require('../db/AuthorManager');\nimport padutils from '../../static/js/pad_utils';\nimport readOnlyManager from '../db/ReadOnlyManager';\nimport settings, {\n  exportAvailable,\n  abiwordAvailable,\n  sofficeAvailable\n} from '../utils/Settings';\nconst securityManager = require('../db/SecurityManager');\nconst plugins = require('../../static/js/pluginfw/plugin_defs');\nimport log4js from 'log4js';\nconst messageLogger = log4js.getLogger('message');\nconst accessLogger = log4js.getLogger('access');\nconst hooks = require('../../static/js/pluginfw/hooks');\nconst stats = require('../stats')\nconst assert = require('assert').strict;\nimport {RateLimiterMemory} from 'rate-limiter-flexible';\nimport {ChangesetRequest, PadUserInfo, SocketClientRequest} from \"../types/SocketClientRequest\";\nimport {APool, AText, PadAuthor, PadType} from \"../types/PadType\";\nimport {ChangeSet} from \"../types/ChangeSet\";\nimport {ChatMessageMessage, ClientReadyMessage, ClientSaveRevisionMessage, ClientSuggestUserName, ClientUserChangesMessage, ClientVarMessage, CustomMessage, PadDeleteMessage, UserNewInfoMessage} from \"../../static/js/types/SocketIOMessage\";\nimport {Builder} from \"../../static/js/Builder\";\nconst webaccess = require('../hooks/express/webaccess');\nconst { checkValidRev } = require('../utils/checkValidRev');\n\nlet rateLimiter:any;\nlet socketio: any = null;\n\nhooks.deprecationNotices.clientReady = 'use the userJoin hook instead';\n\nconst addContextToError = (err:any, pfx:string) => {\n  const newErr = new Error(`${pfx}${err.message}`, {cause: err});\n  if (Error.captureStackTrace) Error.captureStackTrace(newErr, addContextToError);\n  // Check for https://github.com/tc39/proposal-error-cause support, available in Node.js >= v16.10.\n  if (newErr.cause === err) return newErr;\n  err.message = `${pfx}${err.message}`;\n  return err;\n};\n\nexports.socketio = () => {\n  // The rate limiter is created in this hook so that restarting the server resets the limiter. The\n  // settings.commitRateLimiting object is passed directly to the rate limiter so that the limits\n  // can be dynamically changed during runtime by modifying its properties.\n  rateLimiter = new RateLimiterMemory(settings.commitRateLimiting);\n};\n\n/**\n * Contains information about socket.io connections:\n *   - key: Socket.io socket ID.\n *   - value: Object that is initially empty immediately after connect. Once the client's\n *     CLIENT_READY message is processed, it has the following properties:\n *       - auth: Object with the following properties copied from the client's CLIENT_READY message:\n *           - padID: Pad ID requested by the user. Unlike the padId property described below, this\n *             may be a read-only pad ID.\n *           - sessionID: Copied from the client's sessionID cookie, which should be the value\n *             returned from the createSession() HTTP API. This will be null/undefined if\n *             createSession() isn't used or the portal doesn't set the sessionID cookie.\n *           - token: User-supplied token.\n *       - author: The user's author ID.\n *       - padId: The real (not read-only) ID of the pad.\n *       - readOnlyPadId: The read-only ID of the pad.\n *       - readonly: Whether the client has read-only access (true) or read/write access (false).\n *       - rev: The last revision that was sent to the client.\n */\nconst sessioninfos:MapArrayType<any> = {};\nexports.sessioninfos = sessioninfos;\n\nfunction getTotalActiveUsers() {\n  return socketio ? socketio.engine.clientsCount : 0;\n}\n\nexports.getTotalActiveUsers = getTotalActiveUsers;\n\nfunction getActivePadCountFromSessionInfos() {\n  const padIds = new Set();\n  for (const {padId} of Object.values(sessioninfos)) {\n    if (!padId) continue;\n    padIds.add(padId);\n  }\n  return padIds.size;\n}\nexports.getActivePadCountFromSessionInfos = getActivePadCountFromSessionInfos;\n\nstats.gauge('totalUsers', () => getTotalActiveUsers());\nstats.gauge('activePads', () => {\n  return getActivePadCountFromSessionInfos();\n});\n\n/**\n * Processes one task at a time per channel.\n */\nclass Channels {\n  private readonly _exec: (ch:any, task:any) => any;\n  private _promiseChains: Map<any, Promise<any>>;\n  /**\n   * @param {(ch, task) => any} [exec] - Task executor. If omitted, tasks are assumed to be\n   *     functions that will be executed with the channel as the only argument.\n   */\n  constructor(exec = (ch: string, task:any) => task(ch)) {\n    this._exec = exec;\n    this._promiseChains = new Map();\n  }\n\n  /**\n   * Schedules a task for execution. The task will be executed once all previously enqueued tasks\n   * for the named channel have completed.\n   *\n   * @param {any} ch - Identifies the channel.\n   * @param {any} task - The task to give to the executor.\n   * @returns {Promise<any>} The value returned by the executor.\n   */\n  async enqueue(ch:any, task:any): Promise<any> {\n    const p = (this._promiseChains.get(ch) || Promise.resolve()).then(() => this._exec(ch, task));\n    const pc = p\n        .catch(() => {}) // Prevent rejections from halting the queue.\n        .then(() => {\n          // Clean up this._promiseChains if there are no more tasks for the channel.\n          if (this._promiseChains.get(ch) === pc) this._promiseChains.delete(ch);\n        });\n    this._promiseChains.set(ch, pc);\n    return await p;\n  }\n}\n\n/**\n * A changeset queue per pad that is processed by handleUserChanges()\n */\nconst padChannels = new Channels((ch, {socket, message}) => handleUserChanges(socket, message));\n\n/**\n * This Method is called by server.ts to tell the message handler on which socket it should send\n * @param socket_io The Socket\n */\nexports.setSocketIO = (socket_io:any) => {\n  socketio = socket_io;\n};\n\n/**\n * Handles the connection of a new user\n * @param socket the socket.io Socket object for the new connection from the client\n */\nexports.handleConnect = (socket:any) => {\n  stats.meter('connects').mark();\n\n  // Initialize sessioninfos for this new session\n  sessioninfos[socket.id] = {};\n};\n\n/**\n * Kicks all sessions from a pad\n */\nexports.kickSessionsFromPad = (padID: string) => {\n\n  if(socketio.sockets == null) return;\n\n  // skip if there is nobody on this pad\n  if (_getRoomSockets(padID).length === 0) return;\n\n  // disconnect everyone from this pad\n  socketio.in(padID).emit('message', {disconnect: 'deleted'});\n};\n\n/**\n * Handles the disconnection of a user\n * @param socket the socket.io Socket object for the client\n */\nexports.handleDisconnect = async (socket:any) => {\n  stats.meter('disconnects').mark();\n  const session = sessioninfos[socket.id];\n  delete sessioninfos[socket.id];\n  // session.padId can be nullish if the user disconnects before sending CLIENT_READY.\n  if (!session || !session.author || !session.padId) return;\n  const {session: {user} = {}} = socket.client.request as SocketClientRequest;\n  /* eslint-disable prefer-template -- it doesn't support breaking across multiple lines */\n  accessLogger.info('[LEAVE]' +\n                    ` pad:${session.padId}` +\n                    ` socket:${socket.id}` +\n                    ` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` +\n                    ` authorID:${session.author}` +\n                    (user && user.username ? ` username:${user.username}` : ''));\n  /* eslint-enable prefer-template */\n  socket.broadcast.to(session.padId).emit('message', {\n    type: 'COLLABROOM',\n    data: {\n      type: 'USER_LEAVE',\n      userInfo: {\n        colorId: await authorManager.getAuthorColorId(session.author),\n        userId: session.author,\n      },\n    },\n  });\n  await hooks.aCallAll('userLeave', {\n    ...session, // For backwards compatibility.\n    authorId: session.author,\n    readOnly: session.readonly,\n    socket,\n  });\n};\n\n\nconst handlePadDelete = async (socket: any, padDeleteMessage: PadDeleteMessage) => {\n  const session = sessioninfos[socket.id];\n  if (!session || !session.author || !session.padId) throw new Error('session not ready');\n  if (await padManager.doesPadExist(padDeleteMessage.data.padId)) {\n    const retrievedPad = await padManager.getPad(padDeleteMessage.data.padId)\n    // Only the one doing the first revision can delete the pad, otherwise people could troll a lot\n    const firstContributor = await retrievedPad.getRevisionAuthor(0)\n    if (session.author === firstContributor) {\n      retrievedPad.remove()\n    } else {\n\n      type ShoutMessage = {\n        message: string,\n        sticky: boolean,\n      }\n\n      const messageToShout: ShoutMessage = {\n        message: 'You are not the creator of this pad, so you cannot delete it',\n        sticky: false\n      }\n      const messageToSend = {\n        type: \"COLLABROOM\",\n        data: {\n          type: \"shoutMessage\",\n          payload: {\n            message: messageToShout,\n            timestamp: Date.now()\n          }\n        }\n      }\n      socket.emit('shout',\n        messageToSend\n      )\n    }\n  }\n}\n\n\n/**\n * Handles a message from a user\n * @param socket the socket.io Socket object for the client\n * @param message the message from the client\n */\nexports.handleMessage = async (socket:any, message: ClientVarMessage) => {\n  const env = process.env.NODE_ENV || 'development';\n\n  if (env === 'production') {\n    try {\n      await rateLimiter.consume(socket.request.ip); // consume 1 point per event from IP\n    } catch (err) {\n      messageLogger.warn(`Rate limited IP ${socket.request.ip}. To reduce the amount of rate ` +\n                         'limiting that happens edit the rateLimit values in settings.json');\n      stats.meter('rateLimited').mark();\n      socket.emit('message', {disconnect: 'rateLimited'});\n      throw err;\n    }\n  }\n\n  if (message == null) throw new Error('message is null');\n  if (!message.type) throw new Error('message type missing');\n\n  const thisSession = sessioninfos[socket.id];\n  if (!thisSession) throw new Error('message from an unknown connection');\n\n  if (message.type === 'CLIENT_READY') {\n    // Remember this information since we won't have the cookie in further socket.io messages. This\n    // information will be used to check if the sessionId of this connection is still valid since it\n    // could have been deleted by the API.\n    thisSession.auth = {\n      sessionID: message.sessionID,\n      padID: message.padId,\n      token: message.token,\n    };\n\n    // Pad does not exist, so we need to sanitize the id\n    if (!(await padManager.doesPadExist(thisSession.auth.padID))) {\n      thisSession.auth.padID = await padManager.sanitizePadId(thisSession.auth.padID);\n    }\n    const padIds = await readOnlyManager.getIds(thisSession.auth.padID);\n    thisSession.padId = padIds.padId;\n    thisSession.readOnlyPadId = padIds.readOnlyPadId;\n    thisSession.readonly =\n        padIds.readonly || !webaccess.userCanModify(thisSession.auth.padID, socket.client.request);\n  }\n  // Outside of the checks done by this function, message.padId must not be accessed because it is\n  // too easy to introduce a security vulnerability that allows malicious users to read or modify\n  // pads that they should not be able to access. Code should instead use\n  // sessioninfos[socket.id].padId if the real pad ID is needed or\n  // sessioninfos[socket.id].auth.padID if the original user-supplied pad ID is needed.\n  Object.defineProperty(message, 'padId', {get: () => {\n    throw new Error('message.padId must not be accessed (for security reasons)');\n  }});\n\n  const auth = thisSession.auth;\n  if (!auth) {\n    const ip = settings.disableIPlogging ? 'ANONYMOUS' : (socket.request.ip || '<unknown>');\n    const msg = JSON.stringify(message, null, 2);\n    throw new Error(`pre-CLIENT_READY message from IP ${ip}: ${msg}`);\n  }\n\n  const {session: {user} = {}} = socket.client.request as SocketClientRequest;\n  const {accessStatus, authorID} =\n      await securityManager.checkAccess(auth.padID, auth.sessionID, auth.token, user);\n  if (accessStatus !== 'grant') {\n    socket.emit('message', {accessStatus});\n    throw new Error('access denied');\n  }\n  if (thisSession.author != null && thisSession.author !== authorID) {\n    socket.emit('message', {disconnect: 'rejected'});\n    throw new Error([\n      'Author ID changed mid-session. Bad or missing token or sessionID?',\n      `socket:${socket.id}`,\n      `IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}`,\n      `originalAuthorID:${thisSession.author}`,\n      `newAuthorID:${authorID}`,\n      ...(user && user.username) ? [`username:${user.username}`] : [],\n      `message:${message}`,\n    ].join(' '));\n  }\n  thisSession.author = authorID;\n\n  // Allow plugins to bypass the readonly message blocker\n  let readOnly = thisSession.readonly;\n  const context = {\n    message,\n    sessionInfo: {\n      authorId: thisSession.author,\n      padId: thisSession.padId,\n      readOnly: thisSession.readonly,\n    },\n    socket,\n    get client() {\n      padutils.warnDeprecated(\n          'the `client` context property for the handleMessageSecurity and handleMessage hooks ' +\n          'is deprecated; use the `socket` property instead');\n      return this.socket;\n    },\n  };\n  for (const res of await hooks.aCallAll('handleMessageSecurity', context)) {\n    switch (res) {\n      case true:\n        padutils.warnDeprecated(\n            'returning `true` from a `handleMessageSecurity` hook function is deprecated; ' +\n            'return \"permitOnce\" instead');\n        thisSession.readonly = false;\n        // Fall through:\n      case 'permitOnce':\n        readOnly = false;\n        break;\n      default:\n        messageLogger.warn(\n            'Ignoring unsupported return value from handleMessageSecurity hook function:', res);\n    }\n  }\n\n  // Call handleMessage hook. If a plugin returns null, the message will be dropped.\n  if ((await hooks.aCallAll('handleMessage', context)).some((m: null|string) => m == null)) {\n    return;\n  }\n\n  // Drop the message if the client disconnected during the above processing.\n  if (sessioninfos[socket.id] !== thisSession) throw new Error('client disconnected');\n\n  const {type} = message;\n  try {\n    switch (type) {\n      case 'CLIENT_READY': await handleClientReady(socket, message); break;\n      case 'CHANGESET_REQ': await handleChangesetRequest(socket, message); break;\n      case 'COLLABROOM': {\n        if (readOnly) throw new Error('write attempt on read-only pad');\n        const {type} = message.data;\n        try {\n          switch (type) {\n            case 'USER_CHANGES':\n              stats.counter('pendingEdits').inc();\n              await padChannels.enqueue(thisSession.padId, {socket, message});\n              break;\n            case 'PAD_DELETE': await handlePadDelete(socket, message.data as unknown as PadDeleteMessage); break;\n            case 'USERINFO_UPDATE': await handleUserInfoUpdate(socket, message as unknown as UserNewInfoMessage); break;\n            case 'CHAT_MESSAGE': await handleChatMessage(socket, message as unknown as ChatMessageMessage); break;\n            case 'GET_CHAT_MESSAGES': await handleGetChatMessages(socket, message); break;\n            case 'SAVE_REVISION': await handleSaveRevisionMessage(socket, message as unknown as ClientSaveRevisionMessage); break;\n            case 'CLIENT_MESSAGE': {\n              const {type} = message.data.payload;\n              try {\n                switch (type) {\n                  case 'suggestUserName': handleSuggestUserName(socket, message as unknown as ClientSuggestUserName); break;\n                  default: throw new Error('unknown message type');\n                }\n              } catch (err) {\n                throw addContextToError(err, `${type}: `);\n              }\n              break;\n            }\n            default: throw new Error('unknown message type');\n          }\n        } catch (err) {\n          throw addContextToError(err, `${type}: `);\n        }\n        break;\n      }\n      default: throw new Error('unknown message type');\n    }\n  } catch (err) {\n    throw addContextToError(err, `${type}: `);\n  }\n};\n\n\n/**\n * Handles a save revision message\n * @param socket the socket.io Socket object for the client\n * @param message the message from the client\n */\nconst handleSaveRevisionMessage = async (socket:any, message: ClientSaveRevisionMessage) => {\n  const {padId, author: authorId} = sessioninfos[socket.id];\n  const pad = await padManager.getPad(padId, null, authorId);\n  await pad.addSavedRevision(pad.head, authorId);\n};\n\n/**\n * Handles a custom message, different to the function below as it handles\n * objects not strings and you can direct the message to specific sessionID\n *\n * @param msg {Object} the message we're sending\n * @param sessionID {string} the socketIO session to which we're sending this message\n */\nexports.handleCustomObjectMessage = (msg: CustomMessage, sessionID: string) => {\n  if (msg.data.type === 'CUSTOM') {\n    if (sessionID) {\n      // a sessionID is targeted: directly to this sessionID\n      socketio.sockets.socket(sessionID).emit('message', msg);\n    } else {\n      // broadcast to all clients on this pad\n      socketio.sockets.in(msg.data.payload.padId).emit('message', msg);\n    }\n  }\n};\n\n/**\n * Handles a custom message (sent via HTTP API request)\n *\n * @param padID {Pad} the pad to which we're sending this message\n * @param msgString {String} the message we're sending\n */\nexports.handleCustomMessage = (padID: string, msgString:string) => {\n  const time = Date.now();\n  const msg = {\n    type: 'COLLABROOM',\n    data: {\n      type: msgString,\n      time,\n    },\n  };\n  socketio.sockets.in(padID).emit('message', msg);\n};\n\n/**\n * Handles a Chat Message\n * @param socket the socket.io Socket object for the client\n * @param message the message from the client\n */\nconst handleChatMessage = async (socket:any, message: ChatMessageMessage) => {\n  const chatMessage = ChatMessage.fromObject(message.data.message);\n  const {padId, author: authorId} = sessioninfos[socket.id];\n  // Don't trust the user-supplied values.\n  chatMessage.time = Date.now();\n  chatMessage.authorId = authorId;\n  await exports.sendChatMessageToPadClients(chatMessage, padId);\n};\n\n/**\n * Adds a new chat message to a pad and sends it to connected clients.\n *\n * @param {(ChatMessage|number)} mt - Either a chat message object (recommended) or the timestamp of\n *     the chat message in ms since epoch (deprecated).\n * @param {string} puId - If `mt` is a chat message object, this is the destination pad ID.\n *     Otherwise, this is the user's author ID (deprecated).\n * @param {string} [text] - The text of the chat message. Deprecated; use `mt.text` instead.\n * @param {string} [padId] - The destination pad ID. Deprecated; pass a chat message\n *     object as the first argument and the destination pad ID as the second argument instead.\n */\nexports.sendChatMessageToPadClients = async (mt: ChatMessage|number, puId: string, text:string|null = null, padId:string|null = null) => {\n  const message = mt instanceof ChatMessage ? mt : new ChatMessage(text, puId, mt);\n  padId = mt instanceof ChatMessage ? puId : padId;\n  const pad = await padManager.getPad(padId, null, message.authorId);\n  await hooks.aCallAll('chatNewMessage', {message, pad, padId});\n  // pad.appendChatMessage() ignores the displayName property so we don't need to wait for\n  // authorManager.getAuthorName() to resolve before saving the message to the database.\n  const promise = pad.appendChatMessage(message);\n  message.displayName = await authorManager.getAuthorName(message.authorId);\n  socketio.sockets.in(padId).emit('message', {\n    type: 'COLLABROOM',\n    data: {type: 'CHAT_MESSAGE', message},\n  });\n  await promise;\n};\n\n/**\n * Handles the clients request for more chat-messages\n * @param socket the socket.io Socket object for the client\n * @param message the message from the client\n */\nconst handleGetChatMessages = async (socket:any, {data: {start, end}}:any) => {\n  if (!Number.isInteger(start)) throw new Error(`missing or invalid start: ${start}`);\n  if (!Number.isInteger(end)) throw new Error(`missing or invalid end: ${end}`);\n  const count = end - start;\n  if (count < 0 || count > 100) throw new Error(`invalid number of messages: ${count}`);\n  const {padId, author: authorId} = sessioninfos[socket.id];\n  const pad = await padManager.getPad(padId, null, authorId);\n\n  const chatMessages = await pad.getChatMessages(start, end);\n  const infoMsg = {\n    type: 'COLLABROOM',\n    data: {\n      type: 'CHAT_MESSAGES',\n      messages: chatMessages,\n    },\n  };\n\n  // send the messages back to the client\n  socket.emit('message', infoMsg);\n};\n\n/**\n * Handles a handleSuggestUserName, that means a user have suggest a userName for a other user\n * @param socket the socket.io Socket object for the client\n * @param message the message from the client\n */\nconst handleSuggestUserName = (socket:any, message: ClientSuggestUserName) => {\n  const {newName, unnamedId} = message.data.payload;\n  if (newName == null) throw new Error('missing newName');\n  if (unnamedId == null) throw new Error('missing unnamedId');\n  const padId = sessioninfos[socket.id].padId;\n  // search the author and send him this message\n  _getRoomSockets(padId).forEach((socket) => {\n    const session = sessioninfos[socket.id];\n    if (session && session.author === unnamedId) {\n      socket.emit('message', message);\n    }\n  });\n};\n\n/**\n * Handles a USERINFO_UPDATE, that means that a user have changed his color or name.\n * Anyway, we get both informations\n * @param socket the socket.io Socket object for the client\n * @param message the message from the client\n */\nconst handleUserInfoUpdate = async (socket:any, {data: {userInfo: {name, colorId}}}: UserNewInfoMessage) => {\n  if (colorId == null) throw new Error('missing colorId');\n  if (!name) name = null;\n  const session = sessioninfos[socket.id];\n  if (!session || !session.author || !session.padId) throw new Error('session not ready');\n  const author = session.author;\n  if (!/(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(colorId)) {\n    throw new Error(`malformed color: ${colorId}`);\n  }\n\n  // Tell the authorManager about the new attributes\n  const p = Promise.all([\n    authorManager.setAuthorColorId(author, colorId),\n    authorManager.setAuthorName(author, name),\n  ]);\n\n  const padId = session.padId;\n\n  const infoMsg = {\n    type: 'COLLABROOM',\n    data: {\n      // The Client doesn't know about USERINFO_UPDATE, use USER_NEWINFO\n      type: 'USER_NEWINFO',\n      userInfo: {userId: author, name, colorId},\n    },\n  };\n\n  // Send the other clients on the pad the update message\n  socket.broadcast.to(padId).emit('message',infoMsg);\n\n  // Block until the authorManager has stored the new attributes.\n  await p;\n};\n\n/**\n * Handles a USER_CHANGES message, where the client submits its local\n * edits as a changeset.\n *\n * This handler's job is to update the incoming changeset so that it applies\n * to the latest revision, then add it to the pad, broadcast the changes\n * to all other clients, and send a confirmation to the submitting client.\n *\n * This function is based on a similar one in the original Etherpad.\n *   See https://github.com/ether/pad/blob/master/etherpad/src/etherpad/collab/collab_server.js in the function applyUserChanges()\n *\n * @param socket the socket.io Socket object for the client\n * @param message the message from the client\n */\nconst handleUserChanges = async (socket:any, message: {\n  data: ClientUserChangesMessage\n}) => {\n  // This one's no longer pending, as we're gonna process it now\n  stats.counter('pendingEdits').dec();\n\n  // The client might disconnect between our callbacks. We should still\n  // finish processing the changeset, so keep a reference to the session.\n  const thisSession = sessioninfos[socket.id];\n\n  // TODO: this might happen with other messages too => find one place to copy the session\n  // and always use the copy. atm a message will be ignored if the session is gone even\n  // if the session was valid when the message arrived in the first place\n  if (!thisSession) throw new Error('client disconnected');\n\n  // Measure time to process edit\n  const stopWatch = stats.timer('edits').start();\n  try {\n    const {data: {baseRev, apool, changeset}} = message;\n    if (baseRev == null) throw new Error('missing baseRev');\n    if (apool == null) throw new Error('missing apool');\n    if (changeset == null) throw new Error('missing changeset');\n    const wireApool = (new AttributePool()).fromJsonable(apool);\n    const pad = await padManager.getPad(thisSession.padId, null, thisSession.author);\n\n    // Verify that the changeset has valid syntax and is in canonical form\n    checkRep(changeset);\n\n    // Validate all added 'author' attribs to be the same value as the current user\n    for (const op of deserializeOps(unpack(changeset).ops)) {\n      // + can add text with attribs\n      // = can change or add attribs\n      // - can have attribs, but they are discarded and don't show up in the attribs -\n      // but do show up in the pool\n\n      // Besides verifying the author attribute, this serves a second purpose:\n      // AttributeMap.fromString() ensures that all attribute numbers are valid (it will throw if\n      // an attribute number isn't in the pool).\n      const opAuthorId = AttributeMap.fromString(op.attribs, wireApool).get('author');\n      if (opAuthorId && opAuthorId !== thisSession.author) {\n        throw new Error(`Author ${thisSession.author} tried to submit changes as author ` +\n                        `${opAuthorId} in changeset ${changeset}`);\n      }\n    }\n\n    // ex. adoptChangesetAttribs\n\n    // Afaik, it copies the new attributes from the changeset, to the global Attribute Pool\n    let rebasedChangeset = moveOpsToNewPool(changeset, wireApool, pad.pool);\n\n    // ex. applyUserChanges\n    let r = baseRev;\n\n    // The client's changeset might not be based on the latest revision,\n    // since other clients are sending changes at the same time.\n    // Update the changeset so that it can be applied to the latest revision.\n    while (r < pad.getHeadRevisionNumber()) {\n      r++;\n      const {changeset: c, meta: {author: authorId}} = await pad.getRevision(r);\n      if (changeset === c && thisSession.author === authorId) {\n        // Assume this is a retransmission of an already applied changeset.\n        rebasedChangeset = identity(unpack(changeset).oldLen);\n      }\n      // At this point, both \"c\" (from the pad) and \"changeset\" (from the\n      // client) are relative to revision r - 1. The follow function\n      // rebases \"changeset\" so that it is relative to revision r\n      // and can be applied after \"c\".\n      rebasedChangeset = follow(c, rebasedChangeset, false, pad.pool);\n    }\n\n    const prevText = pad.text();\n\n    if (oldLen(rebasedChangeset) !== prevText.length) {\n      throw new Error(\n          `Can't apply changeset ${rebasedChangeset} with oldLen ` +\n          `${oldLen(rebasedChangeset)} to document of length ${prevText.length}`);\n    }\n\n    const newRev = await pad.appendRevision(rebasedChangeset, thisSession.author);\n    // The head revision will either stay the same or increase by 1 depending on whether the\n    // changeset has a net effect.\n    assert([r, r + 1].includes(newRev));\n\n    const correctionChangeset = _correctMarkersInPad(pad.atext, pad.pool);\n    if (correctionChangeset) {\n      await pad.appendRevision(correctionChangeset, thisSession.author);\n    }\n\n    // Make sure the pad always ends with an empty line.\n    if (pad.text().lastIndexOf('\\n') !== pad.text().length - 1) {\n      const nlChangeset = makeSplice(pad.text(), pad.text().length - 1, 0, '\\n');\n      await pad.appendRevision(nlChangeset, thisSession.author);\n    }\n\n    // The client assumes that ACCEPT_COMMIT and NEW_CHANGES messages arrive in order. Make sure we\n    // have already sent any previous ACCEPT_COMMIT and NEW_CHANGES messages.\n    assert.equal(thisSession.rev, r);\n    socket.emit('message', {type: 'COLLABROOM', data: {type: 'ACCEPT_COMMIT', newRev}});\n    thisSession.rev = newRev;\n    if (newRev !== r) thisSession.time = await pad.getRevisionDate(newRev);\n    await exports.updatePadClients(pad);\n  } catch (err:any) {\n    socket.emit('message', {disconnect: 'badChangeset'});\n    stats.meter('failedChangesets').mark();\n    messageLogger.warn(`Failed to apply USER_CHANGES from author ${thisSession.author} ` +\n                       `(socket ${socket.id}) on pad ${thisSession.padId}: ${err.stack || err}`);\n  } finally {\n    stopWatch.end();\n  }\n};\n\nexports.updatePadClients = async (pad: PadType) => {\n  // skip this if no-one is on this pad\n  const roomSockets = _getRoomSockets(pad.id);\n  if (roomSockets.length === 0) return;\n\n  // since all clients usually get the same set of changesets, store them in local cache\n  // to remove unnecessary roundtrip to the datalayer\n  // NB: note below possibly now accommodated via the change to promises/async\n  // TODO: in REAL world, if we're working without datalayer cache,\n  // all requests to revisions will be fired\n  // BEFORE first result will be landed to our cache object.\n  // The solution is to replace parallel processing\n  // via async.forEach with sequential for() loop. There is no real\n  // benefits of running this in parallel,\n  // but benefit of reusing cached revision object is HUGE\n  const revCache:MapArrayType<any> = {};\n\n  await Promise.all(roomSockets.map(async (socket) => {\n    const sessioninfo = sessioninfos[socket.id];\n    // The user might have disconnected since _getRoomSockets() was called.\n    if (sessioninfo == null) return;\n\n    while (sessioninfo.rev < pad.getHeadRevisionNumber()) {\n      const r = sessioninfo.rev + 1;\n      let revision = revCache[r];\n      if (!revision) {\n        revision = await pad.getRevision(r);\n        revCache[r] = revision;\n      }\n\n      const author = revision.meta.author;\n      const revChangeset = revision.changeset;\n      const currentTime = revision.meta.timestamp;\n\n      const forWire = prepareForWire(revChangeset, pad.pool);\n      const msg = {\n        type: 'COLLABROOM',\n        data: {\n          type: 'NEW_CHANGES',\n          newRev: r,\n          changeset: forWire.translated,\n          apool: forWire.pool,\n          author,\n          currentTime,\n          timeDelta: currentTime - sessioninfo.time,\n        },\n      };\n      try {\n        socket.emit('message', msg);\n      } catch (err:any) {\n        messageLogger.error(`Failed to notify user of new revision: ${err.stack || err}`);\n        return;\n      }\n      sessioninfo.time = currentTime;\n      sessioninfo.rev = r;\n    }\n  }));\n};\n\n/**\n * Copied from the Etherpad Source Code. Don't know what this method does excatly...\n */\nconst _correctMarkersInPad = (atext: AText, apool: AttributePool) => {\n  const text = atext.text;\n\n  // collect char positions of line markers (e.g. bullets) in new atext\n  // that aren't at the start of a line\n  const badMarkers = [];\n  let offset = 0;\n  for (const op of deserializeOps(atext.attribs)) {\n    const attribs = AttributeMap.fromString(op.attribs, apool);\n    const hasMarker = AttributeManager.lineAttributes.some((a: string) => attribs.has(a));\n    if (hasMarker) {\n      for (let i = 0; i < op.chars; i++) {\n        if (offset > 0 && text.charAt(offset - 1) !== '\\n') {\n          badMarkers.push(offset);\n        }\n        offset++;\n      }\n    } else {\n      offset += op.chars;\n    }\n  }\n\n  if (badMarkers.length === 0) {\n    return null;\n  }\n\n  // create changeset that removes these bad markers\n  offset = 0;\n\n  const builder = new Builder(text.length);\n\n  badMarkers.forEach((pos) => {\n    builder.keepText(text.substring(offset, pos));\n    builder.remove(1);\n    offset = pos + 1;\n  });\n\n  return builder.toString();\n};\n\n/**\n * Handles a CLIENT_READY. A CLIENT_READY is the first message from the client\n * to the server. The Client sends his token\n * and the pad it wants to enter. The Server answers with the inital values (clientVars) of the pad\n * @param socket the socket.io Socket object for the client\n * @param message the message from the client\n */\nconst handleClientReady = async (socket:any, message: ClientReadyMessage) => {\n  const sessionInfo = sessioninfos[socket.id];\n  if (sessionInfo == null) throw new Error('client disconnected');\n  assert(sessionInfo.author);\n\n  await hooks.aCallAll('clientReady', message); // Deprecated due to awkward context.\n\n  let {colorId: authorColorId, name: authorName} = message.userInfo || {};\n  if (authorColorId && !/^#(?:[0-9A-F]{3}){1,2}$/i.test(authorColorId as string)) {\n    messageLogger.warn(`Ignoring invalid colorId in CLIENT_READY message: ${authorColorId}`);\n    // @ts-ignore\n    authorColorId = null;\n  }\n  await Promise.all([\n    authorName && authorManager.setAuthorName(sessionInfo.author, authorName),\n    authorColorId && authorManager.setAuthorColorId(sessionInfo.author, authorColorId),\n  ]);\n  ({colorId: authorColorId, name: authorName} = await authorManager.getAuthor(sessionInfo.author));\n\n  // load the pad-object from the database\n  const pad = await padManager.getPad(sessionInfo.padId, null, sessionInfo.author);\n\n  // these db requests all need the pad object (timestamp of latest revision, author data)\n  const authors = pad.getAllAuthors();\n\n  // get timestamp of latest revision needed for timeslider\n  const currentTime = await pad.getRevisionDate(pad.getHeadRevisionNumber());\n\n  // get all author data out of the database (in parallel)\n  const historicalAuthorData:MapArrayType<{\n    name: string;\n    colorId: string;\n  }> = {};\n  await Promise.all(authors.map(async (authorId: string) => {\n    const author = await authorManager.getAuthor(authorId);\n    if (!author) {\n      messageLogger.error(`There is no author for authorId: ${authorId}. ` +\n          'This is possibly related to https://github.com/ether/etherpad-lite/issues/2802');\n    } else {\n      // Filter author attribs (e.g. don't send author's pads to all clients)\n      historicalAuthorData[authorId] = {name: author.name, colorId: author.colorId};\n    }\n  }));\n\n  // glue the clientVars together, send them and tell the other clients that a new one is there\n\n  // Check if the user has disconnected during any of the above awaits.\n  if (sessionInfo !== sessioninfos[socket.id]) throw new Error('client disconnected');\n\n  // Check if this author is already on the pad, if yes, kick the other sessions!\n  const roomSockets = _getRoomSockets(pad.id);\n\n  for (const otherSocket of roomSockets) {\n    // The user shouldn't have joined the room yet, but check anyway just in case.\n    if (otherSocket.id === socket.id) continue;\n    const sinfo = sessioninfos[otherSocket.id];\n    if (sinfo && sinfo.author === sessionInfo.author) {\n      // fix user's counter, works on page refresh or if user closes browser window and then rejoins\n      sessioninfos[otherSocket.id] = {};\n      otherSocket.leave(sessionInfo.padId);\n      otherSocket.emit('message', {disconnect: 'userdup'});\n    }\n  }\n\n  const {session: {user} = {}} = socket.client.request as SocketClientRequest;\n  /* eslint-disable prefer-template -- it doesn't support breaking across multiple lines */\n  accessLogger.info(`[${pad.head > 0 ? 'ENTER' : 'CREATE'}]` +\n                    ` pad:${sessionInfo.padId}` +\n                    ` socket:${socket.id}` +\n                    ` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` +\n                    ` authorID:${sessionInfo.author}` +\n                    (user && user.username ? ` username:${user.username}` : ''));\n  /* eslint-enable prefer-template */\n\n  if (message.reconnect) {\n    // If this is a reconnect, we don't have to send the client the ClientVars again\n    // Join the pad and start receiving updates\n    socket.join(sessionInfo.padId);\n\n    // Save the revision in sessioninfos, we take the revision from the info the client send to us\n    sessionInfo.rev = message.client_rev;\n\n    // During the client reconnect, client might miss some revisions from other clients.\n    // By using client revision,\n    // this below code sends all the revisions missed during the client reconnect\n    const revisionsNeeded = [];\n    const changesets:MapArrayType<any> = {};\n\n    let startNum = message.client_rev! + 1;\n    let endNum = pad.getHeadRevisionNumber() + 1;\n\n    const headNum = pad.getHeadRevisionNumber();\n\n    if (endNum > headNum + 1) {\n      endNum = headNum + 1;\n    }\n\n    if (startNum < 0) {\n      startNum = 0;\n    }\n\n    for (let r = startNum; r < endNum; r++) {\n      revisionsNeeded.push(r);\n      changesets[r] = {};\n    }\n\n    await Promise.all(revisionsNeeded.map(async (revNum) => {\n      const cs = changesets[revNum];\n      [cs.changeset, cs.author, cs.timestamp] = await Promise.all([\n        pad.getRevisionChangeset(revNum),\n        pad.getRevisionAuthor(revNum),\n        pad.getRevisionDate(revNum),\n      ]);\n    }));\n\n    // return pending changesets\n    for (const r of revisionsNeeded) {\n      const forWire = prepareForWire(changesets[r].changeset, pad.pool);\n      const wireMsg = {type: 'COLLABROOM',\n        data: {type: 'CLIENT_RECONNECT',\n          headRev: pad.getHeadRevisionNumber(),\n          newRev: r,\n          changeset: forWire.translated,\n          apool: forWire.pool,\n          author: changesets[r].author,\n          currentTime: changesets[r].timestamp}};\n      socket.emit('message', wireMsg);\n    }\n\n    if (startNum === endNum) {\n      const Msg = {type: 'COLLABROOM',\n        data: {type: 'CLIENT_RECONNECT',\n          noChanges: true,\n          newRev: pad.getHeadRevisionNumber()}};\n      socket.emit('message', Msg);\n    }\n  } else {\n    // This is a normal first connect\n    let atext;\n    let apool;\n    // prepare all values for the wire, there's a chance that this throws, if the pad is corrupted\n    try {\n      atext = cloneAText(pad.atext);\n      const attribsForWire = prepareForWire(atext.attribs, pad.pool);\n      apool = attribsForWire.pool.toJsonable();\n      atext.attribs = attribsForWire.translated;\n    } catch (e:any) {\n      messageLogger.error(e.stack || e);\n      socket.emit('message', {disconnect: 'corruptPad'}); // pull the brakes\n      throw new Error('corrupt pad');\n    }\n\n    // Warning: never ever send sessionInfo.padId to the client. If the client is read only you\n    // would open a security hole 1 swedish mile wide...\n    const clientVars:MapArrayType<any> = {\n      skinName: settings.skinName,\n      skinVariants: settings.skinVariants,\n      randomVersionString: settings.randomVersionString,\n      accountPrivs: {\n        maxRevisions: 100,\n      },\n      enableDarkMode: settings.enableDarkMode,\n      automaticReconnectionTimeout: settings.automaticReconnectionTimeout,\n      initialRevisionList: [],\n      initialOptions: {},\n      savedRevisions: pad.getSavedRevisions(),\n      collab_client_vars: {\n        initialAttributedText: atext,\n        clientIp: '127.0.0.1',\n        padId: sessionInfo.auth.padID,\n        historicalAuthorData,\n        apool,\n        rev: pad.getHeadRevisionNumber(),\n        time: currentTime,\n      },\n      colorPalette: authorManager.getColorPalette(),\n      clientIp: '127.0.0.1',\n      userColor: authorColorId,\n      padId: sessionInfo.auth.padID,\n      padOptions: settings.padOptions,\n      padShortcutEnabled: settings.padShortcutEnabled,\n      initialTitle: `Pad: ${sessionInfo.auth.padID}`,\n      opts: {},\n      // tell the client the number of the latest chat-message, which will be\n      // used to request the latest 100 chat-messages later (GET_CHAT_MESSAGES)\n      chatHead: pad.chatHead,\n      numConnectedUsers: roomSockets.length,\n      readOnlyId: sessionInfo.readOnlyPadId,\n      readonly: sessionInfo.readonly,\n      serverTimestamp: Date.now(),\n      sessionRefreshInterval: settings.cookie.sessionRefreshInterval,\n      userId: sessionInfo.author,\n      abiwordAvailable: abiwordAvailable(),\n      sofficeAvailable: sofficeAvailable(),\n      exportAvailable: exportAvailable(),\n      plugins: {\n        plugins: plugins.plugins,\n        parts: plugins.parts,\n      },\n      indentationOnNewLine: settings.indentationOnNewLine,\n      scrollWhenFocusLineIsOutOfViewport: {\n        percentage: {\n          editionAboveViewport:\n              settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionAboveViewport,\n          editionBelowViewport:\n              settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionBelowViewport,\n        },\n        duration: settings.scrollWhenFocusLineIsOutOfViewport.duration,\n        scrollWhenCaretIsInTheLastLineOfViewport:\n            settings.scrollWhenFocusLineIsOutOfViewport.scrollWhenCaretIsInTheLastLineOfViewport,\n        percentageToScrollWhenUserPressesArrowUp:\n            settings.scrollWhenFocusLineIsOutOfViewport.percentageToScrollWhenUserPressesArrowUp,\n      },\n      initialChangesets: [], // FIXME: REMOVE THIS SHIT,\n      mode: process.env.NODE_ENV\n    };\n\n    // Add a username to the clientVars if one avaiable\n    if (authorName != null) {\n      clientVars.userName = authorName;\n    }\n\n    // call the clientVars-hook so plugins can modify them before they get sent to the client\n    const messages = await hooks.aCallAll('clientVars', {clientVars, pad, socket});\n\n    // combine our old object with the new attributes from the hook\n    for (const msg of messages) {\n      Object.assign(clientVars, msg);\n    }\n\n    // Join the pad and start receiving updates\n    socket.join(sessionInfo.padId);\n\n    // Send the clientVars to the Client\n    socket.emit('message', {type: 'CLIENT_VARS', data: clientVars});\n\n    // Save the current revision in sessioninfos, should be the same as in clientVars\n    sessionInfo.rev = pad.getHeadRevisionNumber();\n  }\n\n  // Notify other users about this new user.\n  socket.broadcast.to(sessionInfo.padId).emit('message', {\n    type: 'COLLABROOM',\n    data: {\n      type: 'USER_NEWINFO',\n      userInfo: {\n        colorId: authorColorId,\n        name: authorName,\n        userId: sessionInfo.author,\n      },\n    },\n  });\n\n  // Notify this new user about other users.\n  await Promise.all(_getRoomSockets(pad.id).map(async (roomSocket) => {\n    if (roomSocket.id === socket.id) return;\n\n    // sessioninfos might change while enumerating, so check if the sessionID is still assigned to a\n    // valid session.\n    const sessionInfo = sessioninfos[roomSocket.id];\n    if (sessionInfo == null) return;\n\n    // get the authorname & colorId\n    const authorId = sessionInfo.author;\n    // The authorId of this other user might be unknown if the other user just connected and has\n    // not yet sent a CLIENT_READY message.\n    if (authorId == null) return;\n\n    // reuse previously created cache of author's data\n    const authorInfo = historicalAuthorData[authorId] || await authorManager.getAuthor(authorId);\n    if (authorInfo == null) {\n      messageLogger.error(\n          `Author ${authorId} connected via socket.io session ${roomSocket.id} is missing from ` +\n            'the global author database. This should never happen because the author ID is ' +\n            'generated by the same code that adds the author to the database.');\n      // Don't bother telling the new user about this mystery author.\n      return;\n    }\n\n    const msg = {\n      type: 'COLLABROOM',\n      data: {\n        type: 'USER_NEWINFO',\n        userInfo: {\n          colorId: authorInfo.colorId,\n          name: authorInfo.name,\n          userId: authorId,\n        },\n      },\n    };\n\n    socket.emit('message', msg);\n  }));\n\n  await hooks.aCallAll('userJoin', {\n    authorId: sessionInfo.author,\n    displayName: authorName,\n    padId: sessionInfo.padId,\n    readOnly: sessionInfo.readonly,\n    readOnlyPadId: sessionInfo.readOnlyPadId,\n    socket,\n  });\n};\n\n/**\n * Handles a request for a rough changeset, the timeslider client needs it\n */\nconst handleChangesetRequest = async (socket:any, {data: {granularity, start, requestID}}: ChangesetRequest) => {\n  if (granularity == null) throw new Error('missing granularity');\n  if (!Number.isInteger(granularity)) throw new Error('granularity is not an integer');\n  if (start == null) throw new Error('missing start');\n  start = checkValidRev(start);\n  if (requestID == null) throw new Error('mising requestID');\n  const end = start + (100 * granularity);\n  const {padId, author: authorId} = sessioninfos[socket.id];\n  const pad = await padManager.getPad(padId, null, authorId);\n  const headRev = pad.getHeadRevisionNumber();\n  if (start > headRev)\n    start = headRev;\n  const data:MapArrayType<any> = await getChangesetInfo(pad, start, end, granularity);\n  data.requestID = requestID;\n  socket.emit('message', {type: 'CHANGESET_REQ', data});\n};\n\n/**\n * Tries to rebuild the getChangestInfo function of the original Etherpad\n * https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L144\n */\nconst getChangesetInfo = async (pad: PadType, startNum: number, endNum:number, granularity: number) => {\n  const headRevision = pad.getHeadRevisionNumber();\n\n  // calculate the last full endnum\n  if (endNum > headRevision + 1) endNum = headRevision + 1;\n  endNum = Math.floor(endNum / granularity) * granularity;\n\n  const compositesChangesetNeeded = [];\n  const revTimesNeeded = [];\n\n  // figure out which composite Changeset and revTimes we need, to load them in bulk\n  for (let start = startNum; start < endNum; start += granularity) {\n    const end = start + granularity;\n\n    // add the composite Changeset we needed\n    compositesChangesetNeeded.push({start, end});\n\n    // add the t1 time we need\n    revTimesNeeded.push(start === 0 ? 0 : start - 1);\n\n    // add the t2 time we need\n    revTimesNeeded.push(end - 1);\n  }\n\n  // Get all needed db values in parallel.\n  const composedChangesets:MapArrayType<any> = {};\n  const revisionDate:number[] = [];\n  const [lines] = await Promise.all([\n    getPadLines(pad, startNum - 1),\n    // Get all needed composite Changesets.\n    ...compositesChangesetNeeded.map(async (item) => {\n      const changeset = await exports.composePadChangesets(pad, item.start, item.end);\n      composedChangesets[`${item.start}/${item.end}`] = changeset;\n    }),\n    // Get all needed revision Dates.\n    ...revTimesNeeded.map(async (revNum) => {\n      const revDate = await pad.getRevisionDate(revNum);\n      revisionDate[revNum] = revDate;\n    }),\n  ]);\n\n  // doesn't know what happens here exactly :/\n  const timeDeltas = [];\n  const forwardsChangesets = [];\n  const backwardsChangesets = [];\n  const apool = new AttributePool();\n\n  for (let compositeStart = startNum; compositeStart < endNum; compositeStart += granularity) {\n    const compositeEnd = compositeStart + granularity;\n    if (compositeEnd > endNum || compositeEnd > headRevision + 1) break;\n\n    const forwards = composedChangesets[`${compositeStart}/${compositeEnd}`];\n    const backwards = inverse(forwards, lines.textlines, lines.alines, pad.apool());\n\n    mutateAttributionLines(forwards, lines.alines, pad.apool());\n    mutateTextLines(forwards, lines.textlines);\n\n    const forwards2 = moveOpsToNewPool(forwards, pad.apool(), apool);\n    const backwards2 = moveOpsToNewPool(backwards, pad.apool(), apool);\n\n    const t1 = (compositeStart === 0) ? revisionDate[0] : revisionDate[compositeStart - 1];\n    const t2 = revisionDate[compositeEnd - 1];\n\n    timeDeltas.push(t2 - t1);\n    forwardsChangesets.push(forwards2);\n    backwardsChangesets.push(backwards2);\n  }\n\n  return {forwardsChangesets, backwardsChangesets,\n    apool: apool.toJsonable(), actualEndNum: endNum,\n    timeDeltas, start: startNum, granularity};\n};\n\n/**\n * Tries to rebuild the getPadLines function of the original Etherpad\n * https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L263\n */\nconst getPadLines = async (pad: PadType, revNum: number) => {\n  // get the atext\n  let atext;\n\n  if (revNum >= 0) {\n    atext = await pad.getInternalRevisionAText(revNum);\n  } else {\n    atext = makeAText('\\n');\n  }\n\n  return {\n    textlines: splitTextLines(atext.text),\n    alines: splitAttributionLines(atext.attribs, atext.text),\n  };\n};\n\n/**\n * Tries to rebuild the composePadChangeset function of the original Etherpad\n * https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L241\n */\nexports.composePadChangesets = async (pad: PadType, startNum: number, endNum: number) => {\n  // fetch all changesets we need\n  const headNum = pad.getHeadRevisionNumber();\n  endNum = Math.min(endNum, headNum + 1);\n  startNum = Math.max(startNum, 0);\n\n  // create an array for all changesets, we will\n  // replace the values with the changeset later\n  const changesetsNeeded = [];\n  for (let r = startNum; r < endNum; r++) {\n    changesetsNeeded.push(r);\n  }\n\n  // get all changesets\n  const changesets:MapArrayType<ChangeSet> = {};\n  await Promise.all(changesetsNeeded.map(\n      (revNum) => pad.getRevisionChangeset(revNum)\n          .then((changeset) => changesets[revNum] = changeset)));\n\n  // compose Changesets\n  let r;\n  try {\n    let changeset = changesets[startNum];\n    const pool = pad.apool();\n\n    for (r = startNum + 1; r < endNum; r++) {\n      const cs = changesets[r];\n      changeset = compose(changeset as string, cs as string, pool);\n    }\n    return changeset;\n  } catch (e) {\n    // r-1 indicates the rev that was build starting with startNum, applying startNum+1, +2, +3\n    messageLogger.warn(\n        `failed to compose cs in pad: ${pad.id} startrev: ${startNum} current rev: ${r}`);\n    throw e;\n  }\n};\n\nconst _getRoomSockets = (padID: string) => {\n  const ns = socketio.sockets; // Default namespace.\n  // We could call adapter.clients(), but that method is unnecessarily asynchronous. Replicate what\n  // it does here, but synchronously to avoid a race condition. This code will have to change when\n  // we update to socket.io v3.\n  const room = ns.adapter.rooms?.get(padID);\n\n  if (!room) return [];\n\n  return Array.from(room)\n    .map(socketId => ns.sockets.get(socketId))\n    .filter(socket => socket);\n};\n\n/**\n * Get the number of users in a pad\n */\nexports.padUsersCount = (padID:string) => ({\n  padUsersCount: _getRoomSockets(padID).length,\n});\n\n/**\n * Get the list of users in a pad\n */\nexports.padUsers = async (padID: string) => {\n  const padUsers:PadAuthor[] = [];\n\n  // iterate over all clients (in parallel)\n  await Promise.all(_getRoomSockets(padID).map(async (roomSocket) => {\n    const s = sessioninfos[roomSocket.id];\n    if (s) {\n      const author = await authorManager.getAuthor(s.author);\n      // Fixes: https://github.com/ether/etherpad-lite/issues/4120\n      // On restart author might not be populated?\n      if (author) {\n        author.id = s.author;\n        padUsers.push(author);\n      }\n    }\n  }));\n\n  return {padUsers};\n};\n\nexports.sessioninfos = sessioninfos;\n"
  },
  {
    "path": "src/node/handler/RestAPI.ts",
    "content": "import {ArgsExpressType} from \"../types/ArgsExpressType\";\nimport {MapArrayType} from \"../types/MapType\";\nimport {IncomingForm} from \"formidable\";\nimport {ErrorCaused} from \"../types/ErrorCaused\";\nimport createHTTPError from \"http-errors\";\n\nconst apiHandler = require('./APIHandler')\nimport {serve, setup} from 'swagger-ui-express'\nimport express from \"express\";\n\nimport settings from '../utils/Settings';\n\n\ntype RestAPIMapping = {\n  apiVersion: string;\n  functionName: string,\n  summary?: string,\n  operationId?: string,\n  requestBody?: any,\n  responses?: any,\n  tags?: string[],\n}\n\n\nconst mapping = new Map<string, Record<string, RestAPIMapping>>\n\n\nconst GET = \"GET\"\nconst POST = \"POST\"\nconst PUT = \"PUT\"\nconst DELETE = \"DELETE\"\nconst PATCH = \"PATCH\"\n\n\nconst defaultResponses = {\n  \"200\": {\n    \"description\": \"ok (code 0)\",\n    \"content\": {\n      \"application/json\": {\n        \"schema\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"code\": {\n              \"type\": \"integer\",\n              \"example\": 0\n            },\n            \"message\": {\n              \"type\": \"string\",\n              \"example\": \"ok\"\n            },\n            \"data\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"groupID\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        }\n      }\n    }\n  },\n  \"400\": {\n    \"description\": \"generic api error (code 1)\",\n    \"content\": {\n      \"application/json\": {\n        \"schema\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"code\": {\n              \"type\": \"integer\",\n              \"example\": 1\n            },\n            \"message\": {\n              \"type\": \"string\",\n              \"example\": \"error message\"\n            },\n            \"data\": {\n              \"type\": \"object\",\n              \"example\": null\n            }\n          }\n        }\n      }\n    }\n  },\n  \"401\": {\n    \"description\": \"no or wrong API key (code 4)\",\n    \"content\": {\n      \"application/json\": {\n        \"schema\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"code\": {\n              \"type\": \"integer\",\n              \"example\": 4\n            },\n            \"message\": {\n              \"type\": \"string\",\n              \"example\": \"no or wrong API key\"\n            },\n            \"data\": {\n              \"type\": \"object\",\n              \"example\": null\n            }\n          }\n        }\n      }\n    }\n  },\n  \"500\": {\n    \"description\": \"internal api error (code 2)\",\n    \"content\": {\n      \"application/json\": {\n        \"schema\": {\n          \"type\": \"object\",\n          \"properties\": {\n            \"code\": {\n              \"type\": \"integer\",\n              \"example\": 2\n            },\n            \"message\": {\n              \"type\": \"string\",\n              \"example\": \"internal error\"\n            },\n            \"data\": {\n              \"type\": \"object\",\n              \"example\": null\n            }\n          }\n        }\n      }\n    }\n  },\n  \"tags\": [\n    \"group\"\n  ],\n  \"parameters\": []\n}\n\nconst prepareResponses = (data: {\n  type: string,\n  properties: Record<string, {\n    type: string,\n    items?: Record<string, any>,\n    properties?: Record<string, any>,\n  }>,\n}) => {\n  return {\n    ...defaultResponses,\n    200: {\n      ...defaultResponses[\"200\"],\n      content: {\n        ...defaultResponses[\"200\"].content,\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              ...defaultResponses[\"200\"].content[\"application/json\"].schema.properties,\n              data: data\n            }\n          }\n        }\n      }\n    }\n  }\n}\n\n\nconst prepareDefinition = (mapping: Map<string, Record<string, RestAPIMapping>>, address: string) => {\n  const authenticationMethod = settings.authenticationMethod\n\n\n  const definitions: {\n    \"openapi\": string,\n    \"info\": {\n      \"title\": string,\n      \"description\": string,\n      \"termsOfService\": string,\n      \"contact\": {\n        \"name\": string,\n        \"url\": string,\n        \"email\": string,\n      },\n    },\n    \"components\": {\n      \"securitySchemes\": {\n        \"apiKey\": {\n          \"type\": string,\n          \"name\": string,\n          \"in\": string\n\n        },\n        \"sso\"?: {\n          \"type\": string,\n          \"flows\": {\n            \"authorizationCode\": {\n              \"authorizationUrl\": string,\n              \"tokenUrl\": string,\n              \"scopes\": {\n                \"openid\": string,\n                \"profile\": string,\n                \"email\": string,\n                \"admin\": string\n              }\n            }\n          }\n        }\n      },\n    },\n    \"servers\": [\n      {\n        \"url\": string\n      }\n    ],\n    \"paths\": Record<string, Record<string, {\n      \"summary\": string,\n      \"operationId\": string,\n      \"requestBody\"?: any,\n      \"responses\": any,\n      \"parameters\"?: any,\n      \"tags\": Array<string | { name: string, description: string }>,\n    }>>,\n    \"security\": any[]\n  } = {\n    \"openapi\": \"3.0.2\",\n    \"info\": {\n      \"title\": \"Etherpad API\",\n      \"description\": \"Etherpad is a real-time collaborative editor scalable to thousands of simultaneous real time users. It provides full data export capabilities, and runs on your server, under your control.\",\n      \"termsOfService\": \"https://etherpad.org/\",\n      \"contact\": {\n        \"name\": \"The Etherpad Foundation\",\n        \"url\": \"https://etherpad.org/\",\n        \"email\": \"\",\n      },\n    },\n    \"components\": {\n      \"securitySchemes\": {\n        \"apiKey\": {\n          \"type\": \"apiKey\",\n          \"name\": \"apikey\",\n          \"in\": \"query\"\n\n        },\n      },\n    },\n    \"servers\": [\n      {\n        \"url\": `${address}/api/2`\n      }\n    ],\n    \"paths\": {},\n    \"security\": []\n  }\n\n  if (authenticationMethod === \"apikey\") {\n    definitions.security = [\n      {\n        \"apiKey\": []\n      }\n    ]\n  } else if (authenticationMethod === \"sso\") {\n    definitions.components.securitySchemes.sso = {\n      type: \"oauth2\",\n      flows: {\n        authorizationCode: {\n          authorizationUrl: settings.sso.issuer + \"/oidc/auth\",\n          tokenUrl: settings.sso.issuer + \"/oidc/token\",\n          scopes: {\n            openid: \"openid\",\n            profile: \"profile\",\n            email: \"email\",\n            admin: \"admin\"\n          }\n        }\n      },\n    }\n\n    definitions.security = [\n      {\n        \"sso\": []\n      }\n    ]\n  }\n\n\n  for (const [method, value] of mapping) {\n    for (const [path, mapping] of Object.entries(value)) {\n      const {apiVersion, functionName, summary, operationId, requestBody, responses, tags} = mapping\n      if (!definitions.paths[path]) {\n        definitions.paths[path] = {}\n      }\n\n      const methodLowercased = method.toLowerCase()\n\n      definitions.paths[path][methodLowercased] = {\n        summary: summary!,\n        operationId: operationId!,\n        responses,\n        tags: tags!\n      }\n\n      if (method === GET) {\n        definitions.paths[path][methodLowercased].parameters = requestBody\n      } else {\n        definitions.paths[path][methodLowercased].requestBody = requestBody\n      }\n    }\n  }\n  return definitions\n}\n\n\nexport const expressCreateServer = async (hookName: string, {app}: ArgsExpressType) => {\n  mapping.set(GET, {})\n  mapping.set(POST, {})\n  mapping.set(PUT, {})\n  mapping.set(DELETE, {})\n  mapping.set(PATCH, {})\n\n  // Version 1\n  mapping.get(POST)![\"/groups\"] = {\n    apiVersion: '1',\n    functionName: 'createGroup', summary: 'Creates a new group',\n    operationId: 'createGroup', tags: ['group'], responses: prepareResponses({type: \"object\", properties: {groupID: {type: \"string\"}}})\n\n  }\n  mapping.get(POST)![\"/groups/createIfNotExistsFor\"] = {\n    apiVersion: '1', functionName: 'createGroupIfNotExistsFor',\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              groupMapper: {\n                type: \"string\"\n              }\n            },\n            required: [\"groupMapper\"]\n          }\n        }\n      }\n    },\n    responses: prepareResponses({type: \"object\", properties: {groupID: {type: \"string\"}}}),\n    summary: \"Creates a new group if it doesn't exist\", operationId: 'createGroupIfNotExistsFor', tags: ['group']\n  };\n  mapping.get(GET)![\"/groups/pads\"] = {\n    apiVersion: '1', functionName: 'listPads',\n    summary: \"Lists all pads in a group\", tags: ['group'],\n    operationId: 'listPads', responses: prepareResponses({type: \"object\", properties: {padIDs: {type: \"string\"}}}),\n    requestBody: [\n      {\n        \"name\": \"groupID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ]\n  }\n  mapping.get(DELETE)![\"/groups\"] = {\n    apiVersion: '1', functionName: 'deleteGroup', responses: prepareResponses({type: \"object\", properties: {}}), requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              groupID: {\n                type: \"string\"\n              }\n            },\n            required: [\"groupID\"]\n          }\n        }\n      }\n    }, summary: \"Deletes a group\", operationId: 'deleteGroup', tags: ['group']\n  }\n\n  mapping.get(POST)![\"/authors\"] = {\n    apiVersion: '1', functionName: 'createAuthor', requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              name: {\n                type: \"string\"\n              }\n            },\n            required: [\"name\"]\n          }\n        }\n      }\n    }, tags: [\"author\"]\n  }\n\n\n  mapping.get(POST)![\"/authors/createIfNotExistsFor\"] = {\n    apiVersion: '1', functionName: 'createAuthorIfNotExistsFor',\n    responses: prepareResponses({type: \"object\", properties: {authorID: {type: \"string\"}}}),\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              authorMapper: {\n                type: \"string\"\n              },\n              name: {\n                type: \"string\"\n              }\n            },\n            required: [\"authorMapper\", \"name\"]\n          }\n        }\n      }\n    },\n    tags: [\"author\"],\n  }\n\n\n  mapping.get(GET)![\"/authors/pads\"] = {\n    apiVersion: '1', functionName: 'listPadsOfAuthor',\n    requestBody: [\n      {\n        \"name\": \"authorID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    responses: prepareResponses({type: \"object\", properties: {padIDs: {type: \"array\", items: {type: \"string\"}}}}),\n    tags: [\"author\"]\n  }\n  mapping.get(POST)![\"/sessions\"] = {\n    apiVersion: '1', functionName: 'createSession',\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              groupID: {\n                type: \"string\"\n              },\n              authorID: {\n                type: \"string\"\n              },\n              validUntil: {\n                type: \"string\"\n              }\n            },\n            required: [\"groupID\", \"authorID\", \"validUntil\"]\n          }\n        }\n      }\n    },\n    responses: prepareResponses({type: \"object\", properties: {sessionID: {type: \"string\"}}}),\n    tags: ['session']\n  }\n\n  mapping.get(DELETE)![\"/sessions\"] = {\n    apiVersion: '1', functionName: 'deleteSession',\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              sessionID: {\n                type: \"string\"\n              }\n            },\n            required: [\"sessionID\"]\n          }\n        }\n      }\n    },\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    tags: ['session']\n  }\n\n\n  mapping.get(GET)![\"/sessions/info\"] = {\n    apiVersion: '1', functionName: 'getSessionInfo',\n    requestBody: [\n      {\n        \"name\": \"sessionID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    responses: prepareResponses({\n      \"type\": \"object\",\n      \"properties\": {\n        id: {\n          type: \"string\"\n        },\n        \"groupID\": {\n          \"type\": \"string\"\n        },\n        \"authorID\": {\n          \"type\": \"string\"\n        },\n        \"validUntil\": {\n          \"type\": \"string\"\n        }\n      }\n    }),\n    tags: ['session']\n  }\n\n\n  mapping.get(GET)![\"/sessions/group\"] = {\n    apiVersion: '1', functionName: 'listSessionsOfGroup', summary: 'Lists all sessions in a group',\n    operationId: 'listSessionsOfGroup', tags: ['session'],\n    responses: prepareResponses({\n      type: \"object\", \"properties\": {\n        \"sessions\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"object\",\n            \"properties\": {\n              \"id\": {\n                \"type\": \"string\"\n              },\n              \"authorID\": {\n                \"type\": \"string\"\n              },\n              \"groupID\": {\n                \"type\": \"string\"\n              },\n              \"validUntil\": {\n                \"type\": \"integer\"\n              }\n            }\n          }\n        }\n      }\n    }),\n    requestBody: [\n      {\n        \"name\": \"groupID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ]\n  }\n  mapping.get(GET)![\"/sessions/author\"] = {\n    apiVersion: '1', functionName: 'listSessionsOfAuthor',\n    summary: 'Lists all sessions of an author', operationId: 'listSessionsOfAuthor', tags: ['session'],\n    responses: prepareResponses({\n      type: \"object\", \"properties\": {\n        \"sessions\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"object\",\n            \"properties\": {\n              \"id\": {\n                \"type\": \"string\"\n              },\n              \"authorID\": {\n                \"type\": \"string\"\n              },\n              \"groupID\": {\n                \"type\": \"string\"\n              },\n              \"validUntil\": {\n                \"type\": \"integer\"\n              }\n            }\n          }\n        }\n      }\n    }),\n    requestBody: [\n      {\n        \"name\": \"authorID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ]\n  }\n\n\n  mapping.get(GET)![\"/pads/text\"] = {\n    apiVersion: '1', functionName: 'getText',\n    responses: prepareResponses({type: \"object\", properties: {text: {type: \"string\"}}}),\n    requestBody: [\n      {\n        \"name\": \"padID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    tags: ['pad']\n  }\n\n\n  mapping.get(GET)![\"/pads/html\"] = {\n    apiVersion: '1', functionName: 'getHTML',\n    responses: prepareResponses({type: \"object\", properties: {html: {type: \"string\"}}}),\n    requestBody: [\n      {\n        \"name\": \"padID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    summary: 'Get the HTML of a pad',\n    tags: ['pad']\n  }\n  mapping.get(GET)![\"/pads/revisions\"] = {\n    apiVersion: '1', functionName: 'getRevisionsCount',\n    responses: prepareResponses({type: \"object\", properties: {revisions: {type: \"integer\"}}}),\n    requestBody: [\n      {\n        \"name\": \"padID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    summary: 'Get the number of revisions of a pad',\n    tags: ['pad']\n  }\n\n  mapping.get(GET)![\"/pads/lastEdited\"] = {\n    apiVersion: '1', functionName: 'getLastEdited',\n    responses: prepareResponses({type: \"object\", properties: {lastEdited: {type: \"integer\"}}}),\n    requestBody: [\n      {\n        \"name\": \"padID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    summary: 'Get the timestamp of the last revision of a pad',\n    tags: ['pad']\n  }\n\n\n  mapping.get(DELETE)![\"/pads\"] = {\n    apiVersion: '1', functionName: 'deletePad',\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              padID: {\n                type: \"string\"\n              }\n            },\n            required: [\"padID\"]\n          }\n        }\n      }\n    },\n    summary: 'Deletes a pad',\n    tags: ['pad']\n  }\n  mapping.get(GET)![\"/pads/readonly\"] = {\n    apiVersion: '1', functionName: 'getReadOnlyID',\n    responses: prepareResponses({type: \"object\", properties: {readOnlyID: {type: \"string\"}}}),\n    requestBody: [\n      {\n        \"name\": \"padID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    summary: 'Get the read only id of a pad',\n    tags: ['pad']\n  }\n\n  mapping.get(POST)![\"/pads/publicStatus\"] = {\n    apiVersion: '1', functionName: 'setPublicStatus',\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              padID: {\n                type: \"string\"\n              },\n              publicStatus: {\n                type: \"boolean\"\n              }\n            },\n            required: [\"padID\", \"publicStatus\"]\n          }\n        }\n      }\n    },\n    summary: 'Set the public status of a pad',\n    tags: ['pad']\n\n  }\n  mapping.get(GET)![\"/pads/publicStatus\"] = {\n    apiVersion: '1', functionName: 'getPublicStatus',\n    responses: prepareResponses({type: \"object\", properties: {publicStatus: {type: \"boolean\"}}}),\n    requestBody: [\n      {\n        \"name\": \"padID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    summary: 'Get the public status of a pad',\n    tags: ['pad']\n  }\n  mapping.get(GET)![\"/pads/authors\"] = {\n    apiVersion: '1', functionName: 'listAuthorsOfPad',\n    responses: prepareResponses({type: \"object\", properties: {authorIDs: {type: \"array\", items: {type: \"string\"}}}}),\n    requestBody: [\n      {\n        \"name\": \"padID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    summary: 'Get the authors of a pad',\n    tags: ['pad']\n  }\n  mapping.get(GET)![\"/pads/usersCount\"] = {\n    apiVersion: '1', functionName: 'padUsersCount',\n    responses: prepareResponses({type: \"object\", properties: {padUsersCount: {type: \"integer\"}}}),\n    requestBody: [\n      {\n        \"name\": \"padID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    summary: 'Get the number of users currently editing a pad',\n    tags: ['pad']\n  }\n\n\n  // Version 1.1\n  mapping.get(GET)![\"/authors/name\"] = {\n    apiVersion: '1.1', functionName: 'getAuthorName',\n    responses: prepareResponses({type: \"object\", properties: {authorName: {type: \"string\"}}}),\n    requestBody: [\n      {\n        \"name\": \"authorID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    summary: 'Get the name of an author',\n    tags: ['author']\n  }\n  mapping.get(GET)![\"/pads/users\"] = {\n    apiVersion: '1.1', functionName: 'padUsers',\n    responses: prepareResponses({\n      type: \"object\", properties: {\n        padUsers: {\n          type: \"array\", \"items\": {\n            \"type\": \"object\",\n            \"properties\": {\n              \"id\": {\n                \"type\": \"string\"\n              },\n              \"colorId\": {\n                \"type\": \"string\"\n              },\n              \"name\": {\n                \"type\": \"string\"\n              },\n              \"timestamp\": {\n                \"type\": \"integer\"\n              }\n            }\n          }\n        }\n      }\n    }),\n    requestBody: [\n      {\n        \"name\": \"padID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    summary: 'Get the users currently editing a pad',\n    tags: ['pad']\n  }\n\n\n  mapping.get(POST)![\"/pads/clientsMessage\"] = {\n    apiVersion: '1.1', functionName: 'sendClientsMessage',\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              padID: {\n                type: \"string\"\n              },\n              msg: {\n                type: \"string\"\n              }\n            },\n            required: [\"padID\", \"msg\"]\n          }\n        }\n      }\n    },\n    summary: 'Send a message to all clients of a pad',\n    tags: ['pad']\n  }\n\n\n  mapping.get(GET)![\"/groups\"] = {\n    apiVersion: '1.1', functionName: 'listAllGroups',\n    responses: prepareResponses({type: \"object\", properties: {groupIDs: {type: \"array\", items: {type: \"string\"}}}}),\n    summary: 'Lists all groups',\n    tags: ['group']\n  }\n\n\n  // Version 1.2\n  mapping.get(GET)![\"/checkToken\"] = {\n    apiVersion: '1.2', functionName: 'checkToken',\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    requestBody: [\n      {\n        \"name\": \"token\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    summary: 'Check if a token is valid',\n    tags: ['token']\n\n  }\n\n  // Version 1.2.1\n  mapping.get(GET)![\"/pads\"] = {\n    apiVersion: '1.2.1', functionName: 'listAllPads',\n    summary: 'Lists all pads',\n    tags: ['pad'],\n    requestBody: [\n      {\n        \"name\": \"groupID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    responses: prepareResponses({type: \"object\", properties: {padIDs: {type: \"array\", items: {type: \"string\"}}}})\n  }\n\n  // Version 1.2.7\n  mapping.get(POST)![\"/pads/diff\"] = {\n    apiVersion: '1.2.7', functionName: 'createDiffHTML',\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              padID: {\n                type: \"string\"\n              },\n              startRev: {\n                type: \"integer\"\n              },\n              endRev: {\n                type: \"integer\"\n              }\n            },\n            required: [\"padID\", \"startRev\", \"endRev\"]\n          }\n        }\n      }\n    },\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    summary: 'Creates a diff of a pad',\n    tags: ['pad']\n  }\n  mapping.get(GET)![\"/pads/chatHistory\"] = {\n    apiVersion: '1.2.7', functionName: 'getChatHistory',\n    responses: prepareResponses({\n      type: \"object\", properties: {\n        messages: {\n          type: \"array\", items: {\n            type: \"object\", properties: {\n              \"text\": {\n                \"type\": \"string\"\n              },\n              \"userId\": {\n                \"type\": \"string\"\n              },\n              \"userName\": {\n                \"type\": \"string\"\n              },\n              \"time\": {\n                \"type\": \"integer\"\n              }\n            }\n          }\n        }\n      }\n    }),\n    requestBody: [\n      {\n        \"name\": \"padID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    summary: 'Get the chat history of a pad',\n    tags: ['pad']\n  }\n  mapping.get(GET)![\"/pads/chatHead\"] = {\n    apiVersion: '1.2.7', functionName: 'getChatHead',\n    responses: prepareResponses({\n      type: \"object\", properties: {\n        chatHead: {\n          type: \"object\",\n          properties: {\n            \"text\": {\n              \"type\": \"string\"\n            },\n            \"userId\": {\n              \"type\": \"string\"\n            },\n            \"userName\": {\n              \"type\": \"string\"\n            },\n            \"time\": {\n              \"type\": \"integer\"\n            }\n          }\n        }\n\n      }\n    }),\n    requestBody: [\n      {\n        \"name\": \"padID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    summary: 'Get the chat head of a pad',\n    tags: ['pad']\n\n  }\n\n  // Version 1.2.8\n  mapping.get(GET)![\"/pads/attributePool\"] = {\n    apiVersion: '1.2.8', functionName: 'getAttributePool',\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    requestBody: [\n      {\n        \"name\": \"padID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    summary: 'Get the attribute pool of a pad',\n    tags: ['pad']\n  }\n  mapping.get(GET)![\"/pads/revisionChangeset\"] = {\n    apiVersion: '1.2.8', functionName: 'getRevisionChangeset',\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    requestBody: [\n      {\n        \"name\": \"padID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      },\n      {\n        \"name\": \"rev\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"integer\"\n        }\n      }\n    ],\n    summary: 'Get the changeset of a revision of a pad',\n    tags: ['pad']\n  }\n\n  // Version 1.2.9\n  mapping.get(POST)![\"/pads/copypad\"] = {\n    apiVersion: '1.2.9', functionName: 'copyPad',\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              sourceID: {\n                type: \"string\"\n              },\n              destinationID: {\n                type: \"string\"\n              },\n              force: {\n                type: \"boolean\"\n              }\n            },\n            required: [\"sourceID\", \"destinationID\"]\n          }\n        }\n      }\n    },\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    summary: 'Copies a pad',\n    tags: ['pad']\n  }\n\n\n  mapping.get(POST)![\"/pads/movePad\"] = {\n    apiVersion: '1.2.9', functionName: 'movePad',\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              sourceID: {\n                type: \"string\"\n              },\n              destinationID: {\n                type: \"string\"\n              },\n              force: {\n                type: \"boolean\"\n              }\n            },\n            required: [\"sourceID\", \"destinationID\"]\n          }\n        }\n      }\n    },\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    summary: 'Moves a pad',\n    tags: ['pad']\n  }\n\n  // Version 1.2.10\n  mapping.get(POST)![\"/pads/padId\"] = {\n    apiVersion: '1.2.10', functionName: 'getPadID',\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              roID: {\n                type: \"string\"\n              }\n            },\n            required: [\"roID\"]\n          }\n        }\n      }\n    },\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    summary: 'Get the pad id of a pad',\n    tags: ['pad']\n  }\n\n  // Version 1.2.11\n  mapping.get(GET)![\"/savedRevisions\"] = {\n    apiVersion: '1.2.11', functionName: 'listSavedRevisions',\n    responses: prepareResponses({type: \"object\", properties: {savedRevisions: {type: \"array\", items: {type: \"object\"}}}}),\n    requestBody: [\n      {\n        \"name\": \"padID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    summary: 'Lists all saved revisions of a pad',\n    tags: ['pad']\n  }\n\n\n  mapping.get(POST)![\"/savedRevisions\"] = {\n    apiVersion: '1.2.11', functionName: 'saveRevision',\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              padID: {\n                type: \"string\"\n              },\n              rev: {\n                type: \"integer\"\n              }\n            },\n            required: [\"padID\", \"rev\"]\n          }\n        }\n      }\n    },\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    summary: 'Saves a revision of a pad',\n    tags: ['pad']\n  }\n\n  mapping.get(GET)![\"/savedRevisions/revisionsCount\"] = {\n    apiVersion: '1.2.11', functionName: 'getSavedRevisionsCount',\n    responses: prepareResponses({type: \"object\", properties: {revisionsCount: {type: \"integer\"}}}),\n    requestBody: [\n      {\n        \"name\": \"padID\",\n        \"in\": \"query\",\n        \"schema\": {\n          \"type\": \"string\"\n        }\n      }\n    ],\n    summary: 'Get the number of saved revisions of a pad',\n    tags: ['pad']\n  }\n\n  // Version 1.2.12\n  mapping.get(PATCH)![\"/chats/messages\"] = {\n    apiVersion: '1.2.12', functionName: 'appendChatMessage',\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              padID: {\n                type: \"string\"\n              },\n              text: {\n                type: \"string\"\n              },\n              authorID: {\n                type: \"string\"\n              },\n              time: {\n                type: \"string\"\n              }\n            },\n            required: [\"padID\", \"text\"]\n          }\n        }\n      }\n    },\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    summary: 'Appends a chat message to a pad',\n    tags: ['pad']\n  }\n\n  // Version 1.2.13\n\n  // Version 1.2.14\n  mapping.get(GET)![\"/stats\"] = {\n    apiVersion: '1.2.14', functionName: 'getStats',\n    responses: prepareResponses({type: \"object\", properties: {stats: {type: \"object\"}}}),\n    summary: 'Get stats',\n    tags: ['stats']\n  }\n\n  // Version 1.2.15\n\n  // Version 1.3.0\n  mapping.get(PATCH)![\"/pads/text\"] = {\n    apiVersion: '1.3.0', functionName: 'appendText',\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              padID: {\n                type: \"string\"\n              },\n              text: {\n                type: \"string\"\n              },\n              authorID: {\n                type: \"string\"\n              },\n            },\n            required: [\"padID\", \"text\", \"authorID\"]\n          }\n        }\n      }\n    },\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    summary: 'Appends text to a pad',\n    tags: ['pad']\n  }\n  mapping.get(POST)![\"/pads/copyWithoutHistory\"] = {\n    apiVersion: '1.3.0', functionName: 'copyPadWithoutHistory',\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              sourceID: {\n                type: \"string\"\n              },\n              destinationID: {\n                type: \"string\"\n              },\n              force: {\n                type: \"string\"\n              },\n              authorID: {\n                type: \"string\"\n              }\n            },\n            required: [\"sourceID\", \"destinationID\", \"force\", \"authorID\"]\n          }\n        }\n      }\n    },\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    summary: 'Copies a pad without its history',\n    tags: ['pad']\n  }\n  mapping.get(POST)![\"/pads/group\"] = {\n    apiVersion: '1.3.0', functionName: 'createGroupPad',\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              groupID: {\n                type: \"string\"\n              },\n              padName: {\n                type: \"string\"\n              },\n              text: {\n                type: \"string\"\n              },\n              authorID: {\n                type: \"string\"\n              }\n            },\n            required: [\"groupID\", \"padName\"]\n          }\n        }\n      }\n    },\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    summary: 'Creates a new pad in a group',\n    tags: ['pad']\n\n  }\n  mapping.get(POST)![\"/pads\"] = {\n    apiVersion: '1.3.0', functionName: 'createPad',\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              padID: {\n                type: \"string\"\n              },\n              text: {\n                type: \"string\"\n              },\n              authorId: {\n                type: \"string\"\n              }\n            },\n            required: [\"padName\"]\n          }\n        }\n      }\n    },\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    summary: 'Creates a new pad',\n    tags: ['pad']\n  }\n  mapping.get(PATCH)![\"/savedRevisions\"] = {\n    apiVersion: '1.3.0', functionName: 'restoreRevision',\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              padID: {\n                type: \"string\"\n              },\n              rev: {\n                type: \"integer\"\n              },\n              authorId: {\n                type: \"string\"\n              }\n            },\n            required: [\"padID\", \"rev\", \"authorId\"]\n          }\n        }\n      }\n    },\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    summary: 'Restores a revision of a pad',\n    tags: ['pad']\n  }\n\n\n  mapping.get(POST)![\"/pads/html\"] = {\n    apiVersion: '1.3.0', functionName: 'setHTML',\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              padID: {\n                type: \"string\"\n              },\n              html: {\n                type: \"string\"\n              },\n              authorId: {\n                type: \"string\"\n              }\n            },\n            required: [\"padID\", \"html\", \"authorId\"]\n          }\n        }\n      }\n    },\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    summary: 'Sets the HTML of a pad',\n    tags: ['pad']\n  }\n\n  mapping.get(POST)![\"/pads/text\"] = {\n    apiVersion: '1.3.0', functionName: 'setText',\n    requestBody: {\n      content: {\n        \"application/json\": {\n          schema: {\n            type: \"object\",\n            properties: {\n              padID: {\n                type: \"string\"\n              },\n              text: {\n                type: \"string\"\n              },\n              authorId: {\n                type: \"string\"\n              }\n            },\n            required: [\"padID\", \"text\", \"authorId\"]\n          }\n        }\n      }\n    },\n    responses: prepareResponses({type: \"object\", properties: {}}),\n    summary: 'Sets the text of a pad',\n    tags: ['pad']\n  }\n\n\n  app.use('/api-docs', serve);\n  app.get('/api-docs', setup(undefined, {\n    swaggerOptions: {\n      url: '/api-docs.json',\n    },\n  }));\n\n  app.use(express.json());\n\n  app.get('/api-docs.json', (req, res) => {\n    const fullUrl = req.protocol + '://' + req.get('host');\n    const generatedDefinition = prepareDefinition(mapping, fullUrl)\n    res.json(generatedDefinition)\n  })\n  app.use('/api/2', async (req, res, next) => {\n    const method = req.method\n    const pathToFunction = req.path\n    // parse fields from request\n    const {headers, params, query} = req;\n\n    // read form data if method was POST\n    let formData: MapArrayType<any> = {};\n    if (method.toLowerCase() === 'post' || method.toLowerCase() === \"delete\") {\n      if (!req.headers['content-type'] || req.headers['content-type']!.startsWith('application/json')) {\n        // parse json\n        formData = req.body;\n      } else {\n        const form = new IncomingForm();\n        formData = (await form.parse(req))[0];\n        for (const k of Object.keys(formData)) {\n          if (formData[k] instanceof Array) {\n            formData[k] = formData[k][0];\n          }\n        }\n      }\n    }\n\n    const fields = Object.assign({}, headers, params, query, formData);\n\n    if (mapping.has(method) && pathToFunction in mapping.get(method)!) {\n      const {apiVersion, functionName} = mapping.get(method)![pathToFunction]!\n      // pass to api handler\n      let response;\n      try {\n        try {\n          let data = await apiHandler.handle(apiVersion, functionName, fields, req, res);\n\n          // return in common format\n          response = {code: 0, message: 'ok', data: data || null};\n        } catch (err) {\n          const errCaused = err as ErrorCaused\n          // convert all errors to http errors\n          if (createHTTPError.isHttpError(err)) {\n            // pass http errors thrown by handler forward\n            throw err;\n          } else if (errCaused.name === 'apierror') {\n            // parameters were wrong and the api stopped execution, pass the error\n            // convert to http error\n            throw new createHTTPError.BadRequest(errCaused.message);\n          } else {\n            // an unknown error happened\n            // log it and throw internal error\n            console.error(errCaused.stack || errCaused.toString());\n            throw new createHTTPError.InternalServerError('internal error');\n          }\n        }\n      } catch (err) {\n        const errCaused = err as ErrorCaused\n        // handle http errors\n        // @ts-ignore\n        res.statusCode = errCaused.statusCode || 500;\n\n        // convert to our json response format\n        // https://github.com/ether/etherpad-lite/tree/master/doc/api/http_api.md#response-format\n        switch (res.statusCode) {\n          case 403: // forbidden\n            response = {code: 4, message: errCaused.message, data: null};\n            break;\n          case 401: // unauthorized (no or wrong api key)\n            response = {code: 4, message: errCaused.message, data: null};\n            break;\n          case 404: // not found (no such function)\n            response = {code: 3, message: errCaused.message, data: null};\n            break;\n          case 500: // server error (internal error)\n            response = {code: 2, message: errCaused.message, data: null};\n            break;\n          case 400: // bad request (wrong parameters)\n            // respond with 200 OK to keep old behavior and pass tests\n            res.statusCode = 200; // @TODO: this is bad api design\n            response = {code: 1, message: errCaused.message, data: null};\n            break;\n          default:\n            response = {code: 1, message: errCaused.message, data: null};\n            break;\n        }\n      }\n\n\n      console.debug(`RESPONSE, ${functionName}, ${JSON.stringify(response)}`);\n\n      // return the response data\n      res.json(response);\n    } else {\n      res.json({code: 1, message: 'not found'});\n    }\n  })\n}\n"
  },
  {
    "path": "src/node/handler/SocketIORouter.ts",
    "content": "'use strict';\n/**\n * This is the Socket.IO Router. It routes the Messages between the\n * components of the Server. The components are at the moment: pad and timeslider\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {MapArrayType} from \"../types/MapType\";\nimport {SocketModule} from \"../types/SocketModule\";\nimport log4js from 'log4js';\nimport settings from '../utils/Settings';\nconst stats = require('../../node/stats')\n\nconst logger = log4js.getLogger('socket.io');\n\n/**\n * Saves all components\n * key is the component name\n * value is the component module\n */\nconst components:MapArrayType<any> = {};\n\nlet io:any;\n\n/** adds a component\n * @param {string} moduleName\n * @param {Module} module\n */\nexports.addComponent = (moduleName: string, module: SocketModule) => {\n  if (module == null) return exports.deleteComponent(moduleName);\n  components[moduleName] = module;\n  module.setSocketIO(io);\n};\n\n/**\n * removes a component\n * @param {Module} moduleName\n */\nexports.deleteComponent = (moduleName: string) => { delete components[moduleName]; };\n\n/**\n * sets the socket.io and adds event functions for routing\n * @param {Object} _io the socket.io instance\n */\nexports.setSocketIO = (_io:any) => {\n  io = _io;\n\n  io.sockets.on('connection', (socket:any) => {\n    const ip = settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip;\n    logger.debug(`${socket.id} connected from IP ${ip}`);\n\n    // wrap the original send function to log the messages\n    socket._send = socket.send;\n    socket.send = (message: string) => {\n      logger.debug(`to ${socket.id}: ${JSON.stringify(message)}`);\n      socket._send(message);\n    };\n\n    // tell all components about this connect\n    for (const i of Object.keys(components)) {\n      components[i].handleConnect(socket);\n    }\n\n    socket.on('message', (message: any, ack: any = () => {}) => (async () => {\n      if (!message.component || !components[message.component]) {\n        throw new Error(`unknown message component: ${message.component}`);\n      }\n      logger.debug(`from ${socket.id}:`, message);\n      return await components[message.component].handleMessage(socket, message);\n    })().then(\n        (val) => ack(null, val),\n        (err) => {\n          logger.error(\n              `Error handling ${message.component} message from ${socket.id}: ${err.stack || err}`);\n          ack({name: err.name, message: err.message}); // socket.io can't handle Error objects.\n        }));\n\n    socket.on('disconnect', (reason: string) => {\n      logger.debug(`${socket.id} disconnected: ${reason}`);\n      // store the lastDisconnect as a timestamp, this is useful if you want to know\n      // when the last user disconnected.  If your activePads is 0 and totalUsers is 0\n      // you can say, if there has been no active pads or active users for 10 minutes\n      // this instance can be brought out of a scaling cluster.\n      stats.gauge('lastDisconnect', () => Date.now());\n      // tell all components about this disconnect\n      for (const i of Object.keys(components)) {\n        components[i].handleDisconnect(socket);\n      }\n    });\n  });\n};\n"
  },
  {
    "path": "src/node/hooks/express/admin.ts",
    "content": "'use strict';\nimport {ArgsExpressType} from \"../../types/ArgsExpressType\";\nimport path from \"path\";\nimport fs from \"fs\";\nimport {MapArrayType} from \"../../types/MapType\";\n\nimport settings from 'ep_etherpad-lite/node/utils/Settings';\n\nconst ADMIN_PATH = path.join(settings.root, 'src', 'templates');\nconst PROXY_HEADER = \"x-proxy-path\"\n/**\n * Add the admin navigation link\n * @param hookName {String} the name of the hook\n * @param args {Object} the object containing the arguments\n * @param {Function} cb  the callback function\n * @return {*}\n */\nexports.expressCreateServer = (hookName: string, args: ArgsExpressType, cb: Function): any => {\n\n  if (!fs.existsSync(ADMIN_PATH)) {\n    console.error('admin template not found, skipping admin interface. You need to rebuild it in /admin with pnpm run build-copy')\n    return cb();\n  }\n  args.app.get('/admin/{*filename}', (req: any, res: any) => {\n    // extract URL path\n    let pathname = path.join(ADMIN_PATH, req.url);\n    pathname = path.normalize(pathname)\n\n    if (!pathname.startsWith(ADMIN_PATH)) {\n      res.statusCode = 403;\n      return res.end(\"Forbidden\");\n    }\n    // based on the URL path, extract the file extension. e.g. .js, .doc, ...\n    let ext = path.parse(pathname).ext;\n    // maps file extension to MIME typere\n    const map: MapArrayType<string> = {\n      '.ico': 'image/x-icon',\n      '.html': 'text/html',\n      '.js': 'text/javascript',\n      '.json': 'application/json',\n      '.css': 'text/css',\n      '.png': 'image/png',\n      '.jpg': 'image/jpeg',\n      '.wav': 'audio/wav',\n      '.mp3': 'audio/mpeg',\n      '.svg': 'image/svg+xml',\n      '.pdf': 'application/pdf',\n      '.doc': 'application/msword'\n    };\n\n    fs.exists(pathname, function (exist) {\n      if (!exist) {\n        // if the file is not found, return 404\n        res.statusCode = 200;\n        pathname = ADMIN_PATH + \"/admin/index.html\"\n        ext = path.parse(pathname).ext;\n      }\n\n      // if is a directory search for index file matching the extension\n      if (exist && fs.statSync(pathname).isDirectory()) {\n        pathname = pathname + '/index.html';\n        ext = path.parse(pathname).ext;\n      }\n\n      // read file from file system\n      fs.readFile(pathname, function (err, data) {\n        if (err) {\n          res.statusCode = 500;\n          res.end(`Error getting the file: ${err}.`);\n        } else {\n          let dataToSend:Buffer|string = data\n          // if the file is found, set Content-type and send data\n          res.setHeader('Content-type', map[ext] || 'text/plain');\n          if (ext === \".html\" || ext === \".js\" || ext === \".css\") {\n            if (req.header(PROXY_HEADER)) {\n              let string = data.toString()\n              dataToSend = string.replaceAll(\"/admin\", req.header(PROXY_HEADER) + \"/admin\")\n              dataToSend = dataToSend.replaceAll(\"/socket.io\", req.header(PROXY_HEADER) + \"/socket.io\")\n            }\n          }\n          res.end(dataToSend);\n        }\n      });\n    })\n  });\n  args.app.get('/admin', (req: any, res: any, next: Function) => {\n    if ('/' !== req.path[req.path.length - 1]) return res.redirect('./admin/');\n  })\n  return cb();\n};\n"
  },
  {
    "path": "src/node/hooks/express/adminplugins.ts",
    "content": "'use strict';\n\nimport {ArgsExpressType} from \"../../types/ArgsExpressType\";\nimport {ErrorCaused} from \"../../types/ErrorCaused\";\nimport {QueryType} from \"../../types/QueryType\";\n\nimport {getAvailablePlugins, install, search, uninstall} from \"../../../static/js/pluginfw/installer\";\nimport {PackageData, PackageInfo} from \"../../types/PackageInfo\";\nimport semver from 'semver';\nimport log4js from 'log4js';\nimport {MapArrayType} from \"../../types/MapType\";\n\nconst pluginDefs = require('../../../static/js/pluginfw/plugin_defs');\nconst logger = log4js.getLogger('adminPlugins');\n\n\nexports.socketio = (hookName:string, args:ArgsExpressType, cb:Function) => {\n  const io = args.io.of('/pluginfw/installer');\n  io.on('connection', (socket:any) => {\n    // @ts-ignore\n    const {session: {user: {is_admin: isAdmin} = {}} = {}} = socket.conn.request;\n    if (!isAdmin) return;\n\n    const checkPluginForUpdates = async () => {\n      let results: MapArrayType<PackageInfo>\n      try {\n        results = await getAvailablePlugins(/* maxCacheAge:*/ 60 * 10);\n      } catch (error) {\n        console.error('Error checking for plugin updates:', error);\n        return [];\n      }\n      return Object.keys(pluginDefs.plugins).filter((plugin) => {\n        if (!results[plugin]) return false;\n\n        const latestVersion = results[plugin].version;\n        const currentVersion = pluginDefs.plugins[plugin].package.version;\n\n        return semver.gt(latestVersion, currentVersion);\n      })\n    }\n\n    socket.on('getStats', ()=>{\n      console.log(\"Getting stats for admin plugins\");\n      socket.emit('results:stats', require('../../stats').toJSON());\n    })\n\n    socket.on('getInstalled', async (query: string) => {\n      // send currently installed plugins\n      const installed =\n        Object.keys(pluginDefs.plugins).map((plugin) => pluginDefs.plugins[plugin].package);\n\n      const updatable = await checkPluginForUpdates();\n\n      installed.forEach((plugin) => {\n        plugin.updatable = updatable.includes(plugin.name);\n      })\n\n      socket.emit('results:installed', {installed});\n    });\n\n\n    socket.on('checkUpdates', async () => {\n      // Check plugins for updates\n      try {\n        const updatable = checkPluginForUpdates();\n\n        socket.emit('results:updatable', {updatable});\n      } catch (err) {\n        const errc = err as ErrorCaused\n        console.warn(errc.stack || errc.toString());\n\n        socket.emit('results:updatable', {updatable: {}});\n      }\n    });\n\n    socket.on('getAvailable', async (query:string) => {\n      try {\n        const results = await getAvailablePlugins(/* maxCacheAge:*/ false);\n        socket.emit('results:available', results);\n      } catch (er) {\n        console.error(er);\n        socket.emit('results:available', {});\n      }\n    });\n\n    socket.on('search', async (query: QueryType) => {\n      try {\n        if (query.searchTerm) logger.info(`Plugin search: ${query.searchTerm}'`);\n        const results = await search(query.searchTerm, /* maxCacheAge:*/ 60 * 10);\n        let res = Object.keys(results)\n            .map((pluginName) => results[pluginName])\n            .filter((plugin) => !pluginDefs.plugins[plugin.name]);\n        res = sortPluginList(res, query.sortBy, query.sortDir)\n            .slice(query.offset, query.offset + query.limit);\n        socket.emit('results:search', {results: res, query});\n      } catch (err: any) {\n        logger.error(`Error searching plugins: ${err}`);\n        socket.emit('results:searcherror', {error: err.message, query});\n      }\n    });\n\n    socket.on('install', (pluginName: string) => {\n      install(pluginName, (err: ErrorCaused) => {\n        if (err) console.warn(err.stack || err.toString());\n\n        socket.emit('finished:install', {\n          plugin: pluginName,\n          code: err ? err.code : null,\n          error: err ? err.message : null,\n        });\n      });\n    });\n\n\n    socket.on('uninstall', (pluginName:string) => {\n      uninstall(pluginName, (err:ErrorCaused) => {\n        if (err) console.warn(err.stack || err.toString());\n\n        socket.emit('finished:uninstall', {plugin: pluginName, error: err ? err.message : null});\n      });\n    });\n  });\n  return cb();\n};\n\n/**\n * Sorts  a list of plugins by a property\n * @param {Object} plugins The plugins to sort\n * @param {Object} property The property to sort by\n * @param  {String} dir The directory of the plugin\n * @return {Object[]}\n */\nconst sortPluginList = (plugins:PackageData[], property:string, /* ASC?*/dir:string): PackageData[] => plugins.sort((a, b) => {\n  // @ts-ignore\n  if (a[property] < b[property]) {\n    return dir ? -1 : 1;\n  }\n\n  // @ts-ignore\n  if (a[property] > b[property]) {\n    return dir ? 1 : -1;\n  }\n\n  // a must be equal to b\n  return 0;\n});\n"
  },
  {
    "path": "src/node/hooks/express/adminsettings.ts",
    "content": "'use strict';\n\n\nimport {PadQueryResult, PadSearchQuery} from \"../../types/PadSearchQuery\";\nimport log4js from 'log4js';\n\nconst fsp = require('fs').promises;\nconst hooks = require('../../../static/js/pluginfw/hooks');\nconst plugins = require('../../../static/js/pluginfw/plugins');\nimport settings, {getEpVersion, getGitCommit, reloadSettings} from '../../utils/Settings';\nimport {getLatestVersion} from '../../utils/UpdateCheck';\nconst padManager = require('../../db/PadManager');\nconst api = require('../../db/API');\nimport {deleteRevisions} from '../../utils/Cleanup';\n\n\nconst queryPadLimit = 12;\nconst logger = log4js.getLogger('adminSettings');\n\n\nexports.socketio = (hookName: string, {io}: any) => {\n    io.of('/settings').on('connection', (socket: any) => {\n        // @ts-ignore\n        const {session: {user: {is_admin: isAdmin} = {}} = {}} = socket.conn.request;\n        if (!isAdmin) return;\n\n        socket.on('load', async (query: string): Promise<any> => {\n            let data;\n            try {\n                data = await fsp.readFile(settings.settingsFilename, 'utf8');\n            } catch (err) {\n                return logger.error(`Error loading settings: ${err}`);\n            }\n            // if showSettingsInAdminPage is set to false, then return NOT_ALLOWED in the result\n            if (settings.showSettingsInAdminPage === false) {\n                socket.emit('settings', {results: 'NOT_ALLOWED'});\n            } else {\n                socket.emit('settings', {results: data});\n            }\n        });\n\n        socket.on('saveSettings', async (newSettings: string) => {\n            logger.info('Admin request to save settings through a socket on /admin/settings');\n            try {\n                await fsp.writeFile(settings.settingsFilename, newSettings);\n            } catch (err) {\n                logger.error(`Error saving settings: ${err}`);\n            }\n            socket.emit('saveprogress', 'saved');\n        });\n\n\n        type ShoutMessage = {\n            message: string,\n            sticky: boolean,\n        }\n\n        socket.on('shout', (message: ShoutMessage) => {\n            const messageToSend = {\n                type: \"COLLABROOM\",\n                data: {\n                    type: \"shoutMessage\",\n                    payload: {\n                        message: message,\n                        timestamp: Date.now()\n                    }\n                }\n            }\n\n            io.of('/settings').emit('shout', messageToSend);\n            io.sockets.emit('shout', messageToSend);\n        })\n\n\n        socket.on('help', () => {\n            const gitCommit = getGitCommit();\n            const epVersion = getEpVersion();\n\n            const hooks: Map<string, Map<string, string>> = plugins.getHooks('hooks', false);\n            const clientHooks: Map<string, Map<string, string>> = plugins.getHooks('client_hooks', false);\n\n            function mapToObject(map: Map<string, any>) {\n                let obj = Object.create(null);\n                for (let [k, v] of map) {\n                    if (v instanceof Map) {\n                        obj[k] = mapToObject(v);\n                    } else {\n                        obj[k] = v;\n                    }\n                }\n                return obj;\n            }\n\n            socket.emit('reply:help', {\n                gitCommit,\n                epVersion,\n                installedPlugins: plugins.getPlugins(),\n                installedParts: plugins.getParts(),\n                installedServerHooks: mapToObject(hooks),\n                installedClientHooks: mapToObject(clientHooks),\n                latestVersion: getLatestVersion(),\n            })\n        });\n\n\n        socket.on('padLoad', async (query: PadSearchQuery) => {\n            const {padIDs} = await padManager.listAllPads();\n\n            const data: {\n                total: number,\n                results?: PadQueryResult[]\n            } = {\n                total: padIDs.length,\n            };\n            let result: string[] = padIDs;\n            let maxResult;\n\n            // Filter out matches\n            if (query.pattern) {\n                result = result.filter((padName: string) => padName.includes(query.pattern));\n            }\n\n            data.total = result.length;\n\n            maxResult = result.length - 1;\n            if (maxResult < 0) {\n                maxResult = 0;\n            }\n\n            // Reset to default values if out of bounds\n            if (query.offset && query.offset < 0) {\n                query.offset = 0;\n            } else if (query.offset > maxResult) {\n                query.offset = maxResult;\n            }\n\n            if (query.limit && query.limit < 0) {\n              // Too small\n                query.limit = 0;\n            } else if (query.limit > queryPadLimit) {\n              // Too big\n                query.limit = queryPadLimit;\n            }\n\n\n            if (query.sortBy === 'padName') {\n                result = result.sort((a, b) => {\n                    if (a < b) return query.ascending ? -1 : 1;\n                    if (a > b) return query.ascending ? 1 : -1;\n                    return 0;\n                }).slice(query.offset, query.offset + query.limit);\n\n                data.results = await Promise.all(result.map(async (padName: string) => {\n                    const pad = await padManager.getPad(padName);\n                    const revisionNumber = pad.getHeadRevisionNumber()\n                    const userCount = api.padUsersCount(padName).padUsersCount;\n                    const lastEdited = await pad.getLastEdit();\n\n                    return {\n                        padName,\n                        lastEdited,\n                        userCount,\n                        revisionNumber\n                    }\n                }));\n            } else if (query.sortBy === \"revisionNumber\") {\n                const currentWinners: PadQueryResult[] = []\n                const padMapping = [] as {padId: string, revisionNumber: number}[]\n                for (let res of result) {\n                    const pad = await padManager.getPad(res);\n                    const revisionNumber = pad.getHeadRevisionNumber()\n                    padMapping.push({padId: res, revisionNumber})\n                }\n                padMapping.sort((a, b) => {\n                    if (a.revisionNumber < b.revisionNumber) return query.ascending ? -1 : 1;\n                    if (a.revisionNumber > b.revisionNumber) return query.ascending ? 1 : -1;\n                    return 0;\n                })\n\n              for (const padRetrieval of padMapping.slice(query.offset, query.offset + query.limit)) {\n                let pad = await padManager.getPad(padRetrieval.padId);\n                currentWinners.push({\n                  padName: padRetrieval.padId,\n                  lastEdited: await pad.getLastEdit(),\n                  userCount: api.padUsersCount(pad.padName).padUsersCount,\n                  revisionNumber: padRetrieval.revisionNumber\n                })\n              }\n\n              data.results = currentWinners;\n            } else if (query.sortBy === \"userCount\") {\n              const currentWinners: PadQueryResult[] = []\n              const padMapping = [] as {padId: string, userCount: number}[]\n              for (let res of result) {\n                const userCount = api.padUsersCount(res).padUsersCount\n                padMapping.push({padId: res, userCount})\n              }\n              padMapping.sort((a, b) => {\n                if (a.userCount < b.userCount) return query.ascending ? -1 : 1;\n                if (a.userCount > b.userCount) return query.ascending ? 1 : -1;\n                return 0;\n              })\n\n              for (const padRetrieval of padMapping.slice(query.offset, query.offset + query.limit)) {\n                let pad = await padManager.getPad(padRetrieval.padId);\n                currentWinners.push({\n                  padName: padRetrieval.padId,\n                  lastEdited: await pad.getLastEdit(),\n                  userCount: padRetrieval.userCount,\n                  revisionNumber: pad.getHeadRevisionNumber()\n                })\n              }\n              data.results = currentWinners;\n            } else if (query.sortBy === \"lastEdited\") {\n              const currentWinners: PadQueryResult[] = []\n              const padMapping = [] as {padId: string, lastEdited: string}[]\n              for (let res of result) {\n                const pad = await padManager.getPad(res);\n                const lastEdited = await pad.getLastEdit();\n                padMapping.push({padId: res, lastEdited})\n              }\n              padMapping.sort((a, b) => {\n                if (a.lastEdited < b.lastEdited) return query.ascending ? -1 : 1;\n                if (a.lastEdited > b.lastEdited) return query.ascending ? 1 : -1;\n                return 0;\n              })\n\n              for (const padRetrieval of padMapping.slice(query.offset, query.offset + query.limit)) {\n                let pad = await padManager.getPad(padRetrieval.padId);\n                currentWinners.push({\n                  padName: padRetrieval.padId,\n                  lastEdited: padRetrieval.lastEdited,\n                  userCount: api.padUsersCount(pad.padName).padUsersCount,\n                  revisionNumber: pad.getHeadRevisionNumber()\n                })\n              }\n              data.results = currentWinners;\n            }\n\n            socket.emit('results:padLoad', data);\n        })\n\n\n        socket.on('deletePad', async (padId: string) => {\n            const padExists = await padManager.doesPadExists(padId);\n            if (padExists) {\n                logger.info(`Deleting pad: ${padId}`);\n                const pad = await padManager.getPad(padId);\n                await pad.remove();\n                socket.emit('results:deletePad', padId);\n            }\n        })\n\n      type PadCreationOptions = {\n          padName: string,\n      }\n\n      socket.on('createPad', async ({padName}: PadCreationOptions)=>{\n        const padExists = await padManager.doesPadExists(padName);\n        if (padExists) {\n          socket.emit('results:createPad', {\n            error: 'Pad already exists',\n          });\n          return;\n        }\n        padManager.getPad(padName);\n          socket.emit('results:createPad', {\n            success: `Pad created ${padName}`,\n          });\n          return;\n      })\n\n        socket.on('cleanupPadRevisions', async (padId: string) => {\n          if (!settings.cleanup.enabled) {\n            socket.emit('results:cleanupPadRevisions', {\n              error: 'Cleanup disabled. Enable cleanup in settings.json: cleanup.enabled => true',\n            });\n            return;\n          }\n\n          const padExists = await padManager.doesPadExists(padId);\n          if (padExists) {\n            logger.info(`Cleanup pad revisions: ${padId}`);\n            try {\n              const result = await deleteRevisions(padId, settings.cleanup.keepRevisions)\n              if (result) {\n                socket.emit('results:cleanupPadRevisions', {\n                  padId: padId,\n                  keepRevisions: settings.cleanup.keepRevisions,\n                });\n                logger.info('successful cleaned up pad: ', padId)\n              } else {\n                socket.emit('results:cleanupPadRevisions', {\n                  error: 'Error cleaning up pad',\n                });\n              }\n            } catch (err: any) {\n              logger.error(`Error in pad ${padId}: ${err.stack || err}`);\n              socket.emit('results:cleanupPadRevisions', {\n                error: err.toString(),\n              });\n              return;\n            }\n          }\n        })\n\n        socket.on('restartServer', async () => {\n            logger.info('Admin request to restart server through a socket on /admin/settings');\n            reloadSettings();\n            await plugins.update();\n            await hooks.aCallAll('loadSettings', {settings});\n            await hooks.aCallAll('restartServer');\n        });\n    });\n};\n\n\nconst searchPad = async (query: PadSearchQuery) => {\n\n}\n"
  },
  {
    "path": "src/node/hooks/express/apicalls.ts",
    "content": "'use strict';\n\nimport express from \"express\";\n\nconst log4js = require('log4js');\nconst clientLogger = log4js.getLogger('client');\nconst {Formidable} = require('formidable');\nconst apiHandler = require('../../handler/APIHandler');\nconst util = require('util');\n\n\nfunction objectAsString(obj: any): string {\n  let output = '';\n  for (const property in obj) {\n    if(obj.hasOwnProperty(property) && typeof obj[property] !== 'function') {\n      let value = obj[property];\n      if(typeof value === 'object' && !Array.isArray(value) && value !== null) {\n        value = '{' + objectAsString(value) + '}';\n      }\n      output += property + ': ' + value +'; ';\n    }\n  }\n  return output;\n}\n\nexports.expressPreSession = async (hookName:string, {app}:any) => {\n  app.use(express.json());\n  // The Etherpad client side sends information about how a disconnect happened\n  app.post('/ep/pad/connection-diagnostic-info', async (req:any, res:any) => {\n    if (!req.body ||!req.body.diagnosticInfo || typeof req.body.diagnosticInfo !== 'object') {\n      clientLogger.warn('DIAGNOSTIC-INFO: No diagnostic info provided');\n      res.status(400).end('No diagnostic info provided');\n      return;\n    }\n\n    clientLogger.info(`DIAGNOSTIC-INFO: ${objectAsString(req.body.diagnosticInfo)}`);\n    res.end('OK');\n  });\n\n  const parseJserrorForm = async (req:any) => {\n    const form = new Formidable({\n      maxFileSize: 1, // Files are not expected. Not sure if 0 means unlimited, so 1 is used.\n    });\n    const [fields, files] = await form.parse(req);\n    return fields.errorInfo;\n  };\n\n  // The Etherpad client side sends information about client side javscript errors\n  app.post('/jserror', (req:any, res:any, next:Function) => {\n    (async () => {\n      const data = JSON.parse(await parseJserrorForm(req));\n      clientLogger.warn(`${data.msg} --`, {\n        [util.inspect.custom]: (depth: number, options:any) => {\n          // Depth is forced to infinity to ensure that all of the provided data is logged.\n          options = Object.assign({}, options, {depth: Infinity, colors: true});\n          return util.inspect(data, options);\n        },\n      });\n      res.end('OK');\n    })().catch((err) => next(err || new Error(err)));\n  });\n\n  // Provide a possibility to query the latest available API version\n  app.get('/api', (req:any, res:any) => {\n    res.json({currentVersion: apiHandler.latestApiVersion});\n  });\n};\n"
  },
  {
    "path": "src/node/hooks/express/errorhandling.ts",
    "content": "'use strict';\n\nimport {ArgsExpressType} from \"../../types/ArgsExpressType\";\nimport {ErrorCaused} from \"../../types/ErrorCaused\";\n\nconst stats = require('../../stats')\n\nexports.expressCreateServer = (hook_name:string, args: ArgsExpressType, cb:Function) => {\n  exports.app = args.app;\n\n  // Handle errors\n  args.app.use((err:ErrorCaused, req:any, res:any, next:Function) => {\n    // if an error occurs Connect will pass it down\n    // through these \"error-handling\" middleware\n    // allowing you to respond however you like\n    res.status(500).send({error: 'Sorry, something bad happened!'});\n    console.error(err.stack ? err.stack : err.toString());\n    stats.meter('http500').mark();\n  });\n\n  return cb();\n};\n"
  },
  {
    "path": "src/node/hooks/express/importexport.ts",
    "content": "'use strict';\n\nimport {ArgsExpressType} from \"../../types/ArgsExpressType\";\n\nconst hasPadAccess = require('../../padaccess');\nimport settings, {exportAvailable} from '../../utils/Settings';\nconst exportHandler = require('../../handler/ExportHandler');\nconst importHandler = require('../../handler/ImportHandler');\nconst padManager = require('../../db/PadManager');\nimport readOnlyManager from '../../db/ReadOnlyManager';\nconst rateLimit = require('express-rate-limit');\nconst securityManager = require('../../db/SecurityManager');\nconst webaccess = require('./webaccess');\n\nexports.expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => {\n  const limiter = rateLimit({\n    ...settings.importExportRateLimiting,\n    handler: (request:any) => {\n      if (request.rateLimit.current === request.rateLimit.limit + 1) {\n        // when the rate limiter triggers, write a warning in the logs\n        console.warn('Import/Export rate limiter triggered on ' +\n            `\"${request.originalUrl}\" for IP address ${request.ip}`);\n      }\n    },\n  });\n\n  // handle export requests\n  args.app.use('/p/:pad{/:rev}/export/:type', limiter);\n  args.app.get('/p/:pad{/:rev}/export/:type', (req:any, res:any, next:Function) => {\n    (async () => {\n      const types = ['pdf', 'doc', 'txt', 'html', 'odt', 'etherpad'];\n      // send a 404 if we don't support this filetype\n      if (types.indexOf(req.params.type) === -1) {\n        return next();\n      }\n\n      // if abiword is disabled, and this is a format we only support with abiword, output a message\n      if (exportAvailable() === 'no' &&\n          ['odt', 'pdf', 'doc'].indexOf(req.params.type) !== -1) {\n        console.error(`Impossible to export pad \"${req.params.pad}\" in ${req.params.type} format.` +\n                      ' There is no converter configured');\n\n        // ACHTUNG: do not include req.params.type in res.send() because there is\n        // no HTML escaping and it would lead to an XSS\n        res.send('This export is not enabled at this Etherpad instance. Set the path to Abiword' +\n                 ' or soffice (LibreOffice) in settings.json to enable this feature');\n        return;\n      }\n\n      res.header('Access-Control-Allow-Origin', '*');\n\n      if (await hasPadAccess(req, res)) {\n        let padId = req.params.pad;\n\n        let readOnlyId = null;\n        if (readOnlyManager.isReadOnlyId(padId)) {\n          readOnlyId = padId;\n          padId = await readOnlyManager.getPadId(readOnlyId);\n        }\n\n        const exists = await padManager.doesPadExists(padId);\n        if (!exists) {\n          console.warn(`Someone tried to export a pad that doesn't exist (${padId})`);\n          return next();\n        }\n\n        console.log(`Exporting pad \"${req.params.pad}\" in ${req.params.type} format`);\n        await exportHandler.doExport(req, res, padId, readOnlyId, req.params.type);\n      }\n    })().catch((err) => next(err || new Error(err)));\n  });\n\n  // handle import requests\n  args.app.use('/p/:pad/import', limiter);\n  args.app.post('/p/:pad/import', (req:any, res:any, next:Function) => {\n    (async () => {\n      // @ts-ignore\n      const {session: {user} = {}} = req;\n      const {accessStatus, authorID: authorId} = await securityManager.checkAccess(\n          req.params.pad, req.cookies.sessionID, req.cookies.token, user);\n      if (accessStatus !== 'grant' || !webaccess.userCanModify(req.params.pad, req)) {\n        return res.status(403).send('Forbidden');\n      }\n      await importHandler.doImport(req, res, req.params.pad, authorId);\n    })().catch((err) => next(err || new Error(err)));\n  });\n\n  return cb();\n};\n"
  },
  {
    "path": "src/node/hooks/express/openapi.ts",
    "content": "'use strict';\n\nimport {OpenAPIOperations, OpenAPISuccessResponse, SwaggerUIResource} from \"../../types/SwaggerUIResource\";\nimport {MapArrayType} from \"../../types/MapType\";\nimport {ErrorCaused} from \"../../types/ErrorCaused\";\n\n/**\n * node/hooks/express/openapi.js\n *\n * This module generates OpenAPI definitions for each API version defined by\n * APIHandler.js and hooks into express to route the API using openapi-backend.\n *\n * The openapi definition files are publicly available under:\n *\n * - /api/openapi.json\n * - /rest/openapi.json\n * - /api/{version}/openapi.json\n * - /rest/{version}/openapi.json\n */\n\nconst OpenAPIBackend = require('openapi-backend').default;\nconst IncomingForm = require('formidable').IncomingForm;\nconst cloneDeep = require('lodash.clonedeep');\nconst createHTTPError = require('http-errors');\n\nconst apiHandler = require('../../handler/APIHandler');\nimport settings from '../../utils/Settings';\n\nimport log4js from 'log4js';\nconst logger = log4js.getLogger('API');\n\n// https://github.com/OAI/OpenAPI-Specification/tree/master/schemas/v3.0\nconst OPENAPI_VERSION = '3.0.2'; // Swagger/OAS version\n\nconst info = {\n  title: 'Etherpad API',\n  description:\n      'Etherpad is a real-time collaborative editor scalable to thousands of simultaneous ' +\n      'real time users. It provides full data export capabilities, and runs on your server, ' +\n      'under your control.',\n  termsOfService: 'https://etherpad.org/',\n  contact: {\n    name: 'The Etherpad Foundation',\n    url: 'https://etherpad.org/',\n    email: 'support@example.com',\n  },\n  license: {\n    name: 'Apache 2.0',\n    url: 'https://www.apache.org/licenses/LICENSE-2.0.html',\n  },\n  version: apiHandler.latestApiVersion,\n};\n\nconst APIPathStyle = {\n  FLAT: 'api', // flat paths e.g. /api/createGroup\n  REST: 'rest', // restful paths e.g. /rest/group/create\n};\n\n\n// API resources - describe your API endpoints here\nconst resources:SwaggerUIResource = {\n  // Group\n  group: {\n    create: {\n      operationId: 'createGroup',\n      summary: 'creates a new group',\n      responseSchema: {groupID: {type: 'string'}},\n    },\n    createIfNotExistsFor: {\n      operationId: 'createGroupIfNotExistsFor',\n      summary: 'this functions helps you to map your application group ids to Etherpad group ids',\n      responseSchema: {groupID: {type: 'string'}},\n    },\n    delete: {\n      operationId: 'deleteGroup',\n      summary: 'deletes a group',\n    },\n    listPads: {\n      operationId: 'listPads',\n      summary: 'returns all pads of this group',\n      responseSchema: {padIDs: {type: 'array', items: {type: 'string'}}},\n    },\n    createPad: {\n      operationId: 'createGroupPad',\n      summary: 'creates a new pad in this group',\n    },\n    listSessions: {\n      operationId: 'listSessionsOfGroup',\n      summary: '',\n      responseSchema: {\n        sessions: {type: 'array', items: {$ref: '#/components/schemas/SessionInfo'}},\n      },\n    },\n    list: {\n      operationId: 'listAllGroups',\n      summary: '',\n      responseSchema: {groupIDs: {type: 'array', items: {type: 'string'}}},\n    },\n  },\n\n  // Author\n  author: {\n    create: {\n      operationId: 'createAuthor',\n      summary: 'creates a new author',\n      responseSchema: {authorID: {type: 'string'}},\n    },\n    createIfNotExistsFor: {\n      operationId: 'createAuthorIfNotExistsFor',\n      summary: 'this functions helps you to map your application author ids to Etherpad author ids',\n      responseSchema: {authorID: {type: 'string'}},\n    },\n    listPads: {\n      operationId: 'listPadsOfAuthor',\n      summary: 'returns an array of all pads this author contributed to',\n      responseSchema: {padIDs: {type: 'array', items: {type: 'string'}}},\n    },\n    listSessions: {\n      operationId: 'listSessionsOfAuthor',\n      summary: 'returns all sessions of an author',\n      responseSchema: {\n        sessions: {type: 'array', items: {$ref: '#/components/schemas/SessionInfo'}},\n      },\n    },\n    // We need an operation that return a UserInfo so it can be picked up by the codegen :(\n    getName: {\n      operationId: 'getAuthorName',\n      summary: 'Returns the Author Name of the author',\n      responseSchema: {info: {$ref: '#/components/schemas/UserInfo'}},\n    },\n  },\n\n  // Session\n  session: {\n    create: {\n      operationId: 'createSession',\n      summary: 'creates a new session. validUntil is an unix timestamp in seconds',\n      responseSchema: {sessionID: {type: 'string'}},\n    },\n    delete: {\n      operationId: 'deleteSession',\n      summary: 'deletes a session',\n    },\n    // We need an operation that returns a SessionInfo so it can be picked up by the codegen :(\n    info: {\n      operationId: 'getSessionInfo',\n      summary: 'returns information about a session',\n      responseSchema: {info: {$ref: '#/components/schemas/SessionInfo'}},\n    },\n  },\n\n  // Pad\n  pad: {\n    listAll: {\n      operationId: 'listAllPads',\n      summary: 'list all the pads',\n      responseSchema: {padIDs: {type: 'array', items: {type: 'string'}}},\n    },\n    createDiffHTML: {\n      operationId: 'createDiffHTML',\n      summary: '',\n      responseSchema: {},\n    },\n    create: {\n      operationId: 'createPad',\n      description:\n          'creates a new (non-group) pad. Note that if you need to create a group Pad, ' +\n          'you should call createGroupPad',\n    },\n    getText: {\n      operationId: 'getText',\n      summary: 'returns the text of a pad',\n      responseSchema: {text: {type: 'string'}},\n    },\n    setText: {\n      operationId: 'setText',\n      summary: 'sets the text of a pad',\n    },\n    getHTML: {\n      operationId: 'getHTML',\n      summary: 'returns the text of a pad formatted as HTML',\n      responseSchema: {html: {type: 'string'}},\n    },\n    setHTML: {\n      operationId: 'setHTML',\n      summary: 'sets the text of a pad with HTML',\n    },\n    getRevisionsCount: {\n      operationId: 'getRevisionsCount',\n      summary: 'returns the number of revisions of this pad',\n      responseSchema: {revisions: {type: 'integer'}},\n    },\n    getLastEdited: {\n      operationId: 'getLastEdited',\n      summary: 'returns the timestamp of the last revision of the pad',\n      responseSchema: {lastEdited: {type: 'integer'}},\n    },\n    delete: {\n      operationId: 'deletePad',\n      summary: 'deletes a pad',\n    },\n    getReadOnlyID: {\n      operationId: 'getReadOnlyID',\n      summary: 'returns the read only link of a pad',\n      responseSchema: {readOnlyID: {type: 'string'}},\n    },\n    setPublicStatus: {\n      operationId: 'setPublicStatus',\n      summary: 'sets a boolean for the public status of a pad',\n    },\n    getPublicStatus: {\n      operationId: 'getPublicStatus',\n      summary: 'return true of false',\n      responseSchema: {publicStatus: {type: 'boolean'}},\n    },\n    authors: {\n      operationId: 'listAuthorsOfPad',\n      summary: 'returns an array of authors who contributed to this pad',\n      responseSchema: {authorIDs: {type: 'array', items: {type: 'string'}}},\n    },\n    usersCount: {\n      operationId: 'padUsersCount',\n      summary: 'returns the number of user that are currently editing this pad',\n      responseSchema: {padUsersCount: {type: 'integer'}},\n    },\n    users: {\n      operationId: 'padUsers',\n      summary: 'returns the list of users that are currently editing this pad',\n      responseSchema: {padUsers: {type: 'array', items: {$ref: '#/components/schemas/UserInfo'}}},\n    },\n    sendClientsMessage: {\n      operationId: 'sendClientsMessage',\n      summary: 'sends a custom message of type msg to the pad',\n    },\n    checkToken: {\n      operationId: 'checkToken',\n      summary: 'returns ok when the current api token is valid',\n    },\n    getChatHistory: {\n      operationId: 'getChatHistory',\n      summary: 'returns the chat history',\n      responseSchema: {messages: {type: 'array', items: {$ref: '#/components/schemas/Message'}}},\n    },\n    // We need an operation that returns a Message so it can be picked up by the codegen :(\n    getChatHead: {\n      operationId: 'getChatHead',\n      summary: 'returns the chatHead (chat-message) of the pad',\n      responseSchema: {chatHead: {$ref: '#/components/schemas/Message'}},\n    },\n    appendChatMessage: {\n      operationId: 'appendChatMessage',\n      summary: 'appends a chat message',\n    },\n  },\n};\n\nconst defaultResponses = {\n  Success: {\n    description: 'ok (code 0)',\n    content: {\n      'application/json': {\n        schema: {\n          type: 'object',\n          properties: {\n            code: {\n              type: 'integer',\n              example: 0,\n            },\n            message: {\n              type: 'string',\n              example: 'ok',\n            },\n            data: {\n              type: 'object',\n              example: null,\n            },\n          },\n        },\n      },\n    },\n  },\n  ApiError: {\n    description: 'generic api error (code 1)',\n    content: {\n      'application/json': {\n        schema: {\n          type: 'object',\n          properties: {\n            code: {\n              type: 'integer',\n              example: 1,\n            },\n            message: {\n              type: 'string',\n              example: 'error message',\n            },\n            data: {\n              type: 'object',\n              example: null,\n            },\n          },\n        },\n      },\n    },\n  },\n  InternalError: {\n    description: 'internal api error (code 2)',\n    content: {\n      'application/json': {\n        schema: {\n          type: 'object',\n          properties: {\n            code: {\n              type: 'integer',\n              example: 2,\n            },\n            message: {\n              type: 'string',\n              example: 'internal error',\n            },\n            data: {\n              type: 'object',\n              example: null,\n            },\n          },\n        },\n      },\n    },\n  },\n  NotFound: {\n    description: 'no such function (code 4)',\n    content: {\n      'application/json': {\n        schema: {\n          type: 'object',\n          properties: {\n            code: {\n              type: 'integer',\n              example: 3,\n            },\n            message: {\n              type: 'string',\n              example: 'no such function',\n            },\n            data: {\n              type: 'object',\n              example: null,\n            },\n          },\n        },\n      },\n    },\n  },\n  Unauthorized: {\n    description: 'no or wrong API key (code 4)',\n    content: {\n      'application/json': {\n        schema: {\n          type: 'object',\n          properties: {\n            code: {\n              type: 'integer',\n              example: 4,\n            },\n            message: {\n              type: 'string',\n              example: 'no or wrong API key',\n            },\n            data: {\n              type: 'object',\n              example: null,\n            },\n          },\n        },\n      },\n    },\n  },\n};\n\nconst defaultResponseRefs:OpenAPISuccessResponse = {\n  200: {\n    $ref: '#/components/responses/Success',\n  },\n  400: {\n    $ref: '#/components/responses/ApiError',\n  },\n  401: {\n    $ref: '#/components/responses/Unauthorized',\n  },\n  500: {\n    $ref: '#/components/responses/InternalError',\n  },\n};\n\n// convert to a dictionary of operation objects\nconst operations: OpenAPIOperations = {};\nfor (const [resource, actions] of Object.entries(resources)) {\n  for (const [action, spec] of Object.entries(actions)) {\n    const {operationId,responseSchema, ...operation} = spec;\n\n    // add response objects\n    const responses:OpenAPISuccessResponse = {...defaultResponseRefs};\n    if (responseSchema) {\n      responses[200] = cloneDeep(defaultResponses.Success);\n      responses[200].content!['application/json'].schema.properties.data = {\n        type: 'object',\n        properties: responseSchema,\n      };\n    }\n\n    // add final operation object to dictionary\n    operations[operationId] = {\n      operationId,\n      ...operation,\n      responses,\n      tags: [resource],\n      _restPath: `/${resource}/${action}`,\n    };\n  }\n}\n\nconst generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT) => {\n  const definition = {\n    openapi: OPENAPI_VERSION,\n    info,\n    paths: {},\n    components: {\n      parameters: {},\n      schemas: {\n        SessionInfo: {\n          type: 'object',\n          properties: {\n            id: {\n              type: 'string',\n            },\n            authorID: {\n              type: 'string',\n            },\n            groupID: {\n              type: 'string',\n            },\n            validUntil: {\n              type: 'integer',\n            },\n          },\n        },\n        UserInfo: {\n          type: 'object',\n          properties: {\n            id: {\n              type: 'string',\n            },\n            colorId: {\n              type: 'string',\n            },\n            name: {\n              type: 'string',\n            },\n            timestamp: {\n              type: 'integer',\n            },\n          },\n        },\n        Message: {\n          type: 'object',\n          properties: {\n            text: {\n              type: 'string',\n            },\n            userId: {\n              type: 'string',\n            },\n            userName: {\n              type: 'string',\n            },\n            time: {\n              type: 'integer',\n            },\n          },\n        },\n      },\n      responses: {\n        ...defaultResponses,\n      },\n      securitySchemes: {\n        openid: {\n          type: \"oauth2\",\n          flows: {\n            authorizationCode: {\n              authorizationUrl: settings.sso.issuer+\"/oidc/auth\",\n              tokenUrl: settings.sso.issuer+\"/oidc/token\",\n              scopes: {\n                openid: \"openid\",\n                profile: \"profile\",\n                email: \"email\",\n                admin: \"admin\"\n              }\n            }\n          },\n        },\n      },\n    },\n    security: [{openid: []}],\n  };\n\n  // build operations\n  for (const funcName of Object.keys(apiHandler.version[version])) {\n    let operation:OpenAPIOperations = {};\n    if (operations[funcName]) {\n      operation = {...operations[funcName]};\n    } else {\n      // console.warn(`No operation found for function: ${funcName}`);\n      operation = {\n        operationId: funcName,\n        responses: defaultResponseRefs,\n      };\n    }\n\n    // set parameters\n    operation.parameters = operation.parameters || [];\n    for (const paramName of apiHandler.version[version][funcName]) {\n      operation.parameters.push({$ref: `#/components/parameters/${paramName}`});\n      // @ts-ignore\n      if (!definition.components.parameters[paramName]) {\n        // @ts-ignore\n        definition.components.parameters[paramName] = {\n          name: paramName,\n          in: 'query',\n          schema: {\n            type: 'string',\n          },\n        };\n      }\n    }\n\n    // set path\n    let path = `/${operation.operationId}`; // APIPathStyle.FLAT\n    if (style === APIPathStyle.REST && operation._restPath) {\n      path = operation._restPath;\n    }\n    delete operation._restPath;\n\n    // add to definition\n    // NOTE: It may be confusing that every operation can be called with both GET and POST\n    // @ts-ignore\n    definition.paths[path] = {\n      get: {\n        ...operation,\n        operationId: `${operation.operationId}UsingGET`,\n      },\n      post: {\n        ...operation,\n        operationId: `${operation.operationId}UsingPOST`,\n      },\n    };\n  }\n  return definition;\n};\n\nexports.expressPreSession = async (hookName:string, {app}:any) => {\n  // create openapi-backend handlers for each api version under /api/{version}/*\n  for (const version of Object.keys(apiHandler.version)) {\n    // we support two different styles of api: flat + rest\n    // TODO: do we really want to support both?\n\n    for (const style of [APIPathStyle.FLAT, APIPathStyle.REST]) {\n      const apiRoot = getApiRootForVersion(version, style);\n\n      // generate openapi definition for this API version\n      const definition = generateDefinitionForVersion(version, style);\n\n      // serve version specific openapi definition\n      app.get(`${apiRoot}/openapi.json`, (req:any, res:any) => {\n        // For openapi definitions, wide CORS is probably fine\n        res.header('Access-Control-Allow-Origin', '*');\n        res.json({...definition, servers: [generateServerForApiVersion(apiRoot, req)]});\n      });\n\n      // serve latest openapi definition file under /api/openapi.json\n      const isLatestAPIVersion = version === apiHandler.latestApiVersion;\n      if (isLatestAPIVersion) {\n        app.get(`/${style}/openapi.json`, (req:any, res:any) => {\n          res.header('Access-Control-Allow-Origin', '*');\n          res.json({...definition, servers: [generateServerForApiVersion(apiRoot, req)]});\n        });\n      }\n\n      // build openapi-backend instance for this api version\n      const api = new OpenAPIBackend({\n        definition,\n        validate: false,\n        // for a small optimisation, we can run the quick startup for older\n        // API versions since they are subsets of the latest api definition\n        quick: !isLatestAPIVersion,\n      });\n\n      // register default handlers\n      api.register({\n        notFound: () => {\n          throw new createHTTPError.NotFound('no such function');\n        },\n        notImplemented: () => {\n          throw new createHTTPError.NotImplemented('function not implemented');\n        },\n      });\n\n      // register operation handlers\n      for (const funcName of Object.keys(apiHandler.version[version])) {\n        const handler = async (c: any, req:any, res:any) => {\n          // parse fields from request\n          const {headers, params, query} = c.request;\n\n          // read form data if method was POST\n          let formData:MapArrayType<any> = {};\n          if (c.request.method === 'post') {\n            const form = new IncomingForm();\n            formData = (await form.parse(req))[0];\n            for (const k of Object.keys(formData)) {\n              if (formData[k] instanceof Array) {\n                formData[k] = formData[k][0];\n              }\n            }\n          }\n\n          const fields = Object.assign({}, headers, params, query, formData);\n          if (logger.isDebugEnabled()) {\n            logger.debug(`REQUEST, v${version}:${funcName}, ${JSON.stringify(fields)}`);\n          }\n\n          // pass to api handler\n          let data;\n          try {\n            data = await apiHandler.handle(version, funcName, fields, req, res);\n          } catch (err) {\n            const errCaused = err as ErrorCaused\n            // convert all errors to http errors\n            if (createHTTPError.isHttpError(err)) {\n              // pass http errors thrown by handler forward\n              throw err;\n            } else if (errCaused.name === 'apierror') {\n              // parameters were wrong and the api stopped execution, pass the error\n              // convert to http error\n              throw new createHTTPError.BadRequest(errCaused.message);\n            } else {\n              // an unknown error happened\n              // log it and throw internal error\n              logger.error(errCaused.stack || errCaused.toString());\n              throw new createHTTPError.InternalError('internal error');\n            }\n          }\n\n          // return in common format\n          const response = {code: 0, message: 'ok', data: data || null};\n\n          if (logger.isDebugEnabled()) {\n            logger.debug(`RESPONSE, ${funcName}, ${JSON.stringify(response)}`);\n          }\n\n          // return the response data\n          return response;\n        };\n\n        // each operation can be called with either GET or POST\n        api.register(`${funcName}UsingGET`, handler);\n        api.register(`${funcName}UsingPOST`, handler);\n      }\n\n      // start and bind to express\n      await api.init();\n      app.use(apiRoot, async (req:any, res:any) => {\n        let response = null;\n        try {\n          if (style === APIPathStyle.REST) {\n            // @TODO: Don't allow CORS from everywhere\n            // This is purely to maintain compatibility with old swagger-node-express\n            res.header('Access-Control-Allow-Origin', '*');\n          }\n          // pass to openapi-backend handler\n          response = await api.handleRequest(req, req, res);\n        } catch (err) {\n          const errCaused = err as ErrorCaused\n          // handle http errors\n          // @ts-ignore\n          res.statusCode = errCaused.statusCode || 500;\n\n          // convert to our json response format\n          // https://github.com/ether/etherpad-lite/tree/master/doc/api/http_api.md#response-format\n          switch (res.statusCode) {\n            case 403: // forbidden\n              response = {code: 4, message: errCaused.message, data: null};\n              break;\n            case 401: // unauthorized (no or wrong api key)\n              response = {code: 4, message: errCaused.message, data: null};\n              break;\n            case 404: // not found (no such function)\n              response = {code: 3, message: errCaused.message, data: null};\n              break;\n            case 500: // server error (internal error)\n              response = {code: 2, message: errCaused.message, data: null};\n              break;\n            case 400: // bad request (wrong parameters)\n              // respond with 200 OK to keep old behavior and pass tests\n              res.statusCode = 200; // @TODO: this is bad api design\n              response = {code: 1, message: errCaused.message, data: null};\n              break;\n            default:\n              response = {code: 1, message: errCaused.message, data: null};\n              break;\n          }\n        }\n\n        // send response\n        return res.send(response);\n      });\n    }\n  }\n};\n\n/**\n * Helper to get the current root path for an API version\n * @param {String} version The API version\n * @param {APIPathStyle} style The style of the API path\n * @return {String} The root path for the API version\n */\nconst getApiRootForVersion = (version:string, style:any = APIPathStyle.FLAT): string => `/${style}/${version}`;\n\n/**\n * Helper to generate an OpenAPI server object when serving definitions\n * @param {String} apiRoot The root path for the API version\n * @param {Request} req The express request object\n * @return {url: String} The server object for the OpenAPI definition location\n */\nconst generateServerForApiVersion = (apiRoot:string, req:any): {\n  url:string\n} => ({\n  url: `${settings.ssl ? 'https' : 'http'}://${req.headers.host}${apiRoot}`,\n});\n"
  },
  {
    "path": "src/node/hooks/express/padurlsanitize.ts",
    "content": "'use strict';\n\nimport {ArgsExpressType} from \"../../types/ArgsExpressType\";\n\nconst padManager = require('../../db/PadManager');\n\nexports.expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => {\n  // redirects browser to the pad's sanitized url if needed. otherwise, renders the html\n  args.app.param('pad', (req:any, res:any, next:Function, padId:string) => {\n    (async () => {\n      // ensure the padname is valid and the url doesn't end with a /\n      if (!padManager.isValidPadId(padId) || /\\/$/.test(req.url)) {\n        res.status(404).send('Such a padname is forbidden');\n        return;\n      }\n\n      const sanitizedPadId = await padManager.sanitizePadId(padId);\n\n      if (sanitizedPadId === padId) {\n        // the pad id was fine, so just render it\n        next();\n      } else {\n        // the pad id was sanitized, so we redirect to the sanitized version\n        const realURL =\n            encodeURIComponent(sanitizedPadId) + new URL(req.url, 'http://invalid.invalid').search;\n        res.header('Location', realURL);\n        res.status(302).send(`You should be redirected to <a href=\"${realURL}\">${realURL}</a>`);\n      }\n    })().catch((err) => next(err || new Error(err)));\n  });\n  return cb();\n};\n"
  },
  {
    "path": "src/node/hooks/express/pwa.ts",
    "content": "import {ArgsExpressType} from \"../../types/ArgsExpressType\";\nimport settings from '../../utils/Settings';\n\nconst pwa = {\n  name: settings.title || \"Etherpad\",\n  short_name: settings.title,\n  description: \"A collaborative online editor\",\n  icons: [\n    {\n      \"src\": \"/static/skins/colibris/images/fond.jpg\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"/favicon.ico\",\n      \"sizes\": \"64x64 32x32 24x24 16x16\",\n      type: \"image/png\"\n    }\n  ],\n  start_url: \"/\",\n  display: \"fullscreen\",\n  theme_color: \"#0f775b\",\n  background_color: \"#0f775b\"\n}\n\nexports.expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => {\n  args.app.get('/manifest.json', (req:any, res:any) => {\n    res.json(pwa);\n  });\n\n  return cb();\n}\n"
  },
  {
    "path": "src/node/hooks/express/socketio.ts",
    "content": "'use strict';\n\nimport {ArgsExpressType} from \"../../types/ArgsExpressType\";\n\nimport events from 'events';\nconst express = require('../express');\nimport log4js from 'log4js';\nconst proxyaddr = require('proxy-addr');\nimport settings from '../../utils/Settings';\nimport {Server, Socket} from 'socket.io'\nconst socketIORouter = require('../../handler/SocketIORouter');\nconst hooks = require('../../../static/js/pluginfw/hooks');\nconst padMessageHandler = require('../../handler/PadMessageHandler');\n\nlet io:any;\nconst logger = log4js.getLogger('socket.io');\nconst sockets = new Set();\nconst socketsEvents = new events.EventEmitter();\n\nexport const expressCloseServer = async () => {\n  if (io == null) return;\n  logger.info('Closing socket.io engine...');\n  // Close the socket.io engine to disconnect existing clients and reject new clients. Don't call\n  // io.close() because that closes the underlying HTTP server, which is already done elsewhere.\n  // (Closing an HTTP server twice throws an exception.) The `engine` property of socket.io Server\n  // objects is undocumented, but I don't see any other way to shut down socket.io without also\n  // closing the HTTP server.\n  io.engine.close();\n  // Closing the socket.io engine should disconnect all clients but it is not documented. Wait for\n  // all of the connections to close to make sure, and log the progress so that we can troubleshoot\n  // if socket.io's behavior ever changes.\n  //\n  // Note: `io.sockets.clients()` should not be used here to track the remaining clients.\n  // `io.sockets.clients()` works with socket.io 2.x, but not with 3.x: With socket.io 2.x all\n  // clients are always added to the default namespace (`io.sockets`) even if they specified a\n  // different namespace upon connection, but with socket.io 3.x clients are NOT added to the\n  // default namespace if they have specified a different namespace. With socket.io 3.x there does\n  // not appear to be a way to get all clients across all namespaces without tracking them\n  // ourselves, so that is what we do.\n  let lastLogged = 0;\n  while (sockets.size > 0 && !settings.enableAdminUITests) {\n    if (Date.now() - lastLogged > 1000) { // Rate limit to avoid filling logs.\n      logger.info(`Waiting for ${sockets.size} socket.io clients to disconnect...`);\n      lastLogged = Date.now();\n    }\n    await events.once(socketsEvents, 'updated');\n  }\n  logger.info('All socket.io clients have disconnected');\n};\n\nconst socketSessionMiddleware = (args: any) => (socket: any, next: Function) => {\n  const req = socket.request;\n  // Express sets req.ip but socket.io does not. Replicate Express's behavior here.\n  if (req.ip == null) {\n    if (settings.trustProxy) {\n      req.ip = proxyaddr(req, args.app.get('trust proxy fn'));\n    } else {\n      req.ip = socket.handshake.address;\n    }\n  }\n  if (!req.headers.cookie) {\n    // socketio.js-client on node.js doesn't support cookies, so pass them via a query parameter.\n    req.headers.cookie = socket.handshake.query.cookie;\n  }\n  express.sessionMiddleware(req, {}, next);\n};\n\nexport const expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => {\n  // init socket.io and redirect all requests to the MessageHandler\n  // there shouldn't be a browser that isn't compatible to all\n  // transports in this list at once\n  // e.g. XHR is disabled in IE by default, so in IE it should use jsonp-polling\n  io = new Server(args.server,{\n    transports: settings.socketTransportProtocols,\n    cookie: false,\n    maxHttpBufferSize: settings.socketIo.maxHttpBufferSize,\n  })\n\n\n  const handleConnection = (socket:Socket) => {\n    sockets.add(socket);\n    socketsEvents.emit('updated');\n    // https://socket.io/docs/v3/faq/index.html\n    // @ts-ignore\n    const session = socket.request.session;\n    session.connections++;\n    session.save();\n    socket.on('disconnect', () => {\n      sockets.delete(socket);\n      socketsEvents.emit('updated');\n    });\n  }\n\n  const renewSession = (socket:any, next:Function) => {\n    socket.conn.on('packet', (packet:string) => {\n      // Tell express-session that the session is still active. The session store can use these\n      // touch events to defer automatic session cleanup, and if express-session is configured with\n      // rolling=true the cookie's expiration time will be renewed. (Note that WebSockets does not\n      // have a standard mechanism for periodically updating the browser's cookies, so the browser\n      // will not see the new cookie expiration time unless it makes a new HTTP request or the new\n      // cookie value is sent to the client in a custom socket.io message.)\n      if (socket.request.session != null) socket.request.session.touch();\n    });\n    next();\n  }\n\n\n  io.on('connection', handleConnection);\n\n  io.use(socketSessionMiddleware(args));\n\n  // Temporary workaround so all clients go through middleware and handle connection\n  io.of('/pluginfw/installer')\n      .on('connection',handleConnection)\n      .use(socketSessionMiddleware(args))\n      .use(renewSession)\n  io.of('/settings')\n      .on('connection',handleConnection)\n      .use(socketSessionMiddleware(args))\n      .use(renewSession)\n\n  io.use(renewSession);\n\n  // var socketIOLogger = log4js.getLogger(\"socket.io\");\n  // Debug logging now has to be set at an environment level, this is stupid.\n  // https://github.com/Automattic/socket.io/wiki/Migrating-to-1.0\n  // This debug logging environment is set in Settings.js\n\n  // minify socket.io javascript\n  // Due to a shitty decision by the SocketIO team minification is\n  // no longer available, details available at:\n  // http://stackoverflow.com/questions/23981741/minify-socket-io-socket-io-js-with-1-0\n  // if(settings.minify) io.enable('browser client minification');\n\n  // Initialize the Socket.IO Router\n  socketIORouter.setSocketIO(io);\n  socketIORouter.addComponent('pad', padMessageHandler);\n\n  hooks.callAll('socketio', {app: args.app, io, server: args.server});\n\n  return cb();\n};\n"
  },
  {
    "path": "src/node/hooks/express/specialpages.ts",
    "content": "'use strict';\n\nimport path from 'node:path';\nconst eejs = require('../../eejs')\nimport fs from 'node:fs';\nconst fsp = fs.promises;\nconst toolbar = require('../../utils/toolbar');\nconst hooks = require('../../../static/js/pluginfw/hooks');\nimport settings, {getEpVersion} from '../../utils/Settings';\nimport util from 'node:util';\nconst webaccess = require('./webaccess');\nconst plugins = require('../../../static/js/pluginfw/plugin_defs');\n\nimport {build, buildSync} from 'esbuild'\nimport {ArgsExpressType} from \"../../types/ArgsExpressType\";\nimport prometheus from \"../../prometheus\";\n\nlet ioI: { sockets: { sockets: any[]; }; } | null = null\n\n\nexports.socketio = (hookName: string, {io}: any) => {\n  ioI = io\n}\n\n\nexports.expressPreSession = async (hookName:string, {app}:ArgsExpressType) => {\n  // This endpoint is intended to conform to:\n  // https://www.ietf.org/archive/id/draft-inadarei-api-health-check-06.html\n  app.get('/health', (req:any, res:any) => {\n    res.set('Content-Type', 'application/health+json');\n    res.json({\n      status: 'pass',\n      releaseId: getEpVersion(),\n    });\n  });\n\n  if (settings.enableMetrics) {\n    app.get('/stats', (req:any, res:any) => {\n      res.json(require('../../stats').toJSON());\n    });\n\n    app.get('/stats/prometheus', async (req, res) => {\n      const metrics = await prometheus()\n      res.setHeader('Content-Type', metrics.contentType)\n      res.send(await metrics.metrics())\n    })\n  }\n\n\n  app.get('/javascript', (req:any, res:any) => {\n    res.send(eejs.require('ep_etherpad-lite/templates/javascript.html', {req}));\n  });\n\n  app.get('/robots.txt', (req:any, res:any) => {\n    if (!settings.skinName) {\n      // if no skin is set, send the default robots.txt\n      return res.sendFile(path.join(settings.root, 'src', 'static', 'robots.txt'));\n    }\n    let filePath =\n      path.join(settings.root, 'src', 'static', 'skins', settings.skinName, 'robots.txt');\n    res.sendFile(filePath, (err:any) => {\n      // there is no custom robots.txt, send the default robots.txt which dissallows all\n      if (err) {\n        filePath = path.join(settings.root, 'src', 'static', 'robots.txt');\n        res.sendFile(filePath);\n      }\n    });\n  });\n\n  app.get('/favicon.ico', (req:any, res:any, next:Function) => {\n    (async () => {\n      /*\n        If this is a url we simply redirect to that one.\n       */\n      if (settings.favicon && settings.favicon.startsWith('http')) {\n        res.redirect(settings.favicon);\n        res.send();\n        return;\n      }\n\n\n      const fns = [\n        ...(settings.favicon ? [path.resolve(settings.root, settings.favicon)] : []),\n        settings.skinName && path.join(settings.root, 'src', 'static', 'skins', settings.skinName, 'favicon.ico'),\n        path.join(settings.root, 'src', 'static', 'favicon.ico'),\n      ].filter(f=>f != null);\n      for (const fn of fns) {\n        try {\n          await fsp.access(fn, fs.constants.R_OK);\n        } catch (err) {\n          continue;\n        }\n        res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`);\n        await util.promisify(res.sendFile.bind(res))(fn);\n        return;\n      }\n      next();\n    })().catch((err) => next(err || new Error(err)));\n  });\n};\n\n\n\nconst convertTypescript = (content: string) => {\n  const outputRaw = buildSync({\n    stdin: {\n      contents: content,\n      resolveDir: path.join(settings.root, 'var','js'),\n      loader: 'js'\n    },\n    alias:{\n      \"ep_etherpad-lite/static/js/browser\": 'ep_etherpad-lite/static/js/vendors/browser',\n      \"ep_etherpad-lite/static/js/nice-select\": 'ep_etherpad-lite/static/js/vendors/nice-select'\n    },\n    bundle: true, // Bundle the files together\n    minify: process.env.NODE_ENV === \"production\", // Minify the output\n    sourcemap: !(process.env.NODE_ENV === \"production\"), // Generate source maps\n    sourceRoot: settings.root+\"/src/static/js/\",\n    target: ['es2020'], // Target ECMAScript version\n    metafile: true,\n    write: false, // Do not write to file system,\n  })\n  const output = outputRaw.outputFiles[0].text\n\n  return  {\n    output,\n    hash: outputRaw.outputFiles[0].hash.replaceAll('/','2').replaceAll(\"+\",'5').replaceAll(\"^\",\"7\")\n  }\n}\n\nconst handleLiveReload = async (args: ArgsExpressType, padString: string, timeSliderString: string, indexString: any) => {\n  const chokidar = await import('chokidar')\n  const watcher = chokidar.watch(path.join(settings.root, 'src', 'static', 'js'), {});\n  let routeHandlers: { [key: string]: Function } = {};\n\n  const setRouteHandler = (path: string, newHandler: Function) => {\n    routeHandlers[path] = newHandler;\n  };\n  args.app.use((req: any, res: any, next: Function) => {\n    if (req.path.startsWith('/p/') && req.path.split('/').length == 3) {\n      req.params = {\n        pad: req.path.split('/')[2]\n      }\n      routeHandlers['/p/:pad'](req, res);\n    } else if (req.path.startsWith('/p/') && req.path.split('/').length == 4) {\n      req.params = {\n        pad: req.path.split('/')[2]\n      }\n      routeHandlers['/p/:pad/timeslider'](req, res);\n    } else if (req.path == \"/\"){\n      routeHandlers['/'](req, res);\n    } else if (routeHandlers[req.path]) {\n      routeHandlers[req.path](req, res);\n    } else {\n      next();\n    }\n  });\n\n  function handleUpdate() {\n\n    convertTypescriptWatched(indexString, (output, hash) => {\n      setRouteHandler('/watch/index', (req: any, res: any) => {\n        res.header('Content-Type', 'application/javascript');\n        res.send(output)\n      })\n      setRouteHandler('/', (req: any, res: any) => {\n        res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, entrypoint: '/watch/index?hash=' + hash, settings}));\n      })\n    })\n\n    convertTypescriptWatched(padString, (output, hash) => {\n      console.log(\"New pad hash is\", hash)\n      setRouteHandler('/watch/pad', (req: any, res: any) => {\n        res.header('Content-Type', 'application/javascript');\n        res.send(output)\n      })\n\n\n\n\n      setRouteHandler(\"/p/:pad\", (req: any, res: any, next: Function) => {\n        // The below might break for pads being rewritten\n        const isReadOnly = !webaccess.userCanModify(req.params.pad, req);\n\n        hooks.callAll('padInitToolbar', {\n          toolbar,\n          isReadOnly\n        });\n\n        const content = eejs.require('ep_etherpad-lite/templates/pad.html', {\n          req,\n          toolbar,\n          isReadOnly,\n          entrypoint: '/watch/pad?hash=' + hash,\n          settings: settings.getPublicSettings()\n        })\n        res.send(content);\n      })\n      ioI!.sockets.sockets.forEach(socket => socket.emit('liveupdate'))\n    })\n    convertTypescriptWatched(timeSliderString, (output, hash) => {\n      // serve timeslider.html under /p/$padname/timeslider\n      console.log(\"New timeslider hash is\", hash)\n\n      setRouteHandler('/watch/timeslider', (req: any, res: any) => {\n        res.header('Content-Type', 'application/javascript');\n        res.send(output)\n      })\n\n      setRouteHandler(\"/p/:pad/timeslider\", (req: any, res: any, next: Function) => {\n        console.log(\"Reloading pad\")\n        // The below might break for pads being rewritten\n        const isReadOnly = !webaccess.userCanModify(req.params.pad, req);\n\n        hooks.callAll('padInitToolbar', {\n          toolbar,\n          isReadOnly\n        });\n\n        const content = eejs.require('ep_etherpad-lite/templates/timeslider.html', {\n          req,\n          toolbar,\n          isReadOnly,\n          entrypoint: '/watch/timeslider?hash=' + hash,\n          settings: settings.getPublicSettings()\n        })\n        res.send(content);\n      })\n    })\n  }\n\n  watcher.on('change', path => {\n    console.log(`File ${path} has been changed`);\n    handleUpdate();\n  });\n  handleUpdate()\n}\n\nconst convertTypescriptWatched = (content: string, cb: (output:string, hash: string)=>void) => {\n  build({\n    stdin: {\n      contents: content,\n      resolveDir: path.join(settings.root, 'var','js'),\n      loader: 'js'\n    },\n    alias:{\n      \"ep_etherpad-lite/static/js/browser\": 'ep_etherpad-lite/static/js/vendors/browser',\n      \"ep_etherpad-lite/static/js/nice-select\": 'ep_etherpad-lite/static/js/vendors/nice-select'\n    },\n    bundle: true, // Bundle the files together\n    minify: process.env.NODE_ENV === \"production\", // Minify the output\n    sourcemap: !(process.env.NODE_ENV === \"production\"), // Generate source maps\n    sourceRoot: settings.root+\"/src/static/js/\",\n    target: ['es2020'], // Target ECMAScript version\n    metafile: true,\n    write: false, // Do not write to file system,\n  }).then((outputRaw) => {\n    cb(\n      outputRaw.outputFiles[0].text,\n      outputRaw.outputFiles[0].hash.replaceAll('/','2').replaceAll(\"+\",'5').replaceAll(\"^\",\"7\")\n    )\n  })\n}\n\nexports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, cb: Function) => {\n  const padString =   eejs.require('ep_etherpad-lite/templates/padBootstrap.js', {\n    pluginModules: (() => {\n      const pluginModules = new Set();\n      for (const part of plugins.parts) {\n        for (const [, hookFnName] of Object.entries(part.client_hooks || {})) {\n          // @ts-ignore\n          pluginModules.add(hookFnName.split(':')[0]);\n        }\n      }\n      return [...pluginModules];\n    })(),\n    settings,\n  })\n\n  const indexString = eejs.require('ep_etherpad-lite/templates/indexBootstrap.js', {\n  })\n\n  const timeSliderString = eejs.require('ep_etherpad-lite/templates/timeSliderBootstrap.js', {\n    pluginModules: (() => {\n      const pluginModules = new Set();\n      for (const part of plugins.parts) {\n        for (const [, hookFnName] of Object.entries(part.client_hooks || {})) {\n          // @ts-ignore\n          pluginModules.add(hookFnName.split(':')[0]);\n        }\n      }\n      return [...pluginModules];\n    })(),\n    settings,\n  })\n\n\n\n  const outdir = path.join(settings.root, 'var','js')\n  // Create the outdir if it doesn't exist\n  if (!fs.existsSync(outdir)) {\n    fs.mkdirSync(outdir);\n  }\n\n  let fileNamePad: string\n  let fileNameTimeSlider: string\n  let fileNameIndex: string\n  if(process.env.NODE_ENV === \"production\"){\n    const padSliderWrite = convertTypescript(padString)\n    const timeSliderWrite = convertTypescript(timeSliderString)\n    const indexWrite = convertTypescript(indexString)\n\n    fileNamePad = `padbootstrap-${padSliderWrite.hash}.min.js`\n    fileNameTimeSlider = `timeSliderBootstrap-${timeSliderWrite.hash}.min.js`\n    fileNameIndex = `indexBootstrap-${indexWrite.hash}.min.js`\n\n    args.app.get(\"/\"+fileNamePad, (_req, res) => {\n      res.header('Content-Type', 'application/javascript');\n      res.send(padSliderWrite.output)\n    })\n\n    args.app.get(\"/\"+fileNameIndex, (_req, res) => {\n      res.header('Content-Type', 'application/javascript');\n      res.send(indexWrite.output)\n    })\n\n    args.app.get(\"/\"+fileNameTimeSlider, (_req, res) => {\n      res.header('Content-Type', 'application/javascript');\n      res.send(timeSliderWrite.output)\n    })\n\n    // serve index.html under /\n    args.app.get('/', (req: any, res: any) => {\n      res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: \"./\"+fileNameIndex}));\n    });\n\n\n    // serve pad.html under /p\n    args.app.get('/p/:pad', (req: any, res: any, next: Function) => {\n      // The below might break for pads being rewritten\n      const isReadOnly = !webaccess.userCanModify(req.params.pad, req);\n\n      hooks.callAll('padInitToolbar', {\n        toolbar,\n        isReadOnly\n      });\n\n      const content = eejs.require('ep_etherpad-lite/templates/pad.html', {\n        req,\n        toolbar,\n        isReadOnly,\n        entrypoint: \"../\"+fileNamePad,\n        settings: settings.getPublicSettings()\n      })\n      res.send(content);\n    });\n\n    // serve timeslider.html under /p/$padname/timeslider\n    args.app.get('/p/:pad/timeslider', (req: any, res: any, next: Function) => {\n      hooks.callAll('padInitToolbar', {\n        toolbar,\n      });\n\n      res.send(eejs.require('ep_etherpad-lite/templates/timeslider.html', {\n        req,\n        toolbar,\n        entrypoint: \"../../\"+fileNameTimeSlider,\n        settings: settings.getPublicSettings()\n      }));\n    });\n  } else {\n    await handleLiveReload(args, padString, timeSliderString, indexString)\n  }\n\n  // The client occasionally polls this endpoint to get an updated expiration for the express_sid\n  // cookie. This handler must be installed after the express-session middleware.\n  args.app.put('/_extendExpressSessionLifetime', (req: any, res: any) => {\n    // express-session automatically calls req.session.touch() so we don't need to do it here.\n    res.json({status: 'ok'});\n  });\n};\n"
  },
  {
    "path": "src/node/hooks/express/static.ts",
    "content": "'use strict';\n\nimport {MapArrayType} from \"../../types/MapType\";\nimport {PartType} from \"../../types/PartType\";\n\nconst fs = require('fs').promises;\nimport {minify} from '../../utils/Minify';\nimport path from 'node:path';\nimport {ArgsExpressType} from \"../../types/ArgsExpressType\";\nconst plugins = require('../../../static/js/pluginfw/plugin_defs');\nimport settings from '../../utils/Settings';\n\n// Rewrite tar to include modules with no extensions and proper rooted paths.\nconst getTar = async () => {\n  const prefixLocalLibraryPath = (path:string) => {\n    if (path.charAt(0) === '$') {\n      return path.slice(1);\n    } else {\n      return `ep_etherpad-lite/static/js/${path}`;\n    }\n  };\n\n  const tarJson = await fs.readFile(path.join(settings.root, 'src/node/utils/tar.json'), 'utf8');\n  const tar:MapArrayType<string[]> = {};\n  for (const [key, relativeFiles] of Object.entries(JSON.parse(tarJson)) as [string, string[]][]) {\n    const files = relativeFiles.map(prefixLocalLibraryPath);\n    tar[prefixLocalLibraryPath(key)] = files\n        .concat(files.map((p) => p.replace(/\\.js$/, '')))\n        .concat(files.map((p) => `${p.replace(/\\.js$/, '')}/index.js`));\n  }\n  return tar;\n};\n\nexports.expressPreSession = async (hookName:string, {app}:ArgsExpressType) => {\n\n  // Minify will serve static files compressed (minify enabled). It also has\n  // file-specific hacks for ace/require-kernel/etc.\n  app.all('/static/*filename', minify);\n\n  // serve plugin definitions\n  // not very static, but served here so that client can do\n  // require(\"pluginfw/static/js/plugin-definitions.js\");\n  app.get('/pluginfw/plugin-definitions.json', (_req, res) => {\n    const clientParts = plugins.parts.filter((part: PartType) => part.client_hooks != null);\n    const clientPlugins:MapArrayType<string> = {};\n    for (const name of new Set(clientParts.map((part: PartType) => part.plugin))) {\n      // @ts-ignore\n      clientPlugins[name] = {...plugins.plugins[name]};\n      // @ts-ignore\n      delete clientPlugins[name].package;\n    }\n    res.setHeader('Content-Type', 'application/json; charset=utf-8');\n    res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`);\n    res.write(JSON.stringify({plugins: clientPlugins, parts: clientParts}));\n    res.end();\n  });\n};\n"
  },
  {
    "path": "src/node/hooks/express/tokenTransfer.ts",
    "content": "import {ArgsExpressType} from \"../../types/ArgsExpressType\";\nconst db = require('../../db/DB');\nimport crypto from 'crypto'\n\n\ntype TokenTransferRequest = {\n  token: string;\n  prefsHttp: string,\n  createdAt?: number;\n}\n\nconst tokenTransferKey = \"tokenTransfer:\";\n\nexport const expressCreateServer =  (hookName:string, {app}:ArgsExpressType) => {\n  app.post('/tokenTransfer', async (req, res) => {\n    const token = req.body as TokenTransferRequest;\n    if (!token || !token.token) {\n      return res.status(400).send({error: 'Invalid request'});\n    }\n\n    const id = crypto.randomUUID()\n    token.createdAt = Date.now();\n\n    await db.set(`${tokenTransferKey}:${id}`, token)\n    res.send({id});\n  })\n\n  app.get('/tokenTransfer/:token', async (req, res) => {\n    const id = req.params.token;\n    if (!id) {\n      return res.status(400).send({error: 'Invalid request'});\n    }\n\n    const tokenData = await db.get(`${tokenTransferKey}:${id}`);\n    if (!tokenData) {\n      return res.status(404).send({error: 'Token not found'});\n    }\n\n    const token = await db.get(`${tokenTransferKey}:${id}`)\n\n    res.cookie('token', tokenData.token, {path: '/', maxAge: 1000*60*60*24*365});\n    res.cookie('prefsHttp', tokenData.prefsHttp, {path: '/', maxAge: 1000*60*60*24*365});\n    res.send(token);\n  })\n}\n"
  },
  {
    "path": "src/node/hooks/express/webaccess.ts",
    "content": "'use strict';\n\nimport {strict as assert} from \"assert\";\nimport log4js from 'log4js';\nimport {SocketClientRequest} from \"../../types/SocketClientRequest\";\nimport {WebAccessTypes} from \"../../types/WebAccessTypes\";\nimport {SettingsUser} from \"../../types/SettingsUser\";\nconst httpLogger = log4js.getLogger('http');\nimport settings from '../../utils/Settings';\nconst hooks = require('../../../static/js/pluginfw/hooks');\nimport readOnlyManager from '../../db/ReadOnlyManager';\n\nhooks.deprecationNotices.authFailure = 'use the authnFailure and authzFailure hooks instead';\n\n// Promisified wrapper around hooks.aCallFirst.\nconst aCallFirst = (hookName: string, context:any, pred = null) => new Promise((resolve, reject) => {\n  hooks.aCallFirst(hookName, context, (err:any, r: unknown) => err != null ? reject(err) : resolve(r), pred);\n});\n\nconst aCallFirst0 =\n    // @ts-ignore\n    async (hookName: string, context:any, pred = null) => (await aCallFirst(hookName, context, pred))[0];\n\nexports.normalizeAuthzLevel = (level: string|boolean) => {\n  if (!level) return false;\n  switch (level) {\n    case true:\n      return 'create';\n    case 'readOnly':\n    case 'modify':\n    case 'create':\n      return level;\n    default:\n      httpLogger.warn(`Unknown authorization level '${level}', denying access`);\n  }\n  return false;\n};\n\nexports.userCanModify = (padId: string, req: SocketClientRequest) => {\n  if (readOnlyManager.isReadOnlyId(padId)) return false;\n  if (!settings.requireAuthentication) return true;\n  const {session: {user} = {}} = req;\n  if (!user || user.readOnly) return false;\n  assert(user.padAuthorizations); // This is populated even if !settings.requireAuthorization.\n  const level = exports.normalizeAuthzLevel(user.padAuthorizations[padId]);\n  return level && level !== 'readOnly';\n};\n\n// Exported so that tests can set this to 0 to avoid unnecessary test slowness.\nexports.authnFailureDelayMs = 1000;\n\nconst staticResources = [\n  /^\\/padbootstrap-[a-zA-Z0-9]+\\.min\\.js$/,\n  /^\\/timeSliderBootstrap-[a-zA-Z0-9]+\\.min\\.js$/,\n  /^\\/manifest.json$/\n]\n\nconst checkAccess = async (req:any, res:any, next: Function) => {\n  const requireAdmin = req.path.toLowerCase().startsWith('/admin-auth');\n  for (const staticResource of staticResources) {\n    if (req.path.match(staticResource)) {\n      console.log(`Loading [${staticResource}] ${req.path}`);\n      return next()\n    }\n  }\n\n\n  // ///////////////////////////////////////////////////////////////////////////////////////////////\n  // Step 1: Check the preAuthorize hook for early permit/deny (permit is only allowed for non-admin\n  // pages). If any plugin explicitly grants or denies access, skip the remaining steps. Plugins can\n  // use the preAuthzFailure hook to override the default 403 error.\n  // ///////////////////////////////////////////////////////////////////////////////////////////////\n\n  let results: null|boolean[];\n  let skip = false;\n  const preAuthorizeNext = (...args:any) => { skip = true; next(...args); };\n  try {\n    results = await aCallFirst('preAuthorize', {req, res, next: preAuthorizeNext},\n        // This predicate will cause aCallFirst to call the hook functions one at a time until one\n        // of them returns a non-empty list, with an exception: If the request is for an /admin\n        // page, truthy entries are filtered out before checking to see whether the list is empty.\n        // This prevents plugin authors from accidentally granting admin privileges to the general\n        // public.\n        // @ts-ignore\n        (r) => (skip || (r != null && r.filter((x) => (!requireAdmin || !x)).length > 0))) as boolean[];\n  } catch (err:any) {\n    httpLogger.error(`Error in preAuthorize hook: ${err.stack || err.toString()}`);\n    if (!skip) res.status(500).send('Internal Server Error');\n    return;\n  }\n  if (skip) return;\n  if (requireAdmin) {\n    // Filter out all 'true' entries to prevent plugin authors from accidentally granting admin\n    // privileges to the general public.\n    results = results.filter((x) => !x);\n  }\n  if (results.length > 0) {\n    // Access was explicitly granted or denied. If any value is false then access is denied.\n    if (results.every((x) => x)) return next();\n    if (await aCallFirst0('preAuthzFailure', {req, res})) return;\n    // No plugin handled the pre-authentication authorization failure.\n    return res.status(403).send('Forbidden');\n  }\n\n  // This helper is used in steps 2 and 4 below, so it may be called twice per access: once before\n  // authentication is checked and once after (if settings.requireAuthorization is true).\n  const authorize = async () => {\n    const grant = async (level: string|false) => {\n      level = exports.normalizeAuthzLevel(level);\n      if (!level) return false;\n      const user = req.session.user;\n      if (user == null) return true; // This will happen if authentication is not required.\n      const encodedPadId = (req.path.match(/^\\/p\\/([^/]*)/) || [])[1];\n      if (encodedPadId == null) return true;\n      let padId = decodeURIComponent(encodedPadId);\n      if (readOnlyManager.isReadOnlyId(padId)) {\n        // pad is read-only, first get the real pad ID\n        padId = await readOnlyManager.getPadId(padId);\n        if (padId == null) return false;\n      }\n      // The user was granted access to a pad. Remember the authorization level in the user's\n      // settings so that SecurityManager can approve or deny specific actions.\n      if (user.padAuthorizations == null) user.padAuthorizations = {};\n      user.padAuthorizations[padId] = level;\n      return true;\n    };\n    const isAuthenticated = req.session && req.session.user;\n    if (isAuthenticated && req.session.user.is_admin) return await grant('create');\n    const requireAuthn = requireAdmin || settings.requireAuthentication;\n    if (!requireAuthn) return await grant('create');\n    if (!isAuthenticated) return await grant(false);\n    if (requireAdmin && !req.session.user.is_admin) return await grant(false);\n    if (!settings.requireAuthorization) return await grant('create');\n    return await grant(await aCallFirst0('authorize', {req, res, next, resource: req.path}));\n  };\n\n  // ///////////////////////////////////////////////////////////////////////////////////////////////\n  // Step 2: Try to just access the thing. If access fails (perhaps authentication has not yet\n  // completed, or maybe different credentials are required), go to the next step.\n  // ///////////////////////////////////////////////////////////////////////////////////////////////\n\n  if (await authorize()) {\n    if(requireAdmin) {\n        res.status(200).send('Authorized')\n        return\n    }\n    return next();\n  }\n\n  // ///////////////////////////////////////////////////////////////////////////////////////////////\n  // Step 3: Authenticate the user. (Or, if already logged in, reauthenticate with different\n  // credentials if supported by the authn scheme.) If authentication fails, give the user a 401\n  // error to request new credentials. Otherwise, go to the next step. Plugins can use the\n  // authnFailure hook to override the default error handling behavior (e.g., to redirect to a login\n  // page).\n  // ///////////////////////////////////////////////////////////////////////////////////////////////\n\n  if (settings.users == null) settings.users = {};\n  const ctx:WebAccessTypes = {req, res, users: settings.users, next};\n  // If the HTTP basic auth header is present, extract the username and password so it can be given\n  // to authn plugins.\n  const httpBasicAuth = req.headers.authorization && req.headers.authorization.startsWith('Basic ');\n  if (httpBasicAuth) {\n    const userpass =\n        Buffer.from(req.headers.authorization.split(' ')[1], 'base64').toString().split(':');\n    ctx.username = userpass.shift();\n    // Prevent prototype pollution vulnerabilities in plugins. This also silences a prototype\n    // pollution warning below (when setting settings.users[ctx.username]) that isn't actually a\n    // problem unless the attacker can also set Object.prototype.password.\n    if (ctx.username === '__proto__') ctx.username = null;\n    ctx.password = userpass.join(':');\n  }\n  if (!(await aCallFirst0('authenticate', ctx))) {\n    // Fall back to HTTP basic auth.\n    // @ts-ignore\n    const {[ctx.username]: {password} = {}} = settings.users as SettingsUser;\n\n    if (!httpBasicAuth ||\n        !ctx.username ||\n        password == null || password.toString() !== ctx.password) {\n      httpLogger.info(`Failed authentication from IP ${req.ip}`);\n      if (await aCallFirst0('authnFailure', {req, res})) return;\n      if (await aCallFirst0('authFailure', {req, res, next})) return;\n      // No plugin handled the authentication failure. Fall back to basic authentication.\n      if (!requireAdmin) {\n        res.header('WWW-Authenticate', 'Basic realm=\"Protected Area\"');\n      }\n      // Delay the error response for 1s to slow down brute force attacks.\n      await new Promise((resolve) => setTimeout(resolve, exports.authnFailureDelayMs));\n      res.status(401).send('Authentication Required');\n      return;\n    }\n    if (ctx.username === '__proto__' || ctx.username === 'constructor' || ctx.username === 'prototype') {\n      res.end(403);\n      return;\n    }\n    settings.users[ctx.username].username = ctx.username;\n    // Make a shallow copy so that the password property can be deleted (to prevent it from\n    // appearing in logs or in the database) without breaking future authentication attempts.\n    req.session.user = {...settings.users[ctx.username]};\n    delete req.session.user.password;\n  }\n  if (req.session.user == null) {\n    httpLogger.error('authenticate hook failed to add user settings to session');\n    return res.status(500).send('Internal Server Error');\n  }\n  const {username = '<no username>'} = req.session.user;\n  httpLogger.info(`Successful authentication from IP ${req.ip} for user ${username}`);\n\n  // ///////////////////////////////////////////////////////////////////////////////////////////////\n  // Step 4: Try to access the thing again. If this fails, give the user a 403 error. Plugins can\n  // use the authzFailure hook to override the default error handling behavior (e.g., to redirect to\n  // a login page).\n  // ///////////////////////////////////////////////////////////////////////////////////////////////\n\n  const auth = await authorize()\n  if (auth && !requireAdmin) return next();\n  if(auth && requireAdmin) {\n    res.status(200).send('Authorized')\n    return\n  }\n\n  if (await aCallFirst0('authzFailure', {req, res})) return;\n  if (await aCallFirst0('authFailure', {req, res, next})) return;\n  // No plugin handled the authorization failure.\n  res.status(403).send('Forbidden');\n};\n\n/**\n * Express middleware to authenticate the user and check authorization. Must be installed after the\n * express-session middleware.\n */\nexports.checkAccess = (req:any, res:any, next:Function) => {\n  checkAccess(req, res, next).catch((err) => next(err || new Error(err)));\n};\n"
  },
  {
    "path": "src/node/hooks/express.ts",
    "content": "'use strict';\n\nimport {Socket} from \"node:net\";\nimport type {MapArrayType} from \"../types/MapType\";\n\nimport _ from 'underscore';\nimport cookieParser from 'cookie-parser';\nimport events from 'events';\nimport express from 'express';\nimport expressSession, {Store} from 'express-session';\nimport fs from 'fs';\nconst hooks = require('../../static/js/pluginfw/hooks');\nimport log4js from 'log4js';\nconst SessionStore = require('../db/SessionStore');\nimport settings, {getEpVersion, getGitCommit} from '../utils/Settings';\nconst stats = require('../stats')\nimport util from 'util';\nconst webaccess = require('./express/webaccess');\n\nimport SecretRotator from '../security/SecretRotator';\n\nlet secretRotator: SecretRotator|null = null;\nconst logger = log4js.getLogger('http');\nlet serverName:string;\nlet sessionStore: Store | null;\nconst sockets:Set<Socket> = new Set();\nconst socketsEvents = new events.EventEmitter();\nconst startTime = stats.settableGauge('httpStartTime');\n\nexports.server = null;\n\nconst closeServer = async () => {\n  if (exports.server != null) {\n    logger.info('Closing HTTP server...');\n    // Call exports.server.close() to reject new connections but don't await just yet because the\n    // Promise won't resolve until all preexisting connections are closed.\n    const p = util.promisify(exports.server.close.bind(exports.server))();\n    await hooks.aCallAll('expressCloseServer');\n    // Give existing connections some time to close on their own before forcibly terminating. The\n    // time should be long enough to avoid interrupting most preexisting transmissions but short\n    // enough to avoid a noticeable outage.\n    const timeout = setTimeout(async () => {\n      logger.info(`Forcibly terminating remaining ${sockets.size} HTTP connections...`);\n      for (const socket of sockets) socket.destroy(new Error('HTTP server is closing'));\n    }, 5000);\n    let lastLogged = 0;\n    while (sockets.size > 0  && !settings.enableAdminUITests) {\n      if (Date.now() - lastLogged > 1000) { // Rate limit to avoid filling logs.\n        logger.info(`Waiting for ${sockets.size} HTTP clients to disconnect...`);\n        lastLogged = Date.now();\n      }\n      await events.once(socketsEvents, 'updated');\n    }\n    await p;\n    clearTimeout(timeout);\n    exports.server = null;\n    startTime.setValue(0);\n    logger.info('HTTP server closed');\n  }\n  // @ts-ignore\n  if (sessionStore) sessionStore.shutdown();\n  sessionStore = null;\n  if (secretRotator) secretRotator.stop();\n  secretRotator = null;\n};\n\nexports.createServer = async () => {\n  console.log('Report bugs at https://github.com/ether/etherpad-lite/issues');\n\n  serverName = `Etherpad ${getGitCommit()} (https://etherpad.org)`;\n\n  console.log(`Your Etherpad version is ${getEpVersion()} (${getGitCommit()})`);\n\n  await exports.restartServer();\n\n  if (settings.ip === '') {\n    // using Unix socket for connectivity\n    console.log(`You can access your Etherpad instance using the Unix socket at ${settings.port}`);\n  } else {\n    console.log(`You can access your Etherpad instance at http://${settings.ip}:${settings.port}/`);\n  }\n\n  if (!_.isEmpty(settings.users)) {\n    console.log(`The plugin admin page is at http://${settings.ip}:${settings.port}/admin/plugins`);\n  } else {\n    console.warn('Admin username and password not set in settings.json. ' +\n                 'To access admin please uncomment and edit \"users\" in settings.json');\n  }\n\n  const env = process.env.NODE_ENV || 'development';\n\n  if (env !== 'production') {\n    console.warn('Etherpad is running in Development mode. This mode is slower for users and ' +\n                 'less secure than production mode. You should set the NODE_ENV environment ' +\n                 'variable to production by using: export NODE_ENV=production');\n  }\n};\n\nexports.restartServer = async () => {\n  await closeServer();\n\n  const app = express(); // New syntax for express v3\n\n  if (settings.ssl) {\n    console.log('SSL -- enabled');\n    console.log(`SSL -- server key file: ${settings.ssl.key}`);\n    console.log(`SSL -- Certificate Authority's certificate file: ${settings.ssl.cert}`);\n\n    const options: MapArrayType<any> = {\n      key: fs.readFileSync(settings.ssl.key),\n      cert: fs.readFileSync(settings.ssl.cert),\n    };\n\n    if (settings.ssl.ca) {\n      options.ca = [];\n      for (let i = 0; i < settings.ssl.ca.length; i++) {\n        const caFileName = settings.ssl.ca[i];\n        options.ca.push(fs.readFileSync(caFileName));\n      }\n    }\n\n    const https = require('https');\n    exports.server = https.createServer(options, app);\n  } else {\n    const http = require('http');\n    exports.server = http.createServer(app);\n  }\n\n  app.use((req, res, next) => {\n    // res.header(\"X-Frame-Options\", \"deny\"); // breaks embedded pads\n    if (settings.ssl) {\n      // we use SSL\n      res.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');\n    }\n\n    // Stop IE going into compatability mode\n    // https://github.com/ether/etherpad-lite/issues/2547\n    res.header('X-UA-Compatible', 'IE=Edge,chrome=1');\n\n    // Enable a strong referrer policy. Same-origin won't drop Referers when\n    // loading local resources, but it will drop them when loading foreign resources.\n    // It's still a last bastion of referrer security. External URLs should be\n    // already marked with rel=\"noreferer\" and user-generated content pages are already\n    // marked with <meta name=\"referrer\" content=\"no-referrer\">\n    // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy\n    // https://github.com/ether/etherpad-lite/pull/3636\n    res.header('Referrer-Policy', 'same-origin');\n\n    // send git version in the Server response header if exposeVersion is true.\n    if (settings.exposeVersion) {\n      res.header('Server', serverName);\n    }\n\n    next();\n  });\n\n  if (settings.trustProxy) {\n    /*\n     * If 'trust proxy' === true, the client’s IP address in req.ip will be the\n     * left-most entry in the X-Forwarded-* header.\n     *\n     * Source: https://expressjs.com/en/guide/behind-proxies.html\n     */\n    app.enable('trust proxy');\n  }\n\n  // Measure response time\n  app.use((req, res, next) => {\n    const stopWatch = stats.timer('httpRequests').start();\n    const sendFn = res.send.bind(res);\n    res.send = (...args) => {   stopWatch.end(); return sendFn(...args); };\n    next();\n  });\n\n  // If the log level specified in the config file is WARN or ERROR the application server never\n  // starts listening to requests as reported in issue #158. Not installing the log4js connect\n  // logger when the log level has a higher severity than INFO since it would not log at that level\n  // anyway.\n  if (!(settings.loglevel === 'WARN' || settings.loglevel === 'ERROR')) {\n    app.use(log4js.connectLogger(logger, {\n      level: log4js.levels.DEBUG.levelStr,\n      format: ':status, :method :url',\n    }));\n  }\n\n  const {keyRotationInterval, sessionLifetime} = settings.cookie;\n  let secret = settings.sessionKey;\n  if (keyRotationInterval && sessionLifetime) {\n    secretRotator = new SecretRotator(\n        'expressSessionSecrets', keyRotationInterval, sessionLifetime, settings.sessionKey);\n    await secretRotator.start();\n    const secrets = secretRotator.secrets;\n    if (Array.isArray(secrets)) {\n      secret = secrets[0];\n    } else {\n      secret = secretRotator.secrets as unknown as string;\n    }\n  }\n  if (!secret) throw new Error('missing cookie signing secret');\n\n  app.use(cookieParser(secret, {}));\n\n  sessionStore = new SessionStore(settings.cookie.sessionRefreshInterval);\n  exports.sessionMiddleware = expressSession({\n    rolling: true,\n    secret,\n    store: sessionStore ?? undefined,\n    resave: false,\n    saveUninitialized: false,\n    // Set the cookie name to a javascript identifier compatible string. Makes code handling it\n    // cleaner :)\n    name: 'express_sid',\n    cookie: {\n      maxAge: sessionLifetime || undefined, // Convert 0 to null.\n      sameSite: settings.cookie.sameSite,\n\n      // The automatic express-session mechanism for determining if the application is being served\n      // over ssl is similar to the one used for setting the language cookie, which check if one of\n      // these conditions is true:\n      //\n      //   1. we are directly serving the nodejs application over SSL, using the \"ssl\" options in\n      //      settings.json\n      //\n      //   2. we are serving the nodejs application in plaintext, but we are using a reverse proxy\n      //      that terminates SSL for us. In this case, the user has to set trustProxy = true in\n      //      settings.json, and the information wheter the application is over SSL or not will be\n      //      extracted from the X-Forwarded-Proto HTTP header\n      //\n      // Please note that this will not be compatible with applications being served over http and\n      // https at the same time.\n      //\n      // reference: https://github.com/expressjs/session/blob/v1.17.0/README.md#cookiesecure\n      secure: 'auto',\n    },\n  });\n\n  // Give plugins an opportunity to install handlers/middleware before the express-session\n  // middleware. This allows plugins to avoid creating an express-session record in the database\n  // when it is not needed (e.g., public static content).\n  await hooks.aCallAll('expressPreSession', {app, settings});\n  app.use(exports.sessionMiddleware);\n\n  app.use(webaccess.checkAccess);\n\n  await Promise.all([\n    hooks.aCallAll('expressConfigure', {app}),\n    hooks.aCallAll('expressCreateServer', {app, server: exports.server}),\n  ]);\n  exports.server.on('connection', (socket:Socket) => {\n    sockets.add(socket);\n    socketsEvents.emit('updated');\n    socket.on('close', () => {\n      sockets.delete(socket);\n      socketsEvents.emit('updated');\n    });\n  });\n  await util.promisify(exports.server.listen).bind(exports.server)(settings.port, settings.ip);\n  startTime.setValue(Date.now());\n  logger.info('HTTP server listening for connections');\n};\n\nexports.shutdown = async (hookName:string, context: any) => {\n  await closeServer();\n};\n"
  },
  {
    "path": "src/node/hooks/i18n.ts",
    "content": "'use strict';\n\nimport type {MapArrayType} from \"../types/MapType\";\nimport {I18nPluginDefs} from \"../types/I18nPluginDefs\";\n\nconst languages = require('languages4translatewiki');\nimport fs from 'fs';\nimport path from 'path';\nimport _ from 'underscore';\nconst pluginDefs = require('../../static/js/pluginfw/plugin_defs');\nimport existsSync from '../utils/path_exists';\nimport settings from '../utils/Settings';\n\n// returns all existing messages merged together and grouped by langcode\n// {es: {\"foo\": \"string\"}, en:...}\nconst getAllLocales = () => {\n  const locales2paths:MapArrayType<string[]> = {};\n\n  // Puts the paths of all locale files contained in a given directory\n  // into `locales2paths` (files from various dirs are grouped by lang code)\n  // (only json files with valid language code as name)\n  const extractLangs = (dir: string) => {\n    if (!existsSync(dir)) return;\n    let stat = fs.lstatSync(dir);\n    if (!stat.isDirectory() || stat.isSymbolicLink()) return;\n\n    fs.readdirSync(dir).forEach((file:string) => {\n      file = path.resolve(dir, file);\n      stat = fs.lstatSync(file);\n      if (stat.isDirectory() || stat.isSymbolicLink()) return;\n\n      const ext = path.extname(file);\n      const locale = path.basename(file, ext).toLowerCase();\n\n      if ((ext === '.json') && languages.isValid(locale)) {\n        if (!locales2paths[locale]) locales2paths[locale] = [];\n        locales2paths[locale].push(file);\n      }\n    });\n  };\n\n  // add core supported languages first\n  extractLangs(path.join(settings.root, 'src/locales'));\n\n  // add plugins languages (if any)\n  for (const {package: {path: pluginPath}} of Object.values<I18nPluginDefs>(pluginDefs.plugins)) {\n    // plugin locales should overwrite etherpad's core locales\n    if (pluginPath.endsWith('/ep_etherpad-lite')) continue;\n    extractLangs(path.join(pluginPath, 'locales'));\n  }\n\n  // Build a locale index (merge all locale data other than user-supplied overrides)\n  const locales:MapArrayType<any> = {};\n  _.each(locales2paths, (files: string[], langcode: string) => {\n    locales[langcode] = {};\n\n    files.forEach((file) => {\n      let fileContents;\n      try {\n        fileContents = JSON.parse(fs.readFileSync(file, 'utf8'));\n      } catch (err) {\n        console.error(`failed to read JSON file ${file}: ${err}`);\n        throw err;\n      }\n      _.extend(locales[langcode], fileContents);\n    });\n  });\n\n  // Add custom strings from settings.json\n  // Since this is user-supplied, we'll do some extra sanity checks\n  const wrongFormatErr = Error(\n      'customLocaleStrings in wrong format. See documentation ' +\n    'for Customization for Administrators, under Localization.');\n  if (settings.customLocaleStrings) {\n    if (typeof settings.customLocaleStrings !== 'object') throw wrongFormatErr;\n    _.each(settings.customLocaleStrings, (overrides , langcode) => {\n      if (typeof overrides !== 'object') throw wrongFormatErr;\n      _.each(overrides, (localeString:string|object, key:string) => {\n        if (typeof localeString !== 'string') throw wrongFormatErr;\n        const locale = locales[langcode];\n\n        // Handles the error if an unknown language code is entered\n        if (locale === undefined) {\n          const possibleMatches = [];\n          let strippedLangcode = '';\n          if (langcode.includes('-')) {\n            strippedLangcode = langcode.split('-')[0];\n          }\n          for (const localeInEtherPad of Object.keys(locales)) {\n            if (localeInEtherPad.includes(strippedLangcode)) {\n              possibleMatches.push(localeInEtherPad);\n            }\n          }\n          throw new Error(`Language code ${langcode} is unknown. ` +\n              `Maybe you meant: ${possibleMatches}`);\n        }\n\n        locales[langcode][key] = localeString;\n      });\n    });\n  }\n\n  return locales;\n};\n\n// returns a hash of all available languages availables with nativeName and direction\n// e.g. { es: {nativeName: \"español\", direction: \"ltr\"}, ... }\nconst getAvailableLangs = (locales:MapArrayType<any>) => {\n  const result:MapArrayType<string> = {};\n  for (const langcode of Object.keys(locales)) {\n    result[langcode] = languages.getLanguageInfo(langcode);\n  }\n  return result;\n};\n\n// returns locale index that will be served in /locales.json\nconst generateLocaleIndex = (locales:MapArrayType<string>) => {\n  const result = _.clone(locales); // keep English strings\n  for (const langcode of Object.keys(locales)) {\n    if (langcode !== 'en') result[langcode] = `locales/${langcode}.json`;\n  }\n  return JSON.stringify(result);\n};\n\n\nexports.expressPreSession = async (hookName:string, {app}:any) => {\n  // regenerate locales on server restart\n  const locales = getAllLocales();\n  const localeIndex = generateLocaleIndex(locales);\n  exports.availableLangs = getAvailableLangs(locales);\n\n  app.get('/locales/:locale', (req:any, res:any) => {\n    // works with /locale/en and /locale/en.json requests\n    const locale = req.params.locale.split('.')[0];\n    if (Object.prototype.hasOwnProperty.call(exports.availableLangs, locale)) {\n      res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`);\n      res.setHeader('Content-Type', 'application/json; charset=utf-8');\n      res.send(`{\"${locale}\":${JSON.stringify(locales[locale])}}`);\n    } else {\n      res.status(404).send('Language not available');\n    }\n  });\n\n  app.get('/locales.json', (req: any, res:any) => {\n    res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`);\n    res.setHeader('Content-Type', 'application/json; charset=utf-8');\n    res.send(localeIndex);\n  });\n};\n"
  },
  {
    "path": "src/node/metrics.ts",
    "content": "import Prometheus from 'prom-client';\n\nexport const metrics = {\n  'cpu': new Prometheus.Gauge({ name: 'nodejs_cpu_gauge', help: 'gauge for nodejs cpu' ,labelNames: ['type'] }),\n  'memory_process': new Prometheus.Gauge({ name: 'nodejs_memory_process_gauge', help: 'gauge for nodejs memory_process' ,labelNames: ['type'] }),\n  'memory_physical': new Prometheus.Gauge({ name: 'nodejs_memory_physical_gauge', help: 'gauge for nodejs_memory_physical' ,labelNames: ['type'] }),\n  'eventloop_latency':  new Prometheus.Gauge({ name: 'nodejs_eventloop_latency_gauge' , help: 'gauge for nodejs_eventloop_latency' ,labelNames: ['type'] }),\n  'gc': new Prometheus.Gauge({ name: 'nodejs_gc_gauge' , help: 'gause for nodejs_gc' ,labelNames: ['type']}),\n  'gc_duration': new Prometheus.Summary({ name: 'nodejs_gc_duration' , help: 'gause for nodejs_gc_duration', percentiles: [ 0.5, 0.75, 0.95 ] }),\n  'http_duration': new Prometheus.Summary({ name: 'http_duration', help: 'summary for http_duration', percentiles: [ 0.5, 0.75, 0.95 ] ,labelNames: ['url'] })\n};\n"
  },
  {
    "path": "src/node/padaccess.ts",
    "content": "'use strict';\nconst securityManager = require('./db/SecurityManager');\n\n// checks for padAccess\nmodule.exports = async (req: { params?: any; cookies?: any; session?: any; }, res: { status: (arg0: number) => { (): any; new(): any; send: { (arg0: string): void; new(): any; }; }; }) => {\n  const {session: {user} = {}} = req;\n  const accessObj = await securityManager.checkAccess(\n      req.params.pad, req.cookies.sessionID, req.cookies.token, user);\n\n  if (accessObj.accessStatus === 'grant') {\n    // there is access, continue\n    return true;\n  } else {\n    // no access\n    res.status(403).send(\"403 - Can't touch this\");\n    return false;\n  }\n};\n"
  },
  {
    "path": "src/node/prometheus.ts",
    "content": "import client from 'prom-client';\n\nconst db = require('./db/DB').db;\nconst PadMessageHandler = require('./handler/PadMessageHandler');\n\nconst register = new client.Registry();\nconst gaugeDB = new client.Gauge({\n  name: 'ueberdb_stats',\n  help: 'ueberdb stats',\n  labelNames: ['type'],\n});\nregister.registerMetric(gaugeDB);\n\nconst totalUsersGauge = new client.Gauge({\n  name: 'etherpad_total_users',\n  help: 'Total number of users',\n});\nregister.registerMetric(totalUsersGauge);\n\nconst activePadsGauge = new client.Gauge({\n  name: 'etherpad_active_pads',\n  help: 'Total number of active pads',\n});\nregister.registerMetric(activePadsGauge);\n\nclient.collectDefaultMetrics({register});\n\nconst monitor = async function () {\n  for (const [metric, value] of Object.entries(db.metrics)) {\n    if (typeof value !== 'number') continue;\n    gaugeDB.set({type: metric}, value);\n  }\n  activePadsGauge.set(PadMessageHandler.getActivePadCountFromSessionInfos());\n  totalUsersGauge.set(PadMessageHandler.getTotalActiveUsers());\n  return register;\n};\n\nexport default monitor;\n"
  },
  {
    "path": "src/node/security/OAuth2Provider.ts",
    "content": "import {ArgsExpressType} from \"../types/ArgsExpressType\";\nimport Provider, {Account, Configuration} from 'oidc-provider';\nimport {generateKeyPair, exportJWK, CryptoKey} from 'jose'\nimport MemoryAdapter from \"./OIDCAdapter\";\nimport path from \"path\";\nimport settings from '../utils/Settings';\nimport {IncomingForm} from 'formidable'\nimport express from 'express';\nimport {format} from 'url'\nimport {ParsedUrlQuery} from \"node:querystring\";\nimport {MapArrayType} from \"../types/MapType\";\n\nconst configuration: Configuration = {\n    scopes: ['openid', 'profile', 'email'],\n    findAccount: async (ctx, id) => {\n        const users = settings.users as {\n            [username: string]: {\n                password: string;\n                is_admin: boolean;\n            }\n        }\n        const usersArray1 = Object.keys(users).map((username) => ({\n            username,\n            ...users[username]\n        }));\n\n        const account = usersArray1.find((user) => user.username === id);\n\n        if(account === undefined) {\n            return undefined\n        }\n        if (account.is_admin ) {\n            return {\n                accountId: id,\n                claims: () => ({\n                    sub: id,\n                    admin: true\n                })\n            } as Account\n        } else {\n            return {\n                accountId: id,\n                claims: () => ({\n                    sub: id,\n                })\n            } as Account\n        }\n    },\n    ttl: settings.ttl,\n    claims: {\n        openid: ['sub'],\n        email: ['email'],\n        profile: ['name'],\n        admin: ['admin']\n    },\n    cookies: {\n      keys: ['oidc'],\n    },\n    features:{\n      devInteractions: {enabled: false},\n    },\n    adapter: MemoryAdapter\n};\n\n\nexport let publicKeyExported: CryptoKey|null\nexport let privateKeyExported: CryptoKey|null\n\n/*\nThis function is used to initialize the OAuth2 provider\n */\nexport const expressCreateServer = async (hookName: string, args: ArgsExpressType, cb: Function) => {\n    const {privateKey, publicKey} = await generateKeyPair('RS256', {\n      extractable: true\n    });\n    const privateKeyJWK = await exportJWK(privateKey);\n    publicKeyExported = publicKey\n    privateKeyExported = privateKey\n\n    const oidc = new Provider(settings.sso.issuer, {\n        ...configuration, jwks: {\n            keys: [\n                privateKeyJWK\n            ],\n        },\n        conformIdTokenClaims: false,\n        claims: {\n            address: ['address'],\n            email: ['email', 'email_verified'],\n            phone: ['phone_number', 'phone_number_verified'],\n            profile: ['birthdate', 'family_name', 'gender', 'given_name', 'locale', 'middle_name', 'name',\n                'nickname', 'picture', 'preferred_username', 'profile', 'updated_at', 'website', 'zoneinfo'],\n        },\n        features:{\n             userinfo: {enabled: true},\n             claimsParameter: {enabled: true},\n            clientCredentials: {enabled: true},\n            devInteractions: {enabled: false},\n          resourceIndicators: {enabled: true,   defaultResource(ctx) {\n                  return ctx.origin;\n              },\n              getResourceServerInfo(ctx, resourceIndicator, client) {\n                  return {\n                      scope: \"openid\",\n                      audience: 'account',\n                      accessTokenFormat: 'jwt',\n                  };\n              },\n              useGrantedResource(ctx, model) {\n                  return true;\n              },\n          },\n            jwtResponseModes: {enabled: true},\n        },\n        clientBasedCORS: (ctx, origin, client) => {\n          return true\n        },\n        extraParams: [],\n        extraTokenClaims: async (ctx, token) => {\n            if(token.kind === 'AccessToken') {\n                // Add your custom claims here. For example:\n                const users = settings.users as {\n                    [username: string]: {\n                        password: string;\n                        is_admin: boolean;\n                    }\n                }\n\n                const usersArray1 = Object.keys(users).map((username) => ({\n                    username,\n                    ...users[username]\n                }));\n\n                const account = usersArray1.find((user) => user.username === token.accountId);\n                return {\n                    admin: account?.is_admin\n                };\n            } else if (token.kind === \"ClientCredentials\") {\n                let extraParams: MapArrayType<string> = {}\n\n                settings.sso.clients && settings.sso.clients\n                    .filter((client:any) => client.client_id === token.clientId)\n                    .forEach((client:any) => {\n                    if(client.extraParams !== undefined) {\n                        client.extraParams.forEach((param:any) => {\n                            extraParams[param.name] = param.value\n                        })\n                    }\n                })\n                return extraParams\n            }\n        },\n        clients: settings.sso.clients\n    });\n\n\n    args.app.post('/interaction/:uid', async (req, res, next) => {\n        const formid = new IncomingForm();\n        try {\n            // @ts-ignore\n            const {login, password} = (await formid.parse(req))[0]\n            const {prompt, jti, session,cid, params, grantId} = await oidc.interactionDetails(req, res);\n\n            const client = await oidc.Client.find(params.client_id as string);\n\n            switch (prompt.name) {\n                case 'login': {\n                    const users = settings.users as {\n                        [username: string]: {\n                            password: string;\n                            admin: boolean;\n                        }\n                    }\n                    const usersArray1 = Object.keys(users).map((username) => ({\n                        username,\n                        ...users[username]\n                    }));\n                    const account = usersArray1.find((user) => user.username === login as unknown as string && user.password === password as unknown as string);\n                    if (!account) {\n                        res.setHeader('Content-Type', 'application/json');\n                        res.end(JSON.stringify({error: \"Invalid login\"}));\n                    }\n\n                    if (account) {\n                        await oidc.interactionFinished(req, res, {\n                            login: {accountId: account.username}\n                        }, {mergeWithLastSubmission: false});\n                    }\n                    break;\n                }\n                case 'consent': {\n                    let grant;\n                    if (grantId) {\n                        // we'll be modifying existing grant in existing session\n                        grant = await oidc.Grant.find(grantId);\n                    } else {\n                        // we're establishing a new grant\n                        grant = new oidc.Grant({\n                            accountId: session!.accountId,\n                            clientId: params.client_id as string,\n                        });\n                    }\n\n                    if (prompt.details.missingOIDCScope) {\n                        // @ts-ignore\n                        grant!.addOIDCScope(prompt.details.missingOIDCScope.join(' '));\n                    }\n                    if (prompt.details.missingOIDCClaims) {\n                        grant!.addOIDCClaims(prompt.details.missingOIDCClaims as string[]);\n                    }\n                    if (prompt.details.missingResourceScopes) {\n                        for (const [indicator, scope] of Object.entries(prompt.details.missingResourceScopes)) {\n                            grant!.addResourceScope(indicator, scope.join(' '));\n                        }\n                    }\n                    const result = {consent: {grantId: await grant!.save()}};\n                    await oidc.interactionFinished(req, res, result, {\n                        mergeWithLastSubmission: true,\n                    });\n                    break;\n                }\n            }\n            await next();\n        } catch (err:any) {\n            return res.writeHead(500).end(err.message);\n        }\n    })\n\n\n    args.app.get('/interaction/:uid', async (req, res, next) => {\n        try {\n            const {\n                uid, prompt, params, session,\n            } = await oidc.interactionDetails(req, res);\n\n            params[\"state\"] = uid\n\n            switch (prompt.name) {\n                case 'login': {\n                    res.redirect(format({\n                        pathname: '/views/login.html',\n                        query: params as ParsedUrlQuery\n                    }))\n                    break\n                }\n                case 'consent': {\n                    res.redirect(format({\n                        pathname: '/views/consent.html',\n                        query: params as ParsedUrlQuery\n                    }))\n                    break\n                }\n                default:\n                    return res.sendFile(path.join(settings.root,'src','static', 'oidc','login.html'));\n            }\n        } catch (err) {\n            return next(err);\n        }\n    });\n\n\n    args.app.use('/views/', express.static(path.join(settings.root,'src','static', 'oidc'), {maxAge: 1000 * 60 * 60 * 24}));\n\n\n    oidc.on('authorization.error', (ctx, error) => {\n        console.log('authorization.error', error);\n    })\n\n    oidc.on('server_error', (ctx, error) => {\n        console.log('server_error', error);\n    })\n    oidc.on('grant.error', (ctx, error) => {\n        console.log('grant.error', error);\n    })\n    oidc.on('introspection.error', (ctx, error) => {\n        console.log('introspection.error', error);\n    })\n    oidc.on('revocation.error', (ctx, error) => {\n        console.log('revocation.error', error);\n    })\n    args.app.use(\"/oidc\", oidc.callback());\n    //cb();\n}\n"
  },
  {
    "path": "src/node/security/OAuth2User.ts",
    "content": "export type OAuth2User = {\n    username: string;\n    password: string;\n    admin: boolean;\n}\n"
  },
  {
    "path": "src/node/security/OIDCAdapter.ts",
    "content": "import {LRUCache} from 'lru-cache';\nimport type {Adapter, AdapterPayload} from \"oidc-provider\";\n\n\nconst options = {\n    max: 500,\n    sizeCalculation: (item:any, key:any) => {\n        return 1\n    },\n    // for use with tracking overall storage size\n    maxSize: 5000,\n\n    // how long to live in ms\n    ttl: 1000 * 60 * 5,\n\n    // return stale items before removing from cache?\n    allowStale: false,\n\n    updateAgeOnGet: false,\n    updateAgeOnHas: false,\n}\n\nconst epochTime = (date = Date.now()) => Math.floor(date / 1000);\n\nconst storage = new LRUCache<string,AdapterPayload|string[]|string>(options);\n\nfunction grantKeyFor(id: string) {\n    return `grant:${id}`;\n}\n\nfunction userCodeKeyFor(userCode:string) {\n    return `userCode:${userCode}`;\n}\n\nclass MemoryAdapter implements Adapter{\n    private readonly name: string;\n    constructor(name:string) {\n        this.name = name;\n    }\n\n    key(id:string) {\n        return `${this.name}:${id}`;\n    }\n\n    destroy(id:string) {\n        const key = this.key(id);\n\n        const found = storage.get(key) as AdapterPayload;\n        const grantId = found && found.grantId;\n\n        storage.delete(key);\n\n        if (grantId) {\n            const grantKey = grantKeyFor(grantId);\n            (storage.get(grantKey) as string[])!.forEach(token => storage.delete(token));\n            storage.delete(grantKey);\n        }\n\n        return Promise.resolve();\n    }\n\n    consume(id: string) {\n        (storage.get(this.key(id)) as AdapterPayload)!.consumed = epochTime();\n        return Promise.resolve();\n    }\n\n    find(id: string): Promise<AdapterPayload | void | undefined> {\n        if (storage.has(this.key(id))){\n            return Promise.resolve<AdapterPayload>(storage.get(this.key(id)) as AdapterPayload);\n        }\n        return Promise.resolve<undefined>(undefined)\n    }\n\n    findByUserCode(userCode: string) {\n        const id = storage.get(userCodeKeyFor(userCode)) as string;\n        return this.find(id);\n    }\n\n    upsert(id: string, payload: {\n        iat: number;\n        exp: number;\n        uid: string;\n        kind: string;\n        jti: string;\n        accountId: string;\n        loginTs: number;\n    }, expiresIn: number) {\n        const key = this.key(id);\n\n        storage.set(key, payload, {ttl: expiresIn * 1000});\n\n        return Promise.resolve();\n    }\n\n    findByUid(uid: string): Promise<AdapterPayload | void | undefined> {\n        for(const [_, value] of storage.entries()){\n            if(typeof value ===\"object\" && \"uid\" in value && value.uid === uid){\n                return Promise.resolve(value);\n            }\n        }\n        return Promise.resolve(undefined);\n    }\n\n    revokeByGrantId(grantId: string): Promise<void | undefined> {\n        const grantKey = grantKeyFor(grantId);\n        const grant = storage.get(grantKey) as string[];\n        if (grant) {\n            grant.forEach((token) => storage.delete(token));\n            storage.delete(grantKey);\n        }\n        return Promise.resolve();\n    }\n}\n\nexport default MemoryAdapter\n"
  },
  {
    "path": "src/node/security/SecretRotator.ts",
    "content": "\n\nimport {DeriveModel} from \"../types/DeriveModel\";\nimport {LegacyParams} from \"../types/LegacyParams\";\n\nconst {Buffer} = require('buffer');\nconst crypto = require('./crypto');\nconst db = require('../db/DB');\nconst log4js = require('log4js');\n\nclass Kdf {\n  async generateParams(): Promise<{ salt: string; digest: string; keyLen: number; secret: string }> { throw new Error('not implemented'); }\n  async derive(params: DeriveModel, info: any) { throw new Error('not implemented'); }\n}\n\nclass LegacyStaticSecret extends Kdf {\n  async derive(params:any, info:any) { return params; }\n}\n\nclass Hkdf extends Kdf {\n  private readonly _digest: string\n  private readonly _keyLen: number\n  constructor(digest:string, keyLen:number) {\n    super();\n    this._digest = digest;\n    this._keyLen = keyLen;\n  }\n\n  async generateParams(): Promise<{ salt: string; digest: string; keyLen: number; secret: string }> {\n    const [secret, salt] = (await Promise.all([\n      crypto.randomBytes(this._keyLen),\n      crypto.randomBytes(this._keyLen),\n    ])).map((b) => b.toString('hex'));\n    return {digest: this._digest, keyLen: this._keyLen, salt, secret};\n  }\n\n  async derive(p: DeriveModel, info:any) {\n    return Buffer.from(\n        await crypto.hkdf(p.digest, p.secret, p.salt, info, p.keyLen)).toString('hex');\n  }\n}\n\n// Key derivation algorithms. Do not modify entries in this array, except:\n//   * It is OK to replace an unused algorithm with `null` after any entries in the database\n//     using the algorithm have been deleted.\n//   * It is OK to append a new algorithm to the end.\n// If the entries are modified in any other way then key derivation might fail or produce invalid\n// results due to broken compatibility with existing database records.\nconst algorithms = [\n  new LegacyStaticSecret(),\n  new Hkdf('sha256', 32),\n];\nconst defaultAlgId = algorithms.length - 1;\n\n// In JavaScript, the % operator is remainder, not modulus.\nconst mod = (a:number, n:number) => ((a % n) + n) % n;\nconst intervalStart = (t:number, interval:number) => t - mod(t, interval);\n\n/**\n * Maintains an array of secrets across one or more Etherpad instances sharing the same database,\n * periodically rotating in a new secret and removing the oldest secret.\n *\n * The secrets are generated using a key derivation function (KDF) with input keying material coming\n * from a long-lived secret stored in the database (generated if missing).\n */\nexport class SecretRotator {\n  readonly secrets: string[];\n  private readonly _dbPrefix\n  private readonly _interval\n  private readonly _legacyStaticSecret\n  private readonly _lifetime\n  private readonly _logger\n  private _updateTimeout:any\n  private readonly _t\n  /**\n   * @param {string} dbPrefix - Database key prefix to use for tracking secret metadata.\n   * @param {number} interval - How often to rotate in a new secret.\n   * @param {number} lifetime - How long after the end of an interval before the secret is no longer\n   *     useful.\n   * @param {string} [legacyStaticSecret] - Optional secret to facilitate migration to secret\n   *     rotation. If the oldest known secret starts after `lifetime` ago, this secret will cover\n   *     the time period starting `lifetime` ago and ending at the start of that secret.\n   */\n  constructor(dbPrefix: string, interval: number, lifetime: number, legacyStaticSecret:string|null = null) {\n    /**\n     * The secrets. The first secret in this array is the one that should be used to generate new\n     * MACs. All of the secrets in this array should be used when attempting to authenticate an\n     * existing MAC. The contents of this array will be updated every `interval` milliseconds, but\n     * the Array object itself will never be replaced with a new Array object.\n     *\n     * @type {string[]}\n     * @public\n     */\n    this.secrets = [];\n    Object.defineProperty(this, 'secrets', {writable: false}); // Defend against bugs.\n\n    if (/[*:%]/.test(dbPrefix)) throw new Error(`dbPrefix contains an invalid char: ${dbPrefix}`);\n    this._dbPrefix = dbPrefix;\n    this._interval = interval;\n    this._legacyStaticSecret = legacyStaticSecret;\n    this._lifetime = lifetime;\n    this._logger = log4js.getLogger(`secret-rotation ${dbPrefix}`);\n    this._logger.debug(`new secret rotator (interval ${interval}, lifetime: ${lifetime})`);\n    this._updateTimeout = null;\n\n    // Indirections to facilitate testing.\n    this._t = {now: Date.now.bind(Date), setTimeout, clearTimeout, algorithms};\n  }\n\n  async _publish(params: LegacyParams, id:string|null = null) {\n    // Params are published to the db with a randomly generated key to avoid race conditions with\n    // other instances.\n    if (id == null) id = `${this._dbPrefix}:${(await crypto.randomBytes(32)).toString('hex')}`;\n    await db.set(id, params);\n    return id;\n  }\n\n  async start() {\n    this._logger.debug('starting secret rotation');\n    if (this._updateTimeout != null) return; // Already started.\n    await this._update();\n  }\n\n  stop() {\n    this._logger.debug('stopping secret rotation');\n    this._t.clearTimeout(this._updateTimeout);\n    this._updateTimeout = null;\n  }\n\n  async _deriveSecrets(p: any, now: number) {\n    this._logger.debug('deriving secrets from', p);\n    if (!p.interval) return [await algorithms[p.algId].derive(p.algParams, null)];\n    const t0 = intervalStart(now, p.interval);\n    // Start of the first interval covered by these params. To accommodate clock skew, p.interval is\n    // subtracted. If we did not do this, then the following could happen:\n    //   1. Instance (A) starts up and publishes params starting at the current interval.\n    //   2. Instance (B) starts up with a clock that is in the previous interval.\n    //   3. Instance (B) reads the params published by instance (A) and sees that there's no\n    //      coverage of what it thinks is the current interval.\n    //   4. Instance (B) generates and publishes new params that covers what it thinks is the\n    //      current interval.\n    //   5. Instance (B) starts generating MACs from a secret derived from the new params.\n    //   6. Instance (A) fails to validate the MACs generated by instance (B) until it re-reads\n    //      the published params, which might take as long as interval.\n    // An alternative approach is to backdate p.start by p.interval when creating new params, but\n    // this could affect the end time of legacy secrets.\n    const tA = intervalStart(p.start - p.interval, p.interval);\n    const tZ = intervalStart(p.end - 1, p.interval);\n    this._logger.debug('now:', now, 't0:', t0, 'tA:', tA, 'tZ:', tZ);\n    // Starts of intervals to derive keys for.\n    const tNs = [];\n    // Whether the derived secret for the interval starting at tN is still relevant. If there was no\n    // clock skew, a derived secret is relevant until p.lifetime has elapsed since the end of the\n    // interval. To accommodate clock skew, this end time is extended by p.interval.\n    const expired = (tN:number) => now >= tN + (2 * p.interval) + p.lifetime;\n    // Walk from t0 back until either the start of coverage or the derived secret is expired. t0\n    // must always be the first entry in case p is the current params. (The first derived secret is\n    // used for generating MACs, so the secret derived for t0 must be before the secrets derived for\n    // other times.)\n    for (let tN = Math.min(t0, tZ); tN >= tA && !expired(tN); tN -= p.interval) tNs.push(tN);\n    // Include a future derived secret to accommodate clock skew.\n    if (t0 + p.interval <= tZ) tNs.push(t0 + p.interval);\n    this._logger.debug('deriving secrets for intervals with start times:', tNs);\n    return await Promise.all(\n        tNs.map(async (tN) => await algorithms[p.algId].derive(p.algParams, `${tN}`)));\n  }\n\n  async _update() {\n    const now = this._t.now();\n    const t0 = intervalStart(now, this._interval);\n    let next = t0 + this._interval; // When this._update() should be called again.\n    let legacyEnd = now;\n    // TODO: This is racy. If two instances start up at the same time and there are no existing\n    // matching publications, each will generate and publish their own paramters. In practice this\n    // is unlikely to happen, and if it does it can be fixed by restarting both Etherpad instances.\n    const dbKeys:string[] = await db.findKeys(`${this._dbPrefix}:*`, null) || [];\n    let currentParams:any = null;\n    let currentId = null;\n    const dbWrites:any[] = [];\n    const allParams = [];\n    const legacyParams:LegacyParams[] = [];\n    await Promise.all(dbKeys.map(async (dbKey) => {\n      const p = await db.get(dbKey);\n      if (p.algId === 0 && p.algParams === this._legacyStaticSecret) legacyParams.push(p);\n      if (p.start < legacyEnd) legacyEnd = p.start;\n      // Check if the params have expired. Params are still useful if a MAC generated by a secret\n      // derived from the params is still valid, which can be true up to p.end + p.lifetime if\n      // there was no clock skew. The p.interval factor is added to accommodate clock skew.\n      // p.interval is null for legacy secrets, so fall back to this._interval.\n      if (now >= p.end + p.lifetime + (p.interval || this._interval)) {\n        // This initial keying material (or legacy secret) is expired.\n        dbWrites.push(db.remove(dbKey));\n        dbWrites[dbWrites.length - 1].catch(() => {}); // Prevent unhandled Promise rejections.\n        return;\n      }\n      const t1 = p.interval && intervalStart(now, p.interval) + p.interval; // Start of next intrvl.\n      const tA = intervalStart(p.start, p.interval); // Start of interval containing p.start.\n      if (p.interval) next = Math.min(next, t1);\n      // Determine if these params can be used to generate the current (active) secret. Note that\n      // p.start is allowed to be in the next interval in case there is clock skew.\n      if (p.interval && p.interval === this._interval && p.lifetime === this._lifetime &&\n          tA <= t1 && p.end > now && (currentParams == null || p.start > currentParams.start)) {\n        if (currentParams) allParams.push(currentParams);\n        currentParams = p;\n        currentId = dbKey;\n      } else {\n        allParams.push(p);\n      }\n    }));\n    if (this._legacyStaticSecret && now < legacyEnd + this._lifetime + this._interval &&\n        !legacyParams.find((p) => p.end + p.lifetime >= legacyEnd + this._lifetime)) {\n      const d = new Date(legacyEnd).toJSON();\n      this._logger.debug(`adding legacy static secret for ${d} with lifetime ${this._lifetime}`);\n      const p: LegacyParams = {\n        algId: 0,\n        algParams: this._legacyStaticSecret,\n        // The start time is equal to the end time so that this legacy secret does not affect the\n        // end times of any legacy secrets published by other instances.\n        start: legacyEnd,\n        end: legacyEnd,\n        interval: null,\n        lifetime: this._lifetime,\n      };\n      allParams.push(p);\n      dbWrites.push(this._publish(p));\n      dbWrites[dbWrites.length - 1].catch(() => {}); // Prevent unhandled Promise rejections.\n    }\n    if (currentParams == null) {\n      currentParams = {\n        algId: defaultAlgId,\n        algParams: await algorithms[defaultAlgId].generateParams(),\n        start: now,\n        end: now, // Extended below.\n        interval: this._interval,\n        lifetime: this._lifetime,\n      };\n    }\n    // Advance currentParams's expiration time to the end of the next interval if needed. (The next\n    // interval is used so that the parameters never expire under normal circumstances.) This must\n    // be done before deriving any secrets from currentParams so that a secret for the next interval\n    // can be included (in case there is clock skew).\n    currentParams.end = Math.max(currentParams.end, t0 + (2 * this._interval));\n    dbWrites.push(this._publish(currentParams, currentId));\n    dbWrites[dbWrites.length - 1].catch(() => {}); // Prevent unhandled Promise rejections.\n    // The secrets derived from currentParams MUST be the first secrets.\n    const secrets = await this._deriveSecrets(currentParams, now);\n    await Promise.all(\n        allParams.map(async (p) => secrets.push(...await this._deriveSecrets(p, now))));\n    // Update this.secrets all at once to avoid race conditions.\n    this.secrets.length = 0;\n    this.secrets.push(...secrets);\n    this._logger.debug('active secrets:', this.secrets);\n    // Wait for db writes to finish after updating this.secrets so that the new secrets become\n    // active as soon as possible.\n    await Promise.all(dbWrites);\n    // Use an async function so that test code can tell when it's done publishing the new secrets.\n    // The standard setTimeout() function ignores the callback's return value, but some of the tests\n    // await the returned Promise.\n    this._updateTimeout =\n        this._t.setTimeout(async () => await this._update(), next - this._t.now());\n  }\n}\n\nexport default SecretRotator\n"
  },
  {
    "path": "src/node/security/crypto.ts",
    "content": "'use strict';\n\nconst crypto = require('crypto');\nconst util = require('util');\n\n\n/**\n * Promisified version of Node.js's crypto.hkdf.\n */\nexports.hkdf = util.promisify(crypto.hkdf);\n\n/**\n * Promisified version of Node.js's crypto.randomBytes\n */\nexports.randomBytes = util.promisify(crypto.randomBytes);\n"
  },
  {
    "path": "src/node/server.ts",
    "content": "#!/usr/bin/env node\n\n/**\n * This module is started with src/bin/run.sh. It sets up a Express HTTP and a Socket.IO Server.\n * Static file Requests are answered directly from this module, Socket.IO messages are passed\n * to MessageHandler and minfied requests are passed to minified.\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {PluginType} from \"./types/Plugin\";\nimport {ErrorCaused} from \"./types/ErrorCaused\";\nimport log4js from 'log4js';\nimport pkg from '../package.json';\nimport {checkForMigration} from \"../static/js/pluginfw/installer\";\nimport axios from \"axios\";\n\nimport settings from './utils/Settings';\n\nlet wtfnode: any;\nif (settings.dumpOnUncleanExit) {\n  // wtfnode should be loaded after log4js.replaceConsole() so that it uses log4js for logging, and\n  // it should be above everything else so that it can hook in before resources are used.\n  wtfnode = require('wtfnode');\n}\n\n\nconst addProxyToAxios = (url: URL) => {\n  axios.defaults.proxy = {\n    host: url.hostname,\n    auth: {\n      username: url.username,\n      password: url.password,\n    },\n    port: Number(url.port),\n    protocol: url.protocol,\n  }\n}\n\nif(process.env['http_proxy']) {\n  console.log(\"Using proxy: \" + process.env['http_proxy'])\n  addProxyToAxios(new URL(process.env['http_proxy']));\n}\n\n\nif (process.env['https_proxy']) {\n  console.log(\"Using proxy: \" + process.env['https_proxy'])\n  addProxyToAxios(new URL(process.env['https_proxy']));\n}\n\n\n\n/*\n * early check for version compatibility before calling\n * any modules that require newer versions of NodeJS\n */\nimport {enforceMinNodeVersion, checkDeprecationStatus} from './utils/NodeVersion';\nenforceMinNodeVersion(pkg.engines.node.replace(\">=\", \"\"));\ncheckDeprecationStatus(pkg.engines.node.replace(\">=\", \"\"), '2.1.0');\n\nimport {check} from './utils/UpdateCheck';\nconst db = require('./db/DB');\nconst express = require('./hooks/express');\nconst hooks = require('../static/js/pluginfw/hooks');\nconst pluginDefs = require('../static/js/pluginfw/plugin_defs');\nconst plugins = require('../static/js/pluginfw/plugins');\nimport {Gate} from './utils/promises';\nconst stats = require('./stats')\n\nconst logger = log4js.getLogger('server');\n\nconst State = {\n  INITIAL: 1,\n  STARTING: 2,\n  RUNNING: 3,\n  STOPPING: 4,\n  STOPPED: 5,\n  EXITING: 6,\n  WAITING_FOR_EXIT: 7,\n  STATE_TRANSITION_FAILED: 8,\n};\n\nlet state = State.INITIAL;\n\nconst removeSignalListener = (signal: NodeJS.Signals, listener: any) => {\n  logger.debug(`Removing ${signal} listener because it might interfere with shutdown tasks. ` +\n               `Function code:\\n${listener.toString()}\\n` +\n               `Current stack:\\n${new Error()!.stack!.split('\\n').slice(1).join('\\n')}`);\n  process.off(signal, listener);\n};\n\n\nlet startDoneGate: Gate<unknown>\nexports.start = async () => {\n  switch (state) {\n    case State.INITIAL:\n      break;\n    case State.STARTING:\n      await startDoneGate;\n      // Retry. Don't fall through because it might have transitioned to STATE_TRANSITION_FAILED.\n      return await exports.start();\n    case State.RUNNING:\n      return express.server;\n    case State.STOPPING:\n    case State.STOPPED:\n    case State.EXITING:\n    case State.WAITING_FOR_EXIT:\n    case State.STATE_TRANSITION_FAILED:\n      throw new Error('restart not supported');\n    default:\n      throw new Error(`unknown State: ${state.toString()}`);\n  }\n  logger.info('Starting Etherpad...');\n  startDoneGate = new Gate();\n  state = State.STARTING;\n  try {\n    // Check if the Etherpad version is up to date\n    check();\n\n    // @ts-ignore\n    stats.gauge('memoryUsage', () => process.memoryUsage().rss);\n    // @ts-ignore\n    stats.gauge('memoryUsageHeap', () => process.memoryUsage().heapUsed);\n\n    process.on('uncaughtException', (err: ErrorCaused) => {\n      logger.debug(`uncaught exception: ${err.stack || err}`);\n\n      // eslint-disable-next-line promise/no-promise-in-callback\n      exports.exit(err)\n          .catch((err: ErrorCaused) => {\n            logger.error('Error in process exit', err);\n            // eslint-disable-next-line n/no-process-exit\n            process.exit(1);\n          });\n    });\n    // As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an\n    // unhandled rejection into an uncaught exception, which does cause Node.js to exit.\n    process.on('unhandledRejection', (err: ErrorCaused) => {\n      logger.debug(`unhandled rejection: ${err.stack || err}`);\n      throw err;\n    });\n\n    for (const signal of ['SIGINT', 'SIGTERM'] as NodeJS.Signals[]) {\n      // Forcibly remove other signal listeners to prevent them from terminating node before we are\n      // done cleaning up. See https://github.com/andywer/threads.js/pull/329 for an example of a\n      // problematic listener. This means that exports.exit is solely responsible for performing all\n      // necessary cleanup tasks.\n      for (const listener of process.listeners(signal)) {\n        removeSignalListener(signal, listener);\n      }\n      process.on(signal, exports.exit);\n      // Prevent signal listeners from being added in the future.\n      process.on('newListener', (event, listener) => {\n        if (event !== signal) return;\n        removeSignalListener(signal, listener);\n      });\n    }\n\n    await db.init();\n    await checkForMigration();\n    await plugins.update();\n    const installedPlugins = (Object.values(pluginDefs.plugins) as PluginType[])\n        .filter((plugin) => plugin.package.name !== 'ep_etherpad-lite')\n        .map((plugin) => `${plugin.package.name}@${plugin.package.version}`)\n        .join(', ');\n    logger.info(`Installed plugins: ${installedPlugins}`);\n    logger.debug(`Installed parts:\\n${plugins.formatParts()}`);\n    logger.debug(`Installed server-side hooks:\\n${plugins.formatHooks('hooks', false)}`);\n    await hooks.aCallAll('loadSettings', {settings});\n    await hooks.aCallAll('createServer');\n  } catch (err) {\n    logger.error('Error occurred while starting Etherpad');\n    state = State.STATE_TRANSITION_FAILED;\n    // @ts-ignore\n    startDoneGate.resolve();\n    return await exports.exit(err);\n  }\n\n  logger.info('Etherpad is running');\n  state = State.RUNNING;\n  // @ts-ignore\n  startDoneGate.resolve();\n\n  // Return the HTTP server to make it easier to write tests.\n  return express.server;\n};\n\nconst stopDoneGate = new Gate();\nexports.stop = async () => {\n  switch (state) {\n    case State.STARTING:\n      await exports.start();\n      // Don't fall through to State.RUNNING in case another caller is also waiting for startup.\n      return await exports.stop();\n    case State.RUNNING:\n      break;\n    case State.STOPPING:\n      await stopDoneGate;\n      // fall through\n    case State.INITIAL:\n    case State.STOPPED:\n    case State.EXITING:\n    case State.WAITING_FOR_EXIT:\n    case State.STATE_TRANSITION_FAILED:\n      return;\n    default:\n      throw new Error(`unknown State: ${state.toString()}`);\n  }\n  logger.info('Stopping Etherpad...');\n  state = State.STOPPING;\n  try {\n    let timeout: NodeJS.Timeout = null as unknown as NodeJS.Timeout;\n    await Promise.race([\n      hooks.aCallAll('shutdown'),\n      new Promise((resolve, reject) => {\n        timeout = setTimeout(() => reject(new Error('Timed out waiting for shutdown tasks')), 3000);\n      }),\n    ]);\n    clearTimeout(timeout);\n  } catch (err) {\n    logger.error('Error occurred while stopping Etherpad');\n    state = State.STATE_TRANSITION_FAILED;\n    // @ts-ignore\n    stopDoneGate.resolve();\n    return await exports.exit(err);\n  }\n  logger.info('Etherpad stopped');\n  state = State.STOPPED;\n  // @ts-ignore\n  stopDoneGate.resolve();\n};\n\nlet exitGate: any;\nlet exitCalled = false;\nexports.exit = async (err: ErrorCaused|string|null = null) => {\n  /* eslint-disable no-process-exit */\n  if (err === 'SIGTERM') {\n    // Termination from SIGTERM is not treated as an abnormal termination.\n    logger.info('Received SIGTERM signal');\n    err = null;\n  } else if (typeof err == \"object\" && err != null) {\n    logger.error(`Metrics at time of fatal error:\\n${JSON.stringify(stats.toJSON(), null, 2)}`);\n    logger.error(err.stack || err.toString());\n    process.exitCode = 1;\n    if (exitCalled) {\n      logger.error('Error occurred while waiting to exit. Forcing an immediate unclean exit...');\n      process.exit(1);\n    }\n  }\n  if (!exitCalled) logger.info('Exiting...');\n  exitCalled = true;\n  switch (state) {\n    case State.STARTING:\n    case State.RUNNING:\n    case State.STOPPING:\n      await exports.stop();\n      // Don't fall through to State.STOPPED in case another caller is also waiting for stop().\n      // Don't pass err to exports.exit() because this err has already been processed. (If err is\n      // passed again to exit() then exit() will think that a second error occurred while exiting.)\n      return await exports.exit();\n    case State.INITIAL:\n    case State.STOPPED:\n    case State.STATE_TRANSITION_FAILED:\n      break;\n    case State.EXITING:\n      await exitGate;\n      // fall through\n    case State.WAITING_FOR_EXIT:\n      return;\n    default:\n      throw new Error(`unknown State: ${state.toString()}`);\n  }\n  exitGate = new Gate();\n  state = State.EXITING;\n  exitGate.resolve();\n\n  // Node.js should exit on its own without further action. Add a timeout to force Node.js to exit\n  // just in case something failed to get cleaned up during the shutdown hook. unref() is called\n  // on the timeout so that the timeout itself does not prevent Node.js from exiting.\n  setTimeout(() => {\n    logger.error('Something that should have been cleaned up during the shutdown hook (such as ' +\n                'a timer, worker thread, or open connection) is preventing Node.js from exiting');\n\n    if (settings.dumpOnUncleanExit) {\n      wtfnode.dump();\n    } else {\n      logger.error('Enable `dumpOnUncleanExit` setting to get a dump of objects preventing a ' +\n                  'clean exit');\n    }\n\n    logger.error('Forcing an unclean exit...');\n    process.exit(1);\n  }, 5000).unref();\n\n  logger.info('Waiting for Node.js to exit...');\n  state = State.WAITING_FOR_EXIT;\n  /* eslint-enable no-process-exit */\n};\n\nif (require.main === module) exports.start();\n\n// @ts-ignore\nif (typeof(PhusionPassenger) !== 'undefined') exports.start();\n"
  },
  {
    "path": "src/node/stats.ts",
    "content": "'use strict';\n\nconst measured = require('measured-core');\n\nmodule.exports = measured.createCollection();\n\n// @ts-ignore\nmodule.exports.shutdown = async (hookName, context) => {\n  module.exports.end();\n};"
  },
  {
    "path": "src/node/types/APIHandlerType.ts",
    "content": ""
  },
  {
    "path": "src/node/types/ArgsExpressType.ts",
    "content": "import {Express} from \"express\";\nimport {MapArrayType} from \"./MapType\";\nimport {SettingsType} from \"../utils/Settings\";\n\nexport type ArgsExpressType = {\n    app:Express,\n    io: any,\n    server:any\n    settings: SettingsType\n}\n"
  },
  {
    "path": "src/node/types/AsyncQueueTask.ts",
    "content": "export type AsyncQueueTask = {\n    srcFile: string,\n    destFile: string,\n    type: string\n}"
  },
  {
    "path": "src/node/types/ChangeSet.ts",
    "content": "export type ChangeSet = {\n\n}\n"
  },
  {
    "path": "src/node/types/DeriveModel.ts",
    "content": "export type DeriveModel = {\n    digest: string,\n    secret: string,\n    salt: string,\n    keyLen: number\n}"
  },
  {
    "path": "src/node/types/ErrorCaused.ts",
    "content": "export class ErrorCaused extends  Error {\n    cause: Error;\n    code: any;\n    constructor(message: string, cause: Error) {\n        super();\n        this.cause = cause\n        this.name = \"ErrorCaused\"\n    }\n}\n\n\ntype ErrorCause = {\n\n}"
  },
  {
    "path": "src/node/types/I18nPluginDefs.ts",
    "content": "export type I18nPluginDefs = {\n    package: {\n        path: string\n    }\n}"
  },
  {
    "path": "src/node/types/LegacyParams.ts",
    "content": "export type LegacyParams = {\n    start: number,\n    end: number,\n    lifetime: number,\n    algId: number,\n    algParams: any,\n    interval:number|null\n}"
  },
  {
    "path": "src/node/types/MapType.ts",
    "content": "export type MapType = {\n    [key: string|number]: string|number\n}\n\nexport type MapArrayType<T> = {\n    [key:string]: T\n}"
  },
  {
    "path": "src/node/types/PackageInfo.ts",
    "content": "export type PackageInfo =  {\n    from: string,\n    name: string,\n    version: string,\n    resolved: string,\n    description: string,\n    license: string,\n    author: {\n        name: string\n    },\n    homepage: string,\n    repository: string,\n    path: string\n}\n\n\nexport type PackageData = {\n    version: string,\n    name: string\n}"
  },
  {
    "path": "src/node/types/PadSearchQuery.ts",
    "content": "export type PadSearchQuery = {\n    pattern: string;\n    offset: number;\n    limit: number;\n    ascending: boolean;\n    sortBy: \"padName\" | \"lastEdited\" | \"userCount\" | \"revisionNumber\";\n}\n\n\nexport type PadQueryResult = {\n    padName: string,\n    lastEdited: string,\n    userCount: number,\n    revisionNumber: number\n}\n"
  },
  {
    "path": "src/node/types/PadType.ts",
    "content": "import {MapArrayType} from \"./MapType\";\nimport AttributePool from \"../../static/js/AttributePool\";\n\nexport type PadType = {\n    id: string,\n    apool: ()=>AttributePool,\n    atext: AText,\n    pool: AttributePool,\n    getInternalRevisionAText: (text:number|string)=>Promise<AText>,\n    getValidRevisionRange: (fromRev: string, toRev: string)=>PadRange,\n    getRevisionAuthor: (rev: number)=>Promise<string>,\n    getRevision: (rev?: string)=>Promise<any>,\n    head: number,\n    getAllAuthorColors: ()=>Promise<MapArrayType<string>>,\n    remove: ()=>Promise<void>,\n    text: ()=>string,\n    setText: (text: string, authorId?: string)=>Promise<void>,\n    appendText: (text: string)=>Promise<void>,\n    getHeadRevisionNumber: ()=>number,\n    getRevisionDate: (rev: number)=>Promise<number>,\n    getRevisionChangeset: (rev: number)=>Promise<AChangeSet>,\n    appendRevision: (changeset: AChangeSet, author: string)=>Promise<void>,\n}\n\n\ntype PadRange = {\n    startRev: string,\n    endRev: string,\n}\n\n\nexport type APool = {\n    putAttrib: ([],flag?: boolean)=>number,\n    numToAttrib: MapArrayType<any>,\n    toJsonable: ()=>any,\n    clone: ()=>APool,\n    check: ()=>Promise<void>,\n    eachAttrib: (callback: (key: string, value: any)=>void)=>void,\n    getAttrib: (key: number)=>any,\n}\n\n\nexport type AText = {\n    text: string,\n    attribs: any\n}\n\n\nexport type PadAuthor = {\n\n}\n\nexport type AChangeSet = {\n\n}\n"
  },
  {
    "path": "src/node/types/PartType.ts",
    "content": "export type PartType = {\n    plugin: string,\n    client_hooks:any\n}\n\nexport type PluginDef = {\n    package:{\n        path:string\n    }\n}\n"
  },
  {
    "path": "src/node/types/Plugin.ts",
    "content": "'use strict';\n\n\nexport type PluginType = {\n    package: {\n        name: string,\n        version: string\n    }\n}"
  },
  {
    "path": "src/node/types/PromiseWithStd.ts",
    "content": "import type {Readable} from \"node:stream\";\nimport type {ChildProcess} from \"node:child_process\";\n\nexport type PromiseWithStd = {\n    stdout?: Readable|null,\n    stderr?: Readable|null,\n    child?: ChildProcess\n} & Promise<any>"
  },
  {
    "path": "src/node/types/QueryType.ts",
    "content": "export type QueryType = {\nsearchTerm: string; sortBy: string; sortDir: string; offset: number; limit: number;\n}"
  },
  {
    "path": "src/node/types/Revision.ts",
    "content": "import {AChangeSet} from \"./PadType\";\n\nexport type Revision = {\n  changeset: AChangeSet,\n  meta: {\n    author: string,\n    timestamp: number,\n  }\n}\n"
  },
  {
    "path": "src/node/types/RunCMDOptions.ts",
    "content": "export type RunCMDOptions = {\n    cwd?: string,\n    stdio?: string[],\n    env?: NodeJS.ProcessEnv\n}\n\nexport type RunCMDPromise = {\n    stdout?:Function,\n    stderr?:Function\n}\n\nexport type ErrorExtended = {\n    code?: number|null,\n    signal?: NodeJS.Signals|null\n}"
  },
  {
    "path": "src/node/types/SecretRotatorType.ts",
    "content": "export type SecretRotatorType = {\n    stop: ()=>void\n}"
  },
  {
    "path": "src/node/types/SettingsUser.ts",
    "content": "export type SettingsUser = {\n    [username: string]:{\n        password: string,\n        is_admin?: boolean,\n    }\n}\n"
  },
  {
    "path": "src/node/types/SocketAcknowledge.ts",
    "content": "export type SocketAcknowledge = {\n\n}\n"
  },
  {
    "path": "src/node/types/SocketClientRequest.ts",
    "content": "export type SocketClientRequest = {\n    session: {\n        user: {\n            username: string;\n            readOnly: boolean;\n            padAuthorizations: {\n                [key: string]: string;\n            }\n        }\n    }\n}\n\n\nexport type PadUserInfo = {\n    data: {\n        userInfo: {\n            name: string|null;\n            colorId: string;\n        }\n    }\n}\n\n\nexport type ChangesetRequest = {\n    data: {\n        granularity: number;\n        start: number;\n        requestID: string;\n    }\n}\n"
  },
  {
    "path": "src/node/types/SocketModule.ts",
    "content": "export type SocketModule = {\n    setSocketIO: (io: any) => void;\n}\n"
  },
  {
    "path": "src/node/types/SwaggerUIResource.ts",
    "content": "export type SwaggerUIResource = {\n    [key: string]: {\n        [secondKey: string]: {\n            operationId: string,\n            summary?: string,\n            description?:string\n            responseSchema?: object\n        }\n    }\n}\n\n\nexport type OpenAPISuccessResponse = {\n    [key: number] :{\n        $ref: string,\n        content?: {\n            [key: string]: {\n                schema: {\n                    properties: {\n                        data: {\n                            type: string,\n                            properties: object\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n\n\nexport type OpenAPIOperations = {\n    [key:string]: any\n}"
  },
  {
    "path": "src/node/types/UserSettingsObject.ts",
    "content": "export type UserSettingsObject = {\n    canCreate: boolean,\n    readOnly: boolean,\n    padAuthorizations: any\n}\n"
  },
  {
    "path": "src/node/types/WebAccessTypes.ts",
    "content": "import {SettingsUser} from \"./SettingsUser\";\n\nexport type WebAccessTypes = {\n    username?: string|null;\n    password?: string;\n    req:any;\n    res:any;\n    next:any;\n    users: SettingsUser;\n}\n"
  },
  {
    "path": "src/node/utils/Abiword.ts",
    "content": "'use strict';\n/**\n * Controls the communication with the Abiword application\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {ChildProcess} from \"node:child_process\";\nimport {AsyncQueueTask} from \"../types/AsyncQueueTask\";\n\nconst spawn = require('child_process').spawn;\nconst async = require('async');\nimport settings from './Settings';\nconst os = require('os');\n\n// on windows we have to spawn a process for each convertion,\n// cause the plugin abicommand doesn't exist on this platform\nif (os.type().indexOf('Windows') > -1) {\n  exports.convertFile = async (srcFile: string, destFile: string, type: string) => {\n    const abiword = spawn(settings.abiword, [`--to=${destFile}`, srcFile]);\n    let stdoutBuffer = '';\n    abiword.stdout.on('data', (data: string) => { stdoutBuffer += data.toString(); });\n    abiword.stderr.on('data', (data: string) => { stdoutBuffer += data.toString(); });\n    await new Promise<void>((resolve, reject) => {\n      abiword.on('exit', (code: number) => {\n        if (code !== 0) return reject(new Error(`Abiword died with exit code ${code}`));\n        if (stdoutBuffer !== '') {\n          console.log(stdoutBuffer);\n        }\n        resolve();\n      });\n    });\n  };\n  // on unix operating systems, we can start abiword with abicommand and\n  // communicate with it via stdin/stdout\n  // thats much faster, about factor 10\n} else {\n  let abiword: ChildProcess;\n  let stdoutCallback: Function|null = null;\n  const spawnAbiword = () => {\n    abiword = spawn(settings.abiword, ['--plugin', 'AbiCommand']);\n    let stdoutBuffer = '';\n    let firstPrompt = true;\n    abiword.stderr!.on('data', (data) => { stdoutBuffer += data.toString(); });\n    abiword.on('exit', (code) => {\n      spawnAbiword();\n      if (stdoutCallback != null) {\n        stdoutCallback(new Error(`Abiword died with exit code ${code}`));\n        stdoutCallback = null;\n      }\n    });\n    abiword.stdout!.on('data', (data) => {\n      stdoutBuffer += data.toString();\n      // we're searching for the prompt, cause this means everything we need is in the buffer\n      if (stdoutBuffer.search('AbiWord:>') !== -1) {\n        const err = stdoutBuffer.search('OK') !== -1 ? null : new Error(stdoutBuffer);\n        stdoutBuffer = '';\n        if (stdoutCallback != null && !firstPrompt) {\n          stdoutCallback(err);\n          stdoutCallback = null;\n        }\n        firstPrompt = false;\n      }\n    });\n  };\n  spawnAbiword();\n\n  const queue = async.queue((task: AsyncQueueTask, callback:Function) => {\n    abiword.stdin!.write(`convert ${task.srcFile} ${task.destFile} ${task.type}\\n`);\n    stdoutCallback = (err: string) => {\n      if (err != null) console.error('Abiword File failed to convert', err);\n      callback(err);\n    };\n  }, 1);\n\n  exports.convertFile = async (srcFile: string, destFile: string, type: string) => {\n    await queue.pushAsync({srcFile, destFile, type});\n  };\n}\n"
  },
  {
    "path": "src/node/utils/AbsolutePaths.ts",
    "content": "'use strict';\n/**\n * Library for deterministic relative filename expansion for Etherpad.\n */\n\n/*\n * 2018 - muxator\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport log4js from 'log4js';\nimport path from 'path';\nimport _ from 'underscore';\n\nconst absPathLogger = log4js.getLogger('AbsolutePaths');\n\n/*\n * findEtherpadRoot() computes its value only on first invocation.\n * Subsequent invocations are served from this variable.\n */\nlet etherpadRoot: string|null = null;\n\n/**\n * If stringArray's last elements are exactly equal to lastDesiredElements,\n * returns a copy in which those last elements are popped, or false otherwise.\n *\n * @param {string[]} stringArray - The input array.\n * @param {string[]} lastDesiredElements - The elements to remove from the end\n *                   of the input array.\n * @return {string[]|boolean} The shortened array, or false if there was no\n *                            overlap.\n */\nconst popIfEndsWith = (stringArray: string[], lastDesiredElements: string[]): string[] | false => {\n  if (stringArray.length <= lastDesiredElements.length) {\n    absPathLogger.debug(`In order to pop \"${lastDesiredElements.join(path.sep)}\" ` +\n                        `from \"${stringArray.join(path.sep)}\", it should contain at least ` +\n                        `${lastDesiredElements.length + 1} elements`);\n    return false;\n  }\n\n  const lastElementsFound = _.last(stringArray, lastDesiredElements.length);\n\n  if (_.isEqual(lastElementsFound, lastDesiredElements)) {\n    return _.initial(stringArray, lastDesiredElements.length);\n  }\n\n  absPathLogger.debug(\n      `${stringArray.join(path.sep)} does not end with \"${lastDesiredElements.join(path.sep)}\"`);\n  return false;\n};\n\n/**\n * Heuristically computes the directory in which Etherpad is installed.\n *\n * All the relative paths have to be interpreted against this absolute base\n * path. Since the Windows package install has a different layout on disk, it is\n * dealt with as a special case.\n *\n * The path is computed only on first invocation. Subsequent invocations return\n * a cached value.\n *\n * The cached value is stored in AbsolutePaths.etherpadRoot via a side effect.\n *\n * @return {string} The identified absolute base path. If such path cannot be\n *                  identified, prints a log and exits the application.\n */\nexport const findEtherpadRoot = () => {\n  if (etherpadRoot != null) {\n    return etherpadRoot;\n  }\n\n  const findRoot = require('find-root');\n  const foundRoot = findRoot(__dirname);\n  const splitFoundRoot = foundRoot.split(path.sep);\n\n  /*\n   * On Unix platforms and on Windows manual installs, foundRoot's value will\n   * be:\n   *\n   *   <BASE_DIR>\\src\n   */\n  let maybeEtherpadRoot = popIfEndsWith(splitFoundRoot, ['src']);\n\n  if ((maybeEtherpadRoot === false) && (process.platform === 'win32')) {\n    /*\n     * If we did not find the path we are expecting, and we are running under\n     * Windows, we may still be running from a prebuilt package, whose directory\n     * structure is different:\n     *\n     *   <BASE_DIR>\\node_modules\\ep_etherpad-lite\n     */\n    maybeEtherpadRoot = popIfEndsWith(splitFoundRoot, ['node_modules', 'ep_etherpad-lite']);\n  }\n\n  if (maybeEtherpadRoot === false) {\n    absPathLogger.error('Could not identity Etherpad base path in this ' +\n                        `${process.platform} installation in \"${foundRoot}\"`);\n    process.exit(1);\n  }\n\n  //  SIDE EFFECT on this module-level variable\n  etherpadRoot = maybeEtherpadRoot.join(path.sep);\n\n  if (path.isAbsolute(etherpadRoot)) {\n    return etherpadRoot;\n  }\n\n  absPathLogger.error(\n      `To run, Etherpad has to identify an absolute base path. This is not: \"${etherpadRoot}\"`);\n  process.exit(1);\n};\n\n/**\n * Receives a filesystem path in input. If the path is absolute, returns it\n * unchanged. If the path is relative, an absolute version of it is returned,\n * built prepending exports.findEtherpadRoot() to it.\n *\n * @param  {string} somePath - an absolute or relative path\n * @return {string} An absolute path. If the input path was already absolute,\n *                  it is returned unchanged. Otherwise it is interpreted\n *                  relative to exports.root.\n */\nexport const makeAbsolute = (somePath: string) => {\n  if (path.isAbsolute(somePath)) {\n    return somePath;\n  }\n\n  const rewrittenPath = path.join(findEtherpadRoot(), somePath);\n\n  absPathLogger.debug(`Relative path \"${somePath}\" can be rewritten to \"${rewrittenPath}\"`);\n  return rewrittenPath;\n};\n\n/**\n * Returns whether arbitraryDir is a subdirectory of parent.\n *\n * @param  {string} parent       - a path to check arbitraryDir against\n * @param  {string} arbitraryDir - the function will check if this directory is\n *                                 a subdirectory of the base one\n * @return {boolean}\n */\nexport const isSubdir = (parent: string, arbitraryDir: string): boolean => {\n  // modified from: https://stackoverflow.com/questions/37521893/determine-if-a-path-is-subdirectory-of-another-in-node-js#45242825\n  const relative = path.relative(parent, arbitraryDir);\n  return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative);\n};\n"
  },
  {
    "path": "src/node/utils/Cleanup.ts",
    "content": "'use strict'\n\nimport {AChangeSet} from \"../types/PadType\";\nimport {Revision} from \"../types/Revision\";\n\nimport {timesLimit, firstSatisfies} from './promises';\nconst padManager = require('ep_etherpad-lite/node/db/PadManager');\nconst db = require('ep_etherpad-lite/node/db/DB');\nconst Changeset = require('ep_etherpad-lite/static/js/Changeset');\nconst padMessageHandler = require('ep_etherpad-lite/node/handler/PadMessageHandler');\nimport log4js from 'log4js';\nconst logger = log4js.getLogger('cleanup');\n\n\nexport const deleteAllRevisions = async (padID: string): Promise<void> => {\n\n  const randomPadId = padID + 'aertdfdf' + Math.random().toString(10)\n\n  let pad = await padManager.getPad(padID);\n  await pad.copyPadWithoutHistory(randomPadId, false);\n  pad = await padManager.getPad(randomPadId);\n  await pad.copyPadWithoutHistory(padID, true);\n  await pad.remove();\n}\n\nconst createRevision = async (aChangeset: AChangeSet, timestamp: number, isKeyRev: boolean, authorId: string, atext: any, pool: any) => {\n\n  if (authorId !== '') pool.putAttrib(['author', authorId]);\n\n  return {\n    changeset: aChangeset,\n    meta: {\n      author: authorId,\n      timestamp: timestamp,\n      ...isKeyRev ? {\n        pool: pool,\n        atext: atext,\n      } : {},\n    },\n  };\n}\n\nexport const deleteRevisions = async (padId: string, keepRevisions: number): Promise<boolean> => {\n\n  logger.debug('Start cleanup revisions', padId)\n\n  let pad = await padManager.getPad(padId);\n  await pad.check()\n\n  logger.debug('Initial pad is valid')\n\n  if (pad.head <= keepRevisions) {\n    logger.debug('Pad has not enough revisions')\n    return false\n  }\n\n  padMessageHandler.kickSessionsFromPad(padId)\n\n  const cleanupUntilRevision = pad.head - keepRevisions\n  logger.debug('Composing changesets: ', cleanupUntilRevision)\n  const changeset = await padMessageHandler.composePadChangesets(pad, 0, cleanupUntilRevision + 1)\n\n  const revisions: Revision[] = [];\n\n  await timesLimit(keepRevisions + 1, 500, async (i: number) => {\n    const rev = i + cleanupUntilRevision\n    revisions[rev] = await pad.getRevision(rev)\n  });\n\n  logger.debug('Loaded revisions: ', revisions.length)\n\n  await timesLimit(pad.head + 1, 500, async (i: string) => {\n    await db.remove(`pad:${padId}:revs:${i}`, null);\n  });\n\n  let padContent = await db.get(`pad:${padId}`)\n  padContent.head = keepRevisions\n  if (padContent.savedRevisions) {\n    let newSavedRevisions = []\n\n    for (let i = 0; i < padContent.savedRevisions.length; i++) {\n      if (padContent.savedRevisions[i].revNum > cleanupUntilRevision) {\n        padContent.savedRevisions[i].revNum = padContent.savedRevisions[i].revNum - cleanupUntilRevision\n        newSavedRevisions.push(padContent.savedRevisions[i])\n      }\n    }\n    padContent.savedRevisions = newSavedRevisions\n  }\n  await db.set(`pad:${padId}`, padContent);\n\n  let newAText = Changeset.makeAText('\\n');\n  let pool = pad.apool()\n\n  newAText = Changeset.applyToAText(changeset, newAText, pool);\n\n  const revision = await createRevision(\n    changeset,\n    revisions[cleanupUntilRevision].meta.timestamp,\n    0 === pad.getKeyRevisionNumber(0),\n    '',\n    newAText,\n    pool\n  );\n\n  const p: Promise<void>[] = [];\n\n  p.push(db.set(`pad:${padId}:revs:0`, revision))\n\n  p.push(timesLimit(keepRevisions, 500, async (i: number) => {\n    const rev = i + cleanupUntilRevision + 1\n    const newRev = rev - cleanupUntilRevision;\n\n    newAText = Changeset.applyToAText(revisions[rev].changeset, newAText, pool);\n\n    const revision = await createRevision(\n      revisions[rev].changeset,\n      revisions[rev].meta.timestamp,\n      newRev === pad.getKeyRevisionNumber(newRev),\n      revisions[rev].meta.author,\n      newAText,\n      pool\n    );\n\n    await db.set(`pad:${padId}:revs:${newRev}`, revision);\n  }));\n\n  await Promise.all(p)\n\n  logger.debug('Finished migration. Checking pad now')\n\n  padManager.unloadPad(padId);\n\n  let newPad = await padManager.getPad(padId);\n  await newPad.check();\n\n  return true\n}\n\nexport const checkTodos = async () => {\n  await new Promise(resolve => setTimeout(resolve, 5000));\n\n  // TODO: Move to settings\n  const settings = {\n    minHead: 100,\n    keepRevisions: 100,\n    minAge: 1,//1000 * 60 * 60 * 24,\n  }\n\n  await Promise.all((await padManager.listAllPads()).padIDs.map(async (padId: string) => {\n    // TODO: Handle concurrency\n    const pad = await padManager.getPad(padId);\n\n    const revisionDate = await pad.getRevisionDate(pad.getHeadRevisionNumber())\n\n    if (pad.head < settings.minHead || padMessageHandler.padUsersCount(padId) > 0 || Date.now() < revisionDate + settings.minAge) {\n      return\n    }\n\n    try {\n      const result = await deleteRevisions(padId, settings.keepRevisions)\n      if (result) {\n        logger.info('successful cleaned up pad: ', padId)\n      }\n    } catch (err: any) {\n      logger.error(`Error in pad ${padId}: ${err.stack || err}`);\n      return;\n    }\n  }));\n}\n"
  },
  {
    "path": "src/node/utils/Cli.ts",
    "content": "'use strict';\n/**\n * The CLI module handles command line parameters\n */\n\n/*\n * 2012 Jordan Hollinger\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an\n  \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// An object containing the parsed command-line options\n\nexport const argv: Record<string, string> = {};\n\nconst argvInternal = process.argv.slice(2);\nlet arg, prevArg = \"\";\n\n// Loop through args\nfor (let i = 0; i < argvInternal.length; i++) {\n  arg = argvInternal[i];\n\n  // Override location of settings.json file\n  if (prevArg && prevArg === '--settings' || prevArg === '-s') {\n    console.log(\"Using specified settings from command line\");\n    argv.settings = arg;\n  }\n\n  // Override location of credentials.json file\n  if (prevArg && prevArg === '--credentials') {\n    console.log(\"Using specified credentials from command line\");\n    argv.credentials = arg;\n  }\n\n  // Override location of settings.json file\n  if (prevArg && prevArg === '--sessionkey') {\n    console.log(\"Using specified session key from command line\");\n    argv.sessionkey = arg;\n  }\n\n  // Override location of APIKEY.txt file\n  if (prevArg && prevArg === '--apikey') {\n    console.log(\"Using specified API key from command line\");\n    argv.apikey = arg;\n  }\n\n  prevArg = arg;\n}\n"
  },
  {
    "path": "src/node/utils/ExportEtherpad.ts",
    "content": "'use strict';\n/**\n * 2014 John McLear (Etherpad Foundation / McLear Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst Stream = require('./Stream');\nconst assert = require('assert').strict;\nconst authorManager = require('../db/AuthorManager');\nconst hooks = require('../../static/js/pluginfw/hooks');\nconst padManager = require('../db/PadManager');\n\nexports.getPadRaw = async (padId:string, readOnlyId:string) => {\n  const dstPfx = `pad:${readOnlyId || padId}`;\n  const [pad, customPrefixes] = await Promise.all([\n    padManager.getPad(padId),\n    hooks.aCallAll('exportEtherpadAdditionalContent'),\n  ]);\n  const pluginRecords = await Promise.all(customPrefixes.map(async (customPrefix:string) => {\n    const srcPfx = `${customPrefix}:${padId}`;\n    const dstPfx = `${customPrefix}:${readOnlyId || padId}`;\n    assert(!srcPfx.includes('*'));\n    const srcKeys = await pad.db.findKeys(`${srcPfx}:*`, null);\n    return (function* () {\n      yield [dstPfx, pad.db.get(srcPfx)];\n      for (const k of srcKeys) {\n        assert(k.startsWith(`${srcPfx}:`));\n        yield [`${dstPfx}${k.slice(srcPfx.length)}`, pad.db.get(k)];\n      }\n    })();\n  }));\n  const records = (function* () {\n    for (const authorId of pad.getAllAuthors()) {\n      yield [`globalAuthor:${authorId}`, (async () => {\n        const authorEntry = await authorManager.getAuthor(authorId);\n        if (!authorEntry) return undefined; // Becomes unset when converted to JSON.\n        if (authorEntry.padIDs) authorEntry.padIDs = readOnlyId || padId;\n        return authorEntry;\n      })()];\n    }\n    for (let i = 0; i <= pad.head; ++i) yield [`${dstPfx}:revs:${i}`, pad.getRevision(i)];\n    for (let i = 0; i <= pad.chatHead; ++i) yield [`${dstPfx}:chat:${i}`, pad.getChatMessage(i)];\n    for (const gen of pluginRecords) yield* gen;\n  })();\n  const data = {[dstPfx]: pad};\n  for (const [dstKey, p] of new Stream(records).batch(100).buffer(99)) data[dstKey] = await p;\n  await hooks.aCallAll('exportEtherpad', {\n    pad,\n    data,\n    dstPadId: readOnlyId || padId,\n  });\n  return data;\n};\n"
  },
  {
    "path": "src/node/utils/ExportHelper.ts",
    "content": "'use strict';\n/**\n * Helpers for export requests\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport AttributeMap from '../../static/js/AttributeMap';\nimport AttributePool from \"../../static/js/AttributePool\";\nimport {deserializeOps, splitAttributionLines, subattribution} from '../../static/js/Changeset';\nconst { checkValidRev } = require('./checkValidRev');\n\n/*\n * This method seems unused in core and no plugins depend on it\n */\nexports.getPadPlainText = (pad: { getInternalRevisionAText: (arg0: any) => any; atext: any; pool: any; }, revNum: undefined) => {\n  const _analyzeLine = exports._analyzeLine;\n  const atext = ((revNum !== undefined) ? pad.getInternalRevisionAText(checkValidRev(revNum)) : pad.atext);\n  const textLines = atext.text.slice(0, -1).split('\\n');\n  const attribLines = splitAttributionLines(atext.attribs, atext.text);\n  const apool = pad.pool;\n\n  const pieces = [];\n  for (let i = 0; i < textLines.length; i++) {\n    const line = _analyzeLine(textLines[i], attribLines[i], apool);\n    if (line.listLevel) {\n      const numSpaces = line.listLevel * 2 - 1;\n      const bullet = '*';\n      pieces.push(new Array(numSpaces + 1).join(' '), bullet, ' ', line.text, '\\n');\n    } else {\n      pieces.push(line.text, '\\n');\n    }\n  }\n\n  return pieces.join('');\n};\ntype LineModel = {\n  [id:string]:string|number|LineModel\n}\n\nexports._analyzeLine = (text:string, aline: string, apool: AttributePool) => {\n  const line: LineModel = {};\n\n  // identify list\n  let lineMarker = 0;\n  line.listLevel = 0;\n  if (aline) {\n    const [op] = deserializeOps(aline);\n    if (op != null) {\n      const attribs = AttributeMap.fromString(op.attribs, apool);\n      let listType = attribs.get('list');\n      if (listType) {\n        lineMarker = 1;\n        listType = /([a-z]+)([0-9]+)/.exec(listType);\n        if (listType) {\n          line.listTypeName = listType[1];\n          line.listLevel = Number(listType[2]);\n        }\n      }\n      const start = attribs.get('start');\n      if (start) {\n        line.start = start;\n      }\n    }\n  }\n  if (lineMarker) {\n    line.text = text.substring(1);\n    line.aline = subattribution(aline, 1);\n  } else {\n    line.text = text;\n    line.aline = aline;\n  }\n  return line;\n};\n\n\nexports._encodeWhitespace =\n  (s:string) => s.replace(/[^\\x21-\\x7E\\s\\t\\n\\r]/gu, (c) => `&#${c.codePointAt(0)};`);\n"
  },
  {
    "path": "src/node/utils/ExportHtml.ts",
    "content": "'use strict';\nimport {AText, PadType} from \"../types/PadType\";\nimport {MapArrayType} from \"../types/MapType\";\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {deserializeOps, splitAttributionLines, subattribution} from '../../static/js/Changeset';\nconst attributes = require('../../static/js/attributes');\nconst padManager = require('../db/PadManager');\nconst _ = require('underscore');\nconst Security = require('../../static/js/security');\nconst hooks = require('../../static/js/pluginfw/hooks');\nconst eejs = require('../eejs');\nconst _analyzeLine = require('./ExportHelper')._analyzeLine;\nconst _encodeWhitespace = require('./ExportHelper')._encodeWhitespace;\nimport padutils from \"../../static/js/pad_utils\";\nimport {StringIterator} from \"../../static/js/StringIterator\";\nimport {StringAssembler} from \"../../static/js/StringAssembler\";\n\nconst getPadHTML = async (pad: PadType, revNum: string) => {\n  let atext = pad.atext;\n\n  // fetch revision atext\n  if (revNum !== undefined) {\n    atext = await pad.getInternalRevisionAText(revNum);\n  }\n\n  // convert atext to html\n  return await getHTMLFromAtext(pad, atext);\n};\n\nconst getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string[]) => {\n  const apool = pad.apool();\n  const textLines = atext.text.slice(0, -1).split('\\n');\n  const attribLines = splitAttributionLines(atext.attribs, atext.text);\n\n  const tags = ['h1', 'h2', 'strong', 'em', 'u', 's'];\n  const props = ['heading1', 'heading2', 'bold', 'italic', 'underline', 'strikethrough'];\n\n  await Promise.all([\n    // prepare tags stored as ['tag', true] to be exported\n    hooks.aCallAll('exportHtmlAdditionalTags', pad).then((newProps: string[]) => {\n      newProps.forEach((prop) => {\n        tags.push(prop);\n        props.push(prop);\n      });\n    }),\n    // prepare tags stored as ['tag', 'value'] to be exported. This will generate HTML with tags\n    // like <span data-tag=\"value\">\n    hooks.aCallAll('exportHtmlAdditionalTagsWithData', pad).then((newProps: string[]) => {\n      newProps.forEach((prop) => {\n        tags.push(`span data-${prop[0]}=\"${prop[1]}\"`);\n        props.push(prop);\n      });\n    }),\n  ]);\n\n  // holds a map of used styling attributes (*1, *2, etc) in the apool\n  // and maps them to an index in props\n  // *3:2 -> the attribute *3 means strong\n  // *2:5 -> the attribute *2 means s(trikethrough)\n  const anumMap:MapArrayType<number> = {};\n  let css = '';\n\n  const stripDotFromAuthorID = (id: string) => id.replace(/\\./g, '_');\n\n  if (authorColors) {\n    css += '<style>\\n';\n\n    for (const a of Object.keys(apool.numToAttrib)) {\n      // @ts-ignore\n      const attr = apool.numToAttrib[a];\n\n      // skip non author attributes\n      if (attr[0] === 'author' && attr[1] !== '') {\n        // add to props array\n        const propName = `author${stripDotFromAuthorID(attr[1])}`;\n        const newLength = props.push(propName);\n        anumMap[a] = newLength - 1;\n\n        css += `.${propName} {background-color: ${authorColors[attr[1]]}}\\n`;\n      } else if (attr[0] === 'removed') {\n        const propName = 'removed';\n        const newLength = props.push(propName);\n        anumMap[a] = newLength - 1;\n\n        css += '.removed {text-decoration: line-through; ' +\n             \"-ms-filter:'progid:DXImageTransform.Microsoft.Alpha(Opacity=80)'; \" +\n             'filter: alpha(opacity=80); ' +\n             'opacity: 0.8; ' +\n             '}\\n';\n      }\n    }\n\n    css += '</style>';\n  }\n\n  // iterates over all props(h1,h2,strong,...), checks if it is used in\n  // this pad, and if yes puts its attrib id->props value into anumMap\n  props.forEach((propName, i) => {\n    let attrib = [propName, true];\n    if (Array.isArray(propName)) {\n      // propName can be in the form of ['color', 'red'],\n      // see hook exportHtmlAdditionalTagsWithData\n      attrib = propName;\n    }\n    // @ts-ignore\n    const propTrueNum = apool.putAttrib(attrib, true);\n    if (propTrueNum >= 0) {\n      anumMap[propTrueNum] = i;\n    }\n  });\n\n  const getLineHTML = (text: string, attribs: string[]) => {\n    // Use order of tags (b/i/u) as order of nesting, for simplicity\n    // and decent nesting.  For example,\n    // <b>Just bold<b> <b><i>Bold and italics</i></b> <i>Just italics</i>\n    // becomes\n    // <b>Just bold <i>Bold and italics</i></b> <i>Just italics</i>\n    const taker = new StringIterator(text);\n    const assem = new StringAssembler();\n    const openTags:string[] = [];\n\n    const getSpanClassFor = (i: string) => {\n      // return if author colors are disabled\n      if (!authorColors) return false;\n\n      // @ts-ignore\n      const property = props[i];\n\n      // we are not insterested on properties in the form of ['color', 'red'],\n      // see hook exportHtmlAdditionalTagsWithData\n      if (Array.isArray(property)) {\n        return false;\n      }\n\n      if (property.substr(0, 6) === 'author') {\n        return stripDotFromAuthorID(property);\n      }\n\n      if (property === 'removed') {\n        return 'removed';\n      }\n\n      return false;\n    };\n\n    // tags added by exportHtmlAdditionalTagsWithData will be exported as <span> with\n    // data attributes\n    const isSpanWithData = (i: string) => {\n      // @ts-ignore\n      const property = props[i];\n      return Array.isArray(property);\n    };\n\n    const emitOpenTag = (i: string) => {\n      openTags.unshift(i);\n      const spanClass = getSpanClassFor(i);\n\n      if (spanClass) {\n        assem.append('<span class=\"');\n        assem.append(spanClass);\n        assem.append('\">');\n      } else {\n        assem.append('<');\n        // @ts-ignore\n        assem.append(tags[i]);\n        assem.append('>');\n      }\n    };\n\n    // this closes an open tag and removes its reference from openTags\n    const emitCloseTag = (i: string) => {\n      openTags.shift();\n      const spanClass = getSpanClassFor(i);\n      const spanWithData = isSpanWithData(i);\n\n      if (spanClass || spanWithData) {\n        assem.append('</span>');\n      } else {\n        assem.append('</');\n        // @ts-ignore\n        assem.append(tags[i]);\n        assem.append('>');\n      }\n    };\n\n    const urls = padutils.findURLs(text);\n\n    let idx = 0;\n\n    const processNextChars = (numChars: number) => {\n      if (numChars <= 0) {\n        return;\n      }\n\n      // @ts-ignore\n      const ops = deserializeOps(subattribution(attribs, idx, idx + numChars));\n      idx += numChars;\n\n      // this iterates over every op string and decides which tags to open or to close\n      // based on the attribs used\n      for (const o of ops) {\n        const usedAttribs:string[] = [];\n\n        // mark all attribs as used\n        for (const a of attributes.decodeAttribString(o.attribs)) {\n          if (a in anumMap) {\n            usedAttribs.push(String(anumMap[a])); // i = 0 => bold, etc.\n          }\n        }\n        let outermostTag = -1;\n        // find the outer most open tag that is no longer used\n        for (let i = openTags.length - 1; i >= 0; i--) {\n          if (usedAttribs.indexOf(openTags[i]) === -1) {\n            outermostTag = i;\n            break;\n          }\n        }\n\n        // close all tags upto the outer most\n        if (outermostTag !== -1) {\n          while (outermostTag >= 0) {\n            emitCloseTag(openTags[0]);\n            outermostTag--;\n          }\n        }\n\n        // open all tags that are used but not open\n        for (let i = 0; i < usedAttribs.length; i++) {\n          if (openTags.indexOf(usedAttribs[i]) === -1) {\n            emitOpenTag(usedAttribs[i]);\n          }\n        }\n\n        let chars = o.chars;\n        if (o.lines) {\n          chars--; // exclude newline at end of line, if present\n        }\n\n        let s = taker.take(chars);\n\n        // removes the characters with the code 12. Don't know where they come\n        // from but they break the abiword parser and are completly useless\n        s = s.replace(String.fromCharCode(12), '');\n\n        assem.append(_encodeWhitespace(Security.escapeHTML(s)));\n      } // end iteration over spans in line\n\n      // close all the tags that are open after the last op\n      while (openTags.length > 0) {\n        emitCloseTag(openTags[0]);\n      }\n    };\n    // end processNextChars\n    if (urls) {\n      urls.forEach((urlData: [number, {\n        length: number,\n      }]) => {\n        const startIndex = urlData[0];\n        const url = urlData[1];\n        const urlLength = url.length;\n        processNextChars(startIndex - idx);\n        // Using rel=\"noreferrer\" stops leaking the URL/location of the exported HTML\n        // when clicking links in the document.\n        // Not all browsers understand this attribute, but it's part of the HTML5 standard.\n        // https://html.spec.whatwg.org/multipage/links.html#link-type-noreferrer\n        // Additionally, we do rel=\"noopener\" to ensure a higher level of referrer security.\n        // https://html.spec.whatwg.org/multipage/links.html#link-type-noopener\n        // https://mathiasbynens.github.io/rel-noopener/\n        // https://github.com/ether/etherpad-lite/pull/3636\n        assem.append(`<a href=\"${Security.escapeHTMLAttribute(url)}\" rel=\"noreferrer noopener\">`);\n        processNextChars(urlLength);\n        assem.append('</a>');\n      });\n    }\n    processNextChars(text.length - idx);\n\n    return _processSpaces(assem.toString());\n  };\n  // end getLineHTML\n  const pieces = [css];\n\n  // Need to deal with constraints imposed on HTML lists; can\n  // only gain one level of nesting at once, can't change type\n  // mid-list, etc.\n  // People might use weird indenting, e.g. skip a level,\n  // so we want to do something reasonable there.  We also\n  // want to deal gracefully with blank lines.\n  // => keeps track of the parents level of indentation\n\n  type openList = {\n    level: number,\n    type: string,\n  }\n\n  let openLists: openList[] = [];\n  for (let i = 0; i < textLines.length; i++) {\n    let context;\n    const line = _analyzeLine(textLines[i], attribLines[i], apool);\n    const lineContent = getLineHTML(line.text, line.aline);\n    // If we are inside a list\n    if (line.listLevel) {\n      context = {\n        line,\n        lineContent,\n        apool,\n        attribLine: attribLines[i],\n        text: textLines[i],\n        padId: pad.id,\n      };\n      let prevLine = null;\n      let nextLine = null;\n      if (i > 0) {\n        prevLine = _analyzeLine(textLines[i - 1], attribLines[i - 1], apool);\n      }\n      if (i < textLines.length) {\n        nextLine = _analyzeLine(textLines[i + 1], attribLines[i + 1], apool);\n      }\n      await hooks.aCallAll('getLineHTMLForExport', context);\n      // To create list parent elements\n      if ((!prevLine || prevLine.listLevel !== line.listLevel) ||\n          (line.listTypeName !== prevLine.listTypeName)) {\n        const exists = _.find(openLists, (item:openList) => (\n          item.level === line.listLevel && item.type === line.listTypeName));\n        if (!exists) {\n          let prevLevel = 0;\n          if (prevLine && prevLine.listLevel) {\n            prevLevel = prevLine.listLevel;\n          }\n          if (prevLine && line.listTypeName !== prevLine.listTypeName) {\n            prevLevel = 0;\n          }\n\n          for (let diff = prevLevel; diff < line.listLevel; diff++) {\n            openLists.push({level: diff, type: line.listTypeName});\n            const prevPiece = pieces[pieces.length - 1];\n\n            if (prevPiece.indexOf('<ul') === 0 ||\n                prevPiece.indexOf('<ol') === 0 ||\n                prevPiece.indexOf('</li>') === 0) {\n              /*\n                 uncommenting this breaks nested ols..\n                 if the previous item is NOT a ul, NOT an ol OR closing li then close the list\n                 so we consider this HTML,\n                 I inserted ** where it throws a problem in Example Wrong..\n                 <ol><li>one</li><li><ol><li>1.1</li><li><ol><li>1.1.1</li></ol></li></ol>\n                 </li><li>two</li></ol>\n\n                 Note that closing the li then re-opening for another li item here is wrong.\n                 The correct markup is\n                 <ol><li>one<ol><li>1.1<ol><li>1.1.1</li></ol></li></ol></li><li>two</li></ol>\n\n                 Exmaple Right: <ol class=\"number\"><li>one</li><ol start=\"2\" class=\"number\">\n                 <li>1.1</li><ol start=\"3\" class=\"number\"><li>1.1.1</li></ol></li></ol>\n                 <li>two</li></ol>\n                 Example Wrong: <ol class=\"number\"><li>one</li>**</li>**\n                 <ol start=\"2\" class=\"number\"><li>1.1</li>**</li>**<ol start=\"3\" class=\"number\">\n                 <li>1.1.1</li></ol></li></ol><li>two</li></ol>\n                 So it's firing wrong where the current piece is an li and the previous piece is\n                 an ol and next piece is an ol\n                 So to remedy this we can say if next piece is NOT an OL or UL.\n                 // pieces.push(\"</li>\");\n\n              */\n              if ((nextLine.listTypeName === 'number') && (nextLine.text === '')) {\n                // is the listTypeName check needed here?  null text might be completely fine!\n                // TODO Check against Uls\n                // don't do anything because the next item is a nested ol openener so\n                // we need to keep the li open\n              } else {\n                pieces.push('<li>');\n              }\n            }\n\n            if (line.listTypeName === 'number') {\n              // We introduce line.start here, this is useful for continuing\n              // Ordered list line numbers\n              // in case you have a bullet in a list IE you Want\n              // 1. hello\n              //   * foo\n              // 2. world\n              // Without this line.start logic it would be\n              // 1. hello * foo 1. world because the bullet would kill the OL\n\n              // TODO: This logic could also be used to continue OL with indented content\n              // but that's a job for another day....\n              if (line.start) {\n                pieces.push(`<ol start=\"${Number(line.start)}\" class=\"${line.listTypeName}\">`);\n              } else {\n                pieces.push(`<ol class=\"${line.listTypeName}\">`);\n              }\n            } else {\n              pieces.push(`<ul class=\"${line.listTypeName}\">`);\n            }\n          }\n        }\n      }\n      // if we're going up a level we shouldn't be adding..\n      if (context.lineContent) {\n        pieces.push('<li>', context.lineContent);\n      }\n\n      // To close list elements\n      if (nextLine &&\n          nextLine.listLevel === line.listLevel &&\n          line.listTypeName === nextLine.listTypeName) {\n        if (context.lineContent) {\n          if ((nextLine.listTypeName === 'number') && (nextLine.text === '')) {\n            // is the listTypeName check needed here?  null text might be completely fine!\n            // TODO Check against Uls\n            // don't do anything because the next item is a nested ol openener so we need to\n            // keep the li open\n          } else {\n            pieces.push('</li>');\n          }\n        }\n      }\n      if ((!nextLine ||\n           !nextLine.listLevel ||\n           nextLine.listLevel < line.listLevel) ||\n          (line.listTypeName !== nextLine.listTypeName)) {\n        let nextLevel = 0;\n        if (nextLine && nextLine.listLevel) {\n          nextLevel = nextLine.listLevel;\n        }\n        if (nextLine && line.listTypeName !== nextLine.listTypeName) {\n          nextLevel = 0;\n        }\n\n        for (let diff = nextLevel; diff < line.listLevel; diff++) {\n          openLists = openLists.filter((el) => el.level !== diff && el.type !== line.listTypeName);\n\n          if (pieces[pieces.length - 1].indexOf('</ul') === 0 ||\n              pieces[pieces.length - 1].indexOf('</ol') === 0) {\n            pieces.push('</li>');\n          }\n\n          if (line.listTypeName === 'number') {\n            pieces.push('</ol>');\n          } else {\n            pieces.push('</ul>');\n          }\n        }\n      }\n    } else {\n      // outside any list, need to close line.listLevel of lists\n      context = {\n        line,\n        lineContent,\n        apool,\n        attribLine: attribLines[i],\n        text: textLines[i],\n        padId: pad.id,\n      };\n\n      await hooks.aCallAll('getLineHTMLForExport', context);\n      pieces.push(context.lineContent, '<br>');\n    }\n  }\n\n  return pieces.join('');\n};\n\nexports.getPadHTMLDocument = async (padId: string, revNum: string, readOnlyId: number) => {\n  const pad = await padManager.getPad(padId);\n\n  // Include some Styles into the Head for Export\n  let stylesForExportCSS = '';\n  const stylesForExport: string[] = await hooks.aCallAll('stylesForExport', padId);\n  stylesForExport.forEach((css) => {\n    stylesForExportCSS += css;\n  });\n\n  let html = await getPadHTML(pad, revNum);\n\n  for (const hookHtml of await hooks.aCallAll('exportHTMLAdditionalContent', {padId})) {\n    html += hookHtml;\n  }\n\n  return eejs.require('ep_etherpad-lite/templates/export_html.html', {\n    body: html,\n    padId: Security.escapeHTML(readOnlyId || padId),\n    extraCSS: stylesForExportCSS,\n  });\n};\n\n// copied from ACE\nconst _processSpaces = (s: string) => {\n  const doesWrap = true;\n  if (s.indexOf('<') < 0 && !doesWrap) {\n    // short-cut\n    return s.replace(/ /g, '&nbsp;');\n  }\n  const parts = [];\n  s.replace(/<[^>]*>?| |[^ <]+/g, (m) => {\n    parts.push(m);\n    return m\n  });\n  if (doesWrap) {\n    let endOfLine = true;\n    let beforeSpace = false;\n    // last space in a run is normal, others are nbsp,\n    // end of line is nbsp\n    for (let i = parts.length - 1; i >= 0; i--) {\n      const p = parts[i];\n      if (p === ' ') {\n        if (endOfLine || beforeSpace) parts[i] = '&nbsp;';\n        endOfLine = false;\n        beforeSpace = true;\n      } else if (p.charAt(0) !== '<') {\n        endOfLine = false;\n        beforeSpace = false;\n      }\n    }\n    // beginning of line is nbsp\n    for (let i = 0; i < parts.length; i++) {\n      const p = parts[i];\n      if (p === ' ') {\n        parts[i] = '&nbsp;';\n        break;\n      } else if (p.charAt(0) !== '<') {\n        break;\n      }\n    }\n  } else {\n    for (let i = 0; i < parts.length; i++) {\n      const p = parts[i];\n      if (p === ' ') {\n        parts[i] = '&nbsp;';\n      }\n    }\n  }\n  return parts.join('');\n};\n\nexports.getPadHTML = getPadHTML;\nexports.getHTMLFromAtext = getHTMLFromAtext;\n"
  },
  {
    "path": "src/node/utils/ExportTxt.ts",
    "content": "'use strict';\n/**\n * TXT export\n */\n\n/*\n * 2013 John McLear\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {AText, PadType} from \"../types/PadType\";\nimport {MapType} from \"../types/MapType\";\n\nimport {deserializeOps, splitAttributionLines, subattribution} from '../../static/js/Changeset';\nimport {StringIterator} from \"../../static/js/StringIterator\";\nimport {StringAssembler} from \"../../static/js/StringAssembler\";\nconst attributes = require('../../static/js/attributes');\nconst padManager = require('../db/PadManager');\nconst _analyzeLine = require('./ExportHelper')._analyzeLine;\n\n// This is slightly different than the HTML method as it passes the output to getTXTFromAText\nconst getPadTXT = async (pad: PadType, revNum: string) => {\n  let atext = pad.atext;\n\n  if (revNum !== undefined) {\n    // fetch revision atext\n    atext = await pad.getInternalRevisionAText(revNum);\n  }\n\n  // convert atext to html\n  return getTXTFromAtext(pad, atext);\n};\n\n// This is different than the functionality provided in ExportHtml as it provides formatting\n// functionality that is designed specifically for TXT exports\nconst getTXTFromAtext = (pad: PadType, atext: AText, authorColors?:string) => {\n  const apool = pad.apool();\n  const textLines = atext.text.slice(0, -1).split('\\n');\n  const attribLines = splitAttributionLines(atext.attribs, atext.text);\n\n  const props = ['heading1', 'heading2', 'bold', 'italic', 'underline', 'strikethrough'];\n  const anumMap: MapType = {};\n  const css = '';\n\n  props.forEach((propName, i) => {\n    // @ts-ignore\n    const propTrueNum = apool.putAttrib([propName, true], true);\n    if (propTrueNum >= 0) {\n      anumMap[propTrueNum] = i;\n    }\n  });\n\n  const getLineTXT = (text:string, attribs:any) => {\n    const propVals:(number|boolean)[] = [false, false, false];\n    const ENTER = 1;\n    const STAY = 2;\n    const LEAVE = 0;\n\n    // Use order of tags (b/i/u) as order of nesting, for simplicity\n    // and decent nesting.  For example,\n    // <b>Just bold<b> <b><i>Bold and italics</i></b> <i>Just italics</i>\n    // becomes\n    // <b>Just bold <i>Bold and italics</i></b> <i>Just italics</i>\n    const taker = new StringIterator(text);\n    const assem = new StringAssembler();\n\n    let idx = 0;\n\n    const processNextChars = (numChars: number) => {\n      if (numChars <= 0) {\n        return;\n      }\n\n      const ops = deserializeOps(subattribution(attribs, idx, idx + numChars));\n      idx += numChars;\n\n      for (const o of ops) {\n        let propChanged = false;\n\n        for (const a of attributes.decodeAttribString(o.attribs)) {\n          if (a in anumMap) {\n            const i = anumMap[a] as number; // i = 0 => bold, etc.\n\n            if (!propVals[i]) {\n              propVals[i] = ENTER;\n              propChanged = true;\n            } else {\n              propVals[i] = STAY;\n            }\n          }\n        }\n\n        for (let i = 0; i < propVals.length; i++) {\n          if (propVals[i] === true) {\n            propVals[i] = LEAVE;\n            propChanged = true;\n          } else if (propVals[i] === STAY) {\n            // set it back\n            propVals[i] = true;\n          }\n        }\n\n        // now each member of propVal is in {false,LEAVE,ENTER,true}\n        // according to what happens at start of span\n        if (propChanged) {\n          // leaving bold (e.g.) also leaves italics, etc.\n          let left = false;\n\n          for (let i = 0; i < propVals.length; i++) {\n            const v = propVals[i];\n\n            if (!left) {\n              if (v === LEAVE) {\n                left = true;\n              }\n            } else if (v === true) {\n              // tag will be closed and re-opened\n              propVals[i] = STAY;\n            }\n          }\n\n          const tags2close = [];\n\n          for (let i = propVals.length - 1; i >= 0; i--) {\n            if (propVals[i] === LEAVE) {\n              // emitCloseTag(i);\n              tags2close.push(i);\n              propVals[i] = false;\n            } else if (propVals[i] === STAY) {\n              // emitCloseTag(i);\n              tags2close.push(i);\n            }\n          }\n\n          for (let i = 0; i < propVals.length; i++) {\n            if (propVals[i] === ENTER || propVals[i] === STAY) {\n              propVals[i] = true;\n            }\n          }\n          // propVals is now all {true,false} again\n        } // end if (propChanged)\n\n        let chars = o.chars;\n        if (o.lines) {\n          // exclude newline at end of line, if present\n          chars--;\n        }\n\n        const s = taker.take(chars);\n\n        // removes the characters with the code 12. Don't know where they come\n        // from but they break the abiword parser and are completly useless\n        // s = s.replace(String.fromCharCode(12), \"\");\n\n        // remove * from s, it's just not needed on a blank line..  This stops\n        // plugins from being able to display * at the beginning of a line\n        // s = s.replace(\"*\", \"\"); // Then remove it\n\n        assem.append(s);\n      } // end iteration over spans in line\n\n      const tags2close = [];\n      for (let i = propVals.length - 1; i >= 0; i--) {\n        if (propVals[i]) {\n          tags2close.push(i);\n          propVals[i] = false;\n        }\n      }\n    };\n    // end processNextChars\n\n    processNextChars(text.length - idx);\n    return (assem.toString());\n  };\n  // end getLineHTML\n\n  const pieces = [css];\n\n  // Need to deal with constraints imposed on HTML lists; can\n  // only gain one level of nesting at once, can't change type\n  // mid-list, etc.\n  // People might use weird indenting, e.g. skip a level,\n  // so we want to do something reasonable there.  We also\n  // want to deal gracefully with blank lines.\n  // => keeps track of the parents level of indentation\n\n  const listNumbers:MapType = {};\n  let prevListLevel;\n\n  for (let i = 0; i < textLines.length; i++) {\n    const line = _analyzeLine(textLines[i], attribLines[i], apool);\n    let lineContent = getLineTXT(line.text, line.aline);\n\n    if (line.listTypeName === 'bullet') {\n      lineContent = `* ${lineContent}`; // add a bullet\n    }\n\n    if (line.listTypeName !== 'number') {\n      // We're no longer in an OL so we can reset counting\n      for (const key of Object.keys(listNumbers)) {\n        delete listNumbers[key];\n      }\n    }\n\n    if (line.listLevel > 0) {\n      for (let j = line.listLevel - 1; j >= 0; j--) {\n        pieces.push('\\t'); // tab indent list numbers..\n        if (!listNumbers[line.listLevel]) {\n          listNumbers[line.listLevel] = 0;\n        }\n      }\n\n      if (line.listTypeName === 'number') {\n        /*\n        * listLevel == amount of indentation\n        * listNumber(s) == item number\n        *\n        * Example:\n        * 1. foo\n        *  1.1 bah\n        * 2. latte\n        *  2.1 latte\n        *\n        * To handle going back to 2.1 when prevListLevel is lower number\n        * than current line.listLevel then reset the object value\n        */\n        if (line.listLevel < prevListLevel) {\n          delete listNumbers[prevListLevel];\n        }\n\n        // @ts-ignore\n        listNumbers[line.listLevel]++;\n        if (line.listLevel > 1) {\n          let x = 1;\n          while (x <= line.listLevel - 1) {\n            // if it's undefined to avoid undefined.undefined.1 for 0.0.1\n            if (!listNumbers[x]) listNumbers[x] = 0;\n            pieces.push(`${listNumbers[x]}.`);\n            x++;\n          }\n        }\n        pieces.push(`${listNumbers[line.listLevel]}. `);\n        prevListLevel = line.listLevel;\n      }\n\n      pieces.push(lineContent, '\\n');\n    } else {\n      pieces.push(lineContent, '\\n');\n    }\n  }\n\n  return pieces.join('');\n};\n\nexports.getTXTFromAtext = getTXTFromAtext;\n\nexports.getPadTXTDocument = async (padId:string, revNum:string) => {\n  const pad = await padManager.getPad(padId);\n  return getPadTXT(pad, revNum);\n};\n"
  },
  {
    "path": "src/node/utils/ImportEtherpad.ts",
    "content": "'use strict';\n\nimport {APool} from \"../types/PadType\";\n\n/**\n * 2014 John McLear (Etherpad Foundation / McLear Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport AttributePool from '../../static/js/AttributePool';\nconst {Pad} = require('../db/Pad');\nconst Stream = require('./Stream');\nconst authorManager = require('../db/AuthorManager');\nconst db = require('../db/DB');\nconst hooks = require('../../static/js/pluginfw/hooks');\nimport log4js from 'log4js';\nconst supportedElems = require('../../static/js/contentcollector').supportedElems;\nimport {Database} from 'ueberdb2';\n\nconst logger = log4js.getLogger('ImportEtherpad');\n\nexports.setPadRaw = async (padId: string, r: string, authorId = '') => {\n  const records = JSON.parse(r);\n\n  // get supported block Elements from plugins, we will use this later.\n  hooks.callAll('ccRegisterBlockElements').forEach((element:any) => {\n    supportedElems.add(element);\n  });\n\n  // DB key prefixes for pad records. Each key is expected to have the form `${prefix}:${padId}` or\n  // `${prefix}:${padId}:${otherstuff}`.\n  const padKeyPrefixes = [\n    ...await hooks.aCallAll('exportEtherpadAdditionalContent'),\n    'pad',\n  ];\n\n  let originalPadId:string|null = null;\n  const checkOriginalPadId = (padId: string) => {\n    if (originalPadId == null) originalPadId = padId;\n    if (originalPadId !== padId) throw new Error('unexpected pad ID in record');\n  };\n\n  // First validate and transform values. Do not commit any records to the database yet in case\n  // there is a problem with the data.\n\n  const data = new Map();\n  const existingAuthors = new Set();\n  const padDb = new Database('memory', {data});\n  await padDb.init();\n  try {\n    const processRecord = async (key:string, value: null|{\n      padIDs: string|Record<string, unknown>,\n      pool: AttributePool\n    }) => {\n      if (!value) return;\n      const keyParts = key.split(':');\n      const [prefix, id] = keyParts;\n      if (prefix === 'globalAuthor' && keyParts.length === 2) {\n        // In the database, the padIDs subkey is an object (which is used as a set) that records\n        // every pad the author has worked on. When exported, that object becomes a single string\n        // containing the exported pad's ID.\n        if (typeof value.padIDs !== 'string') {\n          throw new TypeError('globalAuthor padIDs subkey is not a string');\n        }\n        checkOriginalPadId(value.padIDs);\n        if (await authorManager.doesAuthorExist(id)) {\n          existingAuthors.add(id);\n          return;\n        }\n        value.padIDs = {[padId]: 1};\n      } else if (padKeyPrefixes.includes(prefix)) {\n        checkOriginalPadId(id);\n        if (prefix === 'pad' && keyParts.length === 2) {\n          const pool = new AttributePool().fromJsonable(value.pool);\n          const unsupportedElements = new Set();\n          pool.eachAttrib((k: string, v:any) => {\n            if (!supportedElems.has(k)) unsupportedElements.add(k);\n          });\n          if (unsupportedElements.size) {\n            logger.warn(`(pad ${padId}) unsupported attributes (try installing a plugin): ` +\n                        `${[...unsupportedElements].join(', ')}`);\n          }\n        }\n        keyParts[1] = padId;\n        key = keyParts.join(':');\n      } else {\n        logger.debug(`(pad ${padId}) The record with the following key will be ignored unless an ` +\n                     `importEtherpad hook function processes it: ${key}`);\n        return;\n      }\n      // @ts-ignore\n      await padDb.set(key, value);\n    };\n    // @ts-ignore\n    const readOps = new Stream(Object.entries(records)).map(([k, v]) => processRecord(k, v));\n    for (const op of readOps.batch(100).buffer(99)) await op;\n\n    const pad = new Pad(padId, padDb);\n    await pad.init(null, authorId);\n    await hooks.aCallAll('importEtherpad', {\n      pad,\n      // Shallow freeze meant to prevent accidental bugs. It would be better to deep freeze, but\n      // it's not worth the added complexity.\n      data: Object.freeze(records),\n      srcPadId: originalPadId,\n    });\n    await pad.check();\n  } finally {\n    await padDb.close();\n  }\n\n  const writeOps = (function* () {\n    for (const [k, v] of data) yield db.set(k, v);\n    for (const a of existingAuthors) yield authorManager.addPad(a, padId);\n  })();\n  for (const op of new Stream(writeOps).batch(100).buffer(99)) await op;\n};\n"
  },
  {
    "path": "src/node/utils/ImportHtml.ts",
    "content": "'use strict';\n/**\n * Copyright Yaco Sistemas S.L. 2011.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport log4js from 'log4js';\nimport {deserializeOps} from '../../static/js/Changeset';\nconst contentcollector = require('../../static/js/contentcollector');\nimport jsdom from 'jsdom';\nimport {PadType} from \"../types/PadType\";\nimport {Builder} from \"../../static/js/Builder\";\n\nconst apiLogger = log4js.getLogger('ImportHtml');\nlet processor:any;\n\nexports.setPadHTML = async (pad: PadType, html:string, authorId = '') => {\n  if (processor == null) {\n    const [{rehype}, {default: minifyWhitespace}] =\n        await Promise.all([import('rehype'), import('rehype-minify-whitespace')]);\n    processor = rehype().use(minifyWhitespace, {newlines: false});\n  }\n\n  html = String(await processor.process(html));\n  const {window: {document}} = new jsdom.JSDOM(html);\n\n  // Appends a line break, used by Etherpad to ensure a caret is available\n  // below the last line of an import\n  document.body.appendChild(document.createElement('p'));\n\n  apiLogger.debug('html:');\n  apiLogger.debug(html);\n\n  // Convert a dom tree into a list of lines and attribute liens\n  // using the content collector object\n  const cc = contentcollector.makeContentCollector(true, null, pad.pool);\n  try {\n    // we use a try here because if the HTML is bad it will blow up\n    cc.collectContent(document.body);\n  } catch (err: any) {\n    apiLogger.warn(`Error processing HTML: ${err.stack || err}`);\n    throw err;\n  }\n\n  const result = cc.finish();\n\n  apiLogger.debug('Lines:');\n\n  let i;\n  for (i = 0; i < result.lines.length; i++) {\n    apiLogger.debug(`Line ${i + 1} text: ${result.lines[i]}`);\n    apiLogger.debug(`Line ${i + 1} attributes: ${result.lineAttribs[i]}`);\n  }\n\n  // Get the new plain text and its attributes\n  const newText = result.lines.join('\\n');\n  apiLogger.debug('newText:');\n  apiLogger.debug(newText);\n  const newAttribs = `${result.lineAttribs.join('|1+1')}|1+1`;\n\n  // create a new changeset with a helper builder object\n  const builder = new Builder(1);\n\n  // assemble each line into the builder\n  let textIndex = 0;\n  const newTextStart = 0;\n  const newTextEnd = newText.length;\n  for (const op of deserializeOps(newAttribs)) {\n    const nextIndex = textIndex + op.chars;\n    if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) {\n      const start = Math.max(newTextStart, textIndex);\n      const end = Math.min(newTextEnd, nextIndex);\n      builder.insert(newText.substring(start, end), op.attribs);\n    }\n    textIndex = nextIndex;\n  }\n\n  // the changeset is ready!\n  const theChangeset = builder.toString();\n\n  apiLogger.debug(`The changeset: ${theChangeset}`);\n  await pad.setText('\\n', authorId);\n  await pad.appendRevision(theChangeset, authorId);\n};\n"
  },
  {
    "path": "src/node/utils/LibreOffice.ts",
    "content": "'use strict';\n/**\n * Controls the communication with LibreOffice\n */\n\n/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst async = require('async');\nconst fs = require('fs').promises;\nconst log4js = require('log4js');\nconst os = require('os');\nconst path = require('path');\nconst runCmd = require('./run_cmd');\nimport settings from './Settings';\n\nconst logger = log4js.getLogger('LibreOffice');\n\nconst doConvertTask = async (task:{\n  type: string,\n    srcFile: string,\n  fileExtension: string,\n    destFile: string,\n}) => {\n  const tmpDir = os.tmpdir();\n  // @ts-ignore\n  const p = runCmd([\n    settings.soffice,\n    '--headless',\n    '--invisible',\n    '--nologo',\n    '--nolockcheck',\n    '--writer',\n    '--convert-to',\n    task.type,\n    task.srcFile,\n    '--outdir',\n    tmpDir,\n  ], {stdio: [\n    null,\n      // @ts-ignore\n      (line) => logger.info(`[${p.child.pid}] stdout: ${line}`),\n      // @ts-ignore\n      (line) => logger.error(`[${p.child.pid}] stderr: ${line}`),\n  ]});\n  logger.info(`[${p.child.pid}] Converting ${task.srcFile} to ${task.type} in ${tmpDir}`);\n  // Soffice/libreoffice is buggy and often hangs.\n  // To remedy this we kill the spawned process after a while.\n  // TODO: Use the timeout option once support for Node.js < v15.13.0 is dropped.\n  const hangTimeout = setTimeout(() => {\n    logger.error(`[${p.child.pid}] Conversion timed out; killing LibreOffice...`);\n    p.child.kill();\n  }, 120000);\n  try {\n    await p;\n  } catch (err:any) {\n    logger.error(`[${p.child.pid}] Conversion failed: ${err.stack || err}`);\n    throw err;\n  } finally {\n    clearTimeout(hangTimeout);\n  }\n  logger.info(`[${p.child.pid}] Conversion done.`);\n  const filename = path.basename(task.srcFile);\n  const sourceFile = `${filename.substr(0, filename.lastIndexOf('.'))}.${task.fileExtension}`;\n  const sourcePath = path.join(tmpDir, sourceFile);\n  logger.debug(`Renaming ${sourcePath} to ${task.destFile}`);\n  await fs.rename(sourcePath, task.destFile);\n};\n\n// Conversion tasks will be queued up, so we don't overload the system\nconst queue = async.queue(doConvertTask, 1);\n\n/**\n * Convert a file from one type to another\n *\n * @param  {String}     srcFile     The path on disk to convert\n * @param  {String}     destFile    The path on disk where the converted file should be stored\n * @param  {String}     type        The type to convert into\n * @param  {Function}   callback    Standard callback function\n */\nexports.convertFile = async (srcFile: string, destFile: string, type:string) => {\n  // Used for the moving of the file, not the conversion\n  const fileExtension = type;\n\n  if (type === 'html') {\n    // \"html:XHTML Writer File:UTF8\" does a better job than normal html exports\n    if (path.extname(srcFile).toLowerCase() === '.doc') {\n      type = 'html';\n    }\n    // PDF files need to be converted with LO Draw ref https://github.com/ether/etherpad-lite/issues/4151\n    if (path.extname(srcFile).toLowerCase() === '.pdf') {\n      type = 'html:XHTML Draw File';\n    }\n  }\n\n  // soffice can't convert from html to doc directly (verified with LO 5 and 6)\n  // we need to convert to odt first, then to doc\n  // to avoid `Error: no export filter for /tmp/xxxx.doc` error\n  if (type === 'doc') {\n    const intermediateFile = destFile.replace(/\\.doc$/, '.odt');\n    await queue.pushAsync({srcFile, destFile: intermediateFile, type: 'odt', fileExtension: 'odt'});\n    await queue.pushAsync({srcFile: intermediateFile, destFile, type, fileExtension});\n  } else {\n    await queue.pushAsync({srcFile, destFile, type, fileExtension});\n  }\n};\n"
  },
  {
    "path": "src/node/utils/Minify.ts",
    "content": "'use strict';\n\n/**\n * This Module manages all /minified/* requests. It controls the\n * minification && compression of Javascript and CSS.\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {TransformResult} from \"esbuild\";\nimport mime from 'mime-types';\nimport log4js from 'log4js';\nimport {compressCSS, compressJS} from './MinifyWorker'\n\nimport settings from './Settings';\nimport {promises as fs} from 'fs';\nimport path from 'node:path';\nconst plugins = require('../../static/js/pluginfw/plugin_defs');\nimport sanitizePathname from './sanitizePathname';\nconst logger = log4js.getLogger('Minify');\n\nconst ROOT_DIR = path.join(settings.root, 'src/static/');\n\n\nconst LIBRARY_WHITELIST = [\n  'async',\n  'js-cookie',\n  'security',\n  'split-grid',\n  'tinycon',\n  'underscore',\n  'unorm',\n];\n\n// What follows is a terrible hack to avoid loop-back within the server.\n// TODO: Serve files from another service, or directly from the file system.\nconst requestURI = async (url: string | URL, method: any, headers: { [x: string]: any; }) => {\n  const parsedUrl = new URL(url);\n  let status = 500;\n  const content: any[] = [];\n  const mockRequest = {\n    url,\n    method,\n    params: {filename: (parsedUrl.pathname + parsedUrl.search).replace(/^\\/static\\//, '')},\n    headers,\n  };\n  let mockResponse;\n  const p = new Promise((resolve) => {\n    mockResponse = {\n      writeHead: (_status: number, _headers: { [x: string]: any; }) => {\n        status = _status;\n        for (const header in _headers) {\n          if (Object.prototype.hasOwnProperty.call(_headers, header)) {\n            headers[header] = _headers[header];\n          }\n        }\n      },\n      setHeader: (header: string, value: { toString: () => any; }) => {\n        headers[header.toLowerCase()] = value.toString();\n      },\n      header: (header: string, value: { toString: () => any; }) => {\n        headers[header.toLowerCase()] = value.toString();\n      },\n      write: (_content: any) => {\n        _content && content.push(_content);\n      },\n      end: (_content: any) => {\n        _content && content.push(_content);\n        resolve([status, headers, content.join('')]);\n      },\n    };\n  });\n  await _minify(mockRequest, mockResponse);\n  return await p;\n};\n\nconst _requestURIs = (locations: any[], method: any, headers: {\n  [x: string]:\n  /**\n   * This Module manages all /minified/* requests. It controls the\n   * minification && compression of Javascript and CSS.\n   */\n  /*\n   * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n   *\n   * Licensed under the Apache License, Version 2.0 (the \"License\");\n   * you may not use this file except in compliance with the License.\n   * You may obtain a copy of the License at\n   *\n   *      http://www.apache.org/licenses/LICENSE-2.0\n   *\n   * Unless required by applicable law or agreed to in writing, software\n   * distributed under the License is distributed on an \"AS-IS\" BASIS,\n   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   * See the License for the specific language governing permissions and\n   * limitations under the License.\n   */\n    any;\n}, callback: (arg0: any[], arg1: any[], arg2: any[]) => void) => {\n  Promise.all(locations.map(async (loc) => {\n    try {\n      return await requestURI(loc, method, headers);\n    } catch (err) {\n      logger.debug(`requestURI(${JSON.stringify(loc)}, ${JSON.stringify(method)}, ` +\n        // @ts-ignore\n        `${JSON.stringify(headers)}) failed: ${err.stack || err}`);\n      return [500, headers, ''];\n    }\n  })).then((responses) => {\n    // @ts-ignore\n    const statuss = responses.map((x) => x[0]);\n    // @ts-ignore\n    const headerss = responses.map((x) => x[1]);\n    // @ts-ignore\n    const contentss = responses.map((x) => x[2]);\n    callback(statuss, headerss, contentss);\n  });\n};\n\nconst compatPaths = {\n  'js/browser.js': 'js/vendors/browser.js',\n  'js/farbtastic.js': 'js/vendors/farbtastic.js',\n  'js/gritter.js': 'js/vendors/gritter.js',\n  'js/html10n.js': 'js/vendors/html10n.js',\n  'js/jquery.js': 'js/vendors/jquery.js',\n  'js/nice-select.js': 'js/vendors/nice-select.js',\n};\n\n/**\n * creates the minifed javascript for the given minified name\n * @param req the Express request\n * @param res the Express response\n */\nconst _minify = async (req:any, res:any) => {\n  let filename = req.params.filename.join('/');\n  try {\n    filename = sanitizePathname(filename);\n  } catch (err) {\n    // @ts-ignore\n    logger.error(`sanitization of pathname \"${filename}\" failed: ${err.stack || err}`);\n    res.writeHead(404, {});\n    res.end();\n    return;\n  }\n\n  // Backward compatibility for plugins that require() files from old paths.\n  // @ts-ignore\n  const newLocation = compatPaths[filename.replace(/^plugins\\/ep_etherpad-lite\\/static\\//, '')];\n  if (newLocation != null) {\n    logger.warn(`request for deprecated path \"${filename}\", replacing with \"${newLocation}\"`);\n    filename = newLocation;\n  }\n\n  /* Handle static files for plugins/libraries:\n     paths like \"plugins/ep_myplugin/static/js/test.js\"\n     are rewritten into ROOT_PATH_OF_MYPLUGIN/static/js/test.js,\n     commonly ETHERPAD_ROOT/node_modules/ep_myplugin/static/js/test.js\n  */\n  const match = filename.match(/^plugins\\/([^/]+)(\\/(?:(static\\/.*)|.*))?$/);\n  if (match) {\n    const library = match[1];\n    const libraryPath = match[2] || '';\n\n    if (plugins.plugins[library] && match[3]) {\n      const plugin = plugins.plugins[library];\n      const pluginPath = plugin.package.realPath;\n      filename = path.join(pluginPath, libraryPath);\n      // On Windows, path.relative converts forward slashes to backslashes. Convert them back\n      // because some of the code below assumes forward slashes. Node.js treats both the backlash\n      // and the forward slash characters as pathname component separators on Windows so this does\n      // not change the meaning of the pathname. This conversion does not introduce a directory\n      // traversal vulnerability because all '..\\\\' substrings have already been removed by\n      // sanitizePathname.\n      filename = filename.replace(/\\\\/g, '/');\n    } else if (LIBRARY_WHITELIST.indexOf(library) !== -1) {\n      // Go straight into node_modules\n      // Avoid `require.resolve()`, since 'mustache' and 'mustache/index.js'\n      // would end up resolving to logically distinct resources.\n      filename = path.join('../node_modules/', library, libraryPath);\n    }\n  }\n  const [, testf] = /^plugins\\/ep_etherpad-lite\\/(tests\\/frontend\\/.*)/.exec(filename) || [];\n  if (testf != null) filename = `../${testf}`;\n\n  const contentType = mime.lookup(filename);\n\n  const [date, exists] = await statFile(filename, 3);\n  if (date) {\n    date.setMilliseconds(0);\n    res.setHeader('last-modified', date.toUTCString());\n    res.setHeader('date', (new Date()).toUTCString());\n    if (settings.maxAge !== undefined) {\n      const expiresDate = new Date(Date.now() + settings.maxAge * 1000);\n      res.setHeader('expires', expiresDate.toUTCString());\n      res.setHeader('cache-control', `max-age=${settings.maxAge}`);\n    }\n  }\n\n  if (!exists) {\n    res.writeHead(404, {});\n    res.end();\n  } else if (new Date(req.headers['if-modified-since']) >= date) {\n    res.writeHead(304, {});\n    res.end();\n  } else if (req.method === 'HEAD') {\n    res.header('Content-Type', contentType);\n    res.writeHead(200, {});\n    res.end();\n  } else if (req.method === 'GET') {\n    const content = await getFileCompressed(filename, contentType as string);\n    res.header('Content-Type', contentType);\n    res.writeHead(200, {});\n    res.write(content);\n    res.end();\n  } else {\n    res.writeHead(405, {allow: 'HEAD, GET'});\n    res.end();\n  }\n};\n\n// Check for the existance of the file and get the last modification date.\nconst statFile = async (filename: string, dirStatLimit: number):Promise<(any | boolean)[]> => {\n  /*\n   * The only external call to this function provides an explicit value for\n   * dirStatLimit: this check could be removed.\n   */\n  if (typeof dirStatLimit === 'undefined') {\n    dirStatLimit = 3;\n  }\n\n  if (dirStatLimit < 1 || filename === '' || filename === '/') {\n    return [null, false];\n  } else {\n    let stats;\n    try {\n      stats = await fs.stat(path.resolve(ROOT_DIR, filename));\n    } catch (err) {\n      // @ts-ignore\n      if (['ENOENT', 'ENOTDIR'].includes(err.code)) {\n        // Stat the directory instead.\n        const [date] = await statFile(path.dirname(filename), dirStatLimit - 1);\n        return [date, false];\n      }\n      throw err;\n    }\n    return [stats.mtime, stats.isFile()];\n  }\n};\n\nlet contentCache = new Map();\n\nconst getFileCompressed = async (filename: any, contentType: string) => {\n  if (contentCache.has(filename)) {\n    return contentCache.get(filename);\n  }\n  let content: Buffer|string = await getFile(filename);\n  if (!content || !settings.minify) {\n    return content;\n  } else if (contentType === 'application/javascript') {\n    return await new Promise(async (resolve) => {\n      try {\n        logger.info('Compress JS file %s.', filename);\n\n        content = content.toString();\n        try {\n          let compressResult:  TransformResult<{ minify: boolean }>\n          compressResult = await compressJS(content);\n          content = compressResult.code.toString(); // Convert content obj code to string\n        } catch (error) {\n          console.error(`Error compressing JS (${filename}) using esbuild`, error);\n        }\n      } catch (error) {\n        console.error('getFile() returned an error in ' +\n          `getFileCompressed(${filename}, ${contentType}): ${error}`);\n      }\n      contentCache.set(filename, content);\n      resolve(content);\n    });\n  } else if (contentType === 'text/css') {\n    return await new Promise(async (resolve) => {\n      try {\n        logger.info('Compress CSS file %s.', filename);\n\n        try {\n          content = await compressCSS(path.resolve(ROOT_DIR, filename));\n        } catch (error) {\n          console.error(`CleanCSS.minify() returned an error on ${filename}: ${error}`);\n        }\n        contentCache.set(filename, content);\n        resolve(content);\n      } catch (e) {\n        console.error('getFile() returned an error in ' +\n          `getFileCompressed(${filename}, ${contentType}): ${e}`);\n      }\n    })\n  } else {\n    contentCache.set(filename, content);\n    return content;\n  }\n};\n\nconst getFile = async (filename: any) => {\n  return await fs.readFile(path.resolve(ROOT_DIR, filename));\n};\n\nexport const minify = (req:any, res:any, next:Function) => _minify(req, res).catch((err) => next(err || new Error(err)));\n\nexport const requestURIs = _requestURIs;\n\nexport const shutdown = async (hookName: string, context:any) => {\n  contentCache = new Map();\n};\n"
  },
  {
    "path": "src/node/utils/MinifyWorker.ts",
    "content": "'use strict';\n/**\n * Worker thread to minify JS & CSS files out of the main NodeJS thread\n */\n\nimport {build, transform} from 'esbuild';\n\n/*\n  * Minify JS content\n  * @param {string} content - JS content to minify\n */\nexport const compressJS = async (content: string) => {\n  return await transform(content, {minify: true});\n}\n\n/*\n  * Minify CSS content\n  * @param {string} filename - name of the file\n  * @param {string} ROOT_DIR - the root dir of Etherpad\n */\nexport const compressCSS = async (content: string) => {\n  const transformedCSS = await build(\n    {\n      entryPoints: [content],\n      minify: true,\n      bundle: true,\n      loader:{\n        '.jpg': 'dataurl',\n        '.png': 'dataurl',\n        '.gif': 'dataurl',\n        '.ttf': 'dataurl',\n        '.otf': 'dataurl',\n        '.woff': 'dataurl',\n        '.woff2': 'dataurl',\n        '.eot': 'dataurl',\n        '.svg': 'dataurl'\n      },\n      write: false\n    }\n  )\n  return transformedCSS.outputFiles[0].text\n};\n"
  },
  {
    "path": "src/node/utils/NodeVersion.ts",
    "content": "'use strict';\n/**\n * Checks related to Node runtime version\n */\n\n/*\n * 2018 - muxator\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst semver = require('semver');\n\n/**\n * Quits if Etherpad is not running on a given minimum Node version\n *\n * @param  {String}     minNodeVersion   Minimum required Node version\n */\nexport const enforceMinNodeVersion = (minNodeVersion: string) => {\n  const currentNodeVersion = process.version;\n\n  // we cannot use template literals, since we still do not know if we are\n  // running under Node >= 4.0\n  if (semver.lt(currentNodeVersion, minNodeVersion)) {\n    console.error(`Running Etherpad on Node ${currentNodeVersion} is not supported. ` +\n                  `Please upgrade at least to Node ${minNodeVersion}`);\n    process.exit(1);\n  }\n\n  console.debug(`Running on Node ${currentNodeVersion} ` +\n                `(minimum required Node version: ${minNodeVersion})`);\n};\n\n/**\n * Prints a warning if running on a supported but deprecated Node version\n *\n * @param {String} lowestNonDeprecatedNodeVersion all Node version less than this one are\n *     deprecated\n * @param {Function} epRemovalVersion Etherpad version that will remove support for deprecated\n *     Node releases\n */\nexport const checkDeprecationStatus = (lowestNonDeprecatedNodeVersion: string, epRemovalVersion: string) => {\n  const currentNodeVersion = process.version;\n\n  if (semver.lt(currentNodeVersion, lowestNonDeprecatedNodeVersion)) {\n    console.warn(\n        `Support for Node ${currentNodeVersion} will be removed in Etherpad ${epRemovalVersion}. ` +\n        `Please consider updating at least to Node ${lowestNonDeprecatedNodeVersion}`);\n  }\n};\n"
  },
  {
    "path": "src/node/utils/Settings.ts",
    "content": "'use strict';\n/**\n * The Settings module reads the settings out of settings.json and provides\n * this information to the other modules\n *\n * TODO muxator 2020-04-14:\n *\n * 1) get rid of the reloadSettings() call at module loading;\n * 2) provide a factory method that configures the settings module at runtime,\n *    reading the file name either from command line parameters, from a function\n *    argument, or falling back to a default.\n */\n\n/*\n * 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {MapArrayType} from \"../types/MapType\";\nimport {SettingsNode} from \"./SettingsTree\";\n\nimport * as absolutePaths from './AbsolutePaths';\nimport fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\nimport {argv} from './Cli'\nimport jsonminify from 'jsonminify';\nimport log4js from 'log4js';\nimport randomString from './randomstring';\nconst suppressDisableMsg = ' -- To suppress these warning messages change ' +\n    'suppressErrorsInPadText to true in your settings.json\\n';\nimport _ from 'underscore';\n\nconst logger = log4js.getLogger('settings');\n\n// Exported values that settings.json and credentials.json cannot override.\nconst nonSettings = [\n    'credentialsFilename',\n    'settingsFilename',\n];\n\n// This is a function to make it easy to create a new instance. It is important to not reuse a\n// config object after passing it to log4js.configure() because that method mutates the object. :(\nconst defaultLogConfig = (level: string, layoutType: string) => ({\n    appenders: {console: {type: 'console', layout: {type: layoutType}}},\n    categories: {\n        default: {appenders: ['console'], level},\n    }\n});\nconst defaultLogLevel = 'INFO';\nconst defaultLogLayoutType = 'colored';\n\nconst initLogging = (config: any) => {\n    // log4js.configure() modifies settings.logconfig so check for equality first.\n    log4js.configure(config);\n    log4js.getLogger('console');\n\n    // Overwrites for console output methods\n    console.debug = logger.debug.bind(logger);\n    console.log = logger.info.bind(logger);\n    console.warn = logger.warn.bind(logger);\n    console.error = logger.error.bind(logger);\n};\n\n// Initialize logging as early as possible with reasonable defaults. Logging will be re-initialized\n// with the user's chosen log level and logger config after the settings have been loaded.\ninitLogging(defaultLogConfig(defaultLogLevel, defaultLogLayoutType));\n\n// Parse func\n\n\n\n/**\n * - reads the JSON configuration file settingsFilename from disk\n * - strips the comments\n * - replaces environment variables calling lookupEnvironmentVariables()\n * - returns a parsed Javascript object\n *\n * The isSettings variable only controls the error logging.\n */\nconst parseSettings = (settingsFilename: string, isSettings: boolean) => {\n  let settingsStr = '';\n\n  let settingsType, notFoundMessage, notFoundFunction;\n\n  if (isSettings) {\n    settingsType = 'settings';\n    notFoundMessage = 'Continuing using defaults!';\n    notFoundFunction = logger.warn.bind(logger);\n  } else {\n    settingsType = 'credentials';\n    notFoundMessage = 'Ignoring.';\n    notFoundFunction = logger.info.bind(logger);\n  }\n\n  try {\n    // read the settings file\n    settingsStr = fs.readFileSync(settingsFilename).toString();\n  } catch (e) {\n    notFoundFunction(`No ${settingsType} file found in ${settingsFilename}. ${notFoundMessage}`);\n\n    // or maybe undefined!\n    return null;\n  }\n\n  try {\n    settingsStr = jsonminify(settingsStr).replace(',]', ']').replace(',}', '}');\n\n    const settings = JSON.parse(settingsStr);\n\n    logger.info(`${settingsType} loaded from: ${settingsFilename}`);\n\n    return lookupEnvironmentVariables(settings);\n  } catch (e: any) {\n    logger.error(`There was an error processing your ${settingsType} ` +\n      `file from ${settingsFilename}: ${e.message}`);\n\n    process.exit(1);\n  }\n};\n\n\n// Provide git version if available\nexport const getGitCommit = () => {\n  let version = '';\n  try {\n    let rootPath = absolutePaths.findEtherpadRoot();\n    if (fs.lstatSync(`${rootPath}/.git`).isFile()) {\n      rootPath = fs.readFileSync(`${rootPath}/.git`, 'utf8');\n      rootPath = rootPath.split(' ').pop()?.trim() ?? '';\n    } else {\n      rootPath += '/.git';\n    }\n    const ref = fs.readFileSync(`${rootPath}/HEAD`, 'utf-8');\n    if (ref.startsWith('ref: ')) {\n      const refPath = `${rootPath}/${ref.substring(5, ref.indexOf('\\n'))}`;\n      version = fs.readFileSync(refPath, 'utf-8');\n    } else {\n      version = ref;\n    }\n    version = version.substring(0, 7);\n  } catch (e: any) {\n    logger.warn(`Can't get git version for server header\\n${e.message}`);\n  }\n  return version;\n};\n\nexport type SettingsType = {\n  root: string,\n  settingsFilename: string,\n  credentialsFilename: string,\n  title: string,\n  showRecentPads: boolean,\n  favicon: string | null,\n  ttl: {\n    AccessToken: number,\n    AuthorizationCode: number,\n    ClientCredentials: number,\n    IdToken: number,\n    RefreshToken: number,\n  },\n  updateServer: string,\n  enableDarkMode: boolean,\n  skinName: string | null,\n  skinVariants: string,\n  ip: string,\n  port: number | string,\n  suppressErrorsInPadText: boolean,\n  ssl: false |  {\n    key: string,\n    cert: string,\n    ca: string | null,\n  },\n  socketTransportProtocols:  any[],\n  socketIo: {\n    maxHttpBufferSize: number,\n  },\n  authenticationMethod: string,\n  dbType: string,\n  dbSettings: any,\n  defaultPadText: string,\n  padOptions: {\n    noColors: boolean,\n    showControls: boolean,\n    showChat: boolean,\n    showLineNumbers: boolean,\n    useMonospaceFont: boolean,\n    userName: string | null,\n    userColor: string | null,\n    rtl: boolean,\n    alwaysShowChat: boolean,\n    chatAndUsers: boolean,\n    lang: string | null,\n  },\n  enableMetrics: boolean,\n  padShortcutEnabled: {\n    altF9: boolean,\n    altC: boolean,\n    delete: boolean,\n    cmdShift2: boolean,\n    return: boolean,\n    esc: boolean,\n    cmdS: boolean,\n    tab: boolean,\n    cmdZ: boolean,\n    cmdY: boolean,\n    cmdB: boolean,\n    cmdI: boolean,\n    cmdU: boolean,\n    cmd5: boolean,\n    cmdShiftL: boolean,\n    cmdShiftN: boolean,\n    cmdShift1: boolean,\n    cmdShiftC: boolean,\n    cmdH: boolean,\n    ctrlHome: boolean,\n    pageUp: boolean,\n    pageDown: boolean,\n  },\n  toolbar: {\n    left: string[][],\n    right: string[][],\n    timeslider: string[][],\n  },\n  requireSession: boolean,\n  editOnly: boolean,\n  maxAge: number,\n  minify: boolean,\n  abiword: string | null,\n  soffice: string | null,\n  allowUnknownFileEnds: boolean,\n  loglevel: string,\n  logLayoutType: string,\n  disableIPlogging: boolean,\n  automaticReconnectionTimeout: number,\n  loadTest: boolean,\n  dumpOnUncleanExit: boolean,\n  indentationOnNewLine: boolean,\n  logconfig: any | null,\n  sessionKey: string | null,\n  trustProxy: boolean,\n  cookie: {\n    keyRotationInterval: number,\n    sameSite: boolean | \"lax\" | \"strict\" | \"none\" | undefined,\n    sessionLifetime: number,\n    sessionRefreshInterval: number,\n  },\n  requireAuthentication: boolean,\n  requireAuthorization: boolean,\n  users: Record<string, any>,\n  sso: {\n    issuer: string,\n    clients?: {client_id: string}[]\n  },\n  showSettingsInAdminPage: boolean,\n  cleanup: {\n    enabled: boolean,\n    keepRevisions: number,\n  },\n  scrollWhenFocusLineIsOutOfViewport: {\n    percentage: {\n      editionAboveViewport: number,\n      editionBelowViewport: number,\n    },\n    duration: number,\n    percentageToScrollWhenUserPressesArrowUp: number,\n    scrollWhenCaretIsInTheLastLineOfViewport: boolean,\n  },\n  exposeVersion: boolean,\n  customLocaleStrings: Record<string, string>,\n  importExportRateLimiting: {\n    windowMs?: number,\n    max: number,\n  },\n  commitRateLimiting: {\n    duration: number,\n    points: number,\n  },\n  importMaxFileSize: number,\n  enableAdminUITests: boolean,\n  lowerCasePadIds: boolean,\n  randomVersionString: string,\n  gitVersion: string\n  getPublicSettings: () => Pick<SettingsType, \"title\" | \"skinVariants\"|\"randomVersionString\"|\"skinName\"|\"toolbar\"| \"exposeVersion\"| \"gitVersion\">,\n}\n\nconst settings: SettingsType = {\n  /* Root path of the installation */\n  root: absolutePaths.findEtherpadRoot(),\n  settingsFilename: absolutePaths.makeAbsolute(argv.settings || 'settings.json'),\n  credentialsFilename: absolutePaths.makeAbsolute(argv.credentials || 'credentials.json'),\n  /**\n   * The app title, visible e.g. in the browser window\n   */\n  title: 'Etherpad',\n\n  /**\n   * Whether to show recent pads on the homepage\n   */\n  showRecentPads: true,\n\n  /**\n   * Pathname of the favicon you want to use. If null, the skin's favicon is\n   * used if one is provided by the skin, otherwise the default Etherpad favicon\n   * is used. If this is a relative path it is interpreted as relative to the\n   * Etherpad root directory.\n   */\n  favicon: null,\n  ttl: {\n    AccessToken: 1 * 60 * 60, // 1 hour in seconds\n    AuthorizationCode: 10 * 60, // 10 minutes in seconds\n    ClientCredentials: 1 * 60 * 60, // 1 hour in seconds\n    IdToken: 1 * 60 * 60, // 1 hour in seconds\n    RefreshToken: 1 * 24 * 60 * 60, // 1 day in seconds\n  },\n  updateServer: \"https://static.etherpad.org\",\n  enableDarkMode: true,\n  /*\n * Skin name.\n *\n * Initialized to null, so we can spot an old configuration file and invite the\n * user to update it before falling back to the default.\n */\n  skinName: null,\n  skinVariants: 'super-light-toolbar super-light-editor light-background',\n  /**\n   * The IP ep-lite should listen to\n   */\n  ip: '0.0.0.0',\n  /**\n   * The Port ep-lite should listen to\n   */\n  port: process.env.PORT || 9001,\n  /**\n   * Should we suppress Error messages from being in Pad Contents\n   */\n  suppressErrorsInPadText: false,\n  /**\n   * The SSL signed server key and the Certificate Authority's own certificate\n   * default case: ep-lite does *not* use SSL. A signed server key is not required in this case.\n   */\n  ssl: false,\n  /**\n   * socket.io transport methods\n   **/\n  socketTransportProtocols: ['websocket', 'polling'],\n  socketIo: {\n    /**\n     * Maximum permitted client message size (in bytes).\n     *\n     * All messages from clients that are larger than this will be rejected. Large values make it\n     * possible to paste large amounts of text, and plugins may require a larger value to work\n     * properly, but increasing the value increases susceptibility to denial of service attacks\n     * (malicious clients can exhaust memory).\n     */\n    maxHttpBufferSize: 50000,\n  },\n  /*\n  The authentication method used by the server.\n  The default value is sso\n  If you want to use the old authentication system, change this to apikey\n */\n  authenticationMethod: 'sso',\n  /*\n * The Type of the database\n */\n  dbType: 'rustydb',\n  /**\n   * This setting is passed with dbType to ueberDB to set up the database\n   */\n  dbSettings: null,\n  /**\n   * The default Text of a new pad\n   */\n  defaultPadText: [\n    'Welcome to Etherpad!',\n    '',\n    'This pad text is synchronized as you type, so that everyone viewing this page sees the same ' +\n    'text. This allows you to collaborate seamlessly on documents!',\n    '',\n    'Etherpad on Github: https://github.com/ether/etherpad-lite',\n  ].join('\\n'),\n  /**\n   * The default Pad Settings for a user (Can be overridden by changing the setting\n   */\n  padOptions: {\n    noColors: false,\n    showControls: true,\n    showChat: true,\n    showLineNumbers: true,\n    useMonospaceFont: false,\n    userName: null,\n    userColor: null,\n    rtl: false,\n    alwaysShowChat: false,\n    chatAndUsers: false,\n    lang: null,\n  },\n  /**\n   * Wether to enable the /stats endpoint. The functionality in the admin menu is untouched for this.\n   */\n  enableMetrics: true,\n  /**\n   * Whether certain shortcut keys are enabled for a user in the pad\n   */\n  padShortcutEnabled: {\n    altF9: true,\n    altC: true,\n    delete: true,\n    cmdShift2: true,\n    return: true,\n    esc: true,\n    cmdS: true,\n    tab: true,\n    cmdZ: true,\n    cmdY: true,\n    cmdB: true,\n    cmdI: true,\n    cmdU: true,\n    cmd5: true,\n    cmdShiftL: true,\n    cmdShiftN: true,\n    cmdShift1: true,\n    cmdShiftC: true,\n    cmdH: true,\n    ctrlHome: true,\n    pageUp: true,\n    pageDown: true,\n  },\n  /**\n   * The toolbar buttons and order.\n   */\n  toolbar: {\n    left: [\n      ['bold', 'italic', 'underline', 'strikethrough'],\n      ['orderedlist', 'unorderedlist', 'indent', 'outdent'],\n      ['undo', 'redo'],\n      ['clearauthorship'],\n    ],\n    right: [\n      ['importexport', 'timeslider', 'savedrevision'],\n      ['settings', 'embed', 'home'],\n      ['showusers'],\n    ],\n    timeslider: [\n      ['timeslider_export', 'timeslider_settings', 'timeslider_returnToPad'],\n    ],\n  },\n  /**\n   * A flag that requires any user to have a valid session (via the api) before accessing a pad\n   */\n  requireSession: false,\n  /**\n   * A flag that prevents users from creating new pads\n   */\n  editOnly: false,\n  /**\n   * Max age that responses will have (affects caching layer).\n   */\n  maxAge: 1000 * 60 * 60 * 6, // 6 hours\n  /**\n   * A flag that shows if minification is enabled or not\n   */\n  minify: true,\n  /**\n   * The path of the abiword executable\n   */\n  abiword: null,\n  /**\n   * The path of the libreoffice executable\n   */\n  soffice: null,\n  /**\n   * Should we support none natively supported file types on import?\n   */\n  allowUnknownFileEnds: true,\n  /**\n   * The log level of log4js\n   */\n  loglevel: defaultLogLevel,\n  /**\n   * The log layout type of log4js\n   */\n  logLayoutType: defaultLogLayoutType,\n  /**\n   * Disable IP logging\n   */\n  disableIPlogging: false,\n  /**\n   * Number of seconds to automatically reconnect pad\n   */\n  automaticReconnectionTimeout: 0,\n  /**\n   * Disable Load Testing\n   */\n  loadTest: false,\n  /**\n   * Disable dump of objects preventing a clean exit\n   */\n  dumpOnUncleanExit: false,\n  /**\n   * Enable indentation on new lines\n   */\n  indentationOnNewLine: true,\n  /*\n * log4js appender configuration\n */\n  logconfig: null,\n  /*\n * Deprecated cookie signing key.\n */\n  sessionKey: null,\n  /*\n * Trust Proxy, whether or not trust the x-forwarded-for header.\n */\n  trustProxy: false,\n  /*\n * Settings controlling the session cookie issued by Etherpad.\n */\n  cookie: {\n    keyRotationInterval: 1 * 24 * 60 * 60 * 1000,\n    sameSite: 'lax',\n    sessionLifetime: 10 * 24 * 60 * 60 * 1000,\n    sessionRefreshInterval: 1 * 24 * 60 * 60 * 1000,\n  },\n  /*\n * This setting is used if you need authentication and/or\n * authorization. Note: /admin always requires authentication, and\n * either authorization by a module, or a user with is_admin set\n */\n  requireAuthentication: false,\n  requireAuthorization: false,\n  users: {},\n  /*\n * This setting is used for configuring sso\n */\n  sso: {\n    issuer: \"http://localhost:9001\"\n  },\n  /*\n * Show settings in admin page, by default it is true\n */\n  showSettingsInAdminPage: true,\n  /*\n * Settings for cleanup of pads\n */\n  cleanup: {\n    enabled: false,\n    keepRevisions: 100,\n  },\n  /*\n * By default, when caret is moved out of viewport, it scrolls the minimum\n * height needed to make this line visible.\n */\n  scrollWhenFocusLineIsOutOfViewport: {\n    /*\n    * Percentage of viewport height to be additionally scrolled.\n    */\n    percentage: {\n      editionAboveViewport: 0,\n      editionBelowViewport: 0,\n    },\n    /*\n   * Time (in milliseconds) used to animate the scroll transition. Set to 0 to\n   * disable animation\n   */\n    duration: 0,\n    /*\n     * Percentage of viewport height to be additionally scrolled when user presses arrow up\n     * in the line of the top of the viewport.\n     */\n    percentageToScrollWhenUserPressesArrowUp: 0,\n    /*\n    * Flag to control if it should scroll when user places the caret in the last\n    * line of the viewport\n    */\n    scrollWhenCaretIsInTheLastLineOfViewport: false,\n  },\n  /*\n * Expose Etherpad version in the web interface and in the Server http header.\n *\n * Do not enable on production machines.\n */\n  exposeVersion: false,\n  /*\n * Override any strings found in locale directories\n */\n  customLocaleStrings: {},\n  /*\n * From Etherpad 1.8.3 onwards, import and export of pads is always rate\n * limited.\n *\n * The default is to allow at most 10 requests per IP in a 90 seconds window.\n * After that the import/export request is rejected.\n *\n * See https://github.com/nfriedly/express-rate-limit for more options\n */\n  importExportRateLimiting: {\n    // duration of the rate limit window (milliseconds)\n    windowMs: 90000,\n    // maximum number of requests per IP to allow during the rate limit window\n    max: 10,\n  },\n  /*\n * From Etherpad 1.9.0 onwards, commits from individual users are rate limited\n *\n * The default is to allow at most 10 changes per IP in a 1 second window.\n * After that the change is rejected.\n *\n * See https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#websocket-single-connection-prevent-flooding for more options\n */\n  commitRateLimiting: {\n    // duration of the rate limit window (seconds)\n    duration: 1,\n    // maximum number of changes per IP to allow during the rate limit window\n    points: 10,\n  },\n  /*\n * From Etherpad 1.8.3 onwards, the maximum allowed size for a single imported\n * file is always bounded.\n *\n * File size is specified in bytes. Default is 50 MB.\n */\n  importMaxFileSize: 50 * 1024 * 1024,\n  /*\n * Disable Admin UI tests\n */\n  enableAdminUITests: false,\n  /*\n * Enable auto conversion of pad Ids to lowercase.\n * e.g. /p/EtHeRpAd to /p/etherpad\n */\n  lowerCasePadIds: false,\n  randomVersionString: '2123',\n  getPublicSettings: () => {\n    return {\n      gitVersion: settings.gitVersion,\n      toolbar: settings.toolbar,\n      exposeVersion: settings.exposeVersion,\n      randomVersionString: settings.randomVersionString,\n      title: settings.title,\n      skinName: settings.skinName,\n      skinVariants: settings.skinVariants,\n    }\n  },\n  gitVersion: getGitCommit(),\n}\n\nexport default settings;\n\n/**\n * This setting is passed with dbType to ueberDB to set up the database\n */\nsettings.dbSettings =  {filename: path.join(settings.root, 'var/rusty.db')};\n// END OF SETTINGS\n\n// checks if abiword is avaiable\nexport const abiwordAvailable = () => {\n    if (settings.abiword != null) {\n        return os.type().indexOf('Windows') !== -1 ? 'withoutPDF' : 'yes';\n    } else {\n        return 'no';\n    }\n};\n\nexport const sofficeAvailable = () => {\n    if (settings.soffice != null) {\n        return os.type().indexOf('Windows') !== -1 ? 'withoutPDF' : 'yes';\n    } else {\n        return 'no';\n    }\n};\n\nexport const exportAvailable = () => {\n    const abiword = abiwordAvailable();\n    const soffice = sofficeAvailable();\n\n    if (abiword === 'no' && soffice === 'no') {\n        return 'no';\n    } else if ((abiword === 'withoutPDF' && soffice === 'no') ||\n        (abiword === 'no' && soffice === 'withoutPDF')) {\n        return 'withoutPDF';\n    } else {\n        return 'yes';\n    }\n};\n\n\n// Return etherpad version from package.json\nexport const getEpVersion = () => require('../../package.json').version;\n\n\n\n/**\n * Receives a settingsObj and, if the property name is a valid configuration\n * item, stores it in the module's exported properties via a side effect.\n *\n * This code refactors a previous version that copied & pasted the same code for\n * both \"settings.json\" and \"credentials.json\".\n */\nconst storeSettings = (settingsObj: any) => {\n    for (const i of Object.keys(settingsObj || {})) {\n        if (nonSettings.includes(i)) {\n            logger.warn(`Ignoring setting: '${i}'`);\n            continue;\n        }\n\n        // test if the setting starts with a lowercase character\n        if (i.charAt(0).search('[a-z]') !== 0) {\n            logger.warn(`Settings should start with a lowercase character: '${i}'`);\n        }\n\n        // we know this setting, so we overwrite it\n        // or it's a settings hash, specific to a plugin\n        // @ts-ignore\n      if (settings[i] !== undefined || i.indexOf('ep_') === 0) {\n            if (_.isObject(settingsObj[i]) && !Array.isArray(settingsObj[i])) {\n              // @ts-ignore\n              settings[i] = _.defaults(settingsObj[i], settings[i]);\n            } else {\n              // @ts-ignore\n              settings[i] = settingsObj[i];\n            }\n        } else {\n            // this setting is unknown, output a warning and throw it away\n            logger.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`);\n        }\n    }\n};\n\n/*\n * If stringValue is a numeric string, or its value is \"true\" or \"false\", coerce\n * them to appropriate JS types. Otherwise return stringValue as-is.\n *\n * Please note that this function is used for converting types for default\n * values in the settings file (for example: \"${PORT:9001}\"), and that there is\n * no coercition for \"null\" values.\n *\n * If the user wants a variable to be null by default, he'll have to use the\n * short syntax \"${ABIWORD}\", and not \"${ABIWORD:null}\": the latter would result\n * in the literal string \"null\", instead.\n */\nconst coerceValue = (stringValue: string) => {\n    // cooked from https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number\n    // @ts-ignore\n    const isNumeric = !isNaN(stringValue) && !isNaN(parseFloat(stringValue) && isFinite(stringValue));\n\n    if (isNumeric) {\n        // detected numeric string. Coerce to a number\n\n        return +stringValue;\n    }\n\n    switch (stringValue) {\n        case 'true':\n            return true;\n        case 'false':\n            return false;\n        case 'undefined':\n            return undefined;\n        case 'null':\n            return null;\n        default:\n            return stringValue;\n    }\n};\n\n/**\n * Takes a javascript object containing Etherpad's configuration, and returns\n * another object, in which all the string properties whose value is of the form\n * \"${ENV_VAR}\" or \"${ENV_VAR:default_value}\" got their value replaced with the\n * contents of the given environment variable, or with a default value.\n *\n * By definition, an environment variable's value is always a string. However,\n * the code base makes use of the various json types. To maintain compatiblity,\n * some heuristics is applied:\n *\n * - if ENV_VAR does not exist in the environment, null is returned;\n * - if ENV_VAR's value is \"true\" or \"false\", it is converted to the js boolean\n *   values true or false;\n * - if ENV_VAR's value looks like a number, it is converted to a js number\n *   (details in the code).\n *\n * The following is a scheme of the behaviour of this function:\n *\n * +---------------------------+---------------+------------------+\n * | Configuration string in   | Value of      | Resulting confi- |\n * | settings.json             | ENV_VAR       | guration value   |\n * |---------------------------|---------------|------------------|\n * | \"${ENV_VAR}\"              | \"some_string\" | \"some_string\"    |\n * | \"${ENV_VAR}\"              | \"9001\"        | 9001             |\n * | \"${ENV_VAR}\"              | undefined     | null             |\n * | \"${ENV_VAR:some_default}\" | \"some_string\" | \"some_string\"    |\n * | \"${ENV_VAR:some_default}\" | undefined     | \"some_default\"   |\n * +---------------------------+---------------+------------------+\n *\n * IMPLEMENTATION NOTE: variable substitution is performed doing a round trip\n *     conversion to/from json, using a custom replacer parameter in\n *     JSON.stringify(), and parsing the JSON back again. This ensures that\n *     environment variable replacement is performed even on nested objects.\n *\n * see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter\n */\nconst lookupEnvironmentVariables = (obj: MapArrayType<any>) => {\n    const replaceEnvs = (obj: MapArrayType<any>) => {\n        for (let [key, value] of Object.entries(obj)) {\n            /*\n            * the first invocation of replacer() is with an empty key. Just go on, or\n            * we would zap the entire object.\n            */\n            if (key === '') {\n                obj[key] = value;\n                continue\n            }\n\n            /*\n             * If we received from the configuration file a number, a boolean or\n             * something that is not a string, we can be sure that it was a literal\n             * value. No need to perform any variable substitution.\n             *\n             * The environment variable expansion syntax \"${ENV_VAR}\" is just a string\n             * of specific form, after all.\n             */\n\n            if(key === 'undefined' || value === undefined) {\n                delete obj[key]\n                continue\n            }\n\n            if ((typeof value !== 'string' && typeof value !== 'object') || value === null) {\n                obj[key] = value;\n                continue\n            }\n\n            if (typeof obj[key] === \"object\") {\n                replaceEnvs(obj[key]);\n                continue\n            }\n\n\n            /*\n             * Let's check if the string value looks like a variable expansion (e.g.:\n             * \"${ENV_VAR}\" or \"${ENV_VAR:default_value}\")\n             */\n            // MUXATOR 2019-03-21: we could use named capture groups here once we migrate to nodejs v10\n            const match = value.match(/^\\$\\{([^:]*)(:((.|\\n)*))?\\}$/);\n\n            if (match == null) {\n                // no match: use the value literally, without any substitution\n                obj[key] = value;\n                continue\n            }\n\n            /*\n             * We found the name of an environment variable. Let's read its actual value\n             * and its default value, if given\n             */\n            const envVarName = match[1];\n            const envVarValue = process.env[envVarName];\n            const defaultValue = match[3];\n\n            if ((envVarValue === undefined) && (defaultValue === undefined)) {\n                logger.warn(`Environment variable \"${envVarName}\" does not contain any value for ` +\n                    `configuration key \"${key}\", and no default was given. Using null. ` +\n                    'THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF ETHERPAD; you should ' +\n                    'explicitly use \"null\" as the default if you want to continue to use null.');\n\n                /*\n                 * We have to return null, because if we just returned undefined, the\n                 * configuration item \"key\" would be stripped from the returned object.\n                 */\n                obj[key] = null;\n                continue\n            }\n\n            if ((envVarValue === undefined) && (defaultValue !== undefined)) {\n                logger.debug(`Environment variable \"${envVarName}\" not found for ` +\n                    `configuration key \"${key}\". Falling back to default value.`);\n\n                obj[key] = coerceValue(defaultValue);\n                continue\n            }\n\n            // envVarName contained some value.\n\n            /*\n             * For numeric and boolean strings let's convert it to proper types before\n             * returning it, in order to maintain backward compatibility.\n             */\n            logger.debug(\n                `Configuration key \"${key}\" will be read from environment variable \"${envVarName}\"`);\n\n            obj[key] = coerceValue(envVarValue!);\n        }\n        return obj\n    }\n\n    replaceEnvs(obj);\n\n    // Add plugin ENV variables\n\n    /**\n     * If the key contains a double underscore, it's a plugin variable\n     * E.g.\n     */\n    let treeEntries = new Map<string, string | undefined>\n    const root = new SettingsNode(\"EP\")\n\n    for (let [env, envVal] of Object.entries(process.env)) {\n        if (!env.startsWith(\"EP\")) continue\n        treeEntries.set(env, envVal)\n    }\n    treeEntries.forEach((value, key) => {\n        let pathToKey = key.split(\"__\")\n        let currentNode = root\n        let depth = 0\n        depth++\n        currentNode.addChild(pathToKey, value!)\n    })\n\n    //console.log(root.collectFromLeafsUpwards())\n    const rooting = root.collectFromLeafsUpwards()\n    obj = Object.assign(obj, rooting)\n    return obj;\n};\n\n\n\nexport const reloadSettings = () => {\n    const settingsParsed = parseSettings(settings?.settingsFilename, true);\n    const credentials = parseSettings(settings.credentialsFilename, false);\n    storeSettings(settingsParsed);\n    storeSettings(credentials);\n\n    // Init logging config\n    settings.logconfig = defaultLogConfig(\n      settings.loglevel ? settings.loglevel : defaultLogLevel,\n      settings.logLayoutType ? settings.logLayoutType : defaultLogLayoutType\n    );\n    logger.warn(\"loglevel: \" + settings.loglevel);\n    logger.warn(\"logLayoutType: \" + settings.logLayoutType);\n    initLogging(settings.logconfig);\n\n    if (!settings.skinName) {\n        logger.warn('No \"skinName\" parameter found. Please check out settings.json.template and ' +\n            'update your settings.json. Falling back to the default \"colibris\".');\n      settings.skinName = 'colibris';\n    }\n\n    if (!settings.socketTransportProtocols.includes(\"websocket\") || !settings.socketTransportProtocols.includes(\"polling\")) {\n        logger.warn(\"Invalid socketTransportProtocols setting. Please check out settings.json.template and update your settings.json. Falling back to the default ['websocket', 'polling'].\");\n      settings.socketTransportProtocols = ['websocket', 'polling'];\n    }\n\n    // checks if skinName has an acceptable value, otherwise falls back to \"colibris\"\n    if (settings.skinName) {\n        const skinBasePath = path.join(settings.root, 'src', 'static', 'skins');\n        const countPieces = settings.skinName.split(path.sep).length;\n\n        if (countPieces !== 1) {\n            logger.error(`skinName must be the name of a directory under \"${skinBasePath}\". This is ` +\n                `not valid: \"${settings.skinName}\". Falling back to the default \"colibris\".`);\n\n          settings.skinName = 'colibris';\n        }\n\n        // informative variable, just for the log messages\n        let skinPath = path.join(skinBasePath, settings.skinName);\n\n        // what if someone sets skinName == \"..\" or \".\"? We catch him!\n        if (!absolutePaths.isSubdir(skinBasePath, skinPath)) {\n            logger.error(`Skin path ${skinPath} must be a subdirectory of ${skinBasePath}. ` +\n                'Falling back to the default \"colibris\".');\n\n          settings.skinName = 'colibris';\n            skinPath = path.join(skinBasePath, settings.skinName);\n        }\n\n        if (!fs.existsSync(skinPath)) {\n            logger.error(`Skin path ${skinPath} does not exist. Falling back to the default \"colibris\".`);\n          settings.skinName = 'colibris';\n            skinPath = path.join(skinBasePath, settings.skinName);\n        }\n\n        logger.info(`Using skin \"${settings.skinName}\" in dir: ${skinPath}`);\n    }\n\n    if (settings.abiword) {\n        // Check abiword actually exists\n      fs.exists(settings.abiword, (exists: boolean) => {\n        if (!exists) {\n          const abiwordError = 'Abiword does not exist at this path, check your settings file.';\n          if (!settings.suppressErrorsInPadText) {\n            settings.defaultPadText += `\\nError: ${abiwordError}${suppressDisableMsg}`;\n          }\n          logger.error(`${abiwordError} File location: ${settings.abiword}`);\n          settings.abiword = null;\n        }\n      });\n    }\n\n    if (settings.soffice) {\n        fs.exists(settings.soffice, (exists: boolean) => {\n            if (!exists) {\n                const sofficeError =\n                    'soffice (libreoffice) does not exist at this path, check your settings file.';\n\n                if (!settings.suppressErrorsInPadText) {\n                  settings.defaultPadText += `\\nError: ${sofficeError}${suppressDisableMsg}`;\n                }\n                logger.error(`${sofficeError} File location: ${settings.soffice}`);\n              settings.soffice = null;\n            }\n        });\n    }\n\n    const sessionkeyFilename = absolutePaths.makeAbsolute(argv.sessionkey || './SESSIONKEY.txt');\n    if (!settings.sessionKey) {\n        try {\n          settings.sessionKey = fs.readFileSync(sessionkeyFilename, 'utf8');\n            logger.info(`Session key loaded from: ${sessionkeyFilename}`);\n        } catch (err) { /* ignored */\n        }\n        const keyRotationEnabled = settings.cookie.keyRotationInterval && settings.cookie.sessionLifetime;\n        if (!settings.sessionKey && !keyRotationEnabled) {\n            logger.info(\n                `Session key file \"${sessionkeyFilename}\" not found. Creating with random contents.`);\n          settings.sessionKey = randomString(32);\n            fs.writeFileSync(sessionkeyFilename, settings.sessionKey, 'utf8');\n        }\n    } else {\n        logger.warn('Declaring the sessionKey in the settings.json is deprecated. ' +\n            'This value is auto-generated now. Please remove the setting from the file. -- ' +\n            'If you are seeing this error after restarting using the Admin User ' +\n            'Interface then you can ignore this message.');\n    }\n    if (settings.sessionKey) {\n        logger.warn(`The sessionKey setting and ${sessionkeyFilename} file are deprecated; ` +\n            'use automatic key rotation instead (see the cookie.keyRotationInterval setting).');\n    }\n\n    if (settings.dbType === 'dirty') {\n        const dirtyWarning = 'DirtyDB is used. This is not recommended for production.';\n        if (!settings.suppressErrorsInPadText) {\n          settings.defaultPadText += `\\nWarning: ${dirtyWarning}${suppressDisableMsg}`;\n        }\n\n      settings.dbSettings.filename = absolutePaths.makeAbsolute(settings.dbSettings.filename);\n        logger.warn(`${dirtyWarning} File location: ${settings.dbSettings.filename}`);\n    }\n\n    if (settings.dbType === 'rustydb' || settings.dbType === \"sqlite\") {\n      settings.dbSettings.filename = absolutePaths.makeAbsolute(settings.dbSettings.filename);\n      logger.warn(`File location: ${settings.dbSettings.filename}`);\n    }\n\n\n    if (settings.ip === '') {\n        // using Unix socket for connectivity\n        logger.warn('The settings file contains an empty string (\"\") for the \"ip\" parameter. The ' +\n            '\"port\" parameter will be interpreted as the path to a Unix socket to bind at.');\n    }\n\n    /*\n     * At each start, Etherpad generates a random string and appends it as query\n     * parameter to the URLs of the static assets, in order to force their reload.\n     * Subsequent requests will be cached, as long as the server is not reloaded.\n     *\n     * For the rationale behind this choice, see\n     * https://github.com/ether/etherpad-lite/pull/3958\n     *\n     * ACHTUNG: this may prevent caching HTTP proxies to work\n     * TODO: remove the \"?v=randomstring\" parameter, and replace with hashed filenames instead\n     */\n    settings.randomVersionString = randomString(4);\n    logger.info(`Random string used for versioning assets: ${settings.randomVersionString}`);\n};\n\nexport const exportedForTestingOnly = {\n    parseSettings,\n};\n\n// initially load settings\nreloadSettings();\n"
  },
  {
    "path": "src/node/utils/SettingsTree.ts",
    "content": "import {MapArrayType} from \"../types/MapType\";\n\nexport class SettingsTree {\n    private children: Map<string, SettingsNode>;\n    constructor() {\n        this.children = new Map();\n    }\n\n    public addChild(key: string, value: string) {\n        this.children.set(key, new SettingsNode(key, value));\n    }\n\n    public removeChild(key: string) {\n        this.children.delete(key);\n    }\n\n    public getChild(key: string) {\n        return this.children.get(key);\n    }\n\n    public hasChild(key: string) {\n        return this.children.has(key);\n    }\n}\n\n\nexport class SettingsNode {\n    private readonly key: string;\n    private value:  string | number | boolean | null | undefined;\n    private children: MapArrayType<SettingsNode>;\n\n    constructor(key: string, value?:  string | number | boolean | null | undefined) {\n        this.key = key;\n        this.value = value;\n        this.children = {}\n    }\n\n    public addChild(path: string[], value: string) {\n        let currentNode:SettingsNode = this;\n        for (let i = 0; i < path.length; i++) {\n            const key = path[i];\n            /*\n                Skip the current node if the key is the same as the current node's key\n             */\n            if (key === this.key ) {\n                continue\n            }\n            /*\n                If the current node does not have a child with the key, create a new node with the key\n             */\n            if (!currentNode.hasChild(key)) {\n                currentNode = currentNode.children[key] = new SettingsNode(key, this.coerceValue(value));\n            } else {\n                /*\n                 Else move to the child node\n                 */\n                currentNode = currentNode.getChild(key);\n            }\n        }\n    }\n\n\n    public collectFromLeafsUpwards() {\n        let collected:MapArrayType<any> = {};\n        for (const key in this.children) {\n            const child = this.children[key];\n            if (child.hasChildren()) {\n                collected[key] = child.collectFromLeafsUpwards();\n            } else {\n                collected[key] = child.value;\n            }\n        }\n        return collected;\n    }\n\n    coerceValue = (stringValue: string) => {\n        // cooked from https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number\n        // @ts-ignore\n        const isNumeric = !isNaN(stringValue) && !isNaN(parseFloat(stringValue) && isFinite(stringValue));\n\n        if (isNumeric) {\n            // detected numeric string. Coerce to a number\n\n            return +stringValue;\n        }\n\n        switch (stringValue) {\n            case 'true':\n                return true;\n            case 'false':\n                return false;\n            case 'undefined':\n                return undefined;\n            case 'null':\n                return null;\n            default:\n                return stringValue;\n        }\n    };\n\n    public hasChildren() {\n        return Object.keys(this.children).length > 0;\n    }\n\n    public getChild(key: string) {\n        return this.children[key];\n    }\n\n    public hasChild(key: string) {\n        return this.children[key] !== undefined;\n    }\n}\n"
  },
  {
    "path": "src/node/utils/Stream.ts",
    "content": "'use strict';\n\n/**\n * Wrapper around any iterable that adds convenience methods that standard JavaScript iterable\n * objects lack.\n */\nclass Stream {\n  private _iter\n  private _next: any\n  /**\n   * @returns {Stream} A Stream that yields values in the half-open range [start, end).\n   */\n  static range(start: number, end: number) {\n    return new Stream((function* () { for (let i = start; i < end; ++i) yield i; })());\n  }\n\n  /**\n   * @param {Iterable<any>} values - Any iterable of values.\n   */\n  constructor(values: Iterable<any>) {\n    this._iter = values[Symbol.iterator]();\n    this._next = null;\n  }\n\n  /**\n   * Read values a chunk at a time from the underlying iterable. Once a full batch is read (or there\n   * aren't enough values to make a full batch), all of the batch's values are yielded before the\n   * next batch is read.\n   *\n   * This is useful for triggering groups of asynchronous tasks via Promises yielded from a\n   * synchronous generator. A for-await-of (or for-of with an await) loop consumes those Promises\n   * and automatically triggers the next batch of tasks when needed. For example:\n   *\n   *     const resources = (function* () {\n   *       for (let i = 0; i < 100; ++i) yield fetchResource(i);\n   *     }).call(this);\n   *\n   *     // Fetch 10 items at a time so that the fetch engine can bundle multiple requests into a\n   *     // single query message.\n   *     for await (const r of new Stream(resources).batch(10)) {\n   *       processResource(r);\n   *     }\n   *\n   * Chaining .buffer() after .batch() like stream.batch(n).buffer(m) will fetch in batches of n as\n   * needed to ensure that at least m are in flight at all times.\n   *\n   * Any Promise yielded by the underlying iterable has its rejection suppressed to prevent\n   * unhandled rejection errors while the Promise is sitting in the batch waiting to be yielded. It\n   * is assumed that the consumer of any yielded Promises will await the Promise (or call .catch()\n   * or .then()) to prevent the rejection from going unnoticed. If iteration is aborted early, any\n   * Promises read from the underlying iterable that have not yet been yielded will have their\n   * rejections un-suppressed to trigger unhandled rejection errors.\n   *\n   * @param {number} size - The number of values to read at a time.\n   * @returns {Stream} A new Stream that gets its values from this Stream.\n   */\n  batch(size: number) {\n    return new Stream((function* () {\n      const b = [];\n      try {\n        // @ts-ignore\n        for (const v of this) {\n          Promise.resolve(v).catch(() => {}); // Suppress unhandled rejection errors.\n          b.push(v);\n          if (b.length < size) continue;\n          while (b.length) yield b.shift();\n        }\n        while (b.length) yield b.shift();\n      } finally {\n        for (const v of b) Promise.resolve(v).then(() => {}); // Un-suppress unhandled rejections.\n      }\n    }).call(this));\n  }\n\n  /**\n   * Pre-fetch a certain number of values from the underlying iterable before yielding the first\n   * value. Each time a value is yielded (consumed from the buffer), another value is read from the\n   * underlying iterable and added to the buffer.\n   *\n   * This is useful for maintaining a constant number of in-flight asynchronous tasks via Promises\n   * yielded from a synchronous generator. A for-await-of (or for-of with an await) loop should be\n   * used to control the scheduling of the next task. For example:\n   *\n   *     const resources = (function* () {\n   *       for (let i = 0; i < 100; ++i) yield fetchResource(i);\n   *     }).call(this);\n   *\n   *     // Fetching a resource is high latency, so keep multiple in flight at all times until done.\n   *     for await (const r of new Stream(resources).buffer(10)) {\n   *       processResource(r);\n   *     }\n   *\n   * Chaining after .batch() like stream.batch(n).buffer(m) will fetch in batches of n as needed to\n   * ensure that at least m are in flight at all times.\n   *\n   * Any Promise yielded by the underlying iterable has its rejection suppressed to prevent\n   * unhandled rejection errors while the Promise is sitting in the batch waiting to be yielded. It\n   * is assumed that the consumer of any yielded Promises will await the Promise (or call .catch()\n   * or .then()) to prevent the rejection from going unnoticed. If iteration is aborted early, any\n   * Promises read from the underlying iterable that have not yet been yielded will have their\n   * rejections un-suppressed to trigger unhandled rejection errors.\n   *\n   * @param {number} capacity - The number of values to keep buffered.\n   * @returns {Stream} A new Stream that gets its values from this Stream.\n   */\n  buffer(capacity: number) {\n    return new Stream((function* () {\n      const b = [];\n      try {\n        // @ts-ignore\n        for (const v of this) {\n          Promise.resolve(v).catch(() => {}); // Suppress unhandled rejection errors.\n          // Note: V8 has good Array push+shift optimization.\n          while (b.length >= capacity) yield b.shift();\n          b.push(v);\n        }\n        while (b.length) yield b.shift();\n      } finally {\n        for (const v of b) Promise.resolve(v).then(() => {}); // Un-suppress unhandled rejections.\n      }\n    }).call(this));\n  }\n\n  /**\n   * Like Array.map().\n   *\n   * @param {(v: any) => any} fn - Value transformation function.\n   * @returns {Stream} A new Stream that yields this Stream's values, transformed by `fn`.\n   */\n  map(fn:Function) { return new Stream((function* () { // @ts-ignore\n    for (const v of this) yield fn(v); }).call(this)); }\n\n  /**\n   * Implements the JavaScript iterable protocol.\n   */\n  [Symbol.iterator]() { return this._iter; }\n}\n\nmodule.exports = Stream;\n"
  },
  {
    "path": "src/node/utils/UpdateCheck.ts",
    "content": "'use strict';\nimport semver from 'semver';\nimport settings, {getEpVersion} from './Settings';\nimport axios from 'axios';\nconst headers = {\n  'User-Agent': 'Etherpad/' + getEpVersion(),\n}\n\ntype Infos = {\n  latestVersion: string\n}\n\n\nconst updateInterval = 60 * 60 * 1000; // 1 hour\nlet infos: Infos;\nlet lastLoadingTime: number | null = null;\n\nconst loadEtherpadInformations = () => {\n  if (lastLoadingTime !== null && Date.now() - lastLoadingTime < updateInterval) {\n    return infos;\n  }\n\n  return axios.get(`${settings.updateServer}/info.json`, {headers: headers})\n  .then(async (resp: any) => {\n    infos = await resp.data;\n    if (infos === undefined || infos === null) {\n      await Promise.reject(\"Could not retrieve current version\")\n      return\n    }\n\n    lastLoadingTime = Date.now();\n    return infos;\n  })\n  .catch(async (err: Error) => {\n    throw err;\n  });\n}\n\n\nexport const getLatestVersion = () => {\n  needsUpdate().catch();\n  return infos?.latestVersion;\n};\n\nconst needsUpdate = async (cb?: Function) => {\n  try {\n    const info = await loadEtherpadInformations()\n    if (semver.gt(info!.latestVersion, getEpVersion())) {\n      if (cb) return cb(true);\n    }\n  } catch (err) {\n    console.error(`Can not perform Etherpad update check: ${err}`);\n    if (cb) return cb(false);\n  }\n};\n\nexport const check = () => {\n  needsUpdate((needsUpdate: boolean) => {\n    if (needsUpdate) {\n      console.warn(`Update available: Download the actual version ${infos.latestVersion}`);\n    }\n  }).then(()=>{});\n};\n"
  },
  {
    "path": "src/node/utils/checkValidRev.ts",
    "content": "'use strict';\n\nconst CustomError = require('../utils/customError');\n\n// checks if a rev is a legal number\n// pre-condition is that `rev` is not undefined\nconst checkValidRev = (rev: number|string) => {\n  if (typeof rev !== 'number') {\n    rev = parseInt(rev, 10);\n  }\n\n  // check if rev is a number\n  if (isNaN(rev)) {\n    throw new CustomError('rev is not a number', 'apierror');\n  }\n\n  // ensure this is not a negative number\n  if (rev < 0) {\n    throw new CustomError('rev is not a negative number', 'apierror');\n  }\n\n  // ensure this is not a float value\n  if (!isInt(rev)) {\n    throw new CustomError('rev is a float value', 'apierror');\n  }\n\n  return rev;\n};\n\n// checks if a number is an int\nconst isInt = (value:number) => (parseFloat(String(value)) === parseInt(String(value), 10)) && !isNaN(value);\n\nexports.isInt = isInt;\nexports.checkValidRev = checkValidRev;\n"
  },
  {
    "path": "src/node/utils/customError.ts",
    "content": "'use strict';\n/**\n * CustomError\n *\n * This helper modules allows us to create different type of errors we can throw\n *\n * @class CustomError\n * @extends {Error}\n */\nclass CustomError extends Error {\n  /**\n   * Creates an instance of CustomError.\n   * @param {string} message\n   * @param {string} [name='Error'] a custom name for the error object\n   * @memberof CustomError\n   */\n  constructor(message:string, name: string = 'Error') {\n    super(message);\n    this.name = name;\n    Error.captureStackTrace(this, this.constructor);\n  }\n}\n\nmodule.exports = CustomError;\n"
  },
  {
    "path": "src/node/utils/padDiff.ts",
    "content": "'use strict';\n\nimport {PadAuthor, PadType} from \"../types/PadType\";\nimport {MapArrayType} from \"../types/MapType\";\n\nimport AttributeMap from '../../static/js/AttributeMap';\nimport {applyToAText, checkRep, compose, deserializeOps, pack, splitAttributionLines, splitTextLines, unpack} from '../../static/js/Changeset';\nimport {Builder} from \"../../static/js/Builder\";\nimport {OpAssembler} from \"../../static/js/OpAssembler\";\nimport {numToString} from \"../../static/js/ChangesetUtils\";\nimport Op from \"../../static/js/Op\";\nimport {StringAssembler} from \"../../static/js/StringAssembler\";\nconst attributes = require('../../static/js/attributes');\nconst exportHtml = require('./ExportHtml');\n\n\nclass PadDiff {\n  private readonly _pad: PadType;\n    private readonly _fromRev: string;\n    private readonly _toRev: string;\n    private _html: any;\n    public _authors: any[];\n    private self: PadDiff | undefined\n  constructor(pad: PadType, fromRev:string, toRev:string) {\n    // check parameters\n    if (!pad || !pad.id || !pad.atext || !pad.pool) {\n      throw new Error('Invalid pad');\n    }\n\n    const range = pad.getValidRevisionRange(fromRev, toRev);\n    if (!range) throw new Error(`Invalid revision range. startRev: ${fromRev} endRev: ${toRev}`);\n\n    this._pad = pad;\n    this._fromRev = range.startRev;\n    this._toRev = range.endRev;\n    this._html = null;\n    this._authors = [];\n  }\n  _isClearAuthorship(changeset: any){\n    // unpack\n    const unpacked = unpack(changeset);\n\n    // check if there is nothing in the charBank\n    if (unpacked.charBank !== '') {\n      return false;\n    }\n\n    // check if oldLength == newLength\n    if (unpacked.oldLen !== unpacked.newLen) {\n      return false;\n    }\n\n    const [clearOperator, anotherOp] = deserializeOps(unpacked.ops);\n\n    // check if there is only one operator\n    if (anotherOp != null) return false;\n\n    // check if this operator doesn't change text\n    if (clearOperator.opcode !== '=') {\n      return false;\n    }\n\n    // check that this operator applys to the complete text\n    // if the text ends with a new line, its exactly one character less, else it has the same length\n    if (clearOperator.chars !== unpacked.oldLen - 1 && clearOperator.chars !== unpacked.oldLen) {\n      return false;\n    }\n\n    const [appliedAttribute, anotherAttribute] =\n        attributes.attribsFromString(clearOperator.attribs, this._pad.pool);\n\n    // Check that the operation has exactly one attribute.\n    if (appliedAttribute == null || anotherAttribute != null) return false;\n\n    // check if the applied attribute is an anonymous author attribute\n    if (appliedAttribute[0] !== 'author' || appliedAttribute[1] !== '') {\n      return false;\n    }\n\n    return true;\n  }\n  async _createClearAuthorship(rev: any){\n    const atext = await this._pad.getInternalRevisionAText(rev);\n\n    // build clearAuthorship changeset\n    const builder = new Builder(atext.text.length);\n    builder.keepText(atext.text, [['author', '']], this._pad.pool);\n    const changeset = builder.toString();\n\n    return changeset;\n  }\n\n  async _createClearStartAtext(rev: any){\n    // get the atext of this revision\n    const atext = await this._pad.getInternalRevisionAText(rev);\n\n    // create the clearAuthorship changeset\n    const changeset = await this._createClearAuthorship(rev);\n\n    // apply the clearAuthorship changeset\n    const newAText = applyToAText(changeset, atext, this._pad.pool);\n\n    return newAText;\n  }\n  async _getChangesetsInBulk(startRev: any, count: any) {\n    // find out which revisions we need\n    const revisions = [];\n    for (let i = startRev; i < (startRev + count) && i <= this._pad.head; i++) {\n      revisions.push(i);\n    }\n\n    // get all needed revisions (in parallel)\n    const changesets:any[] = [];\n    const authors: any[] = [];\n    await Promise.all(revisions.map((rev) => this._pad.getRevision(rev).then((revision) => {\n      const arrayNum = rev - startRev;\n      changesets[arrayNum] = revision.changeset;\n      authors[arrayNum] = revision.meta.author;\n    })));\n\n    return {changesets, authors};\n  }\n  _addAuthors(authors: PadAuthor[]){\n      this.self = this;\n\n    // add to array if not in the array\n    authors.forEach((author) => {\n      if (this.self!._authors.indexOf(author) === -1) {\n        this.self!._authors.push(author);\n      }\n    });\n  }\n  async _createDiffAtext(){\n    const bulkSize = 100;\n\n    // get the cleaned startAText\n    let atext = await this._createClearStartAtext(this._fromRev);\n\n    let superChangeset = null;\n\n    for (let rev = this._fromRev + 1; rev <= this._toRev; rev += bulkSize) {\n      // get the bulk\n      const {changesets, authors} = await this._getChangesetsInBulk(rev, bulkSize);\n\n      const addedAuthors = [];\n\n      // run through all changesets\n      for (let i = 0; i < changesets.length && (rev + i) <= this._toRev; ++i) {\n        let changeset = changesets[i];\n\n        // skip clearAuthorship Changesets\n        if (this._isClearAuthorship(changeset)) {\n          continue;\n        }\n\n        changeset = this._extendChangesetWithAuthor(changeset, authors[i], this._pad.pool);\n\n        // add this author to the authorarray\n        addedAuthors.push(authors[i]);\n\n        // compose it with the superChangset\n        if (superChangeset == null) {\n          superChangeset = changeset;\n        } else {\n          superChangeset = compose(superChangeset, changeset, this._pad.pool);\n        }\n      }\n\n      // add the authors to the PadDiff authorArray\n      this._addAuthors(addedAuthors);\n    }\n\n    // if there are only clearAuthorship changesets, we don't get a superChangeset,\n    // so we can skip this step\n    if (superChangeset) {\n      const deletionChangeset = this._createDeletionChangeset(superChangeset, atext, this._pad.pool);\n\n      // apply the superChangeset, which includes all addings\n      atext = applyToAText(superChangeset, atext, this._pad.pool);\n\n      // apply the deletionChangeset, which adds a deletions\n      atext = applyToAText(deletionChangeset, atext, this._pad.pool);\n    }\n\n    return atext;\n  }\n  async getHtml(){\n    // cache the html\n    if (this._html != null) {\n      return this._html;\n    }\n\n    // get the diff atext\n    const atext = await this._createDiffAtext();\n\n    // get the authorColor table\n    const authorColors = await this._pad.getAllAuthorColors();\n\n    // convert the atext to html\n    this._html = await exportHtml.getHTMLFromAtext(this._pad, atext, authorColors);\n\n    return this._html;\n  }\n\n  async getAuthors() {\n    // check if html was already produced, if not produce it, this generates\n    // the author array at the same time\n    if (this._html == null) {\n      await this.getHtml();\n    }\n\n    return this.self!._authors;\n  }\n\n  _extendChangesetWithAuthor(changeset: any, author: any, apool: any){\n    // unpack\n    const unpacked = unpack(changeset);\n\n    const assem = new OpAssembler();\n\n    // create deleted attribs\n    const authorAttrib = apool.putAttrib(['author', author || '']);\n    const deletedAttrib = apool.putAttrib(['removed', true]);\n    const attribs = `*${numToString(authorAttrib)}*${numToString(deletedAttrib)}`;\n\n    for (const operator of deserializeOps(unpacked.ops)) {\n      if (operator.opcode === '-') {\n        // this is a delete operator, extend it with the author\n        operator.attribs = attribs;\n      } else if (operator.opcode === '=' && operator.attribs) {\n        // this is operator changes only attributes, let's mark which author did that\n        operator.attribs += `*${numToString(authorAttrib)}`;\n      }\n\n      // append the new operator to our assembler\n      assem.append(operator);\n    }\n\n    // return the modified changeset\n    return pack(unpacked.oldLen, unpacked.newLen, assem.toString(), unpacked.charBank);\n  }\n  _createDeletionChangeset(cs: any, startAText: any, apool: any){\n    const lines = splitTextLines(startAText.text);\n    const alines = splitAttributionLines(startAText.attribs, startAText.text);\n\n    // lines and alines are what the exports is meant to apply to.\n    // They may be arrays or objects with .get(i) and .length methods.\n    // They include final newlines on lines.\n\n    const linesGet = (idx: number) => {\n      // @ts-ignore\n      if (lines.get) {\n        // @ts-ignore\n        return lines.get(idx);\n      } else {\n        // @ts-ignore\n        return lines[idx];\n      }\n    };\n\n    const aLinesGet = (idx: number) => {\n      // @ts-ignore\n      if (alines.get) {\n        // @ts-ignore\n        return alines.get(idx);\n      } else {\n        return alines[idx];\n      }\n    };\n\n    let curLine = 0;\n    let curChar = 0;\n    let curLineOps: { next: () => any; } | null = null;\n    let curLineOpsNext: { done: any; value: any; } | null = null;\n    let curLineOpsLine: number;\n    let curLineNextOp = new Op('+');\n\n    const unpacked = unpack(cs);\n    const builder = new Builder(unpacked.newLen);\n\n    const consumeAttribRuns = (numChars: number, func: Function /* (len, attribs, endsLine)*/) => {\n      if (!curLineOps || curLineOpsLine !== curLine) {\n        curLineOps = deserializeOps(aLinesGet(curLine));\n        curLineOpsNext = curLineOps!.next();\n        curLineOpsLine = curLine;\n        let indexIntoLine = 0;\n        while (!curLineOpsNext!.done) {\n          curLineNextOp = curLineOpsNext!.value;\n          curLineOpsNext = curLineOps!.next();\n          if (indexIntoLine + curLineNextOp.chars >= curChar) {\n            curLineNextOp.chars -= (curChar - indexIntoLine);\n            break;\n          }\n          indexIntoLine += curLineNextOp.chars;\n        }\n      }\n\n      while (numChars > 0) {\n        if (!curLineNextOp.chars && curLineOpsNext!.done) {\n          curLine++;\n          curChar = 0;\n          curLineOpsLine = curLine;\n          curLineNextOp.chars = 0;\n          curLineOps = deserializeOps(aLinesGet(curLine));\n          curLineOpsNext = curLineOps!.next();\n        }\n\n        if (!curLineNextOp.chars) {\n          if (curLineOpsNext!.done) {\n            curLineNextOp = new Op();\n          } else {\n            curLineNextOp = curLineOpsNext!.value;\n            curLineOpsNext = curLineOps!.next();\n          }\n        }\n\n        const charsToUse = Math.min(numChars, curLineNextOp.chars);\n\n        func(charsToUse, curLineNextOp.attribs,\n            charsToUse === curLineNextOp.chars && curLineNextOp.lines > 0);\n        numChars -= charsToUse;\n        curLineNextOp.chars -= charsToUse;\n        curChar += charsToUse;\n      }\n\n      if (!curLineNextOp.chars && curLineOpsNext!.done) {\n        curLine++;\n        curChar = 0;\n      }\n    };\n\n    const skip = (N:number, L:number) => {\n      if (L) {\n        curLine += L;\n        curChar = 0;\n      } else if (curLineOps && curLineOpsLine === curLine) {\n        consumeAttribRuns(N, () => {});\n      } else {\n        curChar += N;\n      }\n    };\n\n    const nextText = (numChars: number) => {\n      let len = 0;\n      const assem = new StringAssembler();\n      const firstString = linesGet(curLine).substring(curChar);\n      len += firstString.length;\n      assem.append(firstString);\n\n      let lineNum = curLine + 1;\n\n      while (len < numChars) {\n        const nextString = linesGet(lineNum);\n        len += nextString.length;\n        assem.append(nextString);\n        lineNum++;\n      }\n\n      return assem.toString().substring(0, numChars);\n    };\n\n    const cachedStrFunc = (func:Function) => {\n      const cache:MapArrayType<any> = {};\n\n      return (s:string) => {\n        if (!cache[s]) {\n          cache[s] = func(s);\n        }\n        return cache[s];\n      };\n    };\n\n    for (const csOp of deserializeOps(unpacked.ops)) {\n      if (csOp.opcode === '=') {\n        const textBank = nextText(csOp.chars);\n\n        // decide if this equal operator is an attribution change or not.\n        // We can see this by checkinf if attribs is set.\n        // If the text this operator applies to is only a star,\n        // than this is a false positive and should be ignored\n        if (csOp.attribs && textBank !== '*') {\n          const attribs = AttributeMap.fromString(csOp.attribs, apool);\n          const undoBackToAttribs = cachedStrFunc((oldAttribsStr: string) => {\n            const oldAttribs = AttributeMap.fromString(oldAttribsStr, apool);\n            const backAttribs = new AttributeMap(apool)\n                .set('author', '')\n                .set('removed', 'true');\n            for (const [key, value] of attribs) {\n              const oldValue = oldAttribs.get(key);\n              if (oldValue !== value) backAttribs.set(key, oldValue);\n            }\n            // TODO: backAttribs does not restore removed attributes (it is missing attributes that\n            // are in oldAttribs but not in attribs). I don't know if that is intentional.\n            return backAttribs.toString();\n          });\n\n          let textLeftToProcess = textBank;\n\n          while (textLeftToProcess.length > 0) {\n            // process till the next line break or process only one line break\n            let lengthToProcess = textLeftToProcess.indexOf('\\n');\n            let lineBreak = false;\n            switch (lengthToProcess) {\n              case -1:\n                lengthToProcess = textLeftToProcess.length;\n                break;\n              case 0:\n                lineBreak = true;\n                lengthToProcess = 1;\n                break;\n            }\n\n            // get the text we want to procceed in this step\n            const processText = textLeftToProcess.substr(0, lengthToProcess);\n\n            textLeftToProcess = textLeftToProcess.substr(lengthToProcess);\n\n            if (lineBreak) {\n              builder.keep(1, 1); // just skip linebreaks, don't do a insert + keep for a linebreak\n\n              // consume the attributes of this linebreak\n              consumeAttribRuns(1, () => {});\n            } else {\n              // add the old text via an insert, but add a deletion attribute +\n              // the author attribute of the author who deleted it\n              let textBankIndex = 0;\n              consumeAttribRuns(lengthToProcess, (len: number, attribs:string, endsLine: string) => {\n                // get the old attributes back\n                const oldAttribs = undoBackToAttribs(attribs);\n\n                builder.insert(processText.substr(textBankIndex, len), oldAttribs);\n                textBankIndex += len;\n              });\n\n              builder.keep(lengthToProcess, 0);\n            }\n          }\n        } else {\n          skip(csOp.chars, csOp.lines);\n          builder.keep(csOp.chars, csOp.lines);\n        }\n      } else if (csOp.opcode === '+') {\n        builder.keep(csOp.chars, csOp.lines);\n      } else if (csOp.opcode === '-') {\n        const textBank = nextText(csOp.chars);\n        let textBankIndex = 0;\n\n        consumeAttribRuns(csOp.chars, (len: number, attribs: string[], endsLine: string) => {\n          builder.insert(textBank.substr(textBankIndex, len), attribs + csOp.attribs);\n          textBankIndex += len;\n        });\n      }\n    }\n\n    return checkRep(builder.toString());\n  }\n\n}\n\n\n// this method is 80% like Changeset.inverse. I just changed so instead of reverting,\n// it adds deletions and attribute changes to the atext.\n// @ts-ignore\nPadDiff.prototype._createDeletionChangeset = function (cs, startAText, apool) {\n\n};\n\n// export the constructor\nmodule.exports = PadDiff;\n"
  },
  {
    "path": "src/node/utils/path_exists.ts",
    "content": "'use strict';\nimport fs from 'node:fs';\n\nconst check = (path:string) => {\n  const existsSync = fs.statSync || fs.existsSync;\n\n  let result;\n  try {\n    result = existsSync(path);\n  } catch (e) {\n    result = false;\n  }\n  return result;\n};\n\nexport default check;\n"
  },
  {
    "path": "src/node/utils/promises.ts",
    "content": "'use strict';\n/**\n * Helpers to manipulate promises (like async but for promises).\n */\n\n// Returns a Promise that resolves to the first resolved value from `promises` that satisfies\n// `predicate`. Resolves to `undefined` if none of the Promises satisfy `predicate`, or if\n// `promises` is empty. If `predicate` is nullish, the truthiness of the resolved value is used as\n// the predicate.\nexport const firstSatisfies = <T>(promises: Promise<T>[], predicate: null|Function) => {\n  if (predicate == null) {\n    predicate = (x: any) => x;\n  }\n\n  // Transform each original Promise into a Promise that never resolves if the original resolved\n  // value does not satisfy `predicate`. These transformed Promises will be passed to Promise.race,\n  // yielding the first resolved value that satisfies `predicate`.\n  const newPromises = promises.map((p) =>\n      new Promise((resolve, reject) => p.then((v) => predicate!(v) && resolve(v), reject)));\n\n  // If `promises` is an empty array or if none of them resolve to a value that satisfies\n  // `predicate`, then `Promise.race(newPromises)` will never resolve. To handle that, add another\n  // Promise that resolves to `undefined` after all of the original Promises resolve.\n  //\n  // Note: If all of the original Promises simultaneously resolve to a value that satisfies\n  // `predicate` (perhaps they were already resolved when this function was called), then this\n  // Promise will resolve too, and with a value of `undefined`. There is no concern that this\n  // Promise will win the race and thus cause an erroneous `undefined` result. This is because\n  // a resolved Promise's `.then()` function is scheduled for execution -- not executed right away\n  // -- and ES guarantees in-order execution of the enqueued invocations. Each of the above\n  // transformed Promises has a `.then()` chain of length one, while the Promise added here has a\n  // `.then()` chain of length two or more (at least one `.then()` that is internal to\n  // `Promise.all()`, plus the `.then()` function added here). By the time the `.then()` function\n  // added here executes, all of the above transformed Promises will have already resolved and one\n  // will have been chosen as the winner.\n  newPromises.push(Promise.all(promises).then(() => {}));\n\n  return Promise.race(newPromises);\n};\n\n// Calls `promiseCreator(i)` a total number of `total` times, where `i` is 0 through `total - 1` (in\n// order). The `concurrency` argument specifies the maximum number of Promises returned by\n// `promiseCreator` that are allowed to be active (unresolved) simultaneously. (In other words: If\n// `total` is greater than `concurrency`, then `concurrency` Promises will be created right away,\n// and each remaining Promise will be created once one of the earlier Promises resolves.) This async\n// function resolves once all `total` Promises have resolved.\nexport const timesLimit = async (total: number, concurrency: number, promiseCreator: Function) => {\n  if (total > 0 && concurrency <= 0) throw new RangeError('concurrency must be positive');\n  let next = 0;\n  const addAnother = () => promiseCreator(next++).finally(() => {\n    if (next < total) return addAnother();\n  });\n  const promises = [];\n  for (let i = 0; i < concurrency && i < total; i++) {\n    promises.push(addAnother());\n  }\n  await Promise.all(promises);\n};\n\n/**\n * An ordinary Promise except the `resolve` and `reject` executor functions are exposed as\n * properties.\n */\nexport class Gate<T> extends Promise<T> {\n  // Coax `.then()` into returning an ordinary Promise, not a Gate. See\n  // https://stackoverflow.com/a/65669070 for the rationale.\n  static get [Symbol.species]() { return Promise; }\n\n  constructor() {\n    // `this` is assigned when `super()` returns, not when it is called, so it is not acceptable to\n    // do the following because it will throw a ReferenceError when it dereferences `this`:\n    //     super((resolve, reject) => Object.assign(this, {resolve, reject}));\n    let props: any;\n    super((resolve, reject) => props = {resolve, reject});\n    Object.assign(this, props);\n  }\n}\n"
  },
  {
    "path": "src/node/utils/randomstring.ts",
    "content": "/**\n * Generates a random String with the given length. Is needed to generate the\n * Author, Group, readonly, session Ids\n */\nimport cryptoMod from 'crypto';\n\nconst randomString = (len: number) => cryptoMod.randomBytes(len).toString('hex');\n\nexport default randomString;\n"
  },
  {
    "path": "src/node/utils/run_cmd.ts",
    "content": "'use strict';\n\nimport {ErrorExtended, RunCMDOptions, RunCMDPromise} from \"../types/RunCMDOptions\";\nimport {ChildProcess} from \"node:child_process\";\nimport {PromiseWithStd} from \"../types/PromiseWithStd\";\nimport {Readable} from \"node:stream\";\n\nimport spawn from 'cross-spawn';\nimport log4js from 'log4js';\nimport path from 'path';\nimport settings from './Settings';\n\nconst logger = log4js.getLogger('runCmd');\n\nconst logLines = (readable: undefined | Readable | null, logLineFn: (arg0: (string | undefined)) => void) => {\n  readable!.setEncoding('utf8');\n  // The process won't necessarily write full lines every time -- it might write a part of a line\n  // then write the rest of the line later.\n  let leftovers: string| undefined = '';\n  readable!.on('data', (chunk) => {\n    const lines = chunk.split('\\n');\n    if (lines.length === 0) return;\n    lines[0] = leftovers + lines[0];\n    leftovers = lines.pop();\n    for (const line of lines) {\n      logLineFn(line);\n    }\n  });\n  readable!.on('end', () => {\n    if (leftovers !== '') logLineFn(leftovers);\n    leftovers = '';\n  });\n};\n\n/**\n * Runs a command, logging its output to Etherpad's logs by default.\n *\n * Examples:\n *\n *   Just run a command, logging stdout and stder to Etherpad's logs:\n *     await runCmd(['ls', '-l']);\n *\n *   Capture just stdout as a string:\n *     const stdout = await runCmd(['ls', '-l'], {stdio: [null, 'string']});\n *\n *   Capture both stdout and stderr as strings:\n *     const p = runCmd(['ls', '-l'], {stdio: 'string'});\n *     const stdout = await p; // Or: await p.stdout;\n *     const stderr = await p.stderr;\n *\n *   Call a callback with each line of stdout:\n *     await runCmd(['ls', '-l'], {stdio: [null, (line) => console.log(line)]});\n *\n * @param args Array of command-line arguments, where `args[0]` is the command to run.\n * @param opts As with `child_process.spawn()`, except:\n *   - `cwd` defaults to the Etherpad root directory.\n *   - `env.PATH` is prefixed with `src/node_modules/.bin:node_modules/.bin` so that utilities from\n *     installed dependencies (e.g., npm) are preferred over system utilities.\n *   - By default stdout and stderr are logged to the Etherpad log at log levels INFO and ERROR.\n *     To pipe without logging you must explicitly use 'pipe' for opts.stdio.\n *   - opts.stdio[1] and opts.stdio[2] can be functions that will be called each time a line (utf8)\n *     is written to stdout or stderr. The line (without its trailing newline, if present) will be\n *     passed as the only argument, and the return value is ignored. opts.stdio = fn is equivalent\n *     to opts.stdio = [null, fn, fn].\n *   - opts.stdio[1] and opts.stdio[2] can be 'string', which will cause output to be collected,\n *     decoded as utf8, and returned (see below). opts.stdio = 'string' is equivalent to\n *     opts.stdio = [null, 'string', 'string'].\n *\n * @returns A Promise that resolves when the command exits. The Promise resolves to the complete\n * stdout if opts.stdio[1] is 'string', otherwise it resolves to undefined. The returned Promise is\n * augmented with these additional properties:\n *   - `stdout`: If opts.stdio[1] is 'pipe', the stdout stream object. If opts.stdio[1] is 'string',\n *     a Promise that will resolve to the complete stdout (utf8 decoded) when the command exits.\n *   - `stderr`: Similar to `stdout` but for stderr.\n *   - `child`: The ChildProcess object.\n */\nmodule.exports = exports = (args: string[], opts:RunCMDOptions = {}) => {\n  logger.debug(`Executing command: ${args.join(' ')}`);\n\n  opts = {cwd: settings.root, ...opts};\n  logger.debug(`cwd: ${opts.cwd}`);\n\n  // Log stdout and stderr by default.\n  const stdio =\n      Array.isArray(opts.stdio) ? opts.stdio.slice() // Copy to avoid mutating the caller's array.\n      : typeof opts.stdio === 'function' ? [null, opts.stdio, opts.stdio]\n      : opts.stdio === 'string' ? [null, 'string', 'string']\n      : Array(3).fill(opts.stdio);\n  const cmdLogger = log4js.getLogger(`runCmd|${args[0]}`);\n  if (stdio[1] == null) stdio[1] = (line: string) => cmdLogger.info(line);\n  if (stdio[2] == null) stdio[2] = (line: string) => cmdLogger.error(line);\n  const stdioLoggers = [];\n  const stdioSaveString = [];\n  for (const fd of [1, 2]) {\n    if (typeof stdio[fd] === 'function') {\n      stdioLoggers[fd] = stdio[fd];\n      stdio[fd] = 'pipe';\n    } else if (stdio[fd] === 'string') {\n      stdioSaveString[fd] = true;\n      stdio[fd] = 'pipe';\n    }\n  }\n  opts.stdio = stdio;\n\n  // On Windows the PATH environment var might be spelled \"Path\".\n  const pathVarName =\n      Object.keys(process.env).filter((k) => k.toUpperCase() === 'PATH')[0] || 'PATH';\n  // Set PATH so that utilities from installed dependencies (e.g., npm) are preferred over system\n  // (global) utilities.\n  const {env = process.env} = opts;\n  const {[pathVarName]: PATH} = env;\n  opts.env = {\n    ...env, // Copy env to avoid modifying process.env or the caller's supplied env.\n    [pathVarName]: [\n      path.join(settings.root, 'src', 'node_modules', '.bin'),\n      path.join(settings.root, 'node_modules', '.bin'),\n      ...(PATH ? PATH.split(path.delimiter) : []),\n    ].join(path.delimiter),\n  };\n  logger.debug(`${pathVarName}=${opts.env[pathVarName]}`);\n\n  // Create an error object to use in case the process fails. This is done here rather than in the\n  // process's `exit` handler so that we get a useful stack trace.\n  const procFailedErr: Error & ErrorExtended = new Error();\n\n  const proc: ChildProcess = spawn(args[0], args.slice(1), opts as any);\n  const streams:[undefined, Readable|null, Readable|null] = [undefined, proc.stdout, proc.stderr];\n\n  let px: { reject: any; resolve: any; };\n  const p:PromiseWithStd = new Promise<string>((resolve, reject) => { px = {resolve, reject}; });\n  [, p.stdout, p.stderr] = streams;\n  p.child = proc;\n\n  const stdioStringPromises = [undefined, Promise.resolve(), Promise.resolve()];\n  for (const fd of [1, 2]) {\n    if (streams[fd] == null) continue;\n    if (stdioLoggers[fd] != null) {\n      logLines(streams[fd], stdioLoggers[fd]);\n    } else if (stdioSaveString[fd]) {\n      // @ts-ignore\n      p[[null, 'stdout', 'stderr'][fd]] = stdioStringPromises[fd] = (async () => {\n        const chunks = [];\n        for await (const chunk of streams[fd]!) chunks.push(chunk);\n        return Buffer.concat(chunks).toString().replace(/\\n+$/g, '');\n      })();\n    }\n  }\n\n  proc.on('exit', async (code, signal) => {\n    const [, stdout] = await Promise.all(stdioStringPromises);\n    if (code !== 0) {\n      procFailedErr.message =\n          `Command exited ${code ? `with code ${code}` : `on signal ${signal}`}: ${args.join(' ')}`;\n      procFailedErr.code = code;\n      procFailedErr.signal = signal;\n      logger.debug(procFailedErr.stack);\n      return px.reject(procFailedErr);\n    }\n    logger.debug(`Command returned successfully: ${args.join(' ')}`);\n    px.resolve(stdout);\n  });\n  return p;\n};\n"
  },
  {
    "path": "src/node/utils/sanitizePathname.ts",
    "content": "import path from 'path';\n\n// Normalizes p and ensures that it is a relative path that does not reach outside. See\n// https://nvd.nist.gov/vuln/detail/CVE-2015-3297 for additional context.\nconst sanitizeRoot =  (p: string, pathApi = path) => {\n  // The documentation for path.normalize() says that it resolves '..' and '.' segments. The word\n  // \"resolve\" implies that it examines the filesystem to resolve symbolic links, so 'a/../b' might\n  // not be the same thing as 'b'. Most path normalization functions from other libraries (e.g.,\n  // Python's os.path.normpath()) clearly state that they do not examine the filesystem. Here we\n  // assume Node.js's path.normalize() does the same; that it is only a simple string manipulation.\n  p = pathApi.normalize(p);\n  if (pathApi.isAbsolute(p)) throw new Error(`absolute paths are forbidden: ${p}`);\n  if (p.split(pathApi.sep)[0] === '..') throw new Error(`directory traversal: ${p}`);\n  // On Windows, path normalization replaces forwardslashes with backslashes. Convert them back to\n  // forwardslashes. Node.js treats both the backlash and the forwardslash characters as pathname\n  // component separators on Windows so this does not change the meaning of the pathname on Windows.\n  // THIS CONVERSION MUST ONLY BE DONE ON WINDOWS, otherwise on POSIXish systems '..\\\\' in the input\n  // pathname would not be normalized away before being converted to '../'.\n  if (pathApi.sep === '\\\\') p = p.replace(/\\\\/g, '/');\n  return p;\n};\n\nexport default sanitizeRoot\n"
  },
  {
    "path": "src/node/utils/tar.json",
    "content": "{\n  \"pad.js\": [\n    \"pad.js\"\n  , \"pad_utils.js\"\n  , \"$js-cookie/dist/js.cookie.js\"\n  , \"security.js\"\n  , \"$security.js\"\n  , \"vendors/browser.js\"\n  , \"pad_cookie.js\"\n  , \"pad_editor.js\"\n  , \"pad_editbar.js\"\n  , \"vendors/nice-select.js\"\n  , \"pad_modals.js\"\n  , \"pad_automatic_reconnect.js\"\n  , \"ace.js\"\n  , \"collab_client.js\"\n  , \"cssmanager.js\"\n  , \"pad_userlist.js\"\n  , \"pad_impexp.js\"\n  , \"pad_savedrevs.js\"\n  , \"pad_connectionstatus.js\"\n  , \"ChatMessage.js\"\n  , \"chat.js\"\n  , \"vendors/gritter.js\"\n  , \"$js-cookie/dist/js.cookie.js\"\n  , \"$tinycon/tinycon.js\"\n  , \"vendors/farbtastic.js\"\n  , \"skin_variants.js\"\n  , \"socketio.js\"\n  , \"colorutils.js\"\n  ]\n, \"timeslider.js\": [\n    \"timeslider.js\"\n  , \"colorutils.js\"\n  , \"draggable.js\"\n  , \"pad_utils.js\"\n  , \"$js-cookie/dist/js.cookie.js\"\n  , \"vendors/browser.js\"\n  , \"pad_cookie.js\"\n  , \"pad_editor.js\"\n  , \"pad_editbar.js\"\n  , \"vendors/nice-select.js\"\n  , \"pad_modals.js\"\n  , \"pad_automatic_reconnect.js\"\n  , \"pad_savedrevs.js\"\n  , \"pad_impexp.js\"\n  , \"AttributePool.js\"\n  , \"Changeset.js\"\n  , \"domline.js\"\n  , \"linestylefilter.js\"\n  , \"cssmanager.js\"\n  , \"broadcast.js\"\n  , \"broadcast_slider.js\"\n  , \"broadcast_revisions.js\"\n  , \"socketio.js\"\n  , \"AttributeManager.js\"\n  , \"AttributeMap.js\"\n  , \"attributes.js\"\n  , \"ChangesetUtils.js\"\n  ]\n, \"ace2_inner.js\": [\n    \"ace2_inner.js\"\n  , \"vendors/browser.js\"\n  , \"AttributePool.js\"\n  , \"Changeset.js\"\n  , \"ChangesetUtils.js\"\n  , \"skiplist.js\"\n  , \"colorutils.js\"\n  , \"undomodule.js\"\n  , \"$unorm/lib/unorm.js\"\n  , \"contentcollector.js\"\n  , \"changesettracker.js\"\n  , \"linestylefilter.js\"\n  , \"domline.js\"\n  , \"AttributeManager.js\"\n  , \"AttributeMap.js\"\n  , \"attributes.js\"\n  , \"scroll.js\"\n  , \"caretPosition.js\"\n  , \"pad_utils.js\"\n  , \"$js-cookie/dist/js.cookie.js\"\n  , \"security.js\"\n  , \"$security.js\"\n  ]\n, \"ace2_common.js\": [\n    \"ace2_common.js\"\n  , \"vendors/browser.js\"\n  , \"vendors/jquery.js\"\n  , \"rjquery.js\"\n  , \"$async.js\"\n  , \"underscore.js\"\n  , \"$underscore.js\"\n  , \"$underscore/underscore.js\"\n  , \"security.js\"\n  , \"$security.js\"\n  , \"pluginfw/client_plugins.js\"\n  , \"pluginfw/plugin_defs.js\"\n  , \"pluginfw/shared.js\"\n  , \"pluginfw/hooks.js\"\n  ]\n}\n"
  },
  {
    "path": "src/node/utils/toolbar.ts",
    "content": "'use strict';\n/**\n * The Toolbar Module creates and renders the toolbars and buttons\n */\nimport {isString, reduce, each, isUndefined, map, first, last, extend, escape} from 'underscore';\n\nconst removeItem = (array: string[], what: string) => {\n    let ax;\n    while ((ax = array.indexOf(what)) !== -1) {\n        array.splice(ax, 1);\n    }\n    return array;\n};\n\nconst defaultButtonAttributes = (name: string, overrides?: boolean) => ({\n    command: name,\n    localizationId: `pad.toolbar.${name}.title`,\n    class: `buttonicon buttonicon-${name}`,\n});\n\nconst tag = (name: string, attributes: AttributeObj, contents?: string) => {\n    const aStr = tagAttributes(attributes);\n\n    if (isString(contents) && contents!.length > 0) {\n        return `<${name}${aStr}>${contents}</${name}>`;\n    } else {\n        return `<${name}${aStr}></${name}>`;\n    }\n};\n\n\ntype AttributeObj = {\n    [id: string]: string\n}\n\nconst tagAttributes = (attributes: AttributeObj) => {\n    attributes = reduce(attributes || {}, (o: AttributeObj, val: string, name: string) => {\n        if (!isUndefined(val)) {\n            o[name] = val;\n        }\n        return o;\n    }, {});\n\n    return ` ${map(attributes, (val: string, name: string) => `${name}=\"${escape(val)}\"`).join(' ')}`;\n};\n\ntype ButtonGroupType = {\n    grouping: string,\n    render: Function\n}\n\nclass ButtonGroup {\n    private buttons: Button[]\n\n    constructor() {\n        this.buttons = []\n    }\n\n    public static fromArray = function (array: string[]) {\n        const btnGroup = new ButtonGroup();\n        each(array, (btnName: string) => {\n            const button = Button.load(btnName) as Button\n            btnGroup.addButton(button);\n        });\n        return btnGroup;\n    }\n\n    private addButton(button: Button) {\n        this.buttons.push(button);\n        return this;\n    }\n\n    render(): string {\n        if (this.buttons && this.buttons.length === 1) {\n            this.buttons[0].grouping = '';\n        } else if (this.buttons && this.buttons.length > 1) {\n            first(this.buttons)!.grouping = 'grouped-left';\n            last(this.buttons)!.grouping = 'grouped-right';\n            each(this.buttons.slice(1, -1), (btn: Button) => {\n                btn.grouping = 'grouped-middle';\n            });\n        }\n\n        // @ts-ignore\n      return map(this.buttons, (btn: ButtonGroup) => {\n            if (btn) return btn.render();\n        }).join('\\n');\n    }\n}\n\n\nclass Button {\n    protected attributes: AttributeObj\n    grouping: string\n\n    constructor(attributes: AttributeObj) {\n        this.attributes = attributes\n        this.grouping = \"\"\n    }\n\n    public static load(btnName: string) {\n        const button = module.exports.availableButtons[btnName];\n        try {\n            if (button.constructor === Button || button.constructor === SelectButton) {\n                return button;\n            } else {\n                return new Button(button);\n            }\n        } catch (e) {\n            console.warn('Error loading button', btnName);\n            return false;\n        }\n    }\n\n    render() {\n        const liAttributes = {\n            'data-type': 'button',\n            'data-key': this.attributes.command,\n        };\n        return tag('li', liAttributes,\n            tag('a', {'class': this.grouping, 'data-l10n-id': this.attributes.localizationId},\n                tag('button', {\n                    'class': ` ${this.attributes.class}`,\n                    'data-l10n-id': this.attributes.localizationId,\n                })));\n    }\n}\n\ntype SelectButtonOptions = {\n    value: string,\n    text: string,\n    attributes: AttributeObj\n}\n\nclass SelectButton extends Button {\n    private readonly options: SelectButtonOptions[];\n\n    constructor(attrs: AttributeObj) {\n        super(attrs);\n        this.options = []\n    }\n\n    addOption(value: string, text: string, attributes: AttributeObj) {\n        this.options.push({\n            value,\n            text,\n            attributes,\n        })\n        return this;\n    }\n\n    select(attributes: AttributeObj) {\n        const options: string[] = [];\n\n        each(this.options, (opt: AttributeSelect) => {\n            const a = extend({\n                value: opt.value,\n            }, opt.attributes);\n\n            options.push(tag('option', a, opt.text));\n        });\n        return tag('select', attributes, options.join(''));\n    }\n\n    render() {\n        const attributes = {\n            'id': this.attributes.id,\n            'data-key': this.attributes.command,\n            'data-type': 'select',\n        };\n        return tag('li', attributes, this.select({id: this.attributes.selectId}));\n    }\n}\n\n\ntype AttributeSelect = {\n    value: string,\n    attributes: AttributeObj,\n    text: string\n}\n\nclass Separator {\n    constructor() {\n    }\n\n    public render() {\n        return tag('li', {class: 'separator'});\n\n    }\n}\n\nmodule.exports = {\n    availableButtons: {\n        bold: defaultButtonAttributes('bold'),\n        italic: defaultButtonAttributes('italic'),\n        underline: defaultButtonAttributes('underline'),\n        strikethrough: defaultButtonAttributes('strikethrough'),\n\n        orderedlist: {\n            command: 'insertorderedlist',\n            localizationId: 'pad.toolbar.ol.title',\n            class: 'buttonicon buttonicon-insertorderedlist',\n        },\n\n        unorderedlist: {\n            command: 'insertunorderedlist',\n            localizationId: 'pad.toolbar.ul.title',\n            class: 'buttonicon buttonicon-insertunorderedlist',\n        },\n\n        indent: defaultButtonAttributes('indent'),\n        outdent: {\n            command: 'outdent',\n            localizationId: 'pad.toolbar.unindent.title',\n            class: 'buttonicon buttonicon-outdent',\n        },\n\n        undo: defaultButtonAttributes('undo'),\n        redo: defaultButtonAttributes('redo'),\n\n        clearauthorship: {\n            command: 'clearauthorship',\n            localizationId: 'pad.toolbar.clearAuthorship.title',\n            class: 'buttonicon buttonicon-clearauthorship',\n        },\n\n        importexport: {\n            command: 'import_export',\n            localizationId: 'pad.toolbar.import_export.title',\n            class: 'buttonicon buttonicon-import_export',\n        },\n\n        timeslider: {\n            command: 'showTimeSlider',\n            localizationId: 'pad.toolbar.timeslider.title',\n            class: 'buttonicon buttonicon-history',\n        },\n\n        savedrevision: defaultButtonAttributes('savedRevision'),\n        settings: defaultButtonAttributes('settings'),\n        embed: defaultButtonAttributes('embed'),\n        showusers: defaultButtonAttributes('showusers'),\n        home: defaultButtonAttributes('home'),\n\n        timeslider_export: {\n            command: 'import_export',\n            localizationId: 'timeslider.toolbar.exportlink.title',\n            class: 'buttonicon buttonicon-import_export',\n        },\n\n        timeslider_settings: {\n            command: 'settings',\n            localizationId: 'pad.toolbar.settings.title',\n            class: 'buttonicon buttonicon-settings',\n        },\n\n        timeslider_returnToPad: {\n            command: 'timeslider_returnToPad',\n            localizationId: 'timeslider.toolbar.returnbutton',\n            class: 'buttontext',\n        },\n    },\n\n    registerButton(buttonName: string, buttonInfo: any) {\n        this.availableButtons[buttonName] = buttonInfo;\n    },\n\n    button: (attributes: AttributeObj) => new Button(attributes),\n\n    separator: () => (new Separator()).render(),\n\n    selectButton: (attributes: AttributeObj) => new SelectButton(attributes),\n\n    /*\n     * Valid values for whichMenu: 'left' | 'right' | 'timeslider-right'\n     * Valid values for page:      'pad'  | 'timeslider'\n     */\n    menu(buttons: string[][], isReadOnly: boolean, whichMenu: string, page: string) {\n        if (isReadOnly) {\n            // The best way to detect if it's the left editbar is to check for a bold button\n            if (buttons[0].indexOf('bold') !== -1) {\n                // Clear all formatting buttons\n                buttons = [];\n            } else {\n                // Remove Save Revision from the right menu\n                removeItem(buttons[0], 'savedrevision');\n            }\n        } else if ((buttons[0].indexOf('savedrevision') === -1) &&\n            (whichMenu === 'right') && (page === 'pad')) {\n            /*\n             * This pad is not read only\n             *\n             * Add back the savedrevision button (the \"star\") if is not already there,\n             * but only on the right toolbar, and only if we are showing a pad (dont't\n             * do it in the timeslider).\n             *\n             * This is a quick fix for #3702 (and subsequent issue #3767): it was\n             * sufficient to visit a single read only pad to cause the disappearence\n             * of the star button from all the pads.\n             */\n            buttons[0].push('savedrevision');\n        }\n\n        const groups = map(buttons, (group: string[]) => ButtonGroup.fromArray(group).render());\n        return groups.join(this.separator());\n    },\n};\n"
  },
  {
    "path": "src/package.json",
    "content": "{\n  \"name\": \"ep_etherpad-lite\",\n  \"description\": \"A free and open source realtime collaborative editor\",\n  \"homepage\": \"https://etherpad.org\",\n  \"keywords\": [\n    \"etherpad\",\n    \"realtime\",\n    \"collaborative\",\n    \"editor\"\n  ],\n  \"author\": \"Etherpad Foundation\",\n  \"contributors\": [\n    {\n      \"name\": \"John McLear\"\n    },\n    {\n      \"name\": \"Antonio Muci\"\n    },\n    {\n      \"name\": \"Hans Pinckaers\"\n    },\n    {\n      \"name\": \"Robin Buse\"\n    },\n    {\n      \"name\": \"Marcel Klehr\"\n    },\n    {\n      \"name\": \"Peter Martischka\"\n    }\n  ],\n  \"dependencies\": {\n    \"async\": \"^3.2.6\",\n    \"axios\": \"^1.13.6\",\n    \"cookie-parser\": \"^1.4.7\",\n    \"cross-env\": \"^10.1.0\",\n    \"cross-spawn\": \"^7.0.6\",\n    \"ejs\": \"^5.0.1\",\n    \"esbuild\": \"^0.27.4\",\n    \"express\": \"^5.2.1\",\n    \"express-rate-limit\": \"^8.3.1\",\n    \"express-session\": \"^1.19.0\",\n    \"find-root\": \"1.1.0\",\n    \"formidable\": \"^3.5.4\",\n    \"http-errors\": \"^2.0.1\",\n    \"jose\": \"^6.2.2\",\n    \"js-cookie\": \"^3.0.5\",\n    \"jsdom\": \"^29.0.0\",\n    \"jsonminify\": \"0.4.2\",\n    \"jsonwebtoken\": \"^9.0.3\",\n    \"jwt-decode\": \"^4.0.0\",\n    \"languages4translatewiki\": \"0.1.3\",\n    \"live-plugin-manager\": \"^1.1.0\",\n    \"lodash.clonedeep\": \"4.5.0\",\n    \"log4js\": \"^6.9.1\",\n    \"lru-cache\": \"^11.2.7\",\n    \"measured-core\": \"^2.0.0\",\n    \"mime-types\": \"^3.0.2\",\n    \"oidc-provider\": \"9.7.1\",\n    \"openapi-backend\": \"^5.16.1\",\n    \"prom-client\": \"^15.1.3\",\n    \"proxy-addr\": \"^2.0.7\",\n    \"rate-limiter-flexible\": \"^10.0.1\",\n    \"rehype\": \"^13.0.2\",\n    \"rehype-minify-whitespace\": \"^6.0.2\",\n    \"resolve\": \"1.22.11\",\n    \"rusty-store-kv\": \"^1.3.1\",\n    \"security\": \"1.0.0\",\n    \"semver\": \"^7.7.4\",\n    \"socket.io\": \"^4.8.3\",\n    \"socket.io-client\": \"^4.8.3\",\n    \"superagent\": \"10.3.0\",\n    \"swagger-ui-express\": \"^5.0.1\",\n    \"tinycon\": \"0.6.8\",\n    \"tsx\": \"4.21.0\",\n    \"ueberdb2\": \"^5.0.23\",\n    \"underscore\": \"1.13.8\",\n    \"unorm\": \"1.6.0\",\n    \"wtfnode\": \"^0.10.1\"\n  },\n  \"bin\": {\n    \"etherpad-healthcheck\": \"../bin/etherpad-healthcheck\",\n    \"etherpad-lite\": \"node/server.ts\"\n  },\n  \"devDependencies\": {\n    \"@playwright/test\": \"^1.58.2\",\n    \"@types/async\": \"^3.2.25\",\n    \"@types/cookie-parser\": \"^1.4.10\",\n    \"@types/cross-spawn\": \"^6.0.6\",\n    \"@types/ejs\": \"^3.1.5\",\n    \"@types/express\": \"^5.0.6\",\n    \"@types/express-session\": \"^1.18.2\",\n    \"@types/formidable\": \"^3.5.0\",\n    \"@types/http-errors\": \"^2.0.5\",\n    \"@types/jquery\": \"^4.0.0\",\n    \"@types/js-cookie\": \"^3.0.6\",\n    \"@types/jsdom\": \"^28.0.0\",\n    \"@types/jsonminify\": \"^0.4.3\",\n    \"@types/jsonwebtoken\": \"^9.0.10\",\n    \"@types/mime-types\": \"^3.0.1\",\n    \"@types/mocha\": \"^10.0.9\",\n    \"@types/node\": \"^25.5.0\",\n    \"@types/oidc-provider\": \"^9.5.0\",\n    \"@types/semver\": \"^7.7.1\",\n    \"@types/sinon\": \"^21.0.0\",\n    \"@types/supertest\": \"^7.2.0\",\n    \"@types/swagger-ui-express\": \"^4.1.8\",\n    \"@types/underscore\": \"^1.13.0\",\n    \"@types/whatwg-mimetype\": \"^5.0.0\",\n    \"chokidar\": \"^5.0.0\",\n    \"eslint\": \"^10.0.3\",\n    \"eslint-config-etherpad\": \"^4.0.4\",\n    \"etherpad-cli-client\": \"^3.0.5\",\n    \"mocha\": \"^11.7.5\",\n    \"mocha-froth\": \"^0.2.10\",\n    \"nodeify\": \"^1.0.1\",\n    \"openapi-schema-validation\": \"^0.4.2\",\n    \"set-cookie-parser\": \"^3.0.1\",\n    \"sinon\": \"^21.0.3\",\n    \"split-grid\": \"^1.0.11\",\n    \"supertest\": \"^7.2.2\",\n    \"typescript\": \"^5.9.3\",\n    \"vitest\": \"^4.1.0\"\n  },\n  \"engines\": {\n    \"node\": \">=20.0.0\",\n    \"npm\": \">=6.14.0\",\n    \"pnpm\": \">=8.3.0\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/ether/etherpad-lite.git\"\n  },\n  \"scripts\": {\n    \"lint\": \"eslint .\",\n    \"test\": \"cross-env NODE_ENV=production mocha --import=tsx --timeout 120000 --recursive tests/backend/specs/**.ts ../node_modules/ep_*/static/tests/backend/specs/**\",\n    \"test-utils\": \"cross-env NODE_ENV=production mocha --import=tsx --timeout 5000 --recursive tests/backend/specs/*utils.ts\",\n    \"test-container\": \"mocha --import=tsx --timeout 5000 tests/container/specs/api\",\n    \"dev\": \"cross-env NODE_ENV=development  node --require tsx/cjs node/server.ts\",\n    \"prod\": \"cross-env NODE_ENV=production node --require tsx/cjs node/server.ts\",\n    \"ts-check\": \"tsc --noEmit\",\n    \"ts-check:watch\": \"tsc --noEmit --watch\",\n    \"test-ui\": \"cross-env NODE_ENV=production npx playwright test tests/frontend-new/specs\",\n    \"test-ui:ui\": \"cross-env NODE_ENV=production npx playwright test tests/frontend-new/specs --ui\",\n    \"test-admin\": \"cross-env NODE_ENV=production npx playwright test tests/frontend-new/admin-spec --workers 1\",\n    \"test-admin:ui\": \"cross-env NODE_ENV=production npx playwright test tests/frontend-new/admin-spec --ui --workers 1\",\n    \"debug:socketio\": \"cross-env DEBUG=socket.io* node --require tsx/cjs node/server.ts\",\n    \"test:vitest\": \"vitest\"\n  },\n  \"version\": \"2.6.1\",\n  \"license\": \"Apache-2.0\"\n}\n"
  },
  {
    "path": "src/playwright.config.ts",
    "content": "import {defineConfig, devices, test} from '@playwright/test';\n\n\nexport const defaultExpectTimeout = process.env.CI ? 20 * 1000 : 5000\nexport const defaultTestTimeout = 90 * 1000\n\n/**\n * See https://playwright.dev/docs/test-configuration.\n */\nexport default defineConfig({\n    testDir: './tests/frontend-new/',\n    /* Run tests in files in parallel */\n    fullyParallel: true,\n    /* Fail the build on CI if you accidentally left test.only in the source code. */\n    /* Reporter to use. See https://playwright.dev/docs/test-reporters */\n    reporter: process.env.CI ? 'github' : 'html',\n    expect: { timeout: defaultExpectTimeout },\n    timeout: defaultTestTimeout,\n    retries: 2,\n    workers: 5,\n    /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */\n    use: {\n        /* Base URL to use in actions like `await page.goto('/')`. */\n        // baseURL: 'http://127.0.0.1:3000',\n        baseURL: \"localhost:9001\",\n        /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */\n        trace: 'on-first-retry',\n        video: 'on-first-retry',\n    },\n\n    /* Configure projects for major browsers */\n    projects: [\n        {\n            name: 'chromium',\n            use: { ...devices['Desktop Chrome'] },\n        },\n\n        {\n            name: 'firefox',\n            use: { ...devices['Desktop Firefox'] },\n        },\n        {\n            name: 'chrome-firefox',\n            use:\n                {...devices['Desktop Firefox'], ...devices['Desktop Chrome']},\n        },\n        {\n            name: 'webkit',\n            use: { ...devices['Desktop Safari'] },\n        },\n\n        /* Test against mobile viewports. */\n        // {\n        //   name: 'Mobile Chrome',\n        //   use: { ...devices['Pixel 5'] },\n        // },\n        // {\n        //   name: 'Mobile Safari',\n        //   use: { ...devices['iPhone 12'] },\n        // },\n\n        /* Test against branded browsers. */\n        // {\n        //   name: 'Microsoft Edge',\n        //   use: { ...devices['Desktop Edge'], channel: 'msedge' },\n        // },\n        // {\n        //   name: 'Google Chrome',\n        //   use: { ...devices['Desktop Chrome'], channel: 'chrome' },\n        // },\n    ],\n\n    /* Run your local dev server before starting the tests */\n    // webServer: {\n    //   command: 'npm run start',\n    //   url: 'http://127.0.0.1:3000',\n    //   reuseExistingServer: !process.env.CI,\n    // },\n});\n"
  },
  {
    "path": "src/static/css/admin.css",
    "content": "html, body {\n  height: 100%;\n  box-sizing: border-box;\n}\n\nbody {\n  margin: 0;\n  color: #333;\n  font: 14px helvetica, sans-serif;\n  background: #eee;\n}\n\ndiv.menu {\n  height: 100%;\n  padding: 15px;\n  width: 220px;\n  border-right: 1px solid #ccc;\n  position: fixed;\n}\n\ndiv.menu ul {\n  padding: 0;\n}\n\ndiv.menu li {\n  list-style: none;\n  margin-left: 3px;\n  line-height: 3;\n  border-top: 1px solid #ccc;\n}\n\ndiv.menu li:last-child {\n  border-bottom: 1px solid #ccc;\n}\n\ndiv.innerwrapper {\n  padding: 15px;\n  padding-left: 265px;\n}\n\ndiv.innerwrapper-err {\n  padding: 15px;\n  padding-left: 265px;\n  display: none;\n}\n\n#wrapper {\n  background: none repeat scroll 0px 0px #FFFFFF;\n  box-shadow: 0px 1px 10px rgba(0, 0, 0, 0.2);\n  margin: auto;\n  max-width: 1150px;\n  min-height: 101%;/*always display a scrollbar*/\n}\n\nh1 {\n  font-size: 29px;\n}\n\nh2 {\n  font-size: 24px;\n}\n\n.separator {\n  margin: 10px 0;\n  height: 1px;\n  background: #aaa;\n  background: -webkit-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff);\n  background: -moz-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff);\n  background: -ms-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff);\n  background: -o-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff);\n}\n\nform {\n  margin-bottom: 0;\n}\n\n#inner {\n  width: 300px;\n  margin: 0 auto;\n}\n\ninput {\n  font-weight: bold;\n  font-size: 15px;\n}\n\ninput[type=\"button\"] {\n  padding: 4px 6px;\n  margin: 0;\n}\n\ntable input[type=\"button\"] {\n  float: right;\n  width: 100px;\n}\n\ninput[type=\"text\"] {\n  border-radius: 3px;\n  box-sizing: border-box;\n  -moz-box-sizing: border-box;\n  padding: 10px;\n  *padding: 0;\n /* IE7 hack */\n  width: 100%;\n  outline: none;\n  border: 1px solid #ddd;\n  margin: 0 0 5px 0;\n  max-width: 500px;\n}\n\n.sort {\n  cursor: pointer;\n}\n.sort:after {\n  content: '▲▼'\n}\n.sort.up:after {\n  content:'▲'\n}\n.sort.down:after {\n  content:'▼'\n}\n\ntable {\n  border: 1px solid #ddd;\n  border-radius: 3px;\n  border-spacing: 0;\n  width: 100%;\n  margin: 20px 0;\n  position:relative; /* Allows us to position the loading indicator relative to the table */\n}\n\ntable thead tr {\n  background: #eee;\n}\n\ntd, th {\n  padding: 5px;\n}\n\n.template {\n  display: none;\n}\n\n#installed-plugins td>div {\n  position: relative;/* Allows us to position the loading indicator relative to this row */\n  display: inline-block; /*make this fill the whole cell*/\n  width:100%;\n}\n\n.messages {\n  height: 5em;\n}\n.messages * {\n  display: none;\n  text-align: center;\n}\n.messages .fetching {\n  display: block;\n}\n\n.progress {\n  position: absolute;\n  top: 0; left: 0; bottom:0; right:0;\n  padding: auto;\n\n  background: rgb(255,255,255);\n  display: none;\n}\n\n#search-progress.progress {\n  padding-top: 20%;\n  background: rgba(255,255,255,0.3);\n}\n\n.progress * {\n  display: block;\n  margin: 0 auto;\n  text-align: center;\n  color: #666;\n}\n\n.settings {\n  outline: none;\n  width: 100%;\n  min-height: 500px;\n}\n\n#response {\n  display: inline;\n}\n\na:link, a:visited, a:hover, a:focus {\n  color: #333333;\n  text-decoration: none;\n}\n\na:focus, a:hover {\n  text-decoration: underline;\n}\n\n.installed-results a:link,\n.search-results a:link,\n.installed-results a:visited,\n.search-results a:visited,\n.installed-results a:hover,\n.search-results a:hover,\n.installed-results a:focus,\n.search-results a:focus  {\n  text-decoration: underline;\n}\n\n.installed-results a:focus,\n.search-results a:focus,\n.installed-results a:hover,\n.search-results a:hover {\n  text-decoration: none;\n}\n\npre {\n  white-space: pre-wrap;\n  word-wrap: break-word;\n}\n\n@media (max-width: 800px) {\n  div.innerwrapper {\n    padding: 0 15px 15px 15px;\n  }\n\n  div.menu {\n    padding: 1px 15px 0 15px;\n    position: static;\n    height: auto;\n    border-right: none;\n    width: auto;\n  }\n\n  table {\n    border: none;\n  }\n\n  table, thead, tbody, td, tr {\n    display: block;\n  }\n\n  thead tr {\n    display: none;\n  }\n\n  tr {\n    border: 1px solid #ccc;\n    margin-bottom: 5px;\n    border-radius: 3px;\n  }\n\n  td {\n    border: none;\n    border-bottom: 1px solid #eee;\n    position: relative;\n    padding-left: 50%;\n    white-space: normal;\n    text-align: left;\n  }\n\n  td.name {\n    word-wrap: break-word;\n  }\n\n  td:before {\n    position: absolute;\n    top: 6px;\n    left: 6px;\n    text-align: left;\n    padding-right: 10px;\n    white-space: nowrap;\n    font-weight: bold;\n    content: attr(data-label);\n  }\n\n  td:last-child {\n    border-bottom: none;\n  }\n\n  table input[type=\"button\"] {\n    float: none;\n  }\n}"
  },
  {
    "path": "src/static/css/iframe_editor.css",
    "content": "/*\n  These CSS rules are included in both the outer and inner ACE iframe (pad editor)\n*/\n\n@import url('./lists_and_indents.css');\n\nhtml.outer-editor, html.inner-editor {\n  background-color: transparent !important;\n}\n#outerdocbody {\n  display: flex;\n  flex-direction: row;\n  justify-content: center;\n  min-height: 100vh; /* take at least full height */\n}\n#outerdocbody iframe {\n  flex: 1 auto;\n  display: flex;\n  width: 100%;\n}\n#outerdocbody #sidediv {\n  order: -1; /* display it on the first row positionning, i.e. on the left */\n}\n\n/* ACE-PAD Container (i.e. where the text is displayed) */\n#innerdocbody {\n  padding-left: 15px;\n  padding-right: 15px;\n  overflow: hidden;\n  background-color: white;\n  line-height: 1.6;\n\n  /* Be careful editing following rules. Longs words should not overflow, ep_align justify should work,\n     Test on chrome, firefox and safari... Copy / Paste a word inside a sentence should not add line-breaks\n     and preserve the style */\n  display: block; /* for safari and firefox, otherwise the break-word does not work */\n  white-space: normal;\n  word-wrap: break-word;\n  overflow-wrap: break-word;\n  /*\n   * Make the contenteditable area at least as big as the screen so that mobile\n   * users can tap anywhere to bring up their device's keyboard.\n   */\n  min-height: 100vh;\n}\n\n#innerdocbody, #sidediv {\n  /* Both must have the same top padding to line up line numbers */\n  padding-top: 15px;\n  /* Some space when we scroll to the bottom */\n  padding-bottom: 15px;\n}\n\n#innerdocbody a {\n  color: #2e96f3;\n}\n#innerdocbody.authorColors [class^='author-'] a {\n  color: inherit;\n}\n\noption {\n  text-transform: capitalize;\n}\n\n#innerdocbody h1,\n#innerdocbody h2,\n#innerdocbody h3,\n#innerdocbody h4 {\n  line-height: 1.2;\n  margin-bottom: .5em;\n}\n#innerdocbody h1 span,\n#innerdocbody h2 span,\n#innerdocbody h3 span,\n#innerdocbody h4 span {\n  padding-top: 0;\n}\n\n/* --------------------- */\n/* -- BROWSER SUPPORT -- */\n/* --------------------- */\n\nbody.mozilla, body.safari {\n  display: table-cell; /* cause \"body\" area (e.g. where clicks are heard) to grow horizontally with text */\n}\n.safari div {\n  padding-right: 1px; /* prevents the caret from disappearing on the longest line of the doc */\n}\n\n\n/* ------------------------------------------ */\n/* -- SIDEDIV (line number, text author..) -- */\n/* ------------------------------------------ */\n\n#sidediv {\n  background-color: transparent;\n  border-right: 1px solid #ccc;\n}\n#sidediv .line-number {\n  font-size: 9px;\n  padding: 0 14px 0 10px;\n  font-family: monospace;\n  cursor: pointer;\n}\n.plugin-ep_author_neat #sidedivinner.authorColors .line-number {\n  padding-right: 10px;\n}\n#sidedivinner {\n  text-align: right;\n  opacity: .9;\n}\n#sidediv:not(.sidedivdelayed) { /* before sidediv get initialized, hide text */\n  color: transparent;\n}\n.line-numbers-hidden #sidediv .line-number {\n  display: none;\n}\n#linemetricsdiv {\n  position: absolute;\n  left: -1000px;\n  top: -1000px;\n  color: white;\n  z-index: -1;\n  font-size: 12px; /* overridden by lineMetricsDiv.style */\n  font-family: monospace; /* overridden by lineMetricsDiv.style */\n}\n@media (max-width: 800px) {\n  #sidediv {\n    /* Do not use display: none to hide the sidediv, otherwise the parent container does not\n       get its height properly calculated by flexboxes */\n    visibility: hidden;\n    width: 0;\n    padding: 0;\n  }\n}\n\n\n\n\n/* ----------- */\n/* -- OTHER -- */\n/* ----------- */\n\n::selection {\n  background: #acf;\n}\n::-moz-selection {\n  background: #acf;\n}\n#innerdocbody a {\n  cursor: pointer !important;\n}\n"
  },
  {
    "path": "src/static/css/lists_and_indents.css",
    "content": "/*\n * These are the common definitions for the styling of bulleted lists, numbered\n * lists, and plain indented blocks, shared by the editor and timeslider pages.\n */\n\nul, ol, li {\n  padding: 0;\n  margin: 0;\n}\n\nul { margin-left: 1.5em; }\nul ul { margin-left: 0 !important; }\nul.list-bullet1 { margin-left: 1.5em; }\nul.list-bullet2 { margin-left: 3em; }\nul.list-bullet3 { margin-left: 4.5em; }\nul.list-bullet4 { margin-left: 6em; }\nul.list-bullet5 { margin-left: 7.5em; }\nul.list-bullet6 { margin-left: 9em; }\nul.list-bullet7 { margin-left: 10.5em; }\nul.list-bullet8 { margin-left: 12em; }\nul.list-bullet9 { margin-left: 13.5em; }\nul.list-bullet10 { margin-left: 15em; }\nul.list-bullet11 { margin-left: 16.5em; }\nul.list-bullet12 { margin-left: 18em; }\nul.list-bullet13 { margin-left: 19.5em; }\nul.list-bullet14 { margin-left: 21em; }\nul.list-bullet15 { margin-left: 22.5em; }\nul.list-bullet16 { margin-left: 24em; }\n\nul { list-style-type: disc; }\nul.list-bullet1 { list-style-type: disc; }\nul.list-bullet2 { list-style-type: circle; }\nul.list-bullet3 { list-style-type: square; }\nul.list-bullet4 { list-style-type: disc; }\nul.list-bullet5 { list-style-type: circle; }\nul.list-bullet6 { list-style-type: square; }\nul.list-bullet7 { list-style-type: disc; }\nul.list-bullet8 { list-style-type: circle; }\nul.list-bullet9 { list-style-type: disc; }\nul.list-bullet10 { list-style-type: circle; }\nul.list-bullet11 { list-style-type: square; }\nul.list-bullet12 { list-style-type: disc; }\nul.list-bullet13 { list-style-type: circle; }\nul.list-bullet14 { list-style-type: square; }\nul.list-bullet15 { list-style-type: disc; }\nul.list-bullet16 { list-style-type: circle; }\n\nul.list-indent1 { margin-left: 1.5em; }\nul.list-indent2 { margin-left: 3em; }\nul.list-indent3 { margin-left: 4.5em; }\nul.list-indent4 { margin-left: 6em; }\nul.list-indent5 { margin-left: 7.5em; }\nul.list-indent6 { margin-left: 9em; }\nul.list-indent7 { margin-left: 10.5em; }\nul.list-indent8 { margin-left: 12em; }\nul.list-indent9 { margin-left: 13.5em; }\nul.list-indent10 { margin-left: 15em; }\nul.list-indent11 { margin-left: 16.5em; }\nul.list-indent12 { margin-left: 18em; }\nul.list-indent13 { margin-left: 19.5em; }\nul.list-indent14 { margin-left: 21em; }\nul.list-indent15 { margin-left: 22.5em; }\nul.list-indent16 { margin-left: 24em; }\n\nul.list-indent1, ul.list-indent2, ul.list-indent3, ul.list-indent4, ul.list-indent5,\nul.list-indent6, ul.list-indent7, ul.list-indent8, ul.list-indent9, ul.list-indent10,\nul.list-indent11, ul.list-indent12, ul.list-indent13,\nul.list-indent14, ul.list-indent15, ul.list-indent16 { list-style-type: none; }\n\nol {\n  list-style-type: decimal;\n}\n\n/* Fixes #2223 and #1836 */\nol > li {\n  display:block;\n}\n\n/* Set the indentation */\nol.list-number1{ text-indent: 0px; }\nol.list-number2{ text-indent: 10px; }\nol.list-number3{ text-indent: 20px; }\nol.list-number4{ text-indent: 30px; }\nol.list-number5{ text-indent: 40px; }\nol.list-number6{ text-indent: 50px; }\nol.list-number7{ text-indent: 60px; }\nol.list-number8{ text-indent: 70px; }\nol.list-number9{ text-indent: 80px; }\nol.list-number10{ text-indent: 90px; }\nol.list-number11{ text-indent: 100px; }\nol.list-number12{ text-indent: 110px; }\nol.list-number13{ text-indent: 120px; }\nol.list-number14{ text-indent: 130px; }\nol.list-number15{ text-indent: 140px; }\nol.list-number16{ text-indent: 150px; }\n\n/* Add styling to the first item in a list */\n\n.list-start-number1 { counter-reset: first second; }\n.list-start-number2 { counter-reset: second; }\n.list-start-number3 { counter-reset: third; }\n.list-start-number4 { counter-reset: fourth; }\n.list-start-number5 { counter-reset: fifth; }\n.list-start-number6 { counter-reset: sixth; }\n.list-start-number7 { counter-reset: seventh; }\n.list-start-number8 { counter-reset: eighth; }\n.list-start-number9 { counter-reset: ninth; }\n.list-start-number10 { counter-reset: tenth; }\n.list-start-number11 { counter-reset: eleventh; }\n.list-start-number12 { counter-reset: twelfth; }\n.list-start-number13 { counter-reset: thirteenth; }\n.list-start-number14 { counter-reset: fourteenth; }\n.list-start-number15 { counter-reset: fifteenth; }\n.list-start-number16 { counter-reset: sixteenth; }\n\n/* The behavior for incrementing and the prefix */\n.list-number1 li:before {\n  content: counter(first) \". \" ;\n  counter-increment: first;\n}\n\n.list-number2 li:before {\n  content: counter(first) \".\" counter(second) \". \";\n  counter-increment: second;\n}\n\n.list-number3 li:before {\n  content: counter(first) \".\" counter(second) \".\" counter(third) \". \";\n  counter-increment: third 1;\n}\n\n.list-number4 li:before {\n  content: counter(first) \".\" counter(second) \".\" counter(third) \".\" counter(fourth) \". \";\n  counter-increment: fourth 1;\n}\n\n.list-number5 li:before {\n  content: counter(first) \".\" counter(second) \".\" counter(third) \".\" counter(fourth) \".\" counter(fifth) \". \";\n  counter-increment: fifth 1;\n}\n\n.list-number6 li:before {\n  content: counter(first) \".\" counter(second) \".\" counter(third) \".\" counter(fourth) \".\" counter(fifth) \".\" counter(sixth) \". \";\n  counter-increment: sixth 1;\n}\n\n.list-number7 li:before {\n  content: counter(first) \".\" counter(second) \".\" counter(third) \".\" counter(fourth) \".\" counter(fifth) \".\" counter(sixth) \".\" counter(seventh) \". \";\n  counter-increment: seventh 1;\n}\n\n.list-number8 li:before {\n  content: counter(first) \".\" counter(second) \".\" counter(third) \".\" counter(fourth) \".\" counter(fifth) \".\" counter(sixth) \".\" counter(seventh) \".\" counter(eighth) \". \" ;\n  counter-increment: eighth 1;\n}\n\n.list-number9 li:before {\n  content: counter(first) \".\" counter(second) \".\" counter(third) \".\" counter(fourth) \".\" counter(fifth) \".\" counter(sixth) \".\" counter(seventh) \".\" counter(eighth) \".\" counter(ninth) \". \";\n  counter-increment: ninth 1;\n}\n\n.list-number10 li:before {\n  content: counter(first) \".\" counter(second) \".\" counter(third) \".\" counter(fourth) \".\" counter(fifth) \".\" counter(sixth) \".\" counter(seventh) \".\" counter(eighth) \".\" counter(ninth) \".\" counter(tenth) \". \";\n  counter-increment: tenth 1;\n}\n\n.list-number11 li:before {\n  content: counter(first) \".\" counter(second) \".\" counter(third) \".\" counter(fourth) \".\" counter(fifth) \".\" counter(sixth) \".\" counter(seventh) \".\" counter(eighth) \".\" counter(ninth) \".\" counter(tenth) \".\" counter(eleventh) \". \";\n  counter-increment: eleventh 1;\n}\n\n.list-number12 li:before {\n  content: counter(first) \".\" counter(second) \".\" counter(third) \".\" counter(fourth) \".\" counter(fifth) \".\" counter(sixth) \".\" counter(seventh) \".\" counter(eighth) \".\" counter(ninth) \".\" counter(tenth) \".\" counter(eleventh) \".\" counter(twelfth) \". \";\n  counter-increment: twelfth 1;\n}\n\n.list-number13 li:before {\n  content: counter(first) \".\" counter(second) \".\" counter(third) \".\" counter(fourth) \".\" counter(fifth) \".\" counter(sixth) \".\" counter(seventh) \".\" counter(eighth) \".\" counter(ninth) \".\" counter(tenth) \".\" counter(eleventh) \".\" counter(twelfth) \".\" counter(thirteenth) \". \";\n  counter-increment: thirteenth 1;\n}\n\n.list-number14 li:before {\n  content: counter(first) \".\" counter(second) \".\" counter(third) \".\" counter(fourth) \".\" counter(fifth) \".\" counter(sixth) \".\" counter(eighth) \".\" counter(ninth) \".\" counter(tenth) \".\" counter(eleventh) \".\" counter(twelfth) \".\" counter(thirteenth) \".\" counter(fourteenth) \". \";\n  counter-increment: fourteenth 1;\n}\n\n.list-number15 li:before {\n  content: counter(first) \".\" counter(second) \".\" counter(third) \".\" counter(fourth) \".\" counter(fifth) \".\" counter(sixth) \".\" counter(eighth) \".\" counter(ninth) \".\" counter(tenth) \".\" counter(eleventh) \".\" counter(twelfth) \".\" counter(thirteenth) \".\" counter(fourteenth) \".\" counter(fifteenth) \". \";\n  counter-increment: fifteenth 1;\n}\n\n.list-number16 li:before {\n  content: counter(first) \".\" counter(second) \".\" counter(third) \".\" counter(fourth) \".\" counter(fifth) \".\" counter(sixth) \".\" counter(eighth) \".\" counter(ninth) \".\" counter(tenth) \".\" counter(eleventh) \".\" counter(twelfth) \".\" counter(thirteenth) \".\" counter(fourteenth) \".\" counter(fifteenth) \".\" counter(sixteenth) \". \";\n  counter-increment: sixteenth 1;\n}\n"
  },
  {
    "path": "src/static/css/pad/chat.css",
    "content": "#chaticon, #chatbox {\n  visibility: hidden;\n  z-index: 400;\n  position: absolute;\n  bottom: 0px;\n  right: 25px;\n}\n#chaticon.visible, #chatbox.visible {\n  visibility: visible;\n}\n#chaticon {\n  border-top-left-radius: 5px;\n  border-top-right-radius: 5px;\n  border: 1px solid #ccc;\n  border-bottom: none;\n}\n.chat-content {\n  width: 400px;\n  height: 300px;\n  border-top-left-radius: 5px;\n  border-top-right-radius: 5px;\n  display: flex;\n  border: 1px solid #ccc;\n  border-bottom: none;\n  z-index: 401;\n  background-color: #f7f7f7;\n  flex-direction: column;\n  transition: all 0.3s cubic-bezier(0.74, -0.05, 0.27, 1.75);\n  opacity: .3;\n  transform: scale(.5);\n  transform-origin: 100% 100%\n}\n#chatbox.visible .chat-content{\n  opacity: 1;\n  transform: scale(1);\n}\n\n#chatbox.stickyChat {\n  position: relative;\n  width: auto;\n  flex: 1 auto; /* take reminaning vertical space */\n  height: 100%;\n  right: 0;\n  display: flex;\n}\n#chatbox.stickyChat .chat-content {\n  background-color: #f1f1f1;\n  border-radius: 0;\n  border: none;\n  border-left: 1px solid #ccc;\n  height: 100%;\n  width: 100%;\n}\n#chatbox.stickyChat .chat-content .stick-to-screen-btn {\n  display: none;\n}\n#chatbox.stickyChat.chatAndUsersChat .chat-content .hide-reduce-btn {\n  display:none;\n}\n\n/* -- TITLE BAR -- */\n#titlebar {\n  font-weight: bold;\n  padding: 5px;\n}\n#titlebar #titlelabel {\n  margin: 4px 0 0 4px;\n  display: inline;\n  font-size: 1.4rem;\n}\n#titlebar .stick-to-screen-btn,\n#titlebar .hide-reduce-btn {\n  font-size: 25px;\n  color: inherit;\n  float: right;\n  text-align: right;\n  text-decoration: none;\n  cursor: pointer;\n}\n#titlebar .stick-to-screen-btn {\n  font-size: 10px;\n  padding-top: 2px;\n}\n\n/* -- MESSAGES -- */\n#chattext {\n  background-color: white;\n  overflow-y: auto;\n  flex: 1 auto;\n  height: 0; /* strange bug on firefox, if height is not set, the chattext grow bigger than the maximum height */\n}\n#chattext p {\n  padding: 3px;\n  overflow-x: hidden;\n  white-space: pre-wrap;\n  word-wrap: break-word;\n}\n#chattext .time {\n  float: right;\n  font-style: italic;\n  /*\n   * 'smaller' is relative to the parent element, so if the parent has its own\n   * 'font-size: smaller' rule then the timestamp will become even smaller (as\n   * desired).\n   */\n  font-size: smaller;\n  opacity: .8;\n  margin-left: 3px;\n  margin-right: 2px;\n}\n\n/* -- INPUT BOX -- */\n#chatinputbox {\n  padding: 5px;\n}\n#chatinputbox #chatinput {\n  width: 100%;\n  resize: vertical;\n}\n\n\n/* -- CHAT ICON -- */\n#chaticon {\n  background-color: #fff;\n  cursor: pointer;\n  display: none;\n  padding: 5px;\n}\n#chaticon a {\n  text-decoration: none\n}\n#chaticon #chatlabel {\n  font-weight: bold;\n  text-decoration: none;\n  margin-right: 3px;\n  vertical-align: middle;\n}\n#chaticon #chatcounter {\n  font-size: .8rem;\n  vertical-align: middle;\n  margin-left: 5px;\n}\n\n/* -- LOAD MESSAGES -- */\n.chatloadmessages\n{\n  margin-bottom: 5px;\n  margin-top: 5px;\n  margin-left: auto;\n  margin-right: auto;\n  display: block;\n}\n#chatloadmessagesbutton\n{\n  line-height: 1.8em;\n}\n#chatloadmessagesball\n{\n  display: none;\n}\n\n@media only screen and (max-width: 800px) {\n  #chatbox {\n    right: 0;\n    bottom: 0;\n    left: 0;\n  }\n  #chatbox .chat-content {\n    width: 100%;\n  }\n}\n"
  },
  {
    "path": "src/static/css/pad/fonts.css",
    "content": "/* Montserrat Font */\n@font-face {\n  font-family: \"Montserrat\";\n  src: url(\"../../../static/font/Montserrat-Light.otf\") format(\"opentype\");\n  font-weight: normal;\n  font-style: normal;\n}\n@font-face {\n    font-family: \"Montserrat\";\n    src: url(\"../../../static/font/Montserrat-Regular.otf\") format(\"opentype\");\n    font-weight: bold;\n    font-style: normal;\n}\n/* End of Monterrat Font */\n\n@font-face {\n  font-family: opendyslexic;\n  src: url(\"../../../static/font/opendyslexic.otf\") format(\"opentype\");\n}\n@font-face {\n    font-family: \"RobotoMono\";\n    src: url(\"../../../static/font/RobotoMono-Regular.ttf\") format(\"truetype\");\n    font-weight: normal;\n    font-style: normal;\n}\n@font-face {\n    font-family: \"RobotoMono\";\n    src: url(\"../../../static/font/RobotoMono-Bold.ttf\") format(\"truetype\");\n    font-weight: bold;\n    font-style: normal;\n}\n@font-face {\n    font-family: \"Quicksand\";\n    src: url(\"../../../static/font/Quicksand-Regular.ttf\") format(\"truetype\");\n    font-weight: 300;\n    font-style: normal;\n}\n@font-face {\n    font-family: \"Quicksand\";\n    src: url(\"../../../static/font/Quicksand-Medium.ttf\") format(\"truetype\");\n    font-weight: normal;\n    font-style: normal;\n}\n@font-face {\n    font-family: \"Quicksand\";\n    src: url(\"../../../static/font/Quicksand-Bold.ttf\") format(\"truetype\");\n    font-weight: bold;\n    font-style: normal;\n}\n@font-face {\n    font-family: \"Roboto\";\n    src: url(\"../../../static/font/Roboto-Regular.ttf\") format(\"truetype\");\n    font-weight: normal;\n    font-style: normal;\n}\n@font-face {\n    font-family: \"Roboto\";\n    src: url(\"../../../static/font/Roboto-Bold.ttf\") format(\"truetype\");\n    font-weight: bold;\n    font-style: normal;\n}\n\n\n@font-face {\n  font-family: 'Alegreya';\n  font-style: normal;\n  font-weight: 400;\n  src: local('Alegreya Medium'), local('Alegreya-Medium'),\n       url('../../../static/font/Aleygreya-Medium.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */\n       url('../../../static/font/Aleygreya-Medium.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */\n}\n@font-face {\n  font-family: 'Alegreya';\n  font-style: normal;\n  font-weight: 700;\n  src: local('Alegreya ExtraBold'), local('Alegreya-ExtraBold'),\n       url('../../../static/font/Aleygreya-ExtraBold.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */\n       url('../../../static/font/Aleygreya-ExtraBold.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */\n}"
  },
  {
    "path": "src/static/css/pad/form.css",
    "content": "select, .nice-select {\n  -webkit-tap-highlight-color: transparent;\n  background-color: #fff;\n  border-radius: 3px;\n  border: 1px solid #ccc;\n  box-sizing: border-box;\n  clear: both;\n  cursor: pointer;\n  display: inline-block;\n  font-family: inherit;\n  font-weight: normal;\n  height: 28px;\n  line-height: 28px;\n  outline: none;\n  padding-left: 8px;\n  padding-right: 24px;\n  position: relative;\n  text-align: left !important;\n  -webkit-transition: all 0.1s ease-in-out;\n  transition: all 0.1s ease-in-out;\n  -webkit-user-select: none;\n  -moz-user-select: none;\n  -ms-user-select: none;\n  user-select: none;\n  white-space: nowrap;\n  min-width: 100px;\n  text-transform: capitalize;\n}\n.nice-select:not(.open):not(:hover):focus {\n  border-color: #a5c8ec;\n}\n.popup .nice-select {\n  padding: 4px 24px 4px 8px;\n}\n.nice-select:hover {\n  border-color: #dbdbdb;\n}\n.nice-select:after {\n  border-bottom: 2px solid #999;\n  border-right: 2px solid #999;\n  content: '';\n  display: block;\n  height: 5px;\n  margin-top: -3px;\n  pointer-events: none;\n  position: absolute;\n  right: 10px;\n  top: 50%;\n  -webkit-transform-origin: 66% 66%;\n  -ms-transform-origin: 66% 66%;\n  transform-origin: 66% 66%;\n  -webkit-transform: rotate(45deg);\n  -ms-transform: rotate(45deg);\n  transform: rotate(45deg);\n  -webkit-transition: all 0.15s ease-in-out;\n  transition: all 0.15s ease-in-out;\n  width: 5px;\n}\n.nice-select.open:after {\n  -webkit-transform: rotate(-135deg);\n  -ms-transform: rotate(-135deg);\n  transform: rotate(-135deg);\n}\n.nice-select.open .list {\n  opacity: 1;\n  pointer-events: auto;\n  -webkit-transform: scale(1) translateY(0);\n  -ms-transform: scale(1) translateY(0);\n  transform: scale(1) translateY(0);\n}\n.nice-select.disabled {\n  border-color: #ededed;\n  color: #999;\n  pointer-events: none;\n}\n.nice-select.disabled:after {\n  border-color: #cccccc;\n}\n.nice-select.wide {\n  width: 100%;\n}\n.nice-select.wide .list {\n  left: 0 !important;\n  right: 0 !important;\n}\n.nice-select.right {\n  float: right;\n}\n.nice-select.right .list {\n  left: auto;\n  right: 0;\n}\n.nice-select.small {\n  font-size: 12px;\n  height: 36px;\n  line-height: 34px;\n}\n.nice-select.small:after {\n  height: 4px;\n  width: 4px;\n}\n.nice-select.small .option {\n  line-height: 34px;\n  min-height: 34px;\n}\n.nice-select .list {\n  display: block;\n  background-color: #fff;\n  border-radius: 3px;\n  box-shadow: 0 0 0 1px rgba(68, 68, 68, 0.11);\n  box-sizing: border-box;\n  margin-top: 4px;\n  opacity: 0;\n  overflow: auto;\n  padding: 0;\n  pointer-events: none;\n  position: absolute;\n  top: 100%;\n  left: 0;\n  max-height: 10px;\n  -webkit-transform-origin: 50% 0;\n  -ms-transform-origin: 50% 0;\n  transform-origin: 50% 0;\n  -webkit-transform: scale(0.75) translateY(-21px);\n  -ms-transform: scale(0.75) translateY(-21px);\n  transform: scale(0.75) translateY(-21px);\n  -webkit-transition: all 0.2s cubic-bezier(0.5, 0, 0, 1.25), opacity 0.15s ease-out;\n  transition: all 0.2s cubic-bezier(0.5, 0, 0.08, 1.10), opacity 0.15s ease-out;\n  z-index: 9;\n}\n.nice-select.reverse .list {\n  bottom: calc(100% + 5px);\n  top: auto;\n}\n.toolbar .nice-select .list {\n  position: fixed;\n  top: auto;\n  left: auto;\n}\n.nice-select .list:hover .option:not(:hover) {\n  background-color: transparent !important;\n}\n.nice-select .option {\n  cursor: pointer;\n  font-weight: 400;\n  line-height: 35px;\n  list-style: none;\n  min-height: 35px;\n  outline: none;\n  margin: 0;\n  padding-left: 8px;\n  padding-right: 8px;\n  text-align: left;\n  -webkit-transition: all 0.2s;\n  transition: all 0.2s;\n  text-transform: capitalize;\n}\n.nice-select .option:hover,.nice-select .option.focus,.nice-select .option.selected.focus {\n  background-color: #f6f6f6;\n}\n.nice-select .option[data-value=\"dummy\"] {\n  display: none;\n}\n.nice-select .option.selected {\n  font-weight: bold;\n}\n.nice-select .option.disabled {\n  background-color: transparent;\n  color: #999;\n  cursor: default;\n}\n .no-csspointerevents.nice-select .list {\n  display: none;\n}\n .no-csspointerevents.nice-select.open .list {\n  display: block;\n}\n"
  },
  {
    "path": "src/static/css/pad/gritter.css",
    "content": "#gritter-container {\n  position: absolute;\n  right: 50%;\n  transform: translateX(50%);\n  text-align: center;\n  z-index: 9999;\n}\n#gritter-container.top {\n  top: 20px;\n}\n#gritter-container.bottom {\n  bottom: 20px;\n}\n\n.gritter-item.popup {\n  position: relative;\n  visibility: visible;\n  right: auto !important;\n  left: auto !important;\n  top: auto;\n  bottom: auto;\n}\n.gritter-item.popup:not(.error) {\n  max-width: 450px;\n}\n\n.gritter-item .popup-content {\n  display: flex;\n}\n\n.gritter-item .gritter-content {\n  flex: 1 auto;\n  text-align: center;\n  width: 95%;\n  overflow-wrap: break-word;\n}\n\n.gritter-item .gritter-close {\n  align-self: center;\n}\n\n.gritter-item.error .popup-content {\n  color: #a84341;\n  background-color: #eed3d4;\n}\n\n@media (max-width: 800px) {\n  #gritter-container {\n    left: 1rem;\n    right: 1rem;\n    transform: none;\n  }\n}\n"
  },
  {
    "path": "src/static/css/pad/icons.css",
    "content": "@font-face {\n  font-family: \"fontawesome-etherpad\";\n  src:url(\"../../../static/font/fontawesome-etherpad.eot?2\");\n  src:url(\"../../../static/font/fontawesome-etherpad.eot?2#iefix\") format(\"embedded-opentype\"),\n    url(\"../../../static/font/fontawesome-etherpad.woff?2\") format(\"woff\"),\n    url(\"../../../static/font/fontawesome-etherpad.ttf?2\") format(\"truetype\"),\n    url(\"../../../static/font/fontawesome-etherpad.svg#fontawesome-etherpad\") format(\"svg\");\n  font-weight: normal;\n  font-style: normal;\n\n}\n\n.buttonicon {\n  border: none;\n  padding: 0;\n  background: none;\n  text-align: center;\n  font-style: normal;\n  font-weight: normal;\n  position: relative;\n  cursor: pointer;\n  -moz-osx-font-smoothing: grayscale;\n  -webkit-font-smoothing: antialiased;\n  font-style: normal;\n  font-variant: normal;\n  text-rendering: auto;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n}\n\n.buttonicon:before, [class^=\"buttonicon-\"]:before, [class*=\" buttonicon-\"]:before {\n  font-family: \"fontawesome-etherpad\";\n  font-style: normal;\n  font-weight: normal;\n  speak: none;\n  font-size: 15px;\n  display: inline-block;\n  text-decoration: inherit;\n\n  /* For safety - reset parent styles, that can break glyph codes*/\n  font-variant: normal;\n  text-transform: none;\n\n  /* Font smoothing. That was taken from TWBS */\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\n.buttonicon-insertorderedlist:before {\n  content: \"\\e844\";\n}\n.buttonicon-insertunorderedlist:before {\n  content: \"\\e82a\";\n}\n.buttonicon-clearauthorship:before {\n  content: \"\\e843\";\n}\n.buttonicon-settings:before {\n  content: \"\\e851\";\n}\n.buttonicon-import_export:before {\n  content: \"\\e837\";\n}\n.buttonicon-embed:before {\n  content: \"\\e853\";\n}\n.buttonicon-history:before {\n  content: \"\\e837\";\n}\n.buttonicon-chat:before {\n  content: \"\\e82d\";\n}\n.buttonicon-showusers:before {\n  content: \"\\e835\";\n}\n.buttonicon-savedRevision:before {\n  content: \"\\e856\";\n}\n.buttonicon-undo:before { content: '\\e84b'; } /* '' */\n.buttonicon-redo:before { content: '\\e84c'; } /* '' */\n\n.ep_font_size > a > .buttonicon:before { content: '\\e852' !important; }\n.ep_font_color .buttonicon:before { content: '\\e84e' !important; border-bottom: solid 2px #e42a2a;  }\n\n.buttonicon-underline:before {\n  /* The baseline of the underscore glyph seems off. Compensate for it here. */\n  top: 0.1em;\n  position: relative;\n}\n\n/* COPY CSS GENERATED BY FONTELLO HERE */\n.buttonicon-sync-alt:before { content: '\\e800'; } /* '' */\n.buttonicon-print:before { content: '\\e801'; } /* '' */\n.buttonicon-stop:before { content: '\\e802'; } /* '' */\n.buttonicon-play:before { content: '\\e803'; } /* '' */\n.buttonicon-align-center:before { content: '\\e804'; } /* '' */\n.buttonicon-align-justify:before { content: '\\e805'; } /* '' */\n.buttonicon-align-left:before { content: '\\e806'; } /* '' */\n.buttonicon-align-right:before { content: '\\e807'; } /* '' */\n.buttonicon-pencil-alt:before { content: '\\e808'; } /* '' */\n.buttonicon-file-code:before { content: '\\e809'; } /* '' */\n.buttonicon-mail:before { content: '\\e80a'; } /* '' */\n.buttonicon-home:before { content: '\\e80b'; font-size: 20px } /* '' */\n.buttonicon-trash:before { content: '\\e80e'; } /* '' */\n.buttonicon-times:before { content: '\\e826'; } /* '' */\n.buttonicon-pause:before { content: '\\e829'; } /* '' */\n.buttonicon-list-ul:before { content: '\\e82a'; } /* '' */\n.buttonicon-step-backward:before { content: '\\e82b'; } /* '' */\n.buttonicon-step-forward:before { content: '\\e82c'; } /* '' */\n.buttonicon-comments:before { content: '\\e82d'; } /* '' */\n.buttonicon-heading:before { content: '\\e82e'; } /* '' */\n.buttonicon-brush:before { content: '\\e830'; } /* '' */\n.buttonicon-slideshare:before { content: '\\e831'; } /* '' */\n.buttonicon-tasks:before { content: '\\e832'; } /* '' */\n.buttonicon-superscript:before { content: '\\e833'; } /* '' */\n.buttonicon-subscript:before { content: '\\e834'; } /* '' */\n.buttonicon-users:before { content: '\\e835'; } /* '' */\n.buttonicon-gauge:before { content: '\\e836'; } /* '' */\n.buttonicon-exchange-alt:before { content: '\\e837'; } /* '' */\n.buttonicon-text-width:before { content: '\\e838'; } /* '' */\n.buttonicon-pencil:before { content: '\\e839'; } /* '' */\n.buttonicon-picture:before { content: '\\e83a'; } /* '' */\n.buttonicon-video:before { content: '\\e83b'; } /* '' */\n.buttonicon-video-slash:before { content: '\\e83c'; } /* '' */\n.buttonicon-microphone-alt:before { content: '\\e83d'; } /* '' */\n.buttonicon-microphone-alt-slash:before { content: '\\e83e'; } /* '' */\n.buttonicon-compress:before { content: '\\e83f'; } /* '' */\n.buttonicon-expand:before { content: '\\e840'; } /* '' */\n.buttonicon-eye-slash:before { content: '\\e843'; } /* '' */\n.buttonicon-list-ol:before { content: '\\e844'; } /* '' */\n.buttonicon-bold:before { content: '\\e845'; } /* '' */\n.buttonicon-underline:before { content: '\\e846'; } /* '' */\n.buttonicon-italic:before { content: '\\e847'; } /* '' */\n.buttonicon-strikethrough:before { content: '\\e848'; } /* '' */\n.buttonicon-indent:before { content: '\\e849'; } /* '' */\n.buttonicon-outdent:before { content: '\\e84a'; } /* '' */\n.buttonicon-undo-alt:before { content: '\\e84b'; } /* '' */\n.buttonicon-redo-alt:before { content: '\\e84c'; } /* '' */\n.buttonicon-link:before { content: '\\e84d'; } /* '' */\n.buttonicon-font:before { content: '\\e84e'; } /* '' */\n.buttonicon-comment-medical:before { content: '\\e84f'; } /* '' */\n.buttonicon-comment:before { content: '\\e850'; } /* '' */\n.buttonicon-cog:before { content: '\\e851'; } /* '' */\n.buttonicon-text-height:before { content: '\\e852'; } /* '' */\n.buttonicon-share-alt:before { content: '\\e853'; } /* '' */\n.buttonicon-code:before { content: '\\e854'; } /* '' */\n.buttonicon-history:before { content: '\\e855'; } /* '' */\n.buttonicon-star:before { content: '\\e856'; } /* '' */\n.buttonicon-file-import:before { content: '\\e857'; } /* '' */\n.buttonicon-file-download:before { content: '\\e858'; } /* '' */\n.buttonicon-file-pdf:before { content: '\\e859'; } /* '' */\n.buttonicon-file-word:before { content: '\\e85a'; } /* '' */\n.buttonicon-file-alt:before { content: '\\e85b'; } /* '' */\n.buttonicon-file:before { content: '\\e85c'; } /* '' */\n.buttonicon-file-powerpoint:before { content: '\\e85d'; } /* '' */\n.buttonicon-table:before { content: '\\f0ce'; } /* '' */\n/* END Of FONTELLO GENERATED CSS */\n\n.icon-spin:before {\n  -webkit-animation: spinAnimation 2s infinite linear;\n  animation: spinAnimation 2s infinite linear;\n  font-family: \"fontawesome-etherpad\";\n  font-size: 22px;\n  z-index: 150;\n  width: 22px;\n  height: 22px;\n}\n\n@-webkit-keyframes spinAnimation {\n  0% {\n    -webkit-transform: rotate(0deg);\n    transform: rotate(0deg);\n  }\n  100% {\n    -webkit-transform: rotate(359deg);\n    transform: rotate(359deg);\n  }\n}\n@keyframes spinAnimation {\n  0% {\n    -webkit-transform: rotate(0deg);\n    transform: rotate(0deg);\n  }\n  100% {\n    -webkit-transform: rotate(359deg);\n    transform: rotate(359deg);\n  }\n}\n"
  },
  {
    "path": "src/static/css/pad/layout.css",
    "content": "html, body {\n  width: 100%;\n  height: auto;\n  margin: 0;\n  padding: 0;\n}\n\n/* used in pad and timeslider */\nhtml.pad, html.pad body {\n  overflow: hidden;\n  height: 100%;\n}\nbody {\n  display: flex;\n  flex-direction: column;\n}\n#editbar {\n  height: auto;\n}\n#editorcontainerbox {\n  flex: 1 auto;\n  position: relative; /* for nested popup to use absolute positionning */\n  background-color: #eee;\n\n  /* For sticky chat */\n  display: flex;\n  flex-direction: row;\n  height: 0; /* strange bug some browser need this to be working ok */\n}\n#editorcontainerbox #editorcontainer {\n  display: flex; /* transfer flex properties to nested elements, here the iframe */\n  height: auto;\n  flex: 1 auto;\n}\n#editorcontainerbox #editorcontainer:not(.initialized) {\n  visibility: hidden;\n}\n#editorcontainerbox #editorcontainer iframe {\n  width: 100%;\n  height: auto;\n}\n#editorcontainerbox .sticky-container { /* container for #users, #chat, #toc (table of content) and so on... */\n  display: flex;\n  flex-direction: column;\n  width: 200px;\n  max-width: 40%;\n  flex-shrink: 0;\n}\n#editorcontainerbox .sticky-container:not(.stikyUsers):not(.stickyChat) {\n  width: 0; /* hide when the container is empty */\n}\n\n.mobile-layout #editorcontainerbox {\n  margin-bottom: 39px; /* Leave space for the bottom toolbar on mobile */\n}\n"
  },
  {
    "path": "src/static/css/pad/loadingbox.css",
    "content": "#editorloadingbox {\n  width: 100%;\n  z-index: 100;\n  position: absolute;\n}\n\n.editorloadingbox-message {\n  padding-top: 100px;\n  font-size: 2.5em;\n  color: #aaa;\n  text-align: center;\n}\n\n#editorloadingbox input{\n  padding:10px;\n}\n\n#editorloadingbox button{\n  padding:10px;\n}\n\n#permissionDenied, #noCookie {\n  display:none;\n}\n"
  },
  {
    "path": "src/static/css/pad/normalize.css",
    "content": "/*! normalize.css v3.0.2 | MIT License | git.io/normalize */\n\n/**\n * 1. Set default font family to sans-serif.\n * 2. Prevent iOS text size adjust after orientation change, without disabling\n *    user zoom.\n */\n\nhtml {\n  font-family: sans-serif; /* 1 */\n  -ms-text-size-adjust: 100%; /* 2 */\n  -webkit-text-size-adjust: 100%; /* 2 */\n}\n\nhtml {\n box-sizing: border-box;\n}\n*, *:before, *:after {\n box-sizing: border-box;\n}\n* {\n  margin: 0;\n  padding: 0;\n}\n\n/**\n * Remove default margin.\n */\n\nbody {\n  margin: 0;\n}\n\n/* HTML5 display definitions\n   ========================================================================== */\n\n/**\n * Correct `block` display not defined for any HTML5 element in IE 8/9.\n * Correct `block` display not defined for `details` or `summary` in IE 10/11\n * and Firefox.\n * Correct `block` display not defined for `main` in IE 11.\n */\n\narticle,\naside,\ndetails,\nfigcaption,\nfigure,\nfooter,\nheader,\nhgroup,\nmain,\nmenu,\nnav,\nsection,\nsummary {\n  display: block;\n}\n\n/**\n * 1. Correct `inline-block` display not defined in IE 8/9.\n * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.\n */\n\naudio,\ncanvas,\nprogress,\nvideo {\n  display: inline-block; /* 1 */\n  vertical-align: baseline; /* 2 */\n}\n\n/**\n * Prevent modern browsers from displaying `audio` without controls.\n * Remove excess height in iOS 5 devices.\n */\n\naudio:not([controls]) {\n  display: none;\n  height: 0;\n}\n\n/**\n * Address `[hidden]` styling not present in IE 8/9/10.\n * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22.\n */\n\n[hidden],\ntemplate {\n  display: none;\n}\n\n/* Links\n   ========================================================================== */\n\n/**\n * Remove the gray background color from active links in IE 10.\n */\n\na {\n  background-color: transparent;\n}\n\n/**\n * Improve readability when focused and also mouse hovered in all browsers.\n */\n\na:active,\na:hover {\n  outline: 0;\n}\n\n/* Text-level semantics\n   ========================================================================== */\n\n/**\n * Address styling not present in IE 8/9/10/11, Safari, and Chrome.\n */\n\nabbr[title] {\n  border-bottom: 1px dotted;\n}\n\n/**\n * Address style set to `bolder` in Firefox 4+, Safari, and Chrome.\n */\n\nb,\nstrong {\n  font-weight: bold;\n}\n\n/**\n * Address styling not present in Safari and Chrome.\n */\n\ndfn {\n  font-style: italic;\n}\n\n/**\n * Address styling not present in IE 8/9.\n */\n\nmark {\n  background: #ff0;\n  color: #000;\n}\n\n/**\n * Address inconsistent and variable font size in all browsers.\n */\n\nsmall {\n  font-size: 80%;\n}\n\n/**\n * Prevent `sub` and `sup` affecting `line-height` in all browsers.\n */\n\nsub,\nsup {\n  font-size: 75%;\n  line-height: 0;\n  position: relative;\n  vertical-align: baseline;\n}\n\nsup {\n  top: -0.5em;\n}\n\nsub {\n  bottom: -0.25em;\n}\n\n/* Embedded content\n   ========================================================================== */\n\n/**\n * Remove border when inside `a` element in IE 8/9/10.\n */\n\nimg {\n  border: 0;\n}\n\n/**\n * Correct overflow not hidden in IE 9/10/11.\n */\n\nsvg:not(:root) {\n  overflow: hidden;\n}\n\n/* Grouping content\n   ========================================================================== */\n\n/**\n * Address margin not present in IE 8/9 and Safari.\n */\n\nfigure {\n  margin: 1em 40px;\n}\n\n/**\n * Address differences between Firefox and other browsers.\n */\n\nhr {\n  -moz-box-sizing: content-box;\n  box-sizing: content-box;\n  height: 0;\n}\n\n/**\n * Contain overflow in all browsers.\n */\n\npre {\n  overflow: auto;\n}\n\n/**\n * Address odd `em`-unit font size rendering in all browsers.\n */\n\ncode,\nkbd,\npre,\nsamp {\n  font-family: monospace, monospace;\n  font-size: 1em;\n}\n\n/* Forms\n   ========================================================================== */\n\n/**\n * Known limitation: by default, Chrome and Safari on OS X allow very limited\n * styling of `select`, unless a `border` property is set.\n */\n\n/**\n * 1. Correct color not being inherited.\n *    Known issue: affects color of disabled elements.\n * 2. Correct font properties not being inherited.\n * 3. Address margins set differently in Firefox 4+, Safari, and Chrome.\n */\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n  color: inherit; /* 1 */\n  font: inherit; /* 2 */\n  margin: 0; /* 3 */\n}\n\n/**\n * Address `overflow` set to `hidden` in IE 8/9/10/11.\n */\n\nbutton {\n  overflow: visible;\n}\n\n/**\n * Address inconsistent `text-transform` inheritance for `button` and `select`.\n * All other form control elements do not inherit `text-transform` values.\n * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.\n * Correct `select` style inheritance in Firefox.\n */\n\nbutton,\nselect {\n  text-transform: none;\n}\n\n/**\n * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`\n *    and `video` controls.\n * 2. Correct inability to style clickable `input` types in iOS.\n * 3. Improve usability and consistency of cursor style between image-type\n *    `input` and others.\n */\n\n/* 1 */ html input[type=\"button\"],\nbutton,\ninput[type=\"reset\"],\ninput[type=\"submit\"] {\n  -webkit-appearance: button; /* 2 */\n  cursor: pointer; /* 3 */\n}\n\n/**\n * Re-set default cursor for disabled elements.\n */\n\nbutton[disabled],\nhtml input[disabled] {\n  cursor: default;\n}\n\n/**\n * Remove inner padding and border in Firefox 4+.\n */\n\nbutton::-moz-focus-inner,\ninput::-moz-focus-inner {\n  border: 0;\n  padding: 0;\n}\n\n/**\n * Address Firefox 4+ setting `line-height` on `input` using `!important` in\n * the UA stylesheet.\n */\n\ninput {\n  line-height: normal;\n}\n\n/**\n * It's recommended that you don't attempt to style these elements.\n * Firefox's implementation doesn't respect box-sizing, padding, or width.\n *\n * 1. Address box sizing set to `content-box` in IE 8/9/10.\n * 2. Remove excess padding in IE 8/9/10.\n */\n\ninput[type=\"checkbox\"],\ninput[type=\"radio\"] {\n  box-sizing: border-box; /* 1 */\n  padding: 0; /* 2 */\n}\n\n/**\n * Fix the cursor style for Chrome's increment/decrement buttons. For certain\n * `font-size` values of the `input`, it causes the cursor style of the\n * decrement button to change from `default` to `text`.\n */\n\ninput[type=\"number\"]::-webkit-inner-spin-button,\ninput[type=\"number\"]::-webkit-outer-spin-button {\n  height: auto;\n}\n\n/**\n * 1. Address `appearance` set to `searchfield` in Safari and Chrome.\n * 2. Address `box-sizing` set to `border-box` in Safari and Chrome\n *    (include `-moz` to future-proof).\n */\n\ninput[type=\"search\"] {\n  -webkit-appearance: textfield; /* 1 */\n  -moz-box-sizing: content-box;\n  -webkit-box-sizing: content-box; /* 2 */\n  box-sizing: content-box;\n}\n\n/**\n * Remove inner padding and search cancel button in Safari and Chrome on OS X.\n * Safari (but not Chrome) clips the cancel button when the search input has\n * padding (and `textfield` appearance).\n */\n\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-decoration {\n  -webkit-appearance: none;\n}\n\n/**\n * Define consistent border, margin, and padding.\n */\n\nfieldset {\n  border: 1px solid #c0c0c0;\n  margin: 0 2px;\n  padding: 0.35em 0.625em 0.75em;\n}\n\n/**\n * 1. Correct `color` not being inherited in IE 8/9/10/11.\n * 2. Remove padding so people aren't caught out if they zero out fieldsets.\n */\n\nlegend {\n  border: 0; /* 1 */\n  padding: 0; /* 2 */\n}\n\n/**\n * Remove default vertical scrollbar in IE 8/9/10/11.\n */\n\ntextarea {\n  overflow: auto;\n}\n\n/**\n * Don't inherit the `font-weight` (applied by a rule above).\n * NOTE: the default cannot safely be changed in Chrome and Safari on OS X.\n */\n\noptgroup {\n  font-weight: bold;\n}\n\n/* Tables\n   ========================================================================== */\n\n/**\n * Remove most spacing between table cells.\n */\n\ntable {\n  border-collapse: collapse;\n  border-spacing: 0;\n}\n\ntd,\nth {\n  padding: 0;\n}\n"
  },
  {
    "path": "src/static/css/pad/popup.css",
    "content": ".popup {\n  position: absolute;\n  top: 10px;\n  right: 30px;\n  /* visibility must transition immediately so that input elements inside the popup can get focus */\n  transition: all 0.3s cubic-bezier(0.74, -0.05, 0.27, 1.75), visibility 0s;\n  z-index: 500;\n}\n\n.popup:not(.popup-show):not(#users.chatAndUsers) {\n  opacity: 0;\n  transform: scale(0.7);\n  /* visibility must not change to hidden until the end of the transition */\n  transition: all 0.3s cubic-bezier(0.74, -0.05, 0.27, 1.75);\n  visibility: hidden;\n}\n\n#mycolorpicker {\n  top: 0;\n}\n.popup.toolbar-popup {\n  right: auto;\n  margin-left: -10px;\n}\n.popup-content {\n  padding: 10px;\n  border-radius: 6px;\n  border: 1px solid #ccc;\n  box-shadow: 0 2px 4px #ddd;\n  background: #f7f7f7;\n  min-width: 300px;\n  max-width: 600px;\n}\n.popup input[type=text] {\n  width: 100%;\n  padding: 5px;\n  display: block;\n  margin-top: 10px;\n}\n.popup input[type=text], #users input[type=text] {\n  outline: none;\n}\n.popup h1 {\n  font-size: 1.8rem;\n  margin-bottom: 10px;\n}\n.popup h2 {\n  opacity: .7;\n  margin: 10px 0;\n  font-size: 1.2rem;\n}\n.popup:not(.comment-modal) p {\n  margin: 5px 0;\n}\n\n/* Mobile devices */\n@media only screen and (max-width: 800px) {\n  .popup {\n    border-radius: 0;\n    top: 1rem;\n    margin: 0 !important;\n    right: 1rem !important;\n    left: 1rem !important;\n    max-width: none !important;\n  }\n  .popup-content {\n    max-height: 80vh;\n    overflow: auto;\n  }\n  .popup#users .popup-content {\n    overflow: visible;\n  }\n}\n/* Move popup to the bottom, except popup linked to left toolbar, like hyperklink popup */\n.mobile-layout .popup:not(.toolbar-popup) {\n  top: auto;\n  left: 1rem;\n  right: auto;\n  bottom: 1rem;\n}\n"
  },
  {
    "path": "src/static/css/pad/popup_connectivity.css",
    "content": "#connectivity .popup-content * {\n  display: none;\n}\n\n#connectivity .visible,\n#connectivity .visible * {\n  display: block;\n}\n\n/* styles for the automatic reconnection timer: */\n#connectivity .visible.with_reconnect_timer button,\n#connectivity .visible.with_reconnect_timer .reconnecttimer * {\n  display: inline-block;\n}\n\n#connectivity .with_reconnect_timer .hidden,\n#connectivity .with_reconnect_timer #defaulttext.hidden,\n#connectivity .with_reconnect_timer button.hidden {\n  display: none;\n}\n\n#connectivity .with_reconnect_timer #cancelreconnect {\n  margin-left: 10px;\n}"
  },
  {
    "path": "src/static/css/pad/popup_import_export.css",
    "content": ".readonly .acl-write {\n  display: none;\n}\n.exportlink {\n  margin-bottom: 10px;\n  display: block;\n}\n.exporttype:before {\n  margin-right: 10px !important;\n}\n\n/* hidden element */\n#importstatusball,\n#importmessagesuccess,\n#importmessageabiword {\n  display: none;\n}\n\n#importmessageabiword {\n  color: #900;\n  font-size: small;\n}\n\n#importsubmitinput {\n  margin-top: 10px;\n}"
  },
  {
    "path": "src/static/css/pad/popup_users.css",
    "content": "/* --------------- */\n/* --- LAYOUT ---- */\n/* --------------- */\n\n.popup#users {\n  flex-direction: column;\n  max-height: 500px;\n  height: auto;\n}\n.popup#users #myuser {\n  display: flex;\n  flex-shrink: 0;\n}\n.popup#users #otherusers {\n  flex: 1 auto;\n  overflow: auto;\n  max-height: 200px;\n}\n\n.popup#users.chatAndUsers {\n  display: flex !important; /* always visible */\n  position: relative;\n  z-index: 1;\n  top: 0;\n  right: 0;\n  left: auto;\n}\n.popup#users.chatAndUsers > .popup-content {\n  border: none;\n  border-bottom: 1px solid #ccc;\n  border-left: 1px solid #ccc;\n  border-right: 0;\n  border-radius: 0;\n  box-shadow: none;\n  height: 200px;\n  min-width: 0;\n  padding: 5px;\n}\n\n\n/* --------------- */\n/* --- MY USER --- */\n/* --------------- */\n\n#myswatchbox {\n  width: 24px;\n  height: 24px;\n  border: 1px solid #ccc;\n  background: transparent;\n  cursor: pointer;\n  flex-shrink: 0;\n}\n#myswatch {\n  width: 100%;\n  height: 100%;\n  background: transparent; /*...initially*/\n}\n\n#myusernameform {\n  margin-left: 10px;\n}\ninput#myusernameedit {\n  height: 26px;\n  font-size: 1.3em;\n  padding: 3px;\n  border: 1px solid #ccc;\n  background-color: transparent;\n  margin: 0;\n}\ninput#myusernameedit:not(.editable) {\n  color: grey;\n}\n#myuser .myusernameedithoverable:hover {\n  background: white;\n}\n#myusernameform .editactive,\n#myusernameform .editempty {\n  background: white;\n  border-left: 1px solid #c3c3c3;\n  border-top: 1px solid #c3c3c3;\n  border-right: 1px solid #e6e6e6;\n  border-bottom: 1px solid #e6e6e6;\n}\n\n#myusernameedit, #otheruserstable .swatch {\n  border: 1px solid #ccc;\n}\n#myusernameform .editempty {\n  opacity: .8;\n}\n\n\n/* --------------------------- */\n/* --- MY USER COLORPICKER --- */\n/* --------------------------- */\n\n#mycolorpicker.popup {\n  min-width: 0;\n  right: calc(100% + 15px);\n  z-index: 101;\n}\n.mobile-layout #users.popup {\n  right: 1rem;\n  left: auto;\n}\n.mobile-layout #mycolorpicker.popup {\n  top: auto;\n  bottom: 0;\n  left: auto !important;\n  right: 0 !important;\n}\n#mycolorpicker.popup .btn-container {\n  margin-top: 10px;\n}\n#mycolorpickerpreview {\n  width: 24px;\n  height: 24px;\n  border-radius: 5px;\n  float: right;\n}\n\n/* ------------------- */\n/* --- OTHER USERS --- */\n/* ------------------- */\n#otheruserstable {\n  display: none;\n}\n#otheruserstable td {\n  height: 26px;\n  padding: 0 2px;\n}\n#otheruserstable .swatch {\n  border: 1px solid #ccc;\n  width: 13px;\n  height: 13px;\n  overflow: hidden;\n  margin: 0 4px;\n  user-select: none;\n}\n.usertdname {\n  font-size: 1.2rem;\n}\n"
  },
  {
    "path": "src/static/css/pad/toolbar.css",
    "content": ".toolbar {\n  display: none;\n  position: relative;\n  background-color: #f4f4f4;\n  color: #666;\n  border-bottom: 1px solid #ccc;\n  overflow: hidden;\n  justify-content: space-between;\n  padding: 0px 5px 5px 5px;\n  flex-shrink: 0;\n}\n.toolbar ul {\n  list-style: none;\n  z-index: 2;\n  overflow: hidden;\n  margin: 0;\n  display: flex;\n  flex-direction: row;\n  align-items: flex-start;\n}\n.toolbar ul.menu_right {\n  flex-shrink: 0; /* prevent from shrinking */\n}\n.toolbar ul li, .toolbar ul > div {\n  display: flex; /* transfer flexbox positionning to children */\n}\n.toolbar ul li {\n  margin-top: 5px; /* when icons goes multi rows, have space betwwen each row */\n}\n.toolbar ul li.separator {\n  visibility: hidden;\n  width: 10px;\n}\n.toolbar ul li a {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  min-width: 28px;\n  height: 28px;\n  border: 1px solid #ccc;\n  border-radius: 3px;\n  background: #fff;\n  cursor: pointer;\n  overflow: hidden;\n  text-decoration: none;\n  transition: background-color .1s;\n}\n.toolbar ul li a.pressed button:active, .toolbar ul li a.pressed button:focus {\n  outline: 0;\n  border: none;\n}\n.toolbar ul li a:hover {\n  text-decoration: none;\n  background-color: #f2f2f2;\n}\n.toolbar ul li a:active, .toolbar ul li a:focus {\n  background: #ddd;\n}\n.toolbar ul li a.selected {\n  background: #dadada;\n}\n\n.toolbar ul li a.grouped-left {\n  border-radius: 3px 0 0 3px;\n}\n.toolbar ul li a.grouped-middle {\n  border-radius: 0;\n  border-left: 0;\n}\n.toolbar ul li a.grouped-right {\n  border-radius: 0 3px 3px 0;\n  border-left: 0;\n}\n\n.toolbar ul li[data-key=showusers] > a {\n  min-width: 35px;\n}\n.toolbar ul li[data-key=showusers] > a .buttonicon-showusers {\n  padding-left: 3px;\n}\n.toolbar ul li[data-key=showusers] > a #online_count {\n  font-weight: bold;\n  font-size: 11px;\n  position: relative;\n  padding-left: 7px;\n}\n\n.toolbar #toolbar-overlay {\n  z-index: 500;\n  display: none;\n  width: 100%;\n  position: absolute;\n  height: inherit;\n  left: 0;\n  top: 0;\n  bottom: 0;\n  right: 0;\n}\n\n.toolbar .show-more-icon-btn {\n  display:none;\n  cursor: pointer;\n  height: 39px;\n  width: 39px;\n  line-height: 39px;\n  text-align: center;\n  font-weight: bold;\n  font-size: 2rem;\n  z-index: 20;\n}\n\n.toolbar.cropped .menu_left {\n  width: calc(100% - 39px);\n  height: 33px;\n  flex-wrap: wrap;\n}\n.toolbar.cropped .show-more-icon-btn {\n  display: block;\n  position: absolute;\n  /*border-bottom: 1px solid #d2d2d2;*/\n  right: 0;\n  top: 0;\n}\n.toolbar.cropped .show-more-icon-btn:after {\n  content: \"+\";\n}\n.toolbar.full-icons .show-more-icon-btn {\n  line-height: 35px;\n}\n.toolbar.full-icons .show-more-icon-btn:after {\n  content: \"-\";\n}\n.toolbar.full-icons .menu_left {\n  height: auto !important;\n  overflow: visible;\n}\n\n@media only screen and (max-width: 800px) {\n  .toolbar ul li.separator {\n    width: 5px;\n  }\n}\n\n/* menu_right act like a new toolbar on the bottom of the screen */\n.mobile-layout .toolbar .menu_right {\n  position: fixed;\n  bottom: 0;\n  right: 0;\n  left: 0;\n  border-top: 1px solid #ccc;\n  background-color: #f4f4f4;\n  padding: 0 5px 5px 5px;\n}\n.mobile-layout .toolbar ul.menu_right > li {\n  margin-right: 8px;\n}\n.mobile-layout .toolbar ul.menu_right > li[data-key=\"showusers\"] {\n  position: absolute;\n  right: 0;\n  top: 0;\n  bottom: 0;\n  margin: 0;\n}\n.mobile-layout .toolbar ul.menu_right > li[data-key=\"showusers\"] a {\n  height: 100%;\n  width: 40px;\n  border-radius: 0;\n}\n.mobile-layout .toolbar ul.menu_right > li.separator {\n  display: none;\n}\n.mobile-layout .toolbar ul.menu_right > li a {\n  border: none;\n  margin-left: 5px;\n}\n.mobile-layout .toolbar ul.menu_right > li a:not(.selected) {\n  background-color: transparent;\n}\n"
  },
  {
    "path": "src/static/css/pad.css",
    "content": "@import url(\"pad/normalize.css\");\n\n@import url(\"pad/layout.css\");\n@import url(\"pad/fonts.css\");\n@import url(\"pad/toolbar.css\");\n@import url(\"pad/popup.css\");\n@import url(\"pad/popup_connectivity.css\");\n@import url(\"pad/popup_import_export.css\");\n@import url(\"pad/popup_users.css\");\n@import url(\"pad/icons.css\");\n@import url(\"pad/chat.css\");\n@import url(\"pad/gritter.css\");\n@import url(\"pad/loadingbox.css\");\n@import url(\"pad/form.css\");\n\nhtml {\n  font-size: 15px;\n  color: #3e3e3e;\n}\n\nhtml,\n#sidedivinner > div:before {\n  font-family: Roboto;\n}\n\n.clear {\n  clear: both\n}\na {\n  color: inherit;\n  overflow-x: auto;\n  white-space: nowrap;\n}\na img {\n  border: 0\n}\n\n.thin-scrollbar::-webkit-scrollbar-track {\n  background-color: #f6f6f6;\n  border: 1px solid #f0f0f0;\n}\n.thin-scrollbar::-webkit-scrollbar {\n  width: 7px;\n}\n.thin-scrollbar::-webkit-scrollbar-thumb {\n  background-color: #C5C5C5;\n}\n\n.buttontext::-moz-focus-inner {\n  padding: 0;\n  border: 0;\n}\n.buttontext:focus{\n  /* Not sure why important is required here but it is */\n  border: 1px solid #666 !important;\n}\n.rtl {\n  direction: RTL\n}\n\n/* fix for misaligned checkboxes */\ninput[type=checkbox] {\n  vertical-align: -1px\n}\ninput {\n  color: inherit;\n}\n.right {\n  float: right\n}\n\n@media (max-width: 800px) {\n  .hide-for-mobile { display: none; }\n}\n\n.etherpadBrand{\n  width:20%;\n  max-width:100px;\n  margin-left:auto;\n  margin-right:auto;\n}\n"
  },
  {
    "path": "src/static/css/timeslider.css",
    "content": "#editbar {\n  padding: 10px;\n  display: block;\n}\n\n.timeslider-bar {\n  display: flex;\n  flex-direction: row;\n}\n\n/* TITLE */\n\n.timeslider-title-container {\n  flex: 1 auto;\n}\n.timeslider-title {\n  font-size: 1.8rem !important;\n}\n.timeslider-subtitle {\n  margin-top: 10px;\n}\n\n/* RIGHT TOOLBAR (export, settings, back to pad) */\n.editbarright {\n  flex-shrink: 0; /* prevent the back to pad button to shrink */\n  margin-top: -10px;\n}\n.editbarright ul li a {\n  background-color: transparent;\n  border: none;\n  margin-left: 10px\n}\n\n\n/*  SLIDER  */\n\n#timeslider-wrapper {\n  display: flex;\n  flex-direction: row;\n  height: 40px;\n}\n\n#timeslider-slider {\n  flex: 1 auto;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  cursor: pointer;\n  position: relative;\n}\n\n#timeslider-slider #timer {\n  position: absolute;\n  top: -10px;\n  right: -10px;\n}\n\n#timeslider-slider #ui-slider-bar {\n  height: 10px;\n  width: 100%;\n  background-color: #c2c2c2;\n  position: relative;\n}\n\n#timeslider-slider #ui-slider-handle {\n  height: 40px;\n  width: 10px;\n  z-index: 2;\n  position: absolute;\n  background-color: #3e3e3e;\n  cursor: move;\n}\n\n#timeslider-slider .star {\n  cursor: pointer;\n  position: absolute;\n  top: -8px\n}\n#timeslider-slider .star:before{\n  font-family: fontawesome-etherpad;\n  color: #da9700;\n  content: \"\\e856\";\n  vertical-align:middle;\n  font-size:16px;\n  margin-left: -2px;\n}\n\n/* BUTTONS TO MOVE SLIDER (Play/Pause, Next, Prev)*/\n\n#slider-btn-container {\n  margin-left: 15px;\n  display: flex;\n  align-items: center;\n}\n#slider-btn-container button {\n  border: 1px solid #666;\n  border-radius: 50%;\n  width: 30px;\n  height: 30px;\n  margin-left: 5px;\n}\n#slider-btn-container #playpause_button_icon {\n  width: 40px;\n  height: 40px;\n  padding-left: 2px;\n}\n#slider-btn-container #playpause_button_icon:before {\n  font-size: 22px;\n}\n#slider-btn-container #playpause_button_icon.pause:before {\n  content: \"\\e829\";\n  padding-left: 0;\n  padding-right: 2px;\n}\n\n\n/* PAD CONTENT */\n\n#editorcontainerbox {\n  overflow-y: auto;\n}\n#outerdocbody {\n  display: block;\n  width: 100%;\n}\n\n#innerdocbody {\n  white-space: normal;\n  word-break: break-word;\n  width: 100%;\n  margin: 0 auto;\n  height: auto;\n}\n\n@media (max-width: 800px) {\n  #timeslider-slider #timer { display: none; }\n\n  .editbarright [data-key=\"timeslider_returnToPad\"] {\n    position: absolute;\n    right: 10px;\n    top: 0;\n  }\n  .timeslider-title {\n    font-size: 1.5rem !important;\n  }\n  .timeslider-title-container {\n    width: 100%;\n  }\n  .authors-label { display: none; }\n  #authorsList {\n    display: flex;\n    flex-wrap: wrap;\n  }\n}"
  },
  {
    "path": "src/static/empty.html",
    "content": "<!DOCTYPE html><html><head><title>Empty</title></head><body></body></html>\n"
  },
  {
    "path": "src/static/font/config.json",
    "content": "{\n  \"name\": \"fontawesome-etherpad\",\n  \"css_prefix_text\": \"buttonicon-\",\n  \"css_use_suffix\": false,\n  \"hinting\": true,\n  \"units_per_em\": 1000,\n  \"ascent\": 850,\n  \"glyphs\": [\n    {\n      \"uid\": \"bf882b30900da12fca090d9796bc3030\",\n      \"css\": \"mail\",\n      \"code\": 59402,\n      \"src\": \"fontawesome\"\n    },\n    {\n      \"uid\": \"7277ded7695b2a307a5f9d50097bb64c\",\n      \"css\": \"print\",\n      \"code\": 59393,\n      \"src\": \"fontawesome\"\n    },\n    {\n      \"uid\": \"9396b2d8849e0213a0f11c5fd7fcc522\",\n      \"css\": \"tasks\",\n      \"code\": 59442,\n      \"src\": \"fontawesome\"\n    },\n    {\n      \"uid\": \"fa9a0b7e788c2d78e24cef1de6b70e80\",\n      \"css\": \"brush\",\n      \"code\": 59440,\n      \"src\": \"fontawesome\"\n    },\n    {\n      \"uid\": \"be13b8c668eb18839d5d53107725f1de\",\n      \"css\": \"slideshare\",\n      \"code\": 59441,\n      \"src\": \"fontawesome\"\n    },\n    {\n      \"uid\": \"d35a1d35efeb784d1dc9ac18b9b6c2b6\",\n      \"css\": \"pencil\",\n      \"code\": 59449,\n      \"src\": \"fontawesome\"\n    },\n    {\n      \"uid\": \"8fb55fd696d9a0f58f3b27c1d8633750\",\n      \"css\": \"table\",\n      \"code\": 61646,\n      \"src\": \"fontawesome\"\n    },\n    {\n      \"uid\": \"1569a5b2bebe7e28bb0d26ddeca34fc8\",\n      \"css\": \"video\",\n      \"code\": 59451,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M656.6 125H93.4C41.8 125 0 166.8 0 218.4V781.6C0 833.2 41.8 875 93.4 875H656.6C708.2 875 750 833.2 750 781.6V218.4C750 166.8 708.2 125 656.6 125ZM1026.6 198.6L812.5 346.3V653.7L1026.6 801.2C1068 829.7 1125 800.6 1125 750.8V249C1125 199.4 1068.2 170.1 1026.6 198.6Z\",\n        \"width\": 1125\n      },\n      \"search\": [\n        \"video\"\n      ]\n    },\n    {\n      \"uid\": \"8fe2c571b78d019e24cab0b780cb61d6\",\n      \"css\": \"video-slash\",\n      \"code\": 59452,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M1237.9 894.7L1130.5 811.7C1160.5 809 1187.5 785 1187.5 751V249C1187.5 199.2 1130.7 170.1 1089.1 198.6L875 346.3V614.3L812.5 566V218.4C812.5 166.8 770.7 125 719.1 125H242L88.9 6.6C75.2-3.9 55.7-1.6 44.9 12.1L6.6 61.3C-3.9 75-1.6 94.5 12.1 105.1L83.4 160.2 812.5 723.8 1161.1 993.4C1174.8 1003.9 1194.3 1001.6 1205.1 987.9L1243.4 938.5C1254.1 925 1251.6 905.3 1237.9 894.7ZM62.5 781.6C62.5 833.2 104.3 875 155.9 875H719.1C741 875 760.9 867.2 777 854.5L62.5 302.1V781.6Z\",\n        \"width\": 1250\n      },\n      \"search\": [\n        \"video-slash\"\n      ]\n    },\n    {\n      \"uid\": \"d8020fccc088a524f7cc6db1f329cb3e\",\n      \"css\": \"microphone-alt\",\n      \"code\": 59453,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M656.3 375H625C607.7 375 593.8 389 593.8 406.3V500C593.8 646.1 467.8 763.3 318.8 748.8 188.9 736.1 93.8 619.4 93.8 488.9V406.3C93.8 389 79.8 375 62.5 375H31.3C14 375 0 389 0 406.3V484.7C0 659.8 124.9 815.8 296.9 839.6V906.3H187.5C170.2 906.3 156.3 920.2 156.3 937.5V968.8C156.3 986 170.2 1000 187.5 1000H500C517.3 1000 531.3 986 531.3 968.8V937.5C531.3 920.2 517.3 906.3 500 906.3H390.6V840.3C558 817.3 687.5 673.6 687.5 500V406.3C687.5 389 673.5 375 656.3 375ZM343.8 687.5C447.3 687.5 531.3 603.6 531.3 500H364.6C353.1 500 343.8 493 343.8 484.4V453.1C343.8 444.5 353.1 437.5 364.6 437.5H531.3V375H364.6C353.1 375 343.8 368 343.8 359.4V328.1C343.8 319.5 353.1 312.5 364.6 312.5H531.3V250H364.6C353.1 250 343.8 243 343.8 234.4V203.1C343.8 194.5 353.1 187.5 364.6 187.5H531.3C531.3 83.9 447.3 0 343.8 0S156.3 83.9 156.3 187.5V500C156.3 603.6 240.2 687.5 343.8 687.5Z\",\n        \"width\": 688\n      },\n      \"search\": [\n        \"microphone-alt\"\n      ]\n    },\n    {\n      \"uid\": \"7d9dd931e0e6305cc5eed55efa435d7c\",\n      \"css\": \"microphone-alt-slash\",\n      \"code\": 59454,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M1237.9 894.7L930.2 656.9C954.6 609.8 968.8 556.6 968.8 500V406.3C968.8 389 954.8 375 937.5 375H906.3C889 375 875 389 875 406.3V500C875 535 867.3 568 854.1 598L802.2 558C808.3 539.6 812.5 520.4 812.5 500H727.2L646.4 437.5H812.5V375H645.8C634.3 375 625 368 625 359.4V328.1C625 319.5 634.3 312.5 645.8 312.5H812.5V250H645.8C634.3 250 625 243 625 234.4V203.1C625 194.5 634.3 187.5 645.8 187.5H812.5C812.5 84 728.6 0 625 0S437.5 84 437.5 187.5V276.1L88.8 6.6C75.2-4 55.5-1.6 44.9 12.1L6.6 61.4C-4 75-1.6 94.7 12.1 105.3L1161.2 993.4C1174.8 1004 1194.5 1001.6 1205.1 987.9L1243.4 938.6C1254 925 1251.6 905.3 1237.9 894.7ZM781.3 906.3H671.9V840.3C694.7 837.1 717 831.9 738.2 824.5L639.8 748.4C626.7 749.2 613.6 750.1 600 748.8 490.9 738.1 407.2 653.8 382.9 549.9L281.3 471.3V484.7C281.3 659.8 406.2 815.8 578.1 839.6V906.3H468.8C451.5 906.3 437.5 920.2 437.5 937.5V968.8C437.5 986 451.5 1000 468.8 1000H781.3C798.5 1000 812.5 986 812.5 968.8V937.5C812.5 920.2 798.5 906.3 781.3 906.3Z\",\n        \"width\": 1250\n      },\n      \"search\": [\n        \"microphone-alt-slash\"\n      ]\n    },\n    {\n      \"uid\": \"63aa8ba99d3f31973dd2ef65274a03bd\",\n      \"css\": \"compress\",\n      \"code\": 59455,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M851.6 375H609.4C583.4 375 562.5 354.1 562.5 328.1V85.9C562.5 73 573 62.5 585.9 62.5H664.1C677 62.5 687.5 73 687.5 85.9V250H851.6C864.5 250 875 260.5 875 273.4V351.6C875 364.5 864.5 375 851.6 375ZM312.5 328.1V85.9C312.5 73 302 62.5 289.1 62.5H210.9C198 62.5 187.5 73 187.5 85.9V250H23.4C10.5 250 0 260.5 0 273.4V351.6C0 364.5 10.5 375 23.4 375H265.6C291.6 375 312.5 354.1 312.5 328.1ZM312.5 914.1V671.9C312.5 645.9 291.6 625 265.6 625H23.4C10.5 625 0 635.5 0 648.4V726.6C0 739.5 10.5 750 23.4 750H187.5V914.1C187.5 927 198 937.5 210.9 937.5H289.1C302 937.5 312.5 927 312.5 914.1ZM687.5 914.1V750H851.6C864.5 750 875 739.5 875 726.6V648.4C875 635.5 864.5 625 851.6 625H609.4C583.4 625 562.5 645.9 562.5 671.9V914.1C562.5 927 573 937.5 585.9 937.5H664.1C677 937.5 687.5 927 687.5 914.1Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"compress\"\n      ]\n    },\n    {\n      \"uid\": \"d71c270fcbdffa89ee7b646e9d5a2667\",\n      \"css\": \"expand\",\n      \"code\": 59456,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M0 351.6V109.4C0 83.4 20.9 62.5 46.9 62.5H289.1C302 62.5 312.5 73 312.5 85.9V164.1C312.5 177 302 187.5 289.1 187.5H125V351.6C125 364.5 114.5 375 101.6 375H23.4C10.5 375 0 364.5 0 351.6ZM562.5 85.9V164.1C562.5 177 573 187.5 585.9 187.5H750V351.6C750 364.5 760.5 375 773.4 375H851.6C864.5 375 875 364.5 875 351.6V109.4C875 83.4 854.1 62.5 828.1 62.5H585.9C573 62.5 562.5 73 562.5 85.9ZM851.6 625H773.4C760.5 625 750 635.5 750 648.4V812.5H585.9C573 812.5 562.5 823 562.5 835.9V914.1C562.5 927 573 937.5 585.9 937.5H828.1C854.1 937.5 875 916.6 875 890.6V648.4C875 635.5 864.5 625 851.6 625ZM312.5 914.1V835.9C312.5 823 302 812.5 289.1 812.5H125V648.4C125 635.5 114.5 625 101.6 625H23.4C10.5 625 0 635.5 0 648.4V890.6C0 916.6 20.9 937.5 46.9 937.5H289.1C302 937.5 312.5 927 312.5 914.1Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"expand\"\n      ]\n    },\n    {\n      \"uid\": \"5d2d07f112b8de19f2c0dbfec3e42c05\",\n      \"css\": \"spin5\",\n      \"code\": 59457,\n      \"src\": \"fontelico\"\n    },\n    {\n      \"uid\": \"54cecf7a3401a3458fe7ea001e622d39\",\n      \"css\": \"trash\",\n      \"code\": 59406,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M843.8 62.5H609.4L591 26A46.9 46.9 0 0 0 549 0H325.8A46.3 46.3 0 0 0 284 26L265.6 62.5H31.3A31.3 31.3 0 0 0 0 93.8V156.3A31.3 31.3 0 0 0 31.3 187.5H843.8A31.3 31.3 0 0 0 875 156.3V93.8A31.3 31.3 0 0 0 843.8 62.5ZM103.9 912.1A93.8 93.8 0 0 0 197.5 1000H677.5A93.8 93.8 0 0 0 771.1 912.1L812.5 250H62.5Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"trash\"\n      ]\n    },\n    {\n      \"uid\": \"f99ec3e571ced9cd747e2b34d8c03436\",\n      \"css\": \"list-ul\",\n      \"code\": 59434,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M187.5 187.5C187.5 239.3 145.5 281.3 93.8 281.3S0 239.3 0 187.5 42 93.8 93.8 93.8 187.5 135.7 187.5 187.5ZM93.8 406.3C42 406.3 0 448.2 0 500S42 593.8 93.8 593.8 187.5 551.8 187.5 500 145.5 406.3 93.8 406.3ZM93.8 718.8C42 718.8 0 760.7 0 812.5S42 906.3 93.8 906.3 187.5 864.3 187.5 812.5 145.5 718.8 93.8 718.8ZM281.3 257.8H968.8C986 257.8 1000 243.8 1000 226.6V148.4C1000 131.2 986 117.2 968.8 117.2H281.3C264 117.2 250 131.2 250 148.4V226.6C250 243.8 264 257.8 281.3 257.8ZM281.3 570.3H968.8C986 570.3 1000 556.3 1000 539.1V460.9C1000 443.7 986 429.7 968.8 429.7H281.3C264 429.7 250 443.7 250 460.9V539.1C250 556.3 264 570.3 281.3 570.3ZM281.3 882.8H968.8C986 882.8 1000 868.8 1000 851.6V773.4C1000 756.2 986 742.2 968.8 742.2H281.3C264 742.2 250 756.2 250 773.4V851.6C250 868.8 264 882.8 281.3 882.8Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"list-ul\"\n      ]\n    },\n    {\n      \"uid\": \"d921283a409a4e9a51ff1632b200c23d\",\n      \"css\": \"eye-slash\",\n      \"code\": 59459,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M625 781.3C476.9 781.3 356.9 666.6 345.9 521.3L141 362.9C114.1 396.7 89.3 432.4 69.3 471.5A63.2 63.2 0 0 0 69.3 528.5C175.2 735.2 384.9 875 625 875 677.6 875 728.3 867.2 777.1 854.6L675.8 776.2A281.5 281.5 0 0 1 625 781.3ZM1237.9 894.7L1022 727.9A647 647 0 0 0 1180.7 528.5 63.2 63.2 0 0 0 1180.7 471.5C1074.8 264.8 865.1 125 625 125A601.9 601.9 0 0 0 337.3 198.6L88.8 6.6A31.3 31.3 0 0 0 44.9 12.1L6.6 61.4A31.3 31.3 0 0 0 12.1 105.3L1161.2 993.4A31.3 31.3 0 0 0 1205.1 987.9L1243.4 938.6A31.3 31.3 0 0 0 1237.9 894.7ZM879.1 617.4L802.3 558A185.1 185.1 0 0 0 812.5 500 185.1 185.1 0 0 0 575.6 319.9 93.1 93.1 0 0 1 593.8 375 91.1 91.1 0 0 1 590.7 394.5L447 283.4A277.9 277.9 0 0 1 625 218.8 281.1 281.1 0 0 1 906.3 500C906.3 542.2 895.9 581.6 879.1 617.4Z\",\n        \"width\": 1250\n      },\n      \"search\": [\n        \"eye-slash\"\n      ]\n    },\n    {\n      \"uid\": \"9f79bb02a62542500d6396747bfbdad5\",\n      \"css\": \"list-ol\",\n      \"code\": 59460,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M6.4 272.5C6.4 257.9 14 249.9 29 249.9H58.5V172C58.5 161.9 59.6 151.5 59.6 151.5H58.9S55.4 156.7 53.3 158.8C44.6 167.2 32.8 167.5 22.7 156.7L11.9 144.6C1.5 134.1 2.2 122.7 13 112.6L55.4 73.6C64.1 65.6 71.7 62.5 83.6 62.5H107.2C122.2 62.5 130.2 70.1 130.2 85.1V249.9H160.4C175.4 249.9 183 257.9 183 272.5V289.9C183 304.5 175.4 312.5 160.4 312.5H29C14 312.5 6.4 304.5 6.4 289.9V272.5ZM4.3 594.9C4.3 502.6 103.8 484.8 103.8 459.8 103.8 445.8 92.2 442.7 85.7 442.7 79.6 442.7 73.1 444.8 67.2 450.2 57.3 459.8 46.7 463.9 35.8 455L19 441.7C7.7 432.8 5 422.5 13.6 411.6 26.5 394.5 50.8 375 92.6 375 130.5 375 179.4 395.5 179.4 452.3 179.4 527.2 88.1 542.9 84.3 563.4H160.6C175.3 563.4 183.2 571.3 183.2 585.7V602.8C183.2 617.1 175.3 625 160.6 625H27.9C14.2 625 4.3 617.1 4.3 602.8V594.9ZM11 887.9L22 869.8C29.5 856.8 39.8 856.1 52.4 863.6 62 867.7 71.2 869.8 80.5 869.8 100.3 869.8 108.5 862.9 108.5 853.7 108.5 840.7 97.6 835.9 77.4 835.9H68.2C56.5 835.9 50 831.8 44.2 820.5L42.2 816.8C37.4 807.5 39.8 797.6 47.6 787.7L58.6 774C71.9 757.6 82.5 747.7 82.5 747.7V747S74.3 749.1 57.9 749.1H32.6C17.9 749.1 10.4 741.2 10.4 726.8V709.7C10.4 695 17.9 687.5 32.6 687.5H146.8C161.5 687.5 169 695.4 169 709.7V716.2C169 727.5 166.3 735.4 159.1 743.9L124.9 783.3C163.2 793.2 181 823.3 181 851.3 181 893 153 937.5 86.3 937.5 53.8 937.5 31.2 928.3 16.2 919 4.9 910.8 3.9 899.9 11 887.9ZM281.3 257.8H968.8C986 257.8 1000 243.8 1000 226.6V148.4C1000 131.2 986 117.2 968.8 117.2H281.3C264 117.2 250 131.2 250 148.4V226.6C250 243.8 264 257.8 281.3 257.8ZM281.3 570.3H968.8C986 570.3 1000 556.3 1000 539.1V460.9C1000 443.7 986 429.7 968.8 429.7H281.3C264 429.7 250 443.7 250 460.9V539.1C250 556.3 264 570.3 281.3 570.3ZM281.3 882.8H968.8C986 882.8 1000 868.8 1000 851.6V773.4C1000 756.2 986 742.2 968.8 742.2H281.3C264 742.2 250 756.2 250 773.4V851.6C250 868.8 264 882.8 281.3 882.8Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"list-ol\"\n      ]\n    },\n    {\n      \"uid\": \"216f7d72d19fbfc4e319fe70240dc9fe\",\n      \"css\": \"bold\",\n      \"code\": 59461,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M595.3 476.3C661 440.1 700.1 370.6 700.1 289.4 700.1 195.2 648.8 118.3 566.1 86 517.8 66.4 470.4 62.5 409.5 62.5H46.9C29.6 62.5 15.6 76.5 15.6 93.8V158.3C15.6 175.6 29.6 189.5 46.9 189.5H111.5V811.7H46.9C29.6 811.7 15.6 825.7 15.6 842.9V906.3C15.6 923.5 29.6 937.5 46.9 937.5H429.1C476.4 937.5 516.6 935 559.7 922.7 659.2 893 734.4 802 734.4 683.6 734.4 581.7 682.5 504.6 595.3 476.3ZM277.8 196.9H409.5C441.3 196.9 463.3 200.8 482.8 210 513.7 226.6 531.4 261.8 531.4 306.6 531.4 375 491.7 417.5 427.9 417.5H277.8V196.9ZM497.8 793.5C478 801.4 453.5 803.1 436.4 803.1H277.8V550.7H442.5C520 550.7 565.7 600.2 565.7 673.8 565.7 729.3 539 776.3 497.8 793.5Z\",\n        \"width\": 750\n      },\n      \"search\": [\n        \"bold\"\n      ]\n    },\n    {\n      \"uid\": \"0dbd89c5def7ede2cbbe99ef8effcbda\",\n      \"css\": \"underline\",\n      \"code\": 59462,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M438 758.3C259 758.3 132.2 658.3 132.2 462.6V125H76.9C59.6 125 45.6 111 45.6 93.8V31.3C45.6 14 59.6 0 76.9 0H345.2C362.5 0 376.5 14 376.5 31.3V93.8C376.5 111 362.5 125 345.2 125H289V462.6C289 567.5 344.3 617.8 438 617.8 529.7 617.8 586.1 568.1 586.1 461.6V125H530.8C513.5 125 499.5 111 499.5 93.8V31.3C499.5 14 513.5 0 530.8 0H798.1C815.4 0 829.4 14 829.4 31.3V93.8C829.4 111 815.4 125 798.1 125H742.9V462.6C742.9 656.7 616.1 758.3 438 758.3ZM31.3 875H843.8C861 875 875 889 875 906.3V968.8C875 986 861 1000 843.8 1000H31.3C14 1000 0 986 0 968.8V906.3C0 889 14 875 31.3 875Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"underline\"\n      ]\n    },\n    {\n      \"uid\": \"daa7f27064d8c218bf22731012103675\",\n      \"css\": \"italic\",\n      \"code\": 59463,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M399.9 812.5H333.8L455.1 187.5H534.6A31.3 31.3 0 0 0 565.3 162.2L577.5 99.7C581.2 80.4 566.5 62.5 546.8 62.5H234.8A31.3 31.3 0 0 0 204.2 87.8L192 150.3C188.2 169.6 203 187.5 222.6 187.5H288.7L167.5 812.5H90.4A31.3 31.3 0 0 0 59.7 837.8L47.5 900.3C43.8 919.6 58.5 937.5 78.2 937.5H387.7A31.3 31.3 0 0 0 418.4 912.2L430.6 849.7C434.4 830.4 419.6 812.5 399.9 812.5Z\",\n        \"width\": 625\n      },\n      \"search\": [\n        \"italic\"\n      ]\n    },\n    {\n      \"uid\": \"638e629bf04f06f100d42a3b6c3afeaa\",\n      \"css\": \"strikethrough\",\n      \"code\": 59464,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M968.8 562.5H31.3C14 562.5 0 548.5 0 531.3V468.8C0 451.5 14 437.5 31.3 437.5H968.8C986 437.5 1000 451.5 1000 468.8V531.3C1000 548.5 986 562.5 968.8 562.5ZM549.5 593.8C602.7 619 640.3 649.8 640.3 703.6 640.3 768.3 583.8 808.4 492.7 808.4 429.5 808.4 342.5 784.8 342.5 722V718.8C342.5 701.5 328.5 687.5 311.3 687.5H222.2C204.9 687.5 190.9 701.5 190.9 718.8V756.3C190.9 886.8 342.7 955.1 492.7 955.1 665.7 955.1 809.1 866.4 809.1 692.6 809.1 653.9 802 621.5 789.3 593.8H549.5ZM489 406.3C425.7 379.9 378 349.7 378 289.7 378 223.4 438.4 197.1 504.9 197.1 588.2 197.1 631.8 229.5 631.8 261.5V265.6C631.8 282.9 645.8 296.9 663 296.9H752.1C769.4 296.9 783.4 282.9 783.4 265.6V206.4C783.4 104 643.3 50.4 504.9 50.4 338.5 50.4 210.5 130.4 210.5 295.8 210.5 340.2 219.6 376.2 235.5 406.3H489Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"strikethrough\"\n      ]\n    },\n    {\n      \"uid\": \"1a1fa90cbaa7da526141f8be54d5491b\",\n      \"css\": \"indent\",\n      \"code\": 59465,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M0 164.1V85.9C0 68.7 14 54.7 31.3 54.7H843.8C861 54.7 875 68.7 875 85.9V164.1C875 181.3 861 195.3 843.8 195.3H31.3C14 195.3 0 181.3 0 164.1ZM343.8 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H343.8C326.5 304.7 312.5 318.7 312.5 335.9V414.1C312.5 431.3 326.5 445.3 343.8 445.3ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM343.8 695.3H843.8C861 695.3 875 681.3 875 664.1V585.9C875 568.7 861 554.7 843.8 554.7H343.8C326.5 554.7 312.5 568.7 312.5 585.9V664.1C312.5 681.3 326.5 695.3 343.8 695.3ZM240.8 477.9L53.3 290.4C33.7 270.8 0 284.7 0 312.5V687.5C0 715.5 33.8 729.1 53.3 709.6L240.8 522.1C253.1 509.9 253.1 490.1 240.8 477.9Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"indent\"\n      ]\n    },\n    {\n      \"uid\": \"8d1d056ea637f2f25e905cd5beac310e\",\n      \"css\": \"outdent\",\n      \"code\": 59466,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M0 164.1V85.9C0 68.7 14 54.7 31.3 54.7H843.8C861 54.7 875 68.7 875 85.9V164.1C875 181.3 861 195.3 843.8 195.3H31.3C14 195.3 0 181.3 0 164.1ZM406.3 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H406.3C389 304.7 375 318.7 375 335.9V414.1C375 431.3 389 445.3 406.3 445.3ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM406.3 695.3H843.8C861 695.3 875 681.3 875 664.1V585.9C875 568.7 861 554.7 843.8 554.7H406.3C389 554.7 375 568.7 375 585.9V664.1C375 681.3 389 695.3 406.3 695.3ZM9.2 522.1L196.7 709.6C216.3 729.2 250 715.3 250 687.5V312.5C250 284.5 216.2 270.9 196.7 290.4L9.2 477.9C-3.1 490.1-3.1 509.9 9.2 522.1Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"outdent\"\n      ]\n    },\n    {\n      \"uid\": \"097d911c1839d50e7183cfb6e7c16934\",\n      \"css\": \"undo-alt\",\n      \"code\": 59467,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M499.1 15.6C369.7 15.9 252.2 66.9 165.4 149.8L95.6 80C66.1 50.5 15.6 71.4 15.6 113.2V375C15.6 400.9 36.6 421.9 62.5 421.9H324.3C366.1 421.9 387 371.4 357.5 341.9L275.9 260.3C336.2 203.9 414.2 172.6 497.1 171.9 677.6 170.3 829.7 316.4 828.1 502.8 826.6 679.7 683.2 828.1 500 828.1 419.7 828.1 343.8 799.5 283.9 747 274.7 738.8 260.7 739.3 252 748L174.5 825.5C165 835 165.4 850.5 175.4 859.6 261.3 937.1 375.1 984.4 500 984.4 767.5 984.4 984.4 767.5 984.4 500 984.4 232.8 766.3 15.1 499.1 15.6Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"undo-alt\"\n      ]\n    },\n    {\n      \"uid\": \"4bd031cc742bc0605f0d2a6c13eeb789\",\n      \"css\": \"redo-alt\",\n      \"code\": 59468,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M500.9 15.6C630.3 15.9 747.8 66.9 834.6 149.8L904.4 80C933.9 50.5 984.4 71.4 984.4 113.2V375C984.4 400.9 963.4 421.9 937.5 421.9H675.7C633.9 421.9 613 371.4 642.5 341.9L724.1 260.3C663.8 203.9 585.8 172.6 502.9 171.9 322.4 170.3 170.3 316.4 171.9 502.8 173.4 679.7 316.8 828.1 500 828.1 580.3 828.1 656.2 799.5 716.1 747 725.3 738.8 739.3 739.3 748 748L825.5 825.5C835 835 834.6 850.5 824.6 859.6 738.7 937.1 624.9 984.4 500 984.4 232.5 984.4 15.6 767.5 15.6 500 15.6 232.8 233.7 15.1 500.9 15.6Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"redo-alt\"\n      ]\n    },\n    {\n      \"uid\": \"8a69d07fcdeb0deda9048dffdfeb03d3\",\n      \"css\": \"link\",\n      \"code\": 59469,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M637.9 362.1C754.6 478.9 753 666.2 638.6 781.2 638.4 781.4 638.1 781.7 637.9 781.9L506.7 913.2C390.9 1028.9 202.6 1028.9 86.8 913.2-28.9 797.4-28.9 609.1 86.8 493.3L159.3 420.9C178.5 401.7 211.6 414.4 212.6 441.6 213.9 476.2 220.1 511 231.5 544.6 235.4 555.9 232.6 568.5 224.1 577L198.6 602.6C143.8 657.3 142.1 746.4 196.3 801.7 251.1 857.5 341 857.9 396.2 802.7L527.4 671.5C582.5 616.4 582.3 527.4 527.4 472.6 520.2 465.4 512.9 459.8 507.2 455.8A31.3 31.3 0 0 1 493.7 431.2C492.9 410.6 500.2 389.3 516.5 373L557.6 331.9C568.4 321.1 585.3 319.8 597.8 328.5A297.8 297.8 0 0 1 637.9 362.1ZM913.2 86.8C797.4-28.9 609.1-28.9 493.3 86.8L362.1 218.1C361.8 218.3 361.6 218.6 361.4 218.8 247 333.8 245.4 521.1 362.1 637.9A297.8 297.8 0 0 0 402.2 671.5C414.7 680.2 431.6 678.9 442.4 668.1L483.5 627C499.8 610.7 507.1 589.4 506.3 568.8A31.3 31.3 0 0 0 492.8 544.2C487.1 540.2 479.8 534.6 472.6 527.4 417.7 472.6 417.5 383.6 472.6 328.5L603.8 197.3C659 142.1 748.9 142.5 803.7 198.3 857.9 253.6 856.2 342.7 801.4 397.4L775.9 423C767.4 431.5 764.6 444.1 768.5 455.4 779.9 489 786.1 523.8 787.4 558.4 788.4 585.6 821.5 598.3 840.7 579.1L913.2 506.7C1028.9 390.9 1028.9 202.6 913.2 86.8Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"link\"\n      ]\n    },\n    {\n      \"uid\": \"195e10d964b70c44cde9513ec217cba4\",\n      \"css\": \"font\",\n      \"code\": 59470,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M843.8 812.5H791.6L538.1 83.4C533.8 70.9 521.9 62.5 508.6 62.5H366.4C353.1 62.5 341.2 70.9 336.9 83.4L83.4 812.5H31.3C14.1 812.5 0 826.6 0 843.8V906.3C0 923.4 14.1 937.5 31.3 937.5H296.9C314.1 937.5 328.1 923.4 328.1 906.3V843.8C328.1 826.6 314.1 812.5 296.9 812.5H250L302 654.7H571.9L623.8 812.5H578.1C560.9 812.5 546.9 826.6 546.9 843.8V906.3C546.9 923.4 560.9 937.5 578.1 937.5H843.8C860.9 937.5 875 923.4 875 906.3V843.8C875 826.6 860.9 812.5 843.8 812.5ZM340.6 524L422.7 281.6C431.1 252 435.5 226.6 437.5 214.1 439.1 226.8 443.2 252.1 452.5 281.8L533.2 524Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"font\"\n      ]\n    },\n    {\n      \"uid\": \"e9352fe9c753373d14694398ce8044fe\",\n      \"css\": \"comment-medical\",\n      \"code\": 59471,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M500 62.5C223.9 62.5 0 244.4 0 468.8 0 565.5 41.8 654.3 111.3 724.1 86.8 822.4 5.3 910.2 4.3 911.1A15.6 15.6 0 0 0 15.6 937.5C145 937.5 242.2 875.5 290.2 837.1A595 595 0 0 0 500 875C776.2 875 1000 693.1 1000 468.8S776.2 62.5 500 62.5ZM687.5 515.6A15.6 15.6 0 0 1 671.9 531.3H562.5V640.6A15.6 15.6 0 0 1 546.9 656.3H453.1A15.6 15.6 0 0 1 437.5 640.6V531.3H328.1A15.6 15.6 0 0 1 312.5 515.6V421.9A15.6 15.6 0 0 1 328.1 406.3H437.5V296.9A15.6 15.6 0 0 1 453.1 281.3H546.9A15.6 15.6 0 0 1 562.5 296.9V406.3H671.9A15.6 15.6 0 0 1 687.5 421.9Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"comment-medical\"\n      ]\n    },\n    {\n      \"uid\": \"a5c7ef2089dd63c12d3328563fee2330\",\n      \"css\": \"comment\",\n      \"code\": 59472,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M500 62.5C223.8 62.5 0 244.3 0 468.8 0 565.6 41.8 654.3 111.3 724 86.9 822.5 5.3 910.2 4.3 911.1 0 915.6-1.2 922.3 1.4 928.1S9.4 937.5 15.6 937.5C145.1 937.5 242.2 875.4 290.2 837.1 354.1 861.1 425 875 500 875 776.2 875 1000 693.2 1000 468.8S776.2 62.5 500 62.5Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"comment\"\n      ]\n    },\n    {\n      \"uid\": \"5455d3369b90673f0404f9290f40f074\",\n      \"css\": \"cog\",\n      \"code\": 59473,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M952 616.6L868.7 568.6C877.1 523.2 877.1 476.8 868.7 431.4L952 383.4C961.5 377.9 965.8 366.6 962.7 356.1 941 286.5 904.1 223.6 855.9 171.3 848.4 163.3 836.3 161.3 827 166.8L743.8 214.8C708.8 184.8 668.6 161.5 625 146.3V50.4C625 39.5 617.4 29.9 606.6 27.5 535 11.5 461.5 12.3 393.4 27.5 382.6 29.9 375 39.5 375 50.4V146.5C331.6 161.9 291.4 185.2 256.3 215L173.2 167C163.7 161.5 151.8 163.3 144.3 171.5 96.1 223.6 59.2 286.5 37.5 356.2 34.2 366.8 38.7 378.1 48.2 383.6L131.4 431.6C123 477 123 523.4 131.4 568.8L48.2 616.8C38.7 622.3 34.4 633.6 37.5 644.1 59.2 713.7 96.1 776.6 144.3 828.9 151.8 836.9 163.9 838.9 173.2 833.4L256.4 785.4C291.4 815.4 331.6 838.7 375.2 853.9V950C375.2 960.9 382.8 970.5 393.6 972.9 465.2 988.9 538.7 988.1 606.8 972.9 617.6 970.5 625.2 960.9 625.2 950V853.9C668.6 838.5 708.8 815.2 743.9 785.4L827.1 833.4C836.7 838.9 848.6 837.1 856.1 828.9 904.3 776.8 941.2 713.9 962.9 644.1 965.8 633.4 961.5 622.1 952 616.6ZM500 656.3C413.9 656.3 343.8 586.1 343.8 500S413.9 343.8 500 343.8 656.3 413.9 656.3 500 586.1 656.3 500 656.3Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"cog\"\n      ]\n    },\n    {\n      \"uid\": \"320da42dd92a9773159f2e4037a1d1db\",\n      \"css\": \"text-height\",\n      \"code\": 59474,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M31.3 62.5H593.8C611 62.5 625 76.5 625 93.8V281.3C625 298.5 611 312.5 593.8 312.5H524.4C507.2 312.5 493.2 298.5 493.2 281.3V187.5H386.2V812.5H453.1C470.4 812.5 484.4 826.5 484.4 843.8V906.3C484.4 923.5 470.4 937.5 453.1 937.5H171.9C154.6 937.5 140.6 923.5 140.6 906.3V843.8C140.6 826.5 154.6 812.5 171.9 812.5H238.8V187.5H131.8V281.3C131.8 298.5 117.8 312.5 100.6 312.5H31.3C14 312.5 0 298.5 0 281.3V93.8C0 76.5 14 62.5 31.3 62.5ZM959.6 71.7L1115.8 227.9C1135.4 247.4 1121.7 281.3 1093.7 281.3H1000V718.8H1093.8C1124.3 718.8 1134.1 753.9 1115.8 772.1L959.6 928.3C947.4 940.6 927.6 940.5 915.4 928.3L759.2 772.1C739.6 752.6 753.3 718.8 781.3 718.8H875V281.3H781.3C750.7 281.3 740.9 246.1 759.2 227.9L915.4 71.7C927.6 59.4 947.4 59.5 959.6 71.7Z\",\n        \"width\": 1125\n      },\n      \"search\": [\n        \"text-height\"\n      ]\n    },\n    {\n      \"uid\": \"bc0f1614c05e71b1c8beaf95bc900761\",\n      \"css\": \"share-alt\",\n      \"code\": 59475,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M687.5 625C643.3 625 602.8 640.3 570.7 665.8L370.6 540.7A188.6 188.6 0 0 0 370.6 459.3L570.7 334.2C602.8 359.7 643.3 375 687.5 375 791.1 375 875 291.1 875 187.5S791.1 0 687.5 0 500 83.9 500 187.5C500 201.5 501.5 215.1 504.4 228.2L304.3 353.3C272.2 327.8 231.7 312.5 187.5 312.5 83.9 312.5 0 396.4 0 500S83.9 687.5 187.5 687.5C231.7 687.5 272.2 672.2 304.3 646.7L504.4 771.8A188.1 188.1 0 0 0 500 812.5C500 916.1 583.9 1000 687.5 1000S875 916.1 875 812.5 791.1 625 687.5 625Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"share-alt\"\n      ]\n    },\n    {\n      \"uid\": \"ef49eade5ad70fcd1daa78d8d16bd68b\",\n      \"css\": \"code\",\n      \"code\": 59476,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M544.7 999L425.6 964.5C413.1 960.9 406.1 947.9 409.6 935.4L676.2 17C679.7 4.5 692.8-2.5 705.3 1L824.4 35.5C836.9 39.1 843.9 52.1 840.4 64.6L573.8 983C570.1 995.5 557.2 1002.7 544.7 999ZM322.1 779.9L407 689.3C416 679.7 415.4 664.5 405.5 655.7L228.5 500 405.5 344.3C415.4 335.5 416.2 320.3 407 310.7L322.1 220.1C313.3 210.7 298.4 210.2 288.9 219.1L7.4 482.8C-2.5 492-2.5 507.8 7.4 517L288.9 780.9C298.4 789.8 313.3 789.5 322.1 779.9ZM961.1 781.1L1242.6 517.2C1252.5 508 1252.5 492.2 1242.6 483L961.1 218.9C951.8 210.2 936.9 210.5 927.9 219.9L843 310.5C834 320.1 834.6 335.4 844.5 344.1L1021.5 500 844.5 655.7C834.6 664.5 833.8 679.7 843 689.3L927.9 779.9C936.7 789.5 951.6 789.8 961.1 781.1Z\",\n        \"width\": 1250\n      },\n      \"search\": [\n        \"code\"\n      ]\n    },\n    {\n      \"uid\": \"b3fb5fc84c956ceabfd7ec42ee3fc5dd\",\n      \"css\": \"history\",\n      \"code\": 59477,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M984.4 499.1C984.9 766 767.2 984.2 500.4 984.4 385.1 984.5 279.2 944.3 196 877.1 174.4 859.7 172.8 827.2 192.4 807.6L214.4 785.6C231.2 768.8 258.1 766.9 276.7 781.7 338 830.3 415.6 859.4 500 859.4 698.6 859.4 859.4 698.6 859.4 500 859.4 301.4 698.6 140.6 500 140.6 404.7 140.6 318.1 177.7 253.8 238.1L352.9 337.3C372.6 357 358.6 390.6 330.8 390.6H46.9C29.6 390.6 15.6 376.6 15.6 359.4V75.4C15.6 47.6 49.3 33.7 69 53.3L165.4 149.8C252.4 66.7 370.2 15.6 500 15.6 767.2 15.6 983.9 232 984.4 499.1ZM631 653L650.2 628.3C666.1 607.9 662.4 578.4 642 562.5L562.5 500.7V296.9C562.5 271 541.5 250 515.6 250H484.4C458.5 250 437.5 271 437.5 296.9V561.8L565.3 661.2C585.7 677.1 615.1 673.4 631 653Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"history\"\n      ]\n    },\n    {\n      \"uid\": \"bed311f2f0699a3e55a635284d86a5c7\",\n      \"css\": \"star\",\n      \"code\": 59478,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M506.4 34.8L378.9 293.4 93.6 335C42.4 342.4 21.9 405.5 59 441.6L265.4 642.8 216.6 927C207.8 978.3 261.9 1016.8 307.2 992.8L562.5 858.6 817.8 992.8C863.1 1016.6 917.2 978.3 908.4 927L859.6 642.8 1066 441.6C1103.1 405.5 1082.6 342.4 1031.4 335L746.1 293.4 618.6 34.8C595.7-11.3 529.5-11.9 506.4 34.8Z\",\n        \"width\": 1125\n      },\n      \"search\": [\n        \"star\"\n      ]\n    },\n    {\n      \"uid\": \"28feb41c0766d59e9f56b2c4c9cb67a5\",\n      \"css\": \"file-import\",\n      \"code\": 59479,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M31.3 562.5C14.1 562.5 0 576.6 0 593.8V656.3C0 673.4 14.1 687.5 31.3 687.5H250V562.5ZM986.3 205.1L795.1 13.7C786.3 4.9 774.4 0 761.9 0H750V250H1000V238.1C1000 225.8 995.1 213.9 986.3 205.1ZM687.5 265.6V0H296.9C270.9 0 250 20.9 250 46.9V562.5H500V435.2C500 407.2 533.8 393.4 553.5 413.1L740.2 601.6C753.1 614.6 753.1 635.5 740.2 648.4L553.3 836.7C533.6 856.4 499.8 842.6 499.8 814.6V687.5H250V953.1C250 979.1 270.9 1000 296.9 1000H953.1C979.1 1000 1000 979.1 1000 953.1V312.5H734.4C708.6 312.5 687.5 291.4 687.5 265.6Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"file-import\"\n      ]\n    },\n    {\n      \"uid\": \"a4382bef7f9361b8dacb8ae0b42691a4\",\n      \"css\": \"file-download\",\n      \"code\": 59480,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M437.5 265.6V0H46.9C20.9 0 0 20.9 0 46.9V953.1C0 979.1 20.9 1000 46.9 1000H703.1C729.1 1000 750 979.1 750 953.1V312.5H484.4C458.6 312.5 437.5 291.4 437.5 265.6ZM586.8 678.4L398.5 865.4C385.5 878.3 364.5 878.3 351.5 865.4L163.2 678.4C143.4 658.8 157.3 625 185.2 625H312.5V468.8C312.5 451.5 326.5 437.5 343.8 437.5H406.3C423.5 437.5 437.5 451.5 437.5 468.8V625H564.8C592.7 625 606.6 658.8 586.8 678.4ZM736.3 205.1L545.1 13.7C536.3 4.9 524.4 0 511.9 0H500V250H750V238.1C750 225.8 745.1 213.9 736.3 205.1Z\",\n        \"width\": 750\n      },\n      \"search\": [\n        \"file-download\"\n      ]\n    },\n    {\n      \"uid\": \"149eec703c4bd1d93f052f3d239cce44\",\n      \"css\": \"file-pdf\",\n      \"code\": 59481,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M355.3 500.2C345.5 468.9 345.7 408.6 351.4 408.6 367.8 408.6 366.2 480.7 355.3 500.2ZM352 592.4C336.9 631.8 318.2 677 296.5 714.8 332.2 701.2 372.7 681.3 419.3 672.1 394.5 653.3 370.7 626.4 352 592.4ZM168.2 836.1C168.2 837.7 193.9 825.6 236.3 757.6 223.2 769.9 179.5 805.5 168.2 836.1ZM484.4 312.5H750V953.1C750 979.1 729.1 1000 703.1 1000H46.9C20.9 1000 0 979.1 0 953.1V46.9C0 20.9 20.9 0 46.9 0H437.5V265.6C437.5 291.4 458.6 312.5 484.4 312.5ZM468.8 648C429.7 624.2 403.7 591.4 385.4 543 394.1 506.8 408 452 397.5 417.6 388.3 360.2 314.6 365.8 304.1 404.3 294.3 440 303.3 490.4 319.9 554.7 297.3 608.6 263.9 680.9 240.2 722.3 240 722.3 240 722.5 239.8 722.5 186.9 749.6 96.1 809.4 133.4 855.3 144.3 868.8 164.6 874.8 175.4 874.8 210.4 874.8 245.1 839.6 294.7 754.1 345.1 737.5 400.4 716.8 449 708.8 491.4 731.8 541 746.9 574 746.9 631.1 746.9 635 684.4 612.5 662.1 585.4 635.5 506.4 643.2 468.7 648ZM736.3 205.1L544.9 13.7C536.1 4.9 524.2 0 511.7 0H500V250H750V238.1C750 225.8 745.1 213.9 736.3 205.1ZM591.6 703.7C599.6 698.4 586.7 680.5 508 686.1 580.5 717 591.6 703.7 591.6 703.7Z\",\n        \"width\": 750\n      },\n      \"search\": [\n        \"file-pdf\"\n      ]\n    },\n    {\n      \"uid\": \"43c33879f17fb9c62a7466659a1a9347\",\n      \"css\": \"file-word\",\n      \"code\": 59482,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M437.5 265.6V0H46.9C20.9 0 0 20.9 0 46.9V953.1C0 979.1 20.9 1000 46.9 1000H703.1C729.1 1000 750 979.1 750 953.1V312.5H484.4C458.6 312.5 437.5 291.4 437.5 265.6ZM549 500H595.7C610.7 500 621.9 513.9 618.6 528.7L544.3 856.8C542 867.6 532.4 875 521.5 875H447.3C436.5 875 427.1 867.6 424.6 857.2 374.2 655.1 384 698.6 374.6 641.4H373.6C371.5 669.3 368.9 675.4 323.6 857.2 321.1 867.6 311.7 875 301 875H228.5C217.6 875 208 867.4 205.7 856.6L131.8 528.5C128.5 513.9 139.6 500 154.7 500H202.5C213.7 500 223.4 507.8 225.6 518.9 256.1 671.3 264.8 732.8 266.6 757.6 269.7 737.7 280.9 693.8 324 518 326.6 507.4 335.9 500.2 346.9 500.2H403.7C414.6 500.2 424 507.6 426.6 518.2 473.4 714.3 482.8 760.4 484.4 770.9 484 749 479.3 736.1 526.6 518.6 528.5 507.6 538.1 500 549 500ZM750 238.1V250H500V0H511.9C524.4 0 536.3 4.9 545.1 13.7L736.3 205.1C745.1 213.9 750 225.8 750 238.1Z\",\n        \"width\": 750\n      },\n      \"search\": [\n        \"file-word\"\n      ]\n    },\n    {\n      \"uid\": \"5d3cbbf4f54f53889ff77614613a050d\",\n      \"css\": \"file-alt\",\n      \"code\": 59483,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M437.5 265.6V0H46.9C20.9 0 0 20.9 0 46.9V953.1C0 979.1 20.9 1000 46.9 1000H703.1C729.1 1000 750 979.1 750 953.1V312.5H484.4C458.6 312.5 437.5 291.4 437.5 265.6ZM562.5 726.6C562.5 739.5 552 750 539.1 750H210.9C198 750 187.5 739.5 187.5 726.6V710.9C187.5 698 198 687.5 210.9 687.5H539.1C552 687.5 562.5 698 562.5 710.9V726.6ZM562.5 601.6C562.5 614.5 552 625 539.1 625H210.9C198 625 187.5 614.5 187.5 601.6V585.9C187.5 573 198 562.5 210.9 562.5H539.1C552 562.5 562.5 573 562.5 585.9V601.6ZM562.5 460.9V476.6C562.5 489.5 552 500 539.1 500H210.9C198 500 187.5 489.5 187.5 476.6V460.9C187.5 448 198 437.5 210.9 437.5H539.1C552 437.5 562.5 448 562.5 460.9ZM750 238.1V250H500V0H511.9C524.4 0 536.3 4.9 545.1 13.7L736.3 205.1C745.1 213.9 750 225.8 750 238.1Z\",\n        \"width\": 750\n      },\n      \"search\": [\n        \"file-alt\"\n      ]\n    },\n    {\n      \"uid\": \"c718261461d9a8046891e6c68d610118\",\n      \"css\": \"file\",\n      \"code\": 59484,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M437.5 265.6V0H46.9C20.9 0 0 20.9 0 46.9V953.1C0 979.1 20.9 1000 46.9 1000H703.1C729.1 1000 750 979.1 750 953.1V312.5H484.4C458.6 312.5 437.5 291.4 437.5 265.6ZM750 238.1V250H500V0H511.9C524.4 0 536.3 4.9 545.1 13.7L736.3 205.1C745.1 213.9 750 225.8 750 238.1Z\",\n        \"width\": 750\n      },\n      \"search\": [\n        \"file\"\n      ]\n    },\n    {\n      \"uid\": \"9a14f9bdf73d4f035ecb964e16f27b5b\",\n      \"css\": \"users\",\n      \"code\": 59445,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M187.5 437.5C256.4 437.5 312.5 381.4 312.5 312.5S256.4 187.5 187.5 187.5 62.5 243.6 62.5 312.5 118.6 437.5 187.5 437.5ZM1062.5 437.5C1131.4 437.5 1187.5 381.4 1187.5 312.5S1131.4 187.5 1062.5 187.5 937.5 243.6 937.5 312.5 993.6 437.5 1062.5 437.5ZM1125 500H1000C965.6 500 934.6 513.9 911.9 536.3 990.6 579.5 1046.5 657.4 1058.6 750H1187.5C1222.1 750 1250 722.1 1250 687.5V625C1250 556.1 1193.9 500 1125 500ZM625 500C745.9 500 843.8 402.1 843.8 281.3S745.9 62.5 625 62.5 406.3 160.4 406.3 281.3 504.1 500 625 500ZM775 562.5H758.8C718.2 582 673 593.8 625 593.8S532 582 491.2 562.5H475C350.8 562.5 250 663.3 250 787.5V843.8C250 895.5 292 937.5 343.8 937.5H906.3C958 937.5 1000 895.5 1000 843.8V787.5C1000 663.3 899.2 562.5 775 562.5ZM338.1 536.3C315.4 513.9 284.4 500 250 500H125C56.1 500 0 556.1 0 625V687.5C0 722.1 27.9 750 62.5 750H191.2C203.5 657.4 259.4 579.5 338.1 536.3Z\",\n        \"width\": 1250\n      },\n      \"search\": [\n        \"users\"\n      ]\n    },\n    {\n      \"uid\": \"5ce9d7d62b842d1e0b42ccb50417ed86\",\n      \"css\": \"pencil-alt\",\n      \"code\": 59400,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M972.5 277.5L882.4 367.6C873.2 376.8 858.4 376.8 849.2 367.6L632.4 150.8C623.2 141.6 623.2 126.8 632.4 117.6L722.5 27.5C759-9 818.4-9 855.1 27.5L972.5 144.9C1009.2 181.4 1009.2 240.8 972.5 277.5ZM555.1 194.9L42.2 707.8 0.8 945.1C-4.9 977.1 23 1004.9 55.1 999.4L292.4 957.8 805.3 444.9C814.5 435.7 814.5 420.9 805.3 411.7L588.5 194.9C579.1 185.7 564.3 185.7 555.1 194.9ZM242.4 663.9C231.6 653.1 231.6 635.9 242.4 625.2L543.2 324.4C553.9 313.7 571.1 313.7 581.8 324.4S592.6 352.3 581.8 363.1L281.1 663.9C270.3 674.6 253.1 674.6 242.4 663.9ZM171.9 828.1H265.6V899L139.6 921.1 78.9 860.4 101 734.4H171.9V828.1Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"pencil-alt\"\n      ]\n    },\n    {\n      \"uid\": \"88a8e61cd1555895e8af136db8b58885\",\n      \"css\": \"times\",\n      \"code\": 59430,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M474.1 500L669.5 304.6C693.5 280.6 693.5 241.7 669.5 217.7L626.1 174.2C602.1 150.3 563.2 150.3 539.2 174.2L343.8 369.7 148.3 174.2C124.3 150.3 85.4 150.3 61.4 174.2L18 217.7C-6 241.7-6 280.5 18 304.6L213.4 500 18 695.4C-6 719.4-6 758.3 18 782.3L61.4 825.8C85.4 849.7 124.3 849.7 148.3 825.8L343.8 630.3 539.2 825.8C563.2 849.7 602.1 849.7 626.1 825.8L669.5 782.3C693.5 758.3 693.5 719.5 669.5 695.4L474.1 500Z\",\n        \"width\": 688\n      },\n      \"search\": [\n        \"times\"\n      ]\n    },\n    {\n      \"uid\": \"91c50bb767ec3d33047773a7e539799e\",\n      \"css\": \"pause\",\n      \"code\": 59433,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M281.3 935.5H93.8C42 935.5 0 893.6 0 841.8V154.3C0 102.5 42 60.5 93.8 60.5H281.3C333 60.5 375 102.5 375 154.3V841.8C375 893.6 333 935.5 281.3 935.5ZM875 841.8V154.3C875 102.5 833 60.5 781.3 60.5H593.8C542 60.5 500 102.5 500 154.3V841.8C500 893.6 542 935.5 593.8 935.5H781.3C833 935.5 875 893.6 875 841.8Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"pause\"\n      ]\n    },\n    {\n      \"uid\": \"3053a00ac47ec0a6e52490d34a2251eb\",\n      \"css\": \"stop\",\n      \"code\": 59394,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M781.3 62.5H93.8C42 62.5 0 104.5 0 156.3V843.8C0 895.5 42 937.5 93.8 937.5H781.3C833 937.5 875 895.5 875 843.8V156.3C875 104.5 833 62.5 781.3 62.5Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"stop\"\n      ]\n    },\n    {\n      \"uid\": \"91b4828047e0874d4b2cfbb44dc16ff9\",\n      \"css\": \"step-backward\",\n      \"code\": 59435,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M125 914.1V85.9C125 73 135.5 62.5 148.4 62.5H242.2C255.1 62.5 265.6 73 265.6 85.9V430.5L647.5 77C687.7 43.6 750 71.5 750 125V875C750 928.5 687.7 956.4 647.5 923L265.6 571.7V914.1C265.6 927 255.1 937.5 242.2 937.5H148.4C135.5 937.5 125 927 125 914.1Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"step-backward\"\n      ]\n    },\n    {\n      \"uid\": \"9a0d3eec2bb3765a51f82dadf9a10bd1\",\n      \"css\": \"step-forward\",\n      \"code\": 59436,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M750 85.9V914.1C750 927 739.5 937.5 726.6 937.5H632.8C619.9 937.5 609.4 927 609.4 914.1V569.5L227.5 923C187.3 956.4 125 928.5 125 875V125C125 71.5 187.3 43.6 227.5 77L609.4 428.3V85.9C609.4 73 619.9 62.5 632.8 62.5H726.6C739.5 62.5 750 73 750 85.9Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"step-forward\"\n      ]\n    },\n    {\n      \"uid\": \"9f8f8db47c9da55d8ea2e0170476eb39\",\n      \"css\": \"play\",\n      \"code\": 59395,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M828.9 419.3L141.4 12.9C85.5-20.1 0 11.9 0 93.6V906.3C0 979.5 79.5 1023.6 141.4 986.9L828.9 580.7C890.2 544.5 890.4 455.5 828.9 419.3Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"play\"\n      ]\n    },\n    {\n      \"uid\": \"e0e61c06ec2c00a0c7b604fcc20b133c\",\n      \"css\": \"comments\",\n      \"code\": 59437,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M812.5 375C812.5 202.3 630.7 62.5 406.3 62.5S0 202.3 0 375C0 442 27.5 503.7 74.2 554.7 48 613.7 4.9 660.5 4.3 661.1 0 665.6-1.2 672.3 1.4 678.1S9.4 687.5 15.6 687.5C87.1 687.5 146.3 663.5 188.9 638.7 251.8 669.3 326.2 687.5 406.3 687.5 630.7 687.5 812.5 547.7 812.5 375ZM1050.8 804.7C1097.5 753.9 1125 692 1125 625 1125 494.3 1020.5 382.4 872.5 335.7 874.2 348.6 875 361.7 875 375 875 581.8 664.6 750 406.3 750 385.2 750 364.6 748.4 344.3 746.3 405.9 858.6 550.4 937.5 718.8 937.5 798.8 937.5 873.2 919.5 936.1 888.7 978.7 913.5 1037.9 937.5 1109.4 937.5 1115.6 937.5 1121.3 933.8 1123.6 928.1 1126.2 922.5 1125 915.8 1120.7 911.1 1120.1 910.5 1077 863.9 1050.8 804.7Z\",\n        \"width\": 1125\n      },\n      \"search\": [\n        \"comments\"\n      ]\n    },\n    {\n      \"uid\": \"7c8b7bccd2548457f00645f3954e2863\",\n      \"css\": \"heading\",\n      \"code\": 59438,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M968.8 156.3V93.8C968.8 76.5 954.8 62.5 937.5 62.5H625C607.7 62.5 593.8 76.5 593.8 93.8V156.3C593.8 173.5 607.7 187.5 625 187.5H698.5V437.5H301.5V187.5H375C392.3 187.5 406.3 173.5 406.3 156.3V93.8C406.3 76.5 392.3 62.5 375 62.5H62.5C45.2 62.5 31.3 76.5 31.3 93.8V156.3C31.3 173.5 45.2 187.5 62.5 187.5H135.3V812.5H62.5C45.2 812.5 31.3 826.5 31.3 843.8V906.3C31.3 923.5 45.2 937.5 62.5 937.5H375C392.3 937.5 406.3 923.5 406.3 906.3V843.8C406.3 826.5 392.3 812.5 375 812.5H301.5V562.5H698.5V812.5H625C607.7 812.5 593.8 826.5 593.8 843.8V906.3C593.8 923.5 607.7 937.5 625 937.5H937.5C954.8 937.5 968.8 923.5 968.8 906.3V843.8C968.8 826.5 954.8 812.5 937.5 812.5H864.7V187.5H937.5C954.8 187.5 968.8 173.5 968.8 156.3Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"heading\"\n      ]\n    },\n    {\n      \"uid\": \"c7ead3a5bb66fddf32a7899a0f3fbb6c\",\n      \"css\": \"align-center\",\n      \"code\": 59396,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M687.5 85.9V164.1C687.5 181.3 673.5 195.3 656.3 195.3H218.8C201.5 195.3 187.5 181.3 187.5 164.1V85.9C187.5 68.7 201.5 54.7 218.8 54.7H656.3C673.5 54.7 687.5 68.7 687.5 85.9ZM31.3 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H31.3C14 304.7 0 318.7 0 335.9V414.1C0 431.3 14 445.3 31.3 445.3ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM656.3 554.7H218.8C201.5 554.7 187.5 568.7 187.5 585.9V664.1C187.5 681.3 201.5 695.3 218.8 695.3H656.3C673.5 695.3 687.5 681.3 687.5 664.1V585.9C687.5 568.7 673.5 554.7 656.3 554.7Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"align-center\"\n      ]\n    },\n    {\n      \"uid\": \"e8e401b7ba1649fce89eb32cc85cb50d\",\n      \"css\": \"align-justify\",\n      \"code\": 59397,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M0 164.1V85.9C0 68.7 14 54.7 31.3 54.7H843.8C861 54.7 875 68.7 875 85.9V164.1C875 181.3 861 195.3 843.8 195.3H31.3C14 195.3 0 181.3 0 164.1ZM31.3 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H31.3C14 304.7 0 318.7 0 335.9V414.1C0 431.3 14 445.3 31.3 445.3ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM31.3 695.3H843.8C861 695.3 875 681.3 875 664.1V585.9C875 568.7 861 554.7 843.8 554.7H31.3C14 554.7 0 568.7 0 585.9V664.1C0 681.3 14 695.3 31.3 695.3Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"align-justify\"\n      ]\n    },\n    {\n      \"uid\": \"eb15f17c97d08c4151e60b4b2f630fb5\",\n      \"css\": \"align-left\",\n      \"code\": 59398,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M562.5 85.9V164.1C562.5 181.3 548.5 195.3 531.3 195.3H31.3C14 195.3 0 181.3 0 164.1V85.9C0 68.7 14 54.7 31.3 54.7H531.3C548.5 54.7 562.5 68.7 562.5 85.9ZM0 335.9V414.1C0 431.3 14 445.3 31.3 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H31.3C14 304.7 0 318.7 0 335.9ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM531.3 554.7H31.3C14 554.7 0 568.7 0 585.9V664.1C0 681.3 14 695.3 31.3 695.3H531.3C548.5 695.3 562.5 681.3 562.5 664.1V585.9C562.5 568.7 548.5 554.7 531.3 554.7Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"align-left\"\n      ]\n    },\n    {\n      \"uid\": \"48f22afc96cf17626d8da876b9b463dc\",\n      \"css\": \"align-right\",\n      \"code\": 59399,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M312.5 164.1V85.9C312.5 68.7 326.5 54.7 343.8 54.7H843.8C861 54.7 875 68.7 875 85.9V164.1C875 181.3 861 195.3 843.8 195.3H343.8C326.5 195.3 312.5 181.3 312.5 164.1ZM31.3 445.3H843.8C861 445.3 875 431.3 875 414.1V335.9C875 318.7 861 304.7 843.8 304.7H31.3C14 304.7 0 318.7 0 335.9V414.1C0 431.3 14 445.3 31.3 445.3ZM31.3 945.3H843.8C861 945.3 875 931.3 875 914.1V835.9C875 818.7 861 804.7 843.8 804.7H31.3C14 804.7 0 818.7 0 835.9V914.1C0 931.3 14 945.3 31.3 945.3ZM343.8 695.3H843.8C861 695.3 875 681.3 875 664.1V585.9C875 568.7 861 554.7 843.8 554.7H343.8C326.5 554.7 312.5 568.7 312.5 585.9V664.1C312.5 681.3 326.5 695.3 343.8 695.3Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"align-right\"\n      ]\n    },\n    {\n      \"uid\": \"b2d03fd882d7c96479a3c6c1dbc1a889\",\n      \"css\": \"file-powerpoint\",\n      \"code\": 59485,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M378.3 529.7C395.5 529.7 408.6 535 418 545.5 436.7 566.8 437.1 609.4 417.6 631.6 408 642.6 394.3 648.2 376.4 648.2H323.8V529.7H378.3ZM736.3 205.1L544.9 13.7C536.1 4.9 524.2 0 511.7 0H500V250H750V238.1C750 225.8 745.1 213.9 736.3 205.1ZM437.5 265.6V0H46.9C20.9 0 0 20.9 0 46.9V953.1C0 979.1 20.9 1000 46.9 1000H703.1C729.1 1000 750 979.1 750 953.1V312.5H484.4C458.6 312.5 437.5 291.4 437.5 265.6ZM541 588.3C541 764.6 367.6 739.8 324 739.8V851.6C324 864.5 313.5 875 300.6 875H240.4C227.5 875 217 864.5 217 851.6V461.3C217 448.4 227.5 437.9 240.4 437.9H398.6C485.5 437.9 541 502 541 588.3Z\",\n        \"width\": 750\n      },\n      \"search\": [\n        \"file-powerpoint\"\n      ]\n    },\n    {\n      \"uid\": \"c59ea6604f4c8a3bebd9cb24630f0e3b\",\n      \"css\": \"superscript\",\n      \"code\": 59443,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M531.3 375H398.8C388.1 375 377.9 380.7 372.3 389.8L293.6 516.8C289.1 523.8 284.8 531.1 281.4 537.3 278.1 531.1 274.2 524 270.3 517L192.4 389.8C186.7 380.7 176.6 375 165.8 375H31.3C14.1 375 0 389.1 0 406.3V468.8C0 485.9 14.1 500 31.3 500H90L193.2 651 82.6 812.5H31.3C14.1 812.5 0 826.6 0 843.8V906.3C0 923.4 14.1 937.5 31.3 937.5H156.3C167 937.5 177.1 931.8 182.8 922.7L270.1 781.8C274.4 774.8 278.3 767.6 281.6 761.1 285.2 767.4 289.3 774.6 293.8 781.1L383 922.9C388.7 932 398.6 937.5 409.4 937.5H531.3C548.4 937.5 562.5 923.4 562.5 906.2V843.7C562.5 826.6 548.4 812.5 531.3 812.5H488.3L373.8 647.9 476.6 500H531.3C548.4 500 562.5 485.9 562.5 468.8V406.3C562.5 389.1 548.4 375 531.3 375ZM968.8 500H771.9C778.7 479.5 808.6 458.4 842.8 436.7 875.2 416 912.1 392.6 941 360.7 975.2 323.4 991.6 282.2 991.6 234.6 991.6 116.2 892.6 62.5 800.6 62.5 717.6 62.5 651.4 105.5 616.2 160.9 607 175.2 611.1 194.1 625.2 203.7L684.4 243.4C698 252.5 716.6 249.4 726.6 236.3 742.2 216 763.3 200.8 788.5 200.8 826.4 200.8 839.8 226 839.8 247.5 839.8 318.2 606.6 358.8 606.6 560 606.6 573 607.8 585.4 609.4 597.7 611.5 613.3 624.6 624.8 640.4 624.8H968.8C985.9 624.8 1000 610.7 1000 593.6V531.1C1000 514.1 985.9 500 968.8 500Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"superscript\"\n      ]\n    },\n    {\n      \"uid\": \"cb9e27f4e2c9fe6182e2351f9ad71c14\",\n      \"css\": \"subscript\",\n      \"code\": 59444,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M531.3 62.5H398.8C388.1 62.5 377.9 68.2 372.3 77.3L293.6 204.3C289.1 211.3 284.8 218.6 281.4 224.8 278.1 218.6 274.2 211.5 270.3 204.5L192.4 77.3C186.7 68.2 176.6 62.5 165.8 62.5H31.3C14.1 62.5 0 76.6 0 93.8V156.3C0 173.4 14.1 187.5 31.3 187.5H90L193.2 338.5 82.6 500H31.3C14.1 500 0 514.1 0 531.3V593.8C0 610.9 14.1 625 31.3 625H156.3C167 625 177.1 619.3 182.8 610.2L270.1 469.3C274.4 462.3 278.3 455.1 281.6 448.6 285.2 454.9 289.3 462.1 293.8 468.6L383 610.4C388.7 619.5 398.6 625 409.4 625H531.3C548.4 625 562.5 610.9 562.5 593.8V531.3C562.5 514.1 548.4 500 531.3 500H488.3L373.8 335.4 476.6 187.5H531.3C548.4 187.5 562.5 173.4 562.5 156.3V93.8C562.5 76.6 548.4 62.5 531.3 62.5ZM968.8 812.5H771.9C778.7 792 808.6 770.9 842.8 749.2 875.2 728.5 912.1 705.1 941 673.2 975.2 635.9 991.6 594.7 991.6 547.1 991.6 428.7 892.6 375 800.6 375 717.6 375 651.4 418 616.2 473.4 607 487.7 611.1 506.6 625.2 516.2L684.4 555.9C698 565 716.6 561.9 726.6 548.8 742.2 528.5 763.3 513.3 788.5 513.3 826.4 513.3 839.8 538.5 839.8 560 839.8 630.7 606.6 671.3 606.6 872.5 606.6 885.5 607.8 897.9 609.4 910.2 611.5 925.8 624.6 937.3 640.4 937.3H968.8C985.9 937.3 1000 923.2 1000 906.1V843.6C1000 826.6 985.9 812.5 968.8 812.5Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"subscript\"\n      ]\n    },\n    {\n      \"uid\": \"2f9853bb94503f2e5149dddae69657f6\",\n      \"css\": \"gauge\",\n      \"code\": 59446,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M562.5 62.5C251.8 62.5 0 314.3 0 625 0 728.1 27.8 824.7 76.3 907.8 87.2 926.6 108.1 937.5 129.9 937.5H995.1C1016.9 937.5 1037.8 926.6 1048.7 907.8 1097.2 824.7 1125 728.1 1125 625 1125 314.3 873.2 62.5 562.5 62.5ZM562.5 187.5C591.2 187.5 614.4 207.3 621.7 233.7 619.6 238.1 616.6 242 615 246.7L597 300.8C587 307.6 575.5 312.5 562.5 312.5 528 312.5 500 284.5 500 250S528 187.5 562.5 187.5ZM187.5 750C153 750 125 722 125 687.5S153 625 187.5 625 250 653 250 687.5 222 750 187.5 750ZM281.3 437.5C246.7 437.5 218.8 409.5 218.8 375S246.7 312.5 281.3 312.5 343.8 340.5 343.8 375 315.8 437.5 281.3 437.5ZM763.2 296.1L643.4 655.4C670.2 678.4 687.5 712 687.5 750 687.5 772.9 680.9 794 670.2 812.5H454.8C444.1 794 437.5 772.9 437.5 750 437.5 683.7 489.3 630 554.5 625.8L674.3 266.4C682.4 241.9 708.9 228.4 733.6 236.8 758.1 245 771.4 271.5 763.2 296.1ZM791.9 407.8L822.2 316.9C828.9 314.4 836.1 312.5 843.8 312.5 878.3 312.5 906.3 340.5 906.3 375S878.3 437.5 843.8 437.5C821.5 437.5 802.9 425.3 791.9 407.8ZM937.5 750C903 750 875 722 875 687.5S903 625 937.5 625 1000 653 1000 687.5 972 750 937.5 750Z\",\n        \"width\": 1125\n      },\n      \"search\": [\n        \"tachometer-alt\"\n      ]\n    },\n    {\n      \"uid\": \"9f61e6a7ba9b929596aba1e946386ca1\",\n      \"css\": \"exchange-alt\",\n      \"code\": 59447,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M0 328.1V296.9C0 271 21 250 46.9 250H750V156.3C750 114.5 800.6 93.7 830 123.1L986.3 279.4C1004.6 297.7 1004.6 327.3 986.3 345.6L830 501.9C800.7 531.2 750 510.7 750 468.8V375H46.9C21 375 0 354 0 328.1ZM953.1 625H250V531.3C250 489.6 199.5 468.6 170 498.1L13.7 654.4C-4.6 672.7-4.6 702.3 13.7 720.6L170 876.9C199.3 906.2 250 885.6 250 843.8V750H953.1C979 750 1000 729 1000 703.1V671.9C1000 646 979 625 953.1 625Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"exchange-alt\"\n      ]\n    },\n    {\n      \"uid\": \"762c1dbaf1d25d6f7365934483e90285\",\n      \"css\": \"text-width\",\n      \"code\": 59448,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M31.3 62.5H843.8C861 62.5 875 76.5 875 93.8V281.3C875 298.5 861 312.5 843.8 312.5H774.4C757.2 312.5 743.2 298.5 743.2 281.3V187.5H511.2V437.5H578.1C595.4 437.5 609.4 451.5 609.4 468.8V531.3C609.4 548.5 595.4 562.5 578.1 562.5H296.9C279.6 562.5 265.6 548.5 265.6 531.3V468.8C265.6 451.5 279.6 437.5 296.9 437.5H363.8V187.5H131.8V281.3C131.8 298.5 117.8 312.5 100.6 312.5H31.3C14 312.5 0 298.5 0 281.3V93.8C0 76.5 14 62.5 31.3 62.5ZM865.8 727.9L709.6 571.7C691.4 553.4 656.3 563.2 656.3 593.8V687.5H218.8V593.8C218.8 565.8 184.9 552.1 165.4 571.7L9.2 727.9C-3 740.1-3.1 759.9 9.2 772.1L165.4 928.3C183.6 946.6 218.8 936.8 218.8 906.3V812.5H656.3V906.2C656.3 934.2 690.1 947.9 709.6 928.3L865.8 772.1C878 759.9 878.1 740.1 865.8 727.9Z\",\n        \"width\": 875\n      },\n      \"search\": [\n        \"text-width\"\n      ]\n    },\n    {\n      \"uid\": \"db94b783531717f104b39b398db3d0f2\",\n      \"css\": \"sync-alt\",\n      \"code\": 59392,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M724.1 260.3C663 203.1 583.8 171.8 499.7 171.9 348.4 172 217.8 275.7 181.8 419.6 179.1 430.1 169.8 437.5 159 437.5H47.1C32.4 437.5 21.3 424.2 24 409.8 66.3 185.4 263.3 15.6 500 15.6 629.8 15.6 747.6 66.7 834.6 149.8L904.4 80C933.9 50.5 984.4 71.4 984.4 113.2V375C984.4 400.9 963.4 421.9 937.5 421.9H675.7C633.9 421.9 613 371.4 642.5 341.9L724.1 260.3ZM62.5 578.1H324.3C366.1 578.1 387 628.6 357.5 658.1L275.9 739.7C337 796.9 416.2 828.2 500.3 828.1 651.5 828 782.2 724.3 818.2 580.4 820.9 569.9 830.2 562.5 841 562.5H952.9C967.6 562.5 978.7 575.8 976 590.2 933.7 814.6 736.7 984.4 500 984.4 370.2 984.4 252.4 933.3 165.4 850.2L95.6 920C66.1 949.5 15.6 928.6 15.6 886.8V625C15.6 599.1 36.6 578.1 62.5 578.1Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"sync-alt\"\n      ]\n    },\n    {\n      \"uid\": \"f0ec8e32814f630fb6234b84cb5d4672\",\n      \"css\": \"picture\",\n      \"code\": 59450,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M906.3 875H93.8C42 875 0 833 0 781.3V218.8C0 167 42 125 93.8 125H906.3C958 125 1000 167 1000 218.8V781.3C1000 833 958 875 906.3 875ZM218.8 234.4C158.3 234.4 109.4 283.3 109.4 343.8S158.3 453.1 218.8 453.1 328.1 404.2 328.1 343.8 279.2 234.4 218.8 234.4ZM125 750H875V531.3L704.1 360.3C694.9 351.2 680.1 351.2 670.9 360.3L406.3 625 297.8 516.6C288.7 507.4 273.8 507.4 264.7 516.6L125 656.3V750Z\",\n        \"width\": 1000\n      },\n      \"search\": [\n        \"image\"\n      ]\n    },\n    {\n      \"uid\": \"5c54164453ce690dddffa89377692bff\",\n      \"css\": \"file-code\",\n      \"code\": 59401,\n      \"src\": \"custom_icons\",\n      \"selected\": true,\n      \"svg\": {\n        \"path\": \"M750 238.2V250H500V0H511.8C524.3 0 536.2 4.9 545 13.7L736.3 205A46.9 46.9 0 0 1 750 238.2ZM484.4 312.5C458.6 312.5 437.5 291.4 437.5 265.6V0H46.9C21 0 0 21 0 46.9V953.1C0 979 21 1000 46.9 1000H703.1C729 1000 750 979 750 953.1V312.5H484.4ZM240.6 782.2A10.5 10.5 0 0 1 225.7 782.7L99 663.9A10.5 10.5 0 0 1 99 648.6L225.7 529.8A10.5 10.5 0 0 1 240.6 530.3L278.9 571.1A10.5 10.5 0 0 1 278.2 586.2L198.5 656.3 278.2 726.3A10.5 10.5 0 0 1 278.9 741.4L240.6 782.2ZM340.8 880.8L287.2 865.3A10.6 10.6 0 0 1 280 852.2L400 438.9A10.6 10.6 0 0 1 413.1 431.7L466.7 447.2A10.5 10.5 0 0 1 473.9 460.3L353.9 873.6A10.5 10.5 0 0 1 340.8 880.8ZM654.9 663.9L528.2 782.7A10.5 10.5 0 0 1 513.3 782.2L475 741.4A10.5 10.5 0 0 1 475.8 726.3L555.4 656.3 475.8 586.2A10.5 10.5 0 0 1 475 571.1L513.3 530.3A10.5 10.5 0 0 1 528.2 529.8L654.9 648.6A10.5 10.5 0 0 1 654.9 663.9Z\",\n        \"width\": 750\n      },\n      \"search\": [\n        \"file-code\"\n      ]\n    },\n    {\n      \"uid\": \"d7271d490b71df4311e32cdacae8b331\",\n      \"css\": \"home\",\n      \"code\": 59403,\n      \"src\": \"fontawesome\"\n    }\n  ]\n}"
  },
  {
    "path": "src/static/js/AttributeManager.ts",
    "content": "// @ts-nocheck\nimport AttributeMap from './AttributeMap';\nimport {compose, deserializeOps, isIdentity} from './Changeset';\nimport {Builder} from \"./Builder\";\nimport {buildKeepRange, buildKeepToStartOfRange, buildRemoveRange} from './ChangesetUtils';\nimport attributes from './attributes';\nimport underscore from \"underscore\";\n\nconst lineMarkerAttribute = 'lmkr';\n\n// Some of these attributes are kept for compatibility purposes.\n// Not sure if we need all of them\nconst DEFAULT_LINE_ATTRIBUTES = ['author', 'lmkr', 'insertorder', 'start'];\n\n// If one of these attributes are set to the first character of a\n// line it is considered as a line attribute marker i.e. attributes\n// set on this marker are applied to the whole line.\n// The list attribute is only maintained for compatibility reasons\nconst lineAttributes = [lineMarkerAttribute, 'list'];\n\n/*\n  The Attribute manager builds changesets based on a document\n  representation for setting and removing range or line-based attributes.\n\n  @param rep the document representation to be used\n  @param applyChangesetCallback this callback will be called\n    once a changeset has been built.\n\n\n  A document representation contains\n  - an array `alines` containing 1 attributes string for each line\n  - an Attribute pool `apool`\n  - a SkipList `lines` containing the text lines of the document.\n*/\n\nconst AttributeManager = function (rep, applyChangesetCallback) {\n  this.rep = rep;\n  this.applyChangesetCallback = applyChangesetCallback;\n  this.author = '';\n\n  // If the first char in a line has one of the following attributes\n  // it will be considered as a line marker\n};\n\nAttributeManager.DEFAULT_LINE_ATTRIBUTES = DEFAULT_LINE_ATTRIBUTES;\nAttributeManager.lineAttributes = lineAttributes;\n\nAttributeManager.prototype = underscore.default(AttributeManager.prototype).extend({\n\n  applyChangeset(changeset) {\n    if (!this.applyChangesetCallback) return changeset;\n\n    const cs = changeset.toString();\n    if (!isIdentity(cs)) {\n      this.applyChangesetCallback(cs);\n    }\n\n    return changeset;\n  },\n\n  /*\n    Sets attributes on a range\n    @param start [row, col] tuple pointing to the start of the range\n    @param end [row, col] tuple pointing to the end of the range\n    @param attribs: an array of attributes\n  */\n  setAttributesOnRange(start, end, attribs) {\n    if (start[0] < 0) throw new RangeError('selection start line number is negative');\n    if (start[1] < 0) throw new RangeError('selection start column number is negative');\n    if (end[0] < 0) throw new RangeError('selection end line number is negative');\n    if (end[1] < 0) throw new RangeError('selection end column number is negative');\n    if (start[0] > end[0] || (start[0] === end[0] && start[1] > end[1])) {\n      throw new RangeError('selection ends before it starts');\n    }\n\n    // instead of applying the attributes to the whole range at once, we need to apply them\n    // line by line, to be able to disregard the \"*\" used as line marker. For more details,\n    // see https://github.com/ether/etherpad-lite/issues/2772\n    let allChangesets;\n    for (let row = start[0]; row <= end[0]; row++) {\n      const [startCol, endCol] = this._findRowRange(row, start, end);\n      const rowChangeset = this._setAttributesOnRangeByLine(row, startCol, endCol, attribs);\n\n      // compose changesets of all rows into a single changeset\n      // as the range might not be continuous\n      // due to the presence of line markers on the rows\n      if (allChangesets) {\n        allChangesets = compose(\n            allChangesets.toString(), rowChangeset.toString(), this.rep.apool);\n      } else {\n        allChangesets = rowChangeset;\n      }\n    }\n\n    return this.applyChangeset(allChangesets);\n  },\n\n  _findRowRange(row, start, end) {\n    if (row < start[0] || row > end[0]) throw new RangeError(`line ${row} not in selection`);\n    if (row >= this.rep.lines.length()) throw new RangeError(`selected line ${row} does not exist`);\n\n    // Subtract 1 for the end-of-line '\\n' (it is never selected).\n    const lineLength =\n        this.rep.lines.offsetOfIndex(row + 1) - this.rep.lines.offsetOfIndex(row) - 1;\n    const markerWidth = this.lineHasMarker(row) ? 1 : 0;\n    if (lineLength - markerWidth < 0) throw new Error(`line ${row} has negative length`);\n\n    if (start[1] < 0) throw new RangeError('selection starts at negative column');\n    const startCol = Math.max(markerWidth, row === start[0] ? start[1] : 0);\n    if (startCol > lineLength) throw new RangeError('selection starts after line end');\n\n    if (end[1] < 0) throw new RangeError('selection ends at negative column');\n    const endCol = Math.max(markerWidth, row === end[0] ? end[1] : lineLength);\n    if (endCol > lineLength) throw new RangeError('selection ends after line end');\n    if (startCol > endCol) throw new RangeError('selection ends before it starts');\n\n    return [startCol, endCol];\n  },\n\n  /**\n   * Sets attributes on a range, by line\n   * @param row the row where range is\n   * @param startCol column where range starts\n   * @param endCol column where range ends (one past the last selected column)\n   * @param attribs an array of attributes\n   */\n  _setAttributesOnRangeByLine(row, startCol, endCol, attribs) {\n    const builder = new Builder(this.rep.lines.totalWidth());\n    buildKeepToStartOfRange(this.rep, builder, [row, startCol]);\n    buildKeepRange(\n        this.rep, builder, [row, startCol], [row, endCol], attribs, this.rep.apool);\n    return builder;\n  },\n\n  /*\n    Returns if the line already has a line marker\n    @param lineNum: the number of the line\n  */\n  lineHasMarker(lineNum) {\n    return lineAttributes.find(\n        (attribute) => this.getAttributeOnLine(lineNum, attribute) !== '') !== undefined;\n  },\n\n  /*\n    Gets a specified attribute on a line\n    @param lineNum: the number of the line to set the attribute for\n    @param attributeKey: the name of the attribute to get, e.g. list\n  */\n  getAttributeOnLine(lineNum, attributeName) {\n    // get  `attributeName` attribute of first char of line\n    const aline = this.rep.alines[lineNum];\n    if (!aline) return '';\n    const [op] = deserializeOps(aline);\n    if (op == null) return '';\n    return AttributeMap.fromString(op.attribs, this.rep.apool).get(attributeName) || '';\n  },\n\n  /*\n    Gets all attributes on a line\n    @param lineNum: the number of the line to get the attribute for\n  */\n  getAttributesOnLine(lineNum) {\n    // get attributes of first char of line\n    const aline = this.rep.alines[lineNum];\n    if (!aline) return [];\n    const [op] = deserializeOps(aline);\n    if (op == null) return [];\n    return [...attributes.attribsFromString(op.attribs, this.rep.apool)];\n  },\n\n  /*\n    Gets a given attribute on a selection\n    @param attributeName\n    @param prevChar\n    returns true or false if an attribute is visible in range\n  */\n  getAttributeOnSelection(attributeName, prevChar) {\n    const rep = this.rep;\n    if (!(rep.selStart && rep.selEnd)) return;\n    // If we're looking for the caret attribute not the selection\n    // has the user already got a selection or is this purely a caret location?\n    const isNotSelection = (rep.selStart[0] === rep.selEnd[0] && rep.selEnd[1] === rep.selStart[1]);\n    if (isNotSelection) {\n      if (prevChar) {\n        // If it's not the start of the line\n        if (rep.selStart[1] !== 0) {\n          rep.selStart[1]--;\n        }\n      }\n    }\n\n    const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString();\n    const withItRegex = new RegExp(`${withIt.replace(/\\*/g, '\\\\*')}(\\\\*|$)`);\n    const hasIt = (attribs) => withItRegex.test(attribs);\n\n    const rangeHasAttrib = (selStart, selEnd) => {\n      // if range is collapsed -> no attribs in range\n      if (selStart[1] === selEnd[1] && selStart[0] === selEnd[0]) return false;\n\n      if (selStart[0] !== selEnd[0]) { // -> More than one line selected\n        // from selStart to the end of the first line\n        let hasAttrib = rangeHasAttrib(\n            selStart, [selStart[0], rep.lines.atIndex(selStart[0]).text.length]);\n\n        // for all lines in between\n        for (let n = selStart[0] + 1; n < selEnd[0]; n++) {\n          hasAttrib = hasAttrib && rangeHasAttrib([n, 0], [n, rep.lines.atIndex(n).text.length]);\n        }\n\n        // for the last, potentially partial, line\n        hasAttrib = hasAttrib && rangeHasAttrib([selEnd[0], 0], [selEnd[0], selEnd[1]]);\n\n        return hasAttrib;\n      }\n\n      // Logic tells us we now have a range on a single line\n\n      const lineNum = selStart[0];\n      const start = selStart[1];\n      const end = selEnd[1];\n      let hasAttrib = true;\n\n      let indexIntoLine = 0;\n      for (const op of deserializeOps(rep.alines[lineNum])) {\n        const opStartInLine = indexIntoLine;\n        const opEndInLine = opStartInLine + op.chars;\n        if (!hasIt(op.attribs)) {\n          // does op overlap selection?\n          if (!(opEndInLine <= start || opStartInLine >= end)) {\n            // since it's overlapping but hasn't got the attrib -> range hasn't got it\n            hasAttrib = false;\n            break;\n          }\n        }\n        indexIntoLine = opEndInLine;\n      }\n\n      return hasAttrib;\n    };\n    return rangeHasAttrib(rep.selStart, rep.selEnd);\n  },\n\n  /*\n    Gets all attributes at a position containing line number and column\n    @param lineNumber starting with zero\n    @param column starting with zero\n    returns a list of attributes in the format\n    [ [\"key\",\"value\"], [\"key\",\"value\"], ...  ]\n  */\n  getAttributesOnPosition(lineNumber, column) {\n    // get all attributes of the line\n    const aline = this.rep.alines[lineNumber];\n\n    if (!aline) {\n      return [];\n    }\n\n    // we need to sum up how much characters each operations take until the wanted position\n    let currentPointer = 0;\n\n    for (const currentOperation of deserializeOps(aline)) {\n      currentPointer += currentOperation.chars;\n      if (currentPointer <= column) continue;\n      return [...attributes.attribsFromString(currentOperation.attribs, this.rep.apool)];\n    }\n    return [];\n  },\n\n  /*\n    Gets all attributes at caret position\n    if the user selected a range, the start of the selection is taken\n    returns a list of attributes in the format\n    [ [\"key\",\"value\"], [\"key\",\"value\"], ...  ]\n  */\n  getAttributesOnCaret() {\n    return this.getAttributesOnPosition(this.rep.selStart[0], this.rep.selStart[1]);\n  },\n\n  /*\n    Sets a specified attribute on a line\n    @param lineNum: the number of the line to set the attribute for\n    @param attributeKey: the name of the attribute to set, e.g. list\n    @param attributeValue: an optional parameter to pass to the attribute (e.g. indention level)\n\n  */\n  setAttributeOnLine(lineNum, attributeName, attributeValue) {\n    let loc = [0, 0];\n    const builder = new Builder(this.rep.lines.totalWidth());\n    const hasMarker = this.lineHasMarker(lineNum);\n\n    buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 0]));\n\n    if (hasMarker) {\n      buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 1]), [\n        [attributeName, attributeValue],\n      ], this.rep.apool);\n    } else {\n      // add a line marker\n      builder.insert('*', [\n        ['author', this.author],\n        ['insertorder', 'first'],\n        [lineMarkerAttribute, '1'],\n        [attributeName, attributeValue],\n      ], this.rep.apool);\n    }\n\n    return this.applyChangeset(builder);\n  },\n\n  /**\n   * Removes a specified attribute on a line\n   *  @param lineNum the number of the affected line\n   *  @param attributeName the name of the attribute to remove, e.g. list\n   *  @param attributeValue if given only attributes with equal value will be removed\n   */\n  removeAttributeOnLine(lineNum, attributeName, attributeValue) {\n    const builder = new Builder(this.rep.lines.totalWidth());\n    const hasMarker = this.lineHasMarker(lineNum);\n    let found = false;\n\n    const attribs = this.getAttributesOnLine(lineNum).map((attrib) => {\n      if (attrib[0] === attributeName && (!attributeValue || attrib[0] === attributeValue)) {\n        found = true;\n        return [attrib[0], ''];\n      } else if (attrib[0] === 'author') {\n        // update last author to make changes to line attributes on this line\n        return [attrib[0], this.author];\n      }\n      return attrib;\n    });\n\n    if (!found) {\n      return;\n    }\n\n    buildKeepToStartOfRange(this.rep, builder, [lineNum, 0]);\n\n    const countAttribsWithMarker = underscore.chain(attribs).filter((a) => !!a[1])\n        .map((a) => a[0]).difference(DEFAULT_LINE_ATTRIBUTES).size().value();\n\n    // if we have marker and any of attributes don't need to have marker. we need delete it\n    if (hasMarker && !countAttribsWithMarker) {\n      buildRemoveRange(this.rep, builder, [lineNum, 0], [lineNum, 1]);\n    } else {\n      buildKeepRange(\n          this.rep, builder, [lineNum, 0], [lineNum, 1], attribs, this.rep.apool);\n    }\n\n    return this.applyChangeset(builder);\n  },\n\n  /*\n     Toggles a line attribute for the specified line number\n     If a line attribute with the specified name exists with any value it will be removed\n     Otherwise it will be set to the given value\n     @param lineNum: the number of the line to toggle the attribute for\n     @param attributeKey: the name of the attribute to toggle, e.g. list\n     @param attributeValue: the value to pass to the attribute (e.g. indention level)\n  */\n  toggleAttributeOnLine(lineNum, attributeName, attributeValue) {\n    return this.getAttributeOnLine(lineNum, attributeName)\n      ? this.removeAttributeOnLine(lineNum, attributeName)\n      : this.setAttributeOnLine(lineNum, attributeName, attributeValue);\n  },\n\n  hasAttributeOnSelectionOrCaretPosition(attributeName) {\n    const hasSelection = (\n      (this.rep.selStart[0] !== this.rep.selEnd[0]) || (this.rep.selEnd[1] !== this.rep.selStart[1])\n    );\n    let hasAttrib;\n    if (hasSelection) {\n      hasAttrib = this.getAttributeOnSelection(attributeName);\n    } else {\n      const attributesOnCaretPosition = this.getAttributesOnCaret();\n      const allAttribs = [].concat(...attributesOnCaretPosition); // flatten\n      hasAttrib = allAttribs.includes(attributeName);\n    }\n    return hasAttrib;\n  },\n});\n\nmodule.exports = AttributeManager;\n"
  },
  {
    "path": "src/static/js/AttributeMap.ts",
    "content": "'use strict';\n\nimport AttributePool from \"./AttributePool\";\nimport {Attribute} from \"./types/Attribute\";\n\nimport attributes from './attributes';\n\n/**\n * A `[key, value]` pair of strings describing a text attribute.\n *\n * @typedef {[string, string]} Attribute\n */\n\n/**\n * A concatenated sequence of zero or more attribute identifiers, each one represented by an\n * asterisk followed by a base-36 encoded attribute number.\n *\n * Examples: '', '*0', '*3*j*z*1q'\n *\n * @typedef {string} AttributeString\n */\n\n/**\n * Convenience class to convert an Op's attribute string to/from a Map of key, value pairs.\n */\nclass AttributeMap extends Map {\n  private readonly pool? : AttributePool|null\n  /**\n   * Converts an attribute string into an AttributeMap.\n   *\n   * @param {AttributeString} str - The attribute string to convert into an AttributeMap.\n   * @param {AttributePool} pool - Attribute pool.\n   * @returns {AttributeMap}\n   */\n  public static fromString(str: string, pool?: AttributePool|null): AttributeMap {\n    return new AttributeMap(pool).updateFromString(str);\n  }\n\n  /**\n   * @param {AttributePool} pool - Attribute pool.\n   */\n  constructor(pool?: AttributePool|null) {\n    super();\n    /** @public */\n    this.pool = pool;\n  }\n\n  /**\n   * @param {string} k - Attribute name.\n   * @param {string} v - Attribute value.\n   * @returns {AttributeMap} `this` (for chaining).\n   */\n  set(k: string, v: string):this {\n    k = k == null ? '' : String(k);\n    v = v == null ? '' : String(v);\n    this.pool!.putAttrib([k, v]);\n    return super.set(k, v);\n  }\n\n  toString() {\n    return attributes.attribsToString(attributes.sort([...this]), this.pool!);\n  }\n\n  /**\n   * @param {Iterable<Attribute>} entries - [key, value] pairs to insert into this map.\n   * @param {boolean} [emptyValueIsDelete] - If true and an entry's value is the empty string, the\n   *     key is removed from this map (if present).\n   * @returns {AttributeMap} `this` (for chaining).\n   */\n  update(entries: Iterable<Attribute>, emptyValueIsDelete: boolean = false): AttributeMap {\n    for (let [k, v] of entries) {\n      k = k == null ? '' : String(k);\n      v = v == null ? '' : String(v);\n      if (!v && emptyValueIsDelete) {\n        this.delete(k);\n      } else {\n        this.set(k, v);\n      }\n    }\n    return this;\n  }\n\n  /**\n   * @param {AttributeString} str - The attribute string identifying the attributes to insert into\n   *     this map.\n   * @param {boolean} [emptyValueIsDelete] - If true and an entry's value is the empty string, the\n   *     key is removed from this map (if present).\n   * @returns {AttributeMap} `this` (for chaining).\n   */\n  updateFromString(str: string, emptyValueIsDelete: boolean = false): AttributeMap {\n    return this.update(attributes.attribsFromString(str, this.pool!), emptyValueIsDelete);\n  }\n}\n\nexport default AttributeMap\n"
  },
  {
    "path": "src/static/js/AttributePool.ts",
    "content": "'use strict';\n/**\n * This code represents the Attribute Pool Object of the original Etherpad.\n * 90% of the code is still like in the original Etherpad\n * Look at https://github.com/ether/pad/blob/master/infrastructure/ace/www/easysync2.js\n * You can find a explanation what a attribute pool is here:\n * https://github.com/ether/etherpad-lite/blob/master/doc/easysync/easysync-notes.txt\n */\n\n/*\n * Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A `[key, value]` pair of strings describing a text attribute.\n *\n * @typedef {[string, string]} Attribute\n */\n\n/**\n * Maps an attribute's identifier to the attribute.\n *\n * @typedef {Object.<number, Attribute>} NumToAttrib\n */\n\n/**\n * An intermediate representation of the contents of an attribute pool, suitable for serialization\n * via `JSON.stringify` and transmission to another user.\n *\n * @typedef {Object} Jsonable\n * @property {NumToAttrib} numToAttrib - The pool's attributes and their identifiers.\n * @property {number} nextNum - The attribute ID to assign to the next new attribute.\n */\n\nimport {Attribute} from \"./types/Attribute\";\n\n/**\n * Represents an attribute pool, which is a collection of attributes (pairs of key and value\n * strings) along with their identifiers (non-negative integers).\n *\n * The attribute pool enables attribute interning: rather than including the key and value strings\n * in changesets, changesets reference attributes by their identifiers.\n *\n * There is one attribute pool per pad, and it includes every current and historical attribute used\n * in the pad.\n */\nclass AttributePool {\n  numToAttrib: {\n    [key: number]: [string, string]\n  }\n  private attribToNum: {\n    [key: number]: [string, string]\n  }\n  private nextNum: number\n\n  constructor() {\n    /**\n     * Maps an attribute identifier to the attribute's `[key, value]` string pair.\n     *\n     * TODO: Rename to `_numToAttrib` once all users have been migrated to call `getAttrib` instead\n     * of accessing this directly.\n     * @private\n     * TODO: Convert to an array.\n     * @type {NumToAttrib}\n     */\n    this.numToAttrib = {}; // e.g. {0: ['foo','bar']}\n\n    /**\n     * Maps the string representation of an attribute (`String([key, value])`) to its non-negative\n     * identifier.\n     *\n     * TODO: Rename to `_attribToNum` once all users have been migrated to use `putAttrib` instead\n     * of accessing this directly.\n     * @private\n     * TODO: Convert to a `Map` object.\n     * @type {Object.<string, number>}\n     */\n    this.attribToNum = {}; // e.g. {'foo,bar': 0}\n\n    /**\n     * The attribute ID to assign to the next new attribute.\n     *\n     * TODO: This property will not be necessary once `numToAttrib` is converted to an array (just\n     * push onto the array).\n     *\n     * @private\n     * @type {number}\n     */\n    this.nextNum = 0;\n  }\n\n  /**\n   * @returns {AttributePool} A deep copy of this attribute pool.\n   */\n  clone() {\n    const c = new AttributePool();\n    for (const [n, a] of Object.entries(this.numToAttrib)){\n      // @ts-ignore\n      c.numToAttrib[n] = [a[0], a[1]];\n    }\n    Object.assign(c.attribToNum, this.attribToNum);\n    c.nextNum = this.nextNum;\n    return c;\n  }\n\n  /**\n   * Add an attribute to the attribute set, or query for an existing attribute identifier.\n   *\n   * @param {Attribute} attrib - The attribute's `[key, value]` pair of strings.\n   * @param {boolean} [dontAddIfAbsent=false] - If true, do not insert the attribute into the pool\n   *     if the attribute does not already exist in the pool. This can be used to test for\n   *     membership in the pool without mutating the pool.\n   * @returns {number} The attribute's identifier, or -1 if the attribute is not in the pool.\n   */\n  putAttrib(attrib: Attribute, dontAddIfAbsent = false) {\n    const str = String(attrib);\n    if (str in this.attribToNum) {\n      // @ts-ignore\n      return this.attribToNum[str];\n    }\n    if (dontAddIfAbsent) {\n      return -1;\n    }\n    const num = this.nextNum++;\n    // @ts-ignore\n    this.attribToNum[str] = num;\n    this.numToAttrib[num] = [String(attrib[0] || ''), String(attrib[1] || '')];\n    return num;\n  }\n\n  /**\n   * @param {number} num - The identifier of the attribute to fetch.\n   * @returns {Attribute} The attribute with the given identifier, or nullish if there is no such\n   *     attribute.\n   */\n  getAttrib(num: number): Attribute {\n    const pair = this.numToAttrib[num];\n    if (!pair) {\n      return pair;\n    }\n    return [pair[0], pair[1]]; // return a mutable copy\n  }\n\n  /**\n   * @param {number} num - The identifier of the attribute to fetch.\n   * @returns {string} Eqivalent to `getAttrib(num)[0]` if the attribute exists, otherwise the empty\n   *     string.\n   */\n  getAttribKey(num: number): string {\n    const pair = this.numToAttrib[num];\n    if (!pair) return '';\n    return pair[0];\n  }\n\n  /**\n   * @param {number} num - The identifier of the attribute to fetch.\n   * @returns {string} Eqivalent to `getAttrib(num)[1]` if the attribute exists, otherwise the empty\n   *     string.\n   */\n  getAttribValue(num: number) {\n    const pair = this.numToAttrib[num];\n    if (!pair) return '';\n    return pair[1];\n  }\n\n  /**\n   * Executes a callback for each attribute in the pool.\n   *\n   * @param {Function} func - Callback to call with two arguments: key and value. Its return value\n   *     is ignored.\n   */\n  eachAttrib(func: (k: string, v: string)=>void) {\n    for (const n in this.numToAttrib) {\n      const pair = this.numToAttrib[n];\n      func(pair[0], pair[1]);\n    }\n  }\n\n  /**\n   * @returns {Jsonable} An object that can be passed to `fromJsonable` to reconstruct this\n   *     attribute pool. The returned object can be converted to JSON. WARNING: The returned object\n   *     has references to internal state (it is not a deep copy). Use the `clone()` method to copy\n   *     a pool -- do NOT do `new AttributePool().fromJsonable(pool.toJsonable())` to copy because\n   *     the resulting shared state will lead to pool corruption.\n   */\n  toJsonable() {\n    return {\n      numToAttrib: this.numToAttrib,\n      nextNum: this.nextNum,\n    };\n  }\n\n  /**\n   * Replace the contents of this attribute pool with values from a previous call to `toJsonable`.\n   *\n   * @param {Jsonable} obj - Object returned by `toJsonable` containing the attributes and their\n   *     identifiers. WARNING: This function takes ownership of the object (it does not make a deep\n   *     copy). Use the `clone()` method to copy a pool -- do NOT do\n   *     `new AttributePool().fromJsonable(pool.toJsonable())` to copy because the resulting shared\n   *     state will lead to pool corruption.\n   */\n  fromJsonable(obj: this) {\n    this.numToAttrib = obj.numToAttrib;\n    this.nextNum = obj.nextNum;\n    this.attribToNum = {};\n    for (const n of Object.keys(this.numToAttrib)) {\n      // @ts-ignore\n      this.attribToNum[String(this.numToAttrib[n])] = Number(n);\n    }\n    return this;\n  }\n\n  /**\n   * Asserts that the data in the pool is consistent. Throws if inconsistent.\n   */\n  check() {\n    if (!Number.isInteger(this.nextNum)) throw new Error('nextNum property is not an integer');\n    if (this.nextNum < 0) throw new Error('nextNum property is negative');\n    for (const prop of ['numToAttrib', 'attribToNum']) {\n      // @ts-ignore\n      const obj = this[prop];\n      if (obj == null) throw new Error(`${prop} property is null`);\n      if (typeof obj !== 'object') throw new TypeError(`${prop} property is not an object`);\n      const keys = Object.keys(obj);\n      if (keys.length !== this.nextNum) {\n        throw new Error(`${prop} size mismatch (want ${this.nextNum}, got ${keys.length})`);\n      }\n    }\n    for (let i = 0; i < this.nextNum; ++i) {\n      const attr = this.numToAttrib[`${i}`];\n      if (!Array.isArray(attr)) throw new TypeError(`attrib ${i} is not an array`);\n      if (attr.length !== 2) throw new Error(`attrib ${i} is not an array of length 2`);\n      const [k, v] = attr;\n      if (k == null) throw new TypeError(`attrib ${i} key is null`);\n      if (typeof k !== 'string') throw new TypeError(`attrib ${i} key is not a string`);\n      if (v == null) throw new TypeError(`attrib ${i} value is null`);\n      if (typeof v !== 'string') throw new TypeError(`attrib ${i} value is not a string`);\n      const attrStr = String(attr);\n      // @ts-ignore\n      if (this.attribToNum[attrStr] !== i) throw new Error(`attribToNum for ${attrStr} !== ${i}`);\n    }\n  }\n}\n\nexport default AttributePool\n"
  },
  {
    "path": "src/static/js/Builder.ts",
    "content": "/**\n * Incrementally builds a Changeset.\n *\n * @typedef {object} Builder\n * @property {Function} insert -\n * @property {Function} keep -\n * @property {Function} keepText -\n * @property {Function} remove -\n * @property {Function} toString -\n */\nimport {SmartOpAssembler} from \"./SmartOpAssembler\";\nimport Op from \"./Op\";\nimport {StringAssembler} from \"./StringAssembler\";\nimport AttributeMap from \"./AttributeMap\";\nimport {Attribute} from \"./types/Attribute\";\nimport AttributePool from \"./AttributePool\";\nimport {opsFromText, pack} from \"./Changeset\";\n\n/**\n * @param {number} oldLen - Old length\n * @returns {Builder}\n */\nexport class Builder {\n  private readonly oldLen: number;\n  private assem: SmartOpAssembler;\n  private readonly o: Op;\n  private charBank: StringAssembler;\n\n  constructor(oldLen: number) {\n    this.oldLen = oldLen\n    this.assem = new SmartOpAssembler()\n    this.o = new Op()\n    this.charBank = new StringAssembler()\n  }\n\n  /**\n   * @param {number} N - Number of characters to keep.\n   * @param {number} L - Number of newlines among the `N` characters. If positive, the last\n   *     character must be a newline.\n   * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...'\n   *     (no pool needed in latter case).\n   * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of\n   *     attribute key, value pairs.\n   * @returns {Builder} this\n   */\n  keep =  (N: number, L?: number, attribs?: string|Attribute[], pool?: AttributePool): Builder => {\n    this.o.opcode = '=';\n    this.o.attribs = typeof attribs === 'string'\n      ? attribs : new AttributeMap(pool).update(attribs || []).toString();\n    this.o.chars = N;\n    this.o.lines = (L || 0);\n    this.assem.append(this.o);\n    return this;\n  }\n\n\n  /**\n   * @param {string} text - Text to keep.\n   * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...'\n   *     (no pool needed in latter case).\n   * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of\n   *     attribute key, value pairs.\n   * @returns {Builder} this\n   */\n  keepText= (text: string, attribs?: string|Attribute[], pool?: AttributePool): Builder=> {\n    for (const op of opsFromText('=', text, attribs, pool)) this.assem.append(op);\n    return this;\n  }\n\n\n  /**\n   * @param {string} text - Text to insert.\n   * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...'\n   *     (no pool needed in latter case).\n   * @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of\n   *     attribute key, value pairs.\n   * @returns {Builder} this\n   */\n  insert= (text: string, attribs: string | Attribute[] | undefined, pool?: AttributePool | null | undefined): Builder => {\n    for (const op of opsFromText('+', text, attribs, pool)) this.assem.append(op);\n    this.charBank.append(text);\n    return this;\n  }\n\n\n  /**\n   * @param {number} N - Number of characters to remove.\n   * @param {number} L - Number of newlines among the `N` characters. If positive, the last\n   *     character must be a newline.\n   * @returns {Builder} this\n   */\n  remove= (N: number, L?: number): Builder => {\n    this.o.opcode = '-';\n    this.o.attribs = '';\n    this.o.chars = N;\n    this.o.lines = (L || 0);\n    this.assem.append(this.o);\n    return this;\n  }\n\n  toString= () => {\n    this.assem.endDocument();\n    const newLen = this.oldLen + this.assem.getLengthChange();\n    return pack(this.oldLen, newLen, this.assem.toString(), this.charBank.toString());\n  }\n}\n\n\n"
  },
  {
    "path": "src/static/js/Changeset.ts",
    "content": "'use strict';\n\n/*\n * Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*\n * This is the Changeset library copied from the old Etherpad with some modifications\n * to use it in node.js. The original can be found at:\n * https://github.com/ether/pad/blob/master/infrastructure/ace/www/easysync2.js\n */\n\nimport AttributeMap from './AttributeMap'\nimport AttributePool from \"./AttributePool\";\nimport {attribsFromString} from './attributes';\nimport padutils from \"./pad_utils\";\nimport Op, {OpCode} from './Op'\nimport {numToString, parseNum} from './ChangesetUtils'\nimport {StringAssembler} from \"./StringAssembler\";\nimport {OpIter} from \"./OpIter\";\nimport {Attribute} from \"./types/Attribute\";\nimport {SmartOpAssembler} from \"./SmartOpAssembler\";\nimport TextLinesMutator from \"./TextLinesMutator\";\nimport {ChangeSet} from \"./types/ChangeSet\";\nimport {AText} from \"./types/AText\";\nimport {ChangeSetBuilder} from \"./types/ChangeSetBuilder\";\nimport {Builder} from \"./Builder\";\nimport {StringIterator} from \"./StringIterator\";\nimport {MergingOpAssembler} from \"./MergingOpAssembler\";\n\n/**\n * A `[key, value]` pair of strings describing a text attribute.\n *\n * @typedef {[string, string]} Attribute\n */\n\n/**\n * A concatenated sequence of zero or more attribute identifiers, each one represented by an\n * asterisk followed by a base-36 encoded attribute number.\n *\n * Examples: '', '*0', '*3*j*z*1q'\n *\n * @typedef {string} AttributeString\n */\n\n/**\n * This method is called whenever there is an error in the sync process.\n *\n * @param {string} msg - Just some message\n */\nconst error = (msg: string) => {\n  const e = new Error(msg);\n  // @ts-ignore\n  e.easysync = true;\n  throw e;\n};\n\n/**\n * Assert that a condition is truthy. If the condition is falsy, the `error` function is called to\n * throw an exception.\n *\n * @param {boolean} b - assertion condition\n * @param {string} msg - error message to include in the exception\n * @type {(b: boolean, msg: string) => asserts b}\n */\nexport const assert: (b: boolean, msg: string) => asserts b = (b: boolean, msg: string): asserts b => {\n  if (!b) error(`Failed assertion: ${msg}`);\n};\n\n\n/**\n * Describes changes to apply to a document. Does not include the attribute pool or the original\n * document.\n *\n * @typedef {object} Changeset\n * @property {number} oldLen - The length of the base document.\n * @property {number} newLen - The length of the document after applying the changeset.\n * @property {string} ops - Serialized sequence of operations. Use `deserializeOps` to parse this\n *     string.\n * @property {string} charBank - Characters inserted by insert operations.\n */\n\n/**\n * Returns the required length of the text before changeset can be applied.\n *\n * @param {string} cs - String representation of the Changeset\n * @returns {number} oldLen property\n */\nexport const oldLen = (cs: string) => unpack(cs).oldLen\n\n/**\n * Returns the length of the text after changeset is applied.\n *\n * @param {string} cs - String representation of the Changeset\n * @returns {number} newLen property\n */\nexport const newLen = (cs: string) => unpack(cs).newLen\n\n/**\n * Parses a string of serialized changeset operations.\n *\n * @param {string} ops - Serialized changeset operations.\n * @yields {Op}\n * @returns {Generator<Op>}\n */\nexport const deserializeOps = function* (ops: string) {\n  // TODO: Migrate to String.prototype.matchAll() once there is enough browser support.\n  const regex = /((?:\\*[0-9a-z]+)*)(?:\\|([0-9a-z]+))?([-+=])([0-9a-z]+)|(.)/g;\n  let match;\n  while ((match = regex.exec(ops)) != null) {\n    if (match[5] === '$') return; // Start of the insert operation character bank.\n    if (match[5] != null) error(`invalid operation: ${ops.slice(regex.lastIndex - 1)}`);\n    const opMatch = match[3] as   \"\"|\"=\" | \"+\" | \"-\" | undefined\n    const op = new Op(opMatch);\n    op.lines = parseNum(match[2] || '0');\n    op.chars = parseNum(match[4]);\n    op.attribs = match[1];\n    yield op;\n  }\n};\n\n\n\n/**\n * Creates an iterator which decodes string changeset operations.\n *\n * @deprecated Use `deserializeOps` instead.\n * @param {string} opsStr - String encoding of the change operations to perform.\n * @returns {OpIter} Operator iterator object.\n */\nexport const opIterator = (opsStr: string) => {\n  padutils.warnDeprecated(\n    'Changeset.opIterator() is deprecated; use Changeset.deserializeOps() instead');\n  return new OpIter(opsStr);\n};\n\n/**\n * Cleans an Op object.\n *\n * @param {Op} op - object to clear\n */\nexport const clearOp = (op: Op) => {\n  op.opcode = '';\n  op.chars = 0;\n  op.lines = 0;\n  op.attribs = '';\n};\n\n/**\n * Creates a new Op object\n *\n * @deprecated Use the `Op` class instead.\n * @param {('+'|'-'|'='|'')} [optOpcode=''] - The operation's operator.\n * @returns {Op}\n */\nexport const newOp = (optOpcode:'+'|'-'|'='|'' ): Op => {\n  padutils.warnDeprecated('Changeset.newOp() is deprecated; use the Changeset.Op class instead');\n  return new Op(optOpcode);\n};\n\n/**\n * Copies op1 to op2\n *\n * @param {Op} op1 - src Op\n * @param {Op} [op2] - dest Op. If not given, a new Op is used.\n * @returns {Op} `op2`\n */\nexport const copyOp = (op1: Op, op2: Op = new Op()): Op => Object.assign(op2, op1);\n\n/**\n * Serializes a sequence of Ops.\n *\n * @typedef {object} OpAssembler\n * @property {Function} append -\n * @property {Function} clear -\n * @property {Function} toString -\n */\n\n/**\n * Efficiently merges consecutive operations that are mergeable, ignores no-ops, and drops final\n * pure \"keeps\". It does not re-order operations.\n *\n * @typedef {object} MergingOpAssembler\n * @property {Function} append -\n * @property {Function} clear -\n * @property {Function} endDocument -\n * @property {Function} toString -\n */\n\n/**\n * Generates operations from the given text and attributes.\n *\n * @param {('-'|'+'|'=')} opcode - The operator to use.\n * @param {string} text - The text to remove/add/keep.\n * @param {(Iterable<Attribute>|AttributeString)} [attribs] - The attributes to insert into the pool\n *     (if necessary) and encode. If an attribute string, no checking is performed to ensure that\n *     the attributes exist in the pool, are in the canonical order, and contain no duplicate keys.\n *     If this is an iterable of attributes, `pool` must be non-null.\n * @param {?AttributePool.ts} pool - Attribute pool. Required if `attribs` is an iterable of\n *     attributes, ignored if `attribs` is an attribute string.\n * @yields {Op} One or two ops (depending on the presense of newlines) that cover the given text.\n * @returns {Generator<Op>}\n */\nexport const opsFromText = function* (opcode: \"\" | \"=\" | \"+\" | \"-\" | undefined, text: string, attribs: string|Attribute[] = '', pool: AttributePool|null = null) {\n  const op = new Op(opcode);\n  op.attribs = typeof attribs === 'string'\n    ? attribs : new AttributeMap(pool).update(attribs || [], opcode === '+').toString();\n  const lastNewlinePos = text.lastIndexOf('\\n');\n  if (lastNewlinePos < 0) {\n    op.chars = text.length;\n    op.lines = 0;\n    yield op;\n  } else {\n    op.chars = lastNewlinePos + 1;\n    op.lines = text.match(/\\n/g)!.length;\n    yield op;\n    const op2 = copyOp(op);\n    op2.chars = text.length - (lastNewlinePos + 1);\n    op2.lines = 0;\n    yield op2;\n  }\n};\n\n\n\n/**\n * Used to check if a Changeset is valid. This function does not check things that require access to\n * the attribute pool (e.g., attribute order) or original text (e.g., newline positions).\n *\n * @param {string} cs - Changeset to check\n * @returns {string} the checked Changeset\n */\nexport const checkRep = (cs: string) => {\n  const unpacked = unpack(cs);\n  const oldLen = unpacked.oldLen;\n  const newLen = unpacked.newLen;\n  const ops = unpacked.ops;\n  let charBank = unpacked.charBank;\n\n  const assem = new SmartOpAssembler();\n  let oldPos = 0;\n  let calcNewLen = 0;\n  for (const o of deserializeOps(ops)) {\n    switch (o.opcode) {\n      case '=':\n        oldPos += o.chars;\n        calcNewLen += o.chars;\n        break;\n      case '-':\n        oldPos += o.chars;\n        assert(oldPos <= oldLen, `${oldPos} > ${oldLen} in ${cs}`);\n        break;\n      case '+':\n      {\n        assert(charBank.length >= o.chars, 'Invalid changeset: not enough chars in charBank');\n        const chars = charBank.slice(0, o.chars);\n        const nlines = (chars.match(/\\n/g) || []).length;\n        assert(nlines === o.lines,\n          'Invalid changeset: number of newlines in insert op does not match the charBank');\n        assert(o.lines === 0 || chars.endsWith('\\n'),\n          'Invalid changeset: multiline insert op does not end with a newline');\n        charBank = charBank.slice(o.chars);\n        calcNewLen += o.chars;\n        assert(calcNewLen <= newLen, `${calcNewLen} > ${newLen} in ${cs}`);\n        break;\n      }\n      default:\n        assert(false, `Invalid changeset: Unknown opcode: ${JSON.stringify(o.opcode)}`);\n    }\n    assem.append(o);\n  }\n  calcNewLen += oldLen - oldPos;\n  assert(calcNewLen === newLen, 'Invalid changeset: claimed length does not match actual length');\n  assert(charBank === '', 'Invalid changeset: excess characters in the charBank');\n  assem.endDocument();\n  const normalized = pack(oldLen, calcNewLen, assem.toString(), unpacked.charBank);\n  assert(normalized === cs, 'Invalid changeset: not in canonical form');\n  return cs;\n};\n\n/**\n * A custom made StringBuffer\n *\n * @typedef {object} StringAssembler\n * @property {Function} append -\n * @property {Function} toString -\n */\n\n/**\n * @typedef {object} StringArrayLike\n * @property {(i: number) => string} get - Returns the line at index `i`.\n * @property {(number|(() => number))} length - The number of lines, or a method that returns the\n *     number of lines.\n * @property {(((start?: number, end?: number) => string[])|undefined)} slice - Like\n *     `Array.prototype.slice()`. Optional if the return value of the `removeLines` method is not\n *     needed.\n * @property {(i: number, d?: number, ...l: string[]) => any} splice - Like\n *     `Array.prototype.splice()`.\n */\n\n\n/**\n * Apply operations to other operations.\n *\n * @param {string} in1 - first Op string\n * @param {string} in2 - second Op string\n * @param {Function} func - Callback that applies an operation to another operation. Will be called\n *     multiple times depending on the number of operations in `in1` and `in2`. `func` has signature\n *     `opOut = f(op1, op2)`:\n *       - `op1` is the current operation from `in1`. `func` is expected to mutate `op1` to\n *         partially or fully consume it, and MUST set `op1.opcode` to the empty string once `op1`\n *         is fully consumed. If `op1` is not fully consumed, `func` will be called again with the\n *         same `op1` value. If `op1` is fully consumed, the next call to `func` will be given the\n *         next operation from `in1`. If there are no more operations in `in1`, `op1.opcode` will be\n *         the empty string.\n *       - `op2` is the current operation from `in2`, to apply to `op1`. Has the same consumption\n *         and advancement semantics as `op1`.\n *       - `opOut` is the result of applying `op2` (before consumption) to `op1` (before\n *         consumption). If there is no result (perhaps `op1` and `op2` canceled each other out),\n *         either `opOut` must be nullish or `opOut.opcode` must be the empty string.\n * @returns {string} the integrated changeset\n */\nconst applyZip = (in1: string, in2: string, func: Function): string => {\n  const ops1 = deserializeOps(in1);\n  const ops2 = deserializeOps(in2);\n  let next1 = ops1.next();\n  let next2 = ops2.next();\n  const assem = new SmartOpAssembler();\n  while (!next1.done || !next2.done) {\n    if (!next1.done && !next1.value.opcode) next1 = ops1.next();\n    if (!next2.done && !next2.value.opcode) next2 = ops2.next();\n    if (next1.value == null) next1.value = new Op();\n    if (next2.value == null) next2.value = new Op();\n    if (!next1.value.opcode && !next2.value.opcode) break;\n    const opOut = func(next1.value, next2.value);\n    if (opOut && opOut.opcode) assem.append(opOut);\n  }\n  assem.endDocument();\n  return assem.toString();\n};\n\n/**\n * Parses an encoded changeset.\n *\n * @param {string} cs - The encoded changeset.\n * @returns {Changeset}\n */\nexport const unpack = (cs: string): ChangeSet => {\n  const headerRegex = /Z:([0-9a-z]+)([><])([0-9a-z]+)|/;\n  const headerMatch = headerRegex.exec(cs);\n  if ((!headerMatch) || (!headerMatch[0])) error(`Not a changeset: ${cs}`);\n  const oldLen = parseNum(headerMatch![1]);\n  const changeSign = (headerMatch![2] === '>') ? 1 : -1;\n  const changeMag = parseNum(headerMatch![3]);\n  const newLen = oldLen + changeSign * changeMag;\n  const opsStart = headerMatch![0].length;\n  let opsEnd = cs.indexOf('$');\n  if (opsEnd < 0) opsEnd = cs.length;\n  return {\n    oldLen,\n    newLen,\n    ops: cs.substring(opsStart, opsEnd),\n    charBank: cs.substring(opsEnd + 1),\n  };\n};\n\n/**\n * Creates an encoded changeset.\n *\n * @param {number} oldLen - The length of the document before applying the changeset.\n * @param {number} newLen - The length of the document after applying the changeset.\n * @param {string} opsStr - Encoded operations to apply to the document.\n * @param {string} bank - Characters for insert operations.\n * @returns {string} The encoded changeset.\n */\nexport const pack = (oldLen: number, newLen: number, opsStr: string, bank: string): string => {\n  const lenDiff = newLen - oldLen;\n  const lenDiffStr = (lenDiff >= 0 ? `>${numToString(lenDiff)}`\n    : `<${numToString(-lenDiff)}`);\n  const a = [];\n  a.push('Z:', numToString(oldLen), lenDiffStr, opsStr, '$', bank);\n  return a.join('');\n};\n\n/**\n * Applies a Changeset to a string.\n *\n * @param {string} cs - String encoded Changeset\n * @param {string} str - String to which a Changeset should be applied\n * @returns {string}\n */\nexport const applyToText = (cs: string, str: string): string => {\n  const unpacked = unpack(cs);\n  assert(str.length === unpacked.oldLen, `mismatched apply: ${str.length} / ${unpacked.oldLen}`);\n  const bankIter = new StringIterator(unpacked.charBank);\n  const strIter = new StringIterator(str);\n  const assem = new StringAssembler();\n  for (const op of deserializeOps(unpacked.ops)) {\n    switch (op.opcode) {\n      case '+':\n        // op is + and op.lines 0: no newlines must be in op.chars\n        // op is + and op.lines >0: op.chars must include op.lines newlines\n        if (op.lines !== bankIter.peek(op.chars).split('\\n').length - 1) {\n          throw new Error(`newline count is wrong in op +; cs:${cs} and text:${str}`);\n        }\n        assem.append(bankIter.take(op.chars));\n        break;\n      case '-':\n        // op is - and op.lines 0: no newlines must be in the deleted string\n        // op is - and op.lines >0: op.lines newlines must be in the deleted string\n        if (op.lines !== strIter.peek(op.chars).split('\\n').length - 1) {\n          throw new Error(`newline count is wrong in op -; cs:${cs} and text:${str}`);\n        }\n        strIter.skip(op.chars);\n        break;\n      case '=':\n        // op is = and op.lines 0: no newlines must be in the copied string\n        // op is = and op.lines >0: op.lines newlines must be in the copied string\n        if (op.lines !== strIter.peek(op.chars).split('\\n').length - 1) {\n          throw new Error(`newline count is wrong in op =; cs:${cs} and text:${str}`);\n        }\n        assem.append(strIter.take(op.chars));\n        break;\n    }\n  }\n  assem.append(strIter.take(strIter.remaining()));\n  return assem.toString();\n};\n\n/**\n * Applies a changeset on an array of lines.\n *\n * @param {string} cs - the changeset to apply\n * @param {string[]} lines - The lines to which the changeset needs to be applied\n */\nexport const mutateTextLines = (cs: string, lines: RegExpMatchArray|string[] | null) => {\n  const unpacked = unpack(cs);\n  const bankIter = new StringIterator(unpacked.charBank);\n  const mut = new TextLinesMutator(lines!);\n  for (const op of deserializeOps(unpacked.ops)) {\n    switch (op.opcode) {\n      case '+':\n        mut.insert(bankIter.take(op.chars), op.lines);\n        break;\n      case '-':\n        mut.remove(op.chars, op.lines);\n        break;\n      case '=':\n        mut.skip(op.chars, op.lines, (!!op.attribs));\n        break;\n    }\n  }\n  mut.close();\n};\n\n/**\n * Composes two attribute strings (see below) into one.\n *\n * @param {AttributeString} att1 - first attribute string\n * @param {AttributeString} att2 - second attribue string\n * @param {boolean} resultIsMutation -\n * @param {AttributePool.ts} pool - attribute pool\n * @returns {string}\n */\nexport const composeAttributes = (att1: string, att2: string, resultIsMutation: boolean, pool?: AttributePool|null): string => {\n  // att1 and att2 are strings like \"*3*f*1c\", asMutation is a boolean.\n  // Sometimes attribute (key,value) pairs are treated as attribute presence\n  // information, while other times they are treated as operations that\n  // mutate a set of attributes, and this affects whether an empty value\n  // is a deletion or a change.\n  // Examples, of the form (att1Items, att2Items, resultIsMutation) -> result\n  // ([], [(bold, )], true) -> [(bold, )]\n  // ([], [(bold, )], false) -> []\n  // ([], [(bold, true)], true) -> [(bold, true)]\n  // ([], [(bold, true)], false) -> [(bold, true)]\n  // ([(bold, true)], [(bold, )], true) -> [(bold, )]\n  // ([(bold, true)], [(bold, )], false) -> []\n  // pool can be null if att2 has no attributes.\n  if ((!att1) && resultIsMutation) {\n    // In the case of a mutation (i.e. composing two exportss),\n    // an att2 composed with an empy att1 is just att2.  If att1\n    // is part of an attribution string, then att2 may remove\n    // attributes that are already gone, so don't do this optimization.\n    return att2;\n  }\n  if (!att2) return att1;\n  return AttributeMap.fromString(att1, pool).updateFromString(att2, !resultIsMutation).toString();\n};\n\n\n/**\n * Applies a changeset to an array of attribute lines.\n *\n * @param {string} cs - The encoded changeset.\n * @param {Array<string>} lines - Attribute lines. Modified in place.\n * @param {AttributePool} pool - Attribute pool.\n */\nexport const mutateAttributionLines = (cs: any, lines: string[] | RegExpMatchArray, pool: AttributePool | null) => {\n  const unpacked = unpack(cs);\n  const csOps = deserializeOps(unpacked.ops);\n  let csOpsNext = csOps.next();\n  const csBank = unpacked.charBank;\n  let csBankIndex = 0;\n  // treat the attribution lines as text lines, mutating a line at a time\n  const mut = new TextLinesMutator(lines);\n\n  /**\n   * The Ops in the current line from `lines`.\n   *\n   * @type {?Generator<Op>}\n   */\n  let lineOps: { next: () => any; } | null = null;\n  let lineOpsNext: { done: any; value: any; } | null = null;\n\n  const lineOpsHasNext = () => lineOpsNext && !lineOpsNext.done;\n  /**\n   * Returns false if we are on the last attribute line in `lines` and there is no additional op in\n   * that line.\n   *\n   * @returns {boolean} True if there are more ops to go through.\n   */\n  const isNextMutOp = () => lineOpsHasNext() || mut.hasMore();\n\n  /**\n   * @returns {Op} The next Op from `lineIter`. If there are no more Ops, `lineIter` is reset to\n   *     iterate over the next line, which is consumed from `mut`. If there are no more lines,\n   *     returns a null Op.\n   */\n  const nextMutOp = () => {\n    if (!lineOpsHasNext() && mut.hasMore()) {\n      // There are more attribute lines in `lines` to do AND either we just started so `lineIter` is\n      // still null or there are no more ops in current `lineIter`.\n      const line = mut.removeLines(1);\n      lineOps = deserializeOps(line);\n      lineOpsNext = lineOps.next();\n    }\n    if (!lineOpsHasNext()) return new Op(); // No more ops and no more lines.\n    const op = lineOpsNext!.value;\n    lineOpsNext = lineOps!.next();\n    return op;\n  };\n  let lineAssem: { append: (arg0: any) => void; toString: () => string; } | null = null;\n\n  /**\n   * Appends an op to `lineAssem`. In case `lineAssem` includes one single newline, adds it to the\n   * `lines` mutator.\n   */\n  const outputMutOp = (op: Op) => {\n    if (!lineAssem) {\n      lineAssem = new MergingOpAssembler();\n    }\n    lineAssem!.append(op);\n    if (op.lines <= 0) return;\n    assert(op.lines === 1, `Can't have op.lines of ${op.lines} in attribution lines`);\n    // ship it to the mut\n    mut.insert(lineAssem!.toString(), 1);\n    lineAssem = null;\n  };\n\n  let csOp = new Op();\n  let attOp = new Op();\n  while (csOp.opcode || !csOpsNext.done || attOp.opcode || isNextMutOp()) {\n    if (!csOp.opcode && !csOpsNext.done) {\n      // coOp done, but more ops in cs.\n      csOp = csOpsNext.value;\n      csOpsNext = csOps.next();\n    }\n    if (!csOp.opcode && !attOp.opcode && !lineAssem && !lineOpsHasNext()) {\n      break; // done\n    } else if (csOp.opcode === '=' && csOp.lines > 0 && !csOp.attribs && !attOp.opcode &&\n      !lineAssem && !lineOpsHasNext()) {\n      // Skip multiple lines without attributes; this is what makes small changes not order of the\n      // document size.\n      mut.skipLines(csOp.lines);\n      csOp.opcode = '';\n    } else if (csOp.opcode === '+') {\n      const opOut = copyOp(csOp);\n      if (csOp.lines > 1) {\n        // Copy the first line from `csOp` to `opOut`.\n        const firstLineLen = csBank.indexOf('\\n', csBankIndex) + 1 - csBankIndex;\n        csOp.chars -= firstLineLen;\n        csOp.lines--;\n        opOut.lines = 1;\n        opOut.chars = firstLineLen;\n      } else {\n        // Either one or no newlines in '+' `csOp`, copy to `opOut` and reset `csOp`.\n        csOp.opcode = '';\n      }\n      outputMutOp(opOut);\n      csBankIndex += opOut.chars;\n    } else {\n      if (!attOp.opcode && isNextMutOp()) attOp = nextMutOp();\n      const opOut = slicerZipperFunc(attOp, csOp, pool);\n      if (opOut.opcode) outputMutOp(opOut);\n    }\n  }\n\n  assert(!lineAssem, `line assembler not finished:${cs}`);\n  mut.close();\n};\n\n/**\n * Function used as parameter for applyZip to apply a Changeset to an attribute.\n *\n * @param {Op} attOp - The op from the sequence that is being operated on, either an attribution\n *     string or the earlier of two exportss being composed.\n * @param {Op} csOp -\n * @param {AttributePool.ts} pool - Can be null if definitely not needed.\n * @returns {Op} The result of applying `csOp` to `attOp`.\n */\nexport const slicerZipperFunc = (attOp: Op, csOp: Op, pool: AttributePool|null):Op => {\n  const opOut = new Op();\n  if (!attOp.opcode) {\n    copyOp(csOp, opOut);\n    csOp.opcode = '';\n  } else if (!csOp.opcode) {\n    copyOp(attOp, opOut);\n    attOp.opcode = '';\n  } else if (attOp.opcode === '-') {\n    copyOp(attOp, opOut);\n    attOp.opcode = '';\n  } else if (csOp.opcode === '+') {\n    copyOp(csOp, opOut);\n    csOp.opcode = '';\n  } else {\n    for (const op of [attOp, csOp]) {\n      assert(op.chars >= op.lines, `op has more newlines than chars: ${op.toString()}`);\n    }\n    assert(\n      attOp.chars < csOp.chars ? attOp.lines <= csOp.lines\n        : attOp.chars > csOp.chars ? attOp.lines >= csOp.lines\n          : attOp.lines === csOp.lines,\n      'line count mismatch when composing changesets A*B; ' +\n      `opA: ${attOp.toString()} opB: ${csOp.toString()}`);\n    assert(['+', '='].includes(attOp.opcode), `unexpected opcode in op: ${attOp.toString()}`);\n    assert(['-', '='].includes(csOp.opcode), `unexpected opcode in op: ${csOp.toString()}`);\n    opOut.opcode = {\n      '+': {\n        '-': '', // The '-' cancels out (some of) the '+', leaving any remainder for the next call.\n        '=': '+',\n      },\n      '=': {\n        '-': '-',\n        '=': '=',\n      },\n    }[attOp.opcode][csOp.opcode] as OpCode;\n    const [fullyConsumedOp, partiallyConsumedOp] = [attOp, csOp].sort((a, b) => a.chars - b.chars);\n    opOut.chars = fullyConsumedOp.chars;\n    opOut.lines = fullyConsumedOp.lines;\n    opOut.attribs = csOp.opcode === '-'\n      // csOp is a remove op and remove ops normally never have any attributes, so this should\n      // normally be the empty string. However, padDiff.js adds attributes to remove ops and needs\n      // them preserved so they are copied here.\n      ? csOp.attribs\n      : composeAttributes(attOp.attribs, csOp.attribs, attOp.opcode === '=', pool);\n    partiallyConsumedOp.chars -= fullyConsumedOp.chars;\n    partiallyConsumedOp.lines -= fullyConsumedOp.lines;\n    if (!partiallyConsumedOp.chars) partiallyConsumedOp.opcode = '';\n    fullyConsumedOp.opcode = '';\n  }\n  return opOut;\n};\n\n/**\n * Applies a Changeset to the attribs string of a AText.\n *\n * @param {string} cs - Changeset\n * @param {string} astr - the attribs string of a AText\n * @param {AttributePool.ts} pool - the attibutes pool\n * @returns {string}\n */\nexport const applyToAttribution = (cs: string, astr: string, pool: AttributePool): string => {\n  const unpacked = unpack(cs);\n  return applyZip(astr, unpacked.ops, (op1: Op, op2:Op) => slicerZipperFunc(op1, op2, pool));\n};\n\n/**\n * Joins several Attribution lines.\n *\n * @param {string[]} theAlines - collection of Attribution lines\n * @returns {string} joined Attribution lines\n */\nexport const joinAttributionLines = (theAlines: string[]): string => {\n  const assem = new MergingOpAssembler();\n  for (const aline of theAlines) {\n    for (const op of deserializeOps(aline)) assem.append(op);\n  }\n  return assem.toString();\n};\n\nexport const splitAttributionLines = (attrOps: string, text: string) => {\n  const assem = new MergingOpAssembler();\n  const lines: string[] = [];\n  let pos = 0;\n\n  const appendOp = (op:Op) => {\n    assem.append(op);\n    if (op.lines > 0) {\n      lines.push(assem.toString());\n      assem.clear();\n    }\n    pos += op.chars;\n  };\n\n  for (const op of deserializeOps(attrOps)) {\n    let numChars = op.chars;\n    let numLines = op.lines;\n    while (numLines > 1) {\n      const newlineEnd = text.indexOf('\\n', pos) + 1;\n      assert(newlineEnd > 0, 'newlineEnd <= 0 in splitAttributionLines');\n      op.chars = newlineEnd - pos;\n      op.lines = 1;\n      appendOp(op);\n      numChars -= op.chars;\n      numLines -= op.lines;\n    }\n    if (numLines === 1) {\n      op.chars = numChars;\n      op.lines = 1;\n    }\n    appendOp(op);\n  }\n\n  return lines;\n};\n\n/**\n * Splits text into lines.\n *\n * @param {string} text - text to split\n * @returns {string[]}\n */\nexport const splitTextLines = (text:string) => text.match(/[^\\n]*(?:\\n|[^\\n]$)/g);\n\n/**\n * Compose two Changesets.\n *\n * @param {string} cs1 - first Changeset\n * @param {string} cs2 - second Changeset\n * @param {AttributePool.ts} pool - Attribs pool\n * @returns {string}\n */\nexport const compose = (cs1: string, cs2:string, pool: AttributePool): string => {\n  const unpacked1 = unpack(cs1);\n  const unpacked2 = unpack(cs2);\n  const len1 = unpacked1.oldLen;\n  const len2 = unpacked1.newLen;\n  assert(len2 === unpacked2.oldLen, 'mismatched composition of two changesets');\n  const len3 = unpacked2.newLen;\n  const bankIter1 = new StringIterator(unpacked1.charBank);\n  const bankIter2 = new StringIterator(unpacked2.charBank);\n  const bankAssem = new StringAssembler();\n\n  const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1: Op, op2: Op) => {\n    const op1code = op1.opcode;\n    const op2code = op2.opcode;\n    if (op1code === '+' && op2code === '-') {\n      bankIter1.skip(Math.min(op1.chars, op2.chars));\n    }\n    const opOut = slicerZipperFunc(op1, op2, pool);\n    if (opOut.opcode === '+') {\n      if (op2code === '+') {\n        bankAssem.append(bankIter2.take(opOut.chars));\n      } else {\n        bankAssem.append(bankIter1.take(opOut.chars));\n      }\n    }\n    return opOut;\n  });\n\n  return pack(len1, len3, newOps, bankAssem.toString());\n};\n\n/**\n * Returns a function that tests if a string of attributes (e.g. '*3*4') contains a given attribute\n * key,value that is already present in the pool.\n *\n * @param {Attribute} attribPair - `[key, value]` pair of strings.\n * @param {AttributePool.ts} pool - Attribute pool\n * @returns {Function}\n */\nexport const attributeTester = (attribPair: Attribute, pool: AttributePool): Function => {\n  const never = (attribs: Attribute[]) => false;\n  if (!pool) return never;\n  const attribNum = pool.putAttrib(attribPair, true);\n  if (attribNum < 0) return never;\n  const re = new RegExp(`\\\\*${numToString(attribNum)}(?!\\\\w)`);\n  return (attribs: string) => re.test(attribs);\n};\n\n/**\n * Creates the identity Changeset of length N.\n *\n * @param {number} N - length of the identity changeset\n * @returns {string}\n */\nexport const identity = (N: number): string => pack(N, N, '', '');\n\n/**\n * Creates a Changeset which works on oldFullText and removes text from spliceStart to\n * spliceStart+numRemoved and inserts newText instead. Also gives possibility to add attributes\n * optNewTextAPairs for the new text.\n *\n * @param {string} orig - Original text.\n * @param {number} start - Index into `orig` where characters should be removed and inserted.\n * @param {number} ndel - Number of characters to delete at `start`.\n * @param {string} ins - Text to insert at `start` (after deleting `ndel` characters).\n * @param {string} [attribs] - Optional attributes to apply to the inserted text.\n * @param {AttributePool.ts} [pool] - Attribute pool.\n * @returns {string}\n */\nexport const makeSplice = (orig: string, start: number, ndel: number, ins: string|null, attribs?: string | Attribute[] | undefined, pool?: AttributePool | null | undefined): string => {\n  if (start < 0) throw new RangeError(`start index must be non-negative (is ${start})`);\n  if (ndel < 0) throw new RangeError(`characters to delete must be non-negative (is ${ndel})`);\n  if (start > orig.length) start = orig.length;\n  if (ndel > orig.length - start) ndel = orig.length - start;\n  const deleted = orig.substring(start, start + ndel);\n  const assem = new SmartOpAssembler();\n  const ops = (function* () {\n    yield* opsFromText('=', orig.substring(0, start));\n    yield* opsFromText('-', deleted);\n    yield* opsFromText('+', ins as string, attribs, pool);\n  })();\n  for (const op of ops) assem.append(op);\n  assem.endDocument();\n  return pack(orig.length, orig.length + ins!.length - ndel, assem.toString(), ins!);\n};\n\n/**\n * Transforms a changeset into a list of splices in the form [startChar, endChar, newText] meaning\n * replace text from startChar to endChar with newText.\n *\n * @param {string} cs - Changeset\n * @returns {[number, number, string][]}\n */\nconst toSplices = (cs: string): [number, number, string][] => {\n  const unpacked = unpack(cs);\n  /** @type {[number, number, string][]} */\n  const splices: [number, number, string][] = [];\n\n  let oldPos = 0;\n  const charIter = new StringIterator(unpacked.charBank);\n  let inSplice = false;\n  for (const op of deserializeOps(unpacked.ops)) {\n    if (op.opcode === '=') {\n      oldPos += op.chars;\n      inSplice = false;\n    } else {\n      if (!inSplice) {\n        splices.push([oldPos, oldPos, '']);\n        inSplice = true;\n      }\n      if (op.opcode === '-') {\n        oldPos += op.chars;\n        splices[splices.length - 1][1] += op.chars;\n      } else if (op.opcode === '+') {\n        splices[splices.length - 1][2] += charIter.take(op.chars);\n      }\n    }\n  }\n\n  return splices;\n};\n\n/**\n * @param {string} cs -\n * @param {number} startChar -\n * @param {number} endChar -\n * @param {number} insertionsAfter -\n * @returns {[number, number]}\n */\nexport const characterRangeFollow = (cs: string, startChar: number, endChar: number, insertionsAfter: number):[number, number] => {\n  let newStartChar = startChar;\n  let newEndChar = endChar;\n  let lengthChangeSoFar = 0;\n  for (const splice of toSplices(cs)) {\n    const spliceStart = splice[0] + lengthChangeSoFar;\n    const spliceEnd = splice[1] + lengthChangeSoFar;\n    const newTextLength = splice[2].length;\n    const thisLengthChange = newTextLength - (spliceEnd - spliceStart);\n\n    if (spliceStart <= newStartChar && spliceEnd >= newEndChar) {\n      // splice fully replaces/deletes range\n      // (also case that handles insertion at a collapsed selection)\n      if (insertionsAfter) {\n        newStartChar = newEndChar = spliceStart;\n      } else {\n        newStartChar = newEndChar = spliceStart + newTextLength;\n      }\n    } else if (spliceEnd <= newStartChar) {\n      // splice is before range\n      newStartChar += thisLengthChange;\n      newEndChar += thisLengthChange;\n    } else if (spliceStart >= newEndChar) {\n      // splice is after range\n    } else if (spliceStart >= newStartChar && spliceEnd <= newEndChar) {\n      // splice is inside range\n      newEndChar += thisLengthChange;\n    } else if (spliceEnd < newEndChar) {\n      // splice overlaps beginning of range\n      newStartChar = spliceStart + newTextLength;\n      newEndChar += thisLengthChange;\n    } else {\n      // splice overlaps end of range\n      newEndChar = spliceStart;\n    }\n\n    lengthChangeSoFar += thisLengthChange;\n  }\n\n  return [newStartChar, newEndChar];\n};\n\n/**\n * Iterate over attributes in a changeset and move them from oldPool to newPool.\n *\n * @param {string} cs - Chageset/attribution string to iterate over\n * @param {AttributePool} oldPool - old attributes pool\n * @param {AttributePool} newPool - new attributes pool\n * @returns {string} the new Changeset\n */\nexport const moveOpsToNewPool = (cs: string, oldPool: AttributePool, newPool: AttributePool): string => {\n  // works on exports or attribution string\n  let dollarPos = cs.indexOf('$');\n  if (dollarPos < 0) {\n    dollarPos = cs.length;\n  }\n  const upToDollar = cs.substring(0, dollarPos);\n  const fromDollar = cs.substring(dollarPos);\n  // order of attribs stays the same\n  return upToDollar.replace(/\\*([0-9a-z]+)/g, (_, a) => {\n    const oldNum = parseNum(a);\n    const pair = oldPool.getAttrib(oldNum);\n    // The attribute might not be in the old pool if the user is viewing the current revision in the\n    // timeslider and text is deleted. See: https://github.com/ether/etherpad-lite/issues/3932\n    if (!pair) return '';\n    const newNum = newPool.putAttrib(pair);\n    return `*${numToString(newNum)}`;\n  }) + fromDollar;\n};\n\n/**\n * Create an attribution inserting a text.\n *\n * @param {string} text - text to insert\n * @returns {string}\n */\nexport const makeAttribution = (text: string) => {\n  const assem = new SmartOpAssembler();\n  for (const op of opsFromText('+', text)) assem.append(op);\n  return assem.toString();\n};\n\n/**\n * Iterates over attributes in exports, attribution string, or attribs property of an op and runs\n * function func on them.\n *\n * @deprecated Use `attributes.decodeAttribString()` instead.\n * @param {string} cs - changeset\n * @param {Function} func - function to call\n */\nexport const eachAttribNumber = (cs: string, func: Function) => {\n  padutils.warnDeprecated(\n    'Changeset.eachAttribNumber() is deprecated; use attributes.decodeAttribString() instead');\n  let dollarPos = cs.indexOf('$');\n  if (dollarPos < 0) {\n    dollarPos = cs.length;\n  }\n  const upToDollar = cs.substring(0, dollarPos);\n\n  // WARNING: The following cannot be replaced with a call to `attributes.decodeAttribString()`\n  // because that function only works on attribute strings, not serialized operations or changesets.\n  upToDollar.replace(/\\*([0-9a-z]+)/g, (_, a) => {\n    func(parseNum(a));\n    return '';\n  });\n};\n\n/**\n * Filter attributes which should remain in a Changeset. Callable on a exports, attribution string,\n * or attribs property of an op, though it may easily create adjacent ops that can be merged.\n *\n * @param {string} cs - changeset to filter\n * @param {Function} filter - fnc which returns true if an attribute X (int) should be kept in the\n *     Changeset\n * @returns {string}\n */\nexport const filterAttribNumbers = (cs: string, filter: Function) => mapAttribNumbers(cs, filter);\n\n/**\n * Does exactly the same as filterAttribNumbers.\n *\n * @param {string} cs -\n * @param {Function} func -\n * @returns {string}\n */\nexport const mapAttribNumbers = (cs: string, func: Function): string => {\n  let dollarPos = cs.indexOf('$');\n  if (dollarPos < 0) {\n    dollarPos = cs.length;\n  }\n  const upToDollar = cs.substring(0, dollarPos);\n\n  const newUpToDollar = upToDollar.replace(/\\*([0-9a-z]+)/g, (s, a) => {\n    const n = func(parseNum(a));\n    if (n === true) {\n      return s;\n    } else if ((typeof n) === 'number') {\n      return `*${numToString(n)}`;\n    } else {\n      return '';\n    }\n  });\n\n  return newUpToDollar + cs.substring(dollarPos);\n};\n\n/**\n * Represents text with attributes.\n *\n * @typedef {object} AText\n * @property {string} attribs - Serialized sequence of insert operations that cover the text in\n *     `text`. These operations describe which parts of the text have what attributes.\n * @property {string} text - The text.\n */\n\n/**\n * Create a Changeset going from Identity to a certain state.\n *\n * @param {string} text - text of the final change\n * @param {string} attribs - optional, operations which insert the text and also puts the right\n *     attributes\n * @returns {AText}\n */\nexport const makeAText = (text: string, attribs?: string): AText => ({\n  text,\n  attribs: (attribs || makeAttribution(text)),\n});\n\n/**\n * Apply a Changeset to a AText.\n *\n * @param {string} cs - Changeset to apply\n * @param {AText} atext -\n * @param {AttributePool.ts} pool - Attribute Pool to add to\n * @returns {AText}\n */\nexport const applyToAText = (cs: string, atext: AText, pool: AttributePool): AText => ({\n  text: applyToText(cs, atext.text),\n  attribs: applyToAttribution(cs, atext.attribs, pool),\n});\n\n/**\n * Clones a AText structure.\n *\n * @param {AText} atext -\n * @returns {AText}\n */\nexport const cloneAText = (atext: AText): AText => {\n  if (!atext) error('atext is null');\n  return {\n    text: atext.text,\n    attribs: atext.attribs,\n  };\n};\n\n/**\n * Copies a AText structure from atext1 to atext2.\n *\n * @param {AText} atext1 -\n * @param {AText} atext2 -\n */\nexport const copyAText = (atext1: AText, atext2: AText) => {\n  atext2.text = atext1.text;\n  atext2.attribs = atext1.attribs;\n};\n\n/**\n * Convert AText to a series of operations. Strips final newline.\n *\n * @param {AText} atext - The AText to convert.\n * @yields {Op}\n * @returns {Generator<Op>}\n */\nexport const opsFromAText = function* (atext: AText): Generator<Op> {\n  // intentionally skips last newline char of atext\n  let lastOp = null;\n  for (const op of deserializeOps(atext.attribs)) {\n    if (lastOp != null) yield lastOp;\n    lastOp = op;\n  }\n  if (lastOp == null) return;\n  // exclude final newline\n  if (lastOp.lines <= 1) {\n    lastOp.lines = 0;\n    lastOp.chars--;\n  } else {\n    const nextToLastNewlineEnd = atext.text.lastIndexOf('\\n', atext.text.length - 2) + 1;\n    const lastLineLength = atext.text.length - nextToLastNewlineEnd - 1;\n    lastOp.lines--;\n    lastOp.chars -= (lastLineLength + 1);\n    yield copyOp(lastOp);\n    lastOp.lines = 0;\n    lastOp.chars = lastLineLength;\n  }\n  if (lastOp.chars) yield lastOp;\n};\n\n/**\n * Append the set of operations from atext to an assembler.\n *\n * @deprecated Use `opsFromAText` instead.\n * @param {AText} atext -\n * @param assem - Assembler like SmartOpAssembler TODO add desc\n */\nexport const appendATextToAssembler = (atext: AText, assem: SmartOpAssembler) => {\n  padutils.warnDeprecated(\n    'Changeset.appendATextToAssembler() is deprecated; use Changeset.opsFromAText() instead');\n  for (const op of opsFromAText(atext)) assem.append(op);\n};\n\ntype WirePrep = {\n  translated: string,\n  pool: AttributePool\n}\n\n/**\n * Creates a clone of a Changeset and it's APool.\n *\n * @param {string} cs -\n * @param {AttributePool.ts} pool -\n * @returns {{translated: string, pool: AttributePool.ts}}\n */\nexport const prepareForWire = (cs: string, pool: AttributePool): WirePrep => {\n  const newPool = new AttributePool();\n  const newCs = moveOpsToNewPool(cs, pool, newPool);\n  return {\n    translated: newCs,\n    pool: newPool,\n  };\n};\n\n/**\n * Checks if a changeset s the identity changeset.\n *\n * @param {string} cs -\n * @returns {boolean}\n */\nexport const isIdentity = (cs: string): boolean => {\n  const unpacked = unpack(cs);\n  return unpacked.ops === '' && unpacked.oldLen === unpacked.newLen;\n};\n\n/**\n * @deprecated Use an AttributeMap instead.\n */\nconst _attribsAttributeValue = (attribs: string, key: string, pool: AttributePool) => {\n  if (!attribs) return '';\n  for (const [k, v] of attribsFromString(attribs, pool)) {\n    if (k === key) return v;\n  }\n  return '';\n};\n\n/**\n * Returns all the values of attributes with a certain key in an Op attribs string.\n *\n * @deprecated Use an AttributeMap instead.\n * @param {Op} op - Op\n * @param {string} key - string to search for\n * @param {AttributePool.ts} pool - attribute pool\n * @returns {string}\n */\nexport const opAttributeValue = (op: Op, key: string, pool: AttributePool):string => {\n  padutils.warnDeprecated(\n    'Changeset.opAttributeValue() is deprecated; use an AttributeMap instead');\n  return _attribsAttributeValue(op.attribs, key, pool);\n};\n\n/**\n * Returns all the values of attributes with a certain key in an attribs string.\n *\n * @deprecated Use an AttributeMap instead.\n * @param {AttributeString} attribs - Attribute string\n * @param {string} key - string to search for\n * @param {AttributePool.ts} pool - attribute pool\n * @returns {string}\n */\nexport const attribsAttributeValue = (attribs: string, key: string, pool: AttributePool) => {\n  padutils.warnDeprecated(\n    'Changeset.attribsAttributeValue() is deprecated; use an AttributeMap instead');\n  return _attribsAttributeValue(attribs, key, pool);\n};\n\n\n\n/**\n * Constructs an attribute string from a sequence of attributes.\n *\n * @deprecated Use `AttributeMap.prototype.toString()` or `attributes.attribsToString()` instead.\n * @param {string} opcode - The opcode for the Op that will get the resulting attribute string.\n * @param {?(Iterable<Attribute>|AttributeString)} attribs - The attributes to insert into the pool\n *     (if necessary) and encode. If an attribute string, no checking is performed to ensure that\n *     the attributes exist in the pool, are in the canonical order, and contain no duplicate keys.\n *     If this is an iterable of attributes, `pool` must be non-null.\n * @param {AttributePool.ts} pool - Attribute pool. Required if `attribs` is an iterable of attributes,\n *     ignored if `attribs` is an attribute string.\n * @returns {AttributeString}\n */\nexport const makeAttribsString = (opcode: string, attribs: Attribute[]|string, pool: AttributePool | null | undefined): string => {\n  padutils.warnDeprecated(\n    'Changeset.makeAttribsString() is deprecated; ' +\n    'use AttributeMap.prototype.toString() or attributes.attribsToString() instead');\n  if (!attribs || !['=', '+'].includes(opcode)) return '';\n  if (typeof attribs === 'string') return attribs;\n  return new AttributeMap(pool).update(attribs, opcode === '+').toString();\n};\n\n/**\n * Like \"substring\" but on a single-line attribution string.\n */\nexport const subattribution = (astr: string, start: number, optEnd?: number) => {\n  const attOps = deserializeOps(astr);\n  let attOpsNext = attOps.next();\n  const assem = new SmartOpAssembler();\n  let attOp = new Op();\n  const csOp = new Op();\n\n  const doCsOp = () => {\n    if (!csOp.chars) return;\n    while (csOp.opcode && (attOp.opcode || !attOpsNext.done)) {\n      if (!attOp.opcode) {\n        attOp = attOpsNext.value as Op;\n        attOpsNext = attOps.next();\n      }\n      if (csOp.opcode && attOp.opcode && csOp.chars >= attOp.chars &&\n        attOp.lines > 0 && csOp.lines <= 0) {\n        csOp.lines++;\n      }\n      const opOut = slicerZipperFunc(attOp, csOp, null);\n      if (opOut.opcode) assem.append(opOut);\n    }\n  };\n\n  csOp.opcode = '-';\n  csOp.chars = start;\n\n  doCsOp();\n\n  if (optEnd === undefined) {\n    if (attOp.opcode) {\n      assem.append(attOp);\n    }\n    while (!attOpsNext.done) {\n      assem.append(attOpsNext.value);\n      attOpsNext = attOps.next();\n    }\n  } else {\n    csOp.opcode = '=';\n    csOp.chars = optEnd - start;\n    doCsOp();\n  }\n\n  return assem.toString();\n};\n\nexport const inverse = (cs: string, lines: string|RegExpMatchArray|string[] | null, alines: string[]|{\n  get: (idx: number) => string,\n}, pool: AttributePool) => {\n  // lines and alines are what the exports is meant to apply to.\n  // They may be arrays or objects with .get(i) and .length methods.\n  // They include final newlines on lines.\n\n  const linesGet = (idx: number) => {\n    // @ts-ignore\n    if (\"get\" in lines) {\n      // @ts-ignore\n      return lines.get(idx);\n    } else {\n      return lines![idx];\n    }\n  };\n\n  /**\n   * @param {number} idx -\n   * @returns {string}\n   */\n  const alinesGet = (idx: number): string => {\n    // @ts-ignore\n    if (\"get\" in alines) {\n      return alines.get(idx);\n    } else {\n      return alines[idx];\n    }\n  };\n\n  let curLine = 0;\n  let curChar = 0;\n  let curLineOps: null|Generator<Op> = null;\n  let curLineOpsNext:IteratorResult<Op>|null = null;\n  let curLineOpsLine: number;\n  let curLineNextOp = new Op('+');\n\n  const unpacked = unpack(cs);\n  const builder = new Builder(unpacked.newLen);\n\n  const consumeAttribRuns = (numChars: number, func: Function /* (len, attribs, endsLine)*/) => {\n    if (!curLineOps || curLineOpsLine !== curLine) {\n      curLineOps = deserializeOps(alinesGet(curLine));\n      curLineOpsNext = curLineOps.next();\n      curLineOpsLine = curLine;\n      let indexIntoLine = 0;\n      while (!curLineOpsNext.done) {\n        curLineNextOp = curLineOpsNext.value;\n        curLineOpsNext = curLineOps.next();\n        if (indexIntoLine + curLineNextOp.chars >= curChar) {\n          curLineNextOp.chars -= (curChar - indexIntoLine);\n          break;\n        }\n        indexIntoLine += curLineNextOp.chars;\n      }\n    }\n\n    while (numChars > 0) {\n      if (!curLineNextOp.chars && curLineOpsNext!.done) {\n        curLine++;\n        curChar = 0;\n        curLineOpsLine = curLine;\n        curLineNextOp.chars = 0;\n        curLineOps = deserializeOps(alinesGet(curLine));\n        curLineOpsNext = curLineOps!.next();\n      }\n      if (!curLineNextOp.chars) {\n        if (curLineOpsNext!.done) {\n          curLineNextOp = new Op();\n        } else {\n          curLineNextOp = curLineOpsNext!.value;\n          curLineOpsNext = curLineOps.next();\n        }\n      }\n      const charsToUse = Math.min(numChars, curLineNextOp.chars);\n      func(charsToUse, curLineNextOp.attribs, charsToUse === curLineNextOp.chars &&\n        curLineNextOp.lines > 0);\n      numChars -= charsToUse;\n      curLineNextOp.chars -= charsToUse;\n      curChar += charsToUse;\n    }\n\n    if (!curLineNextOp.chars && curLineOpsNext!.done) {\n      curLine++;\n      curChar = 0;\n    }\n  };\n\n  const skip = (N: number, L: number) => {\n    if (L) {\n      curLine += L;\n      curChar = 0;\n    } else if (curLineOps && curLineOpsLine === curLine) {\n      consumeAttribRuns(N, () => {});\n    } else {\n      curChar += N;\n    }\n  };\n\n  const nextText = (numChars: number) => {\n    let len = 0;\n    const assem = new StringAssembler();\n    const firstString = linesGet(curLine).substring(curChar);\n    len += firstString.length;\n    assem.append(firstString);\n\n    let lineNum = curLine + 1;\n    while (len < numChars) {\n      const nextString = linesGet(lineNum);\n      len += nextString.length;\n      assem.append(nextString);\n      lineNum++;\n    }\n\n    return assem.toString().substring(0, numChars);\n  };\n\n  const cachedStrFunc = (func: Function) => {\n    const cache:{\n      [key: string]: string\n    } = {};\n    return (s: string | number) => {\n      if (!cache[s]) {\n        cache[s] = func(s);\n      }\n      return cache[s];\n    };\n  };\n\n  for (const csOp of deserializeOps(unpacked.ops)) {\n    if (csOp.opcode === '=') {\n      if (csOp.attribs) {\n        const attribs = AttributeMap.fromString(csOp.attribs, pool);\n        const undoBackToAttribs = cachedStrFunc((oldAttribsStr: string) => {\n          const oldAttribs = AttributeMap.fromString(oldAttribsStr, pool);\n          const backAttribs = new AttributeMap(pool);\n          for (const [key, value] of attribs) {\n            const oldValue = oldAttribs.get(key) || '';\n            if (oldValue !== value) backAttribs.set(key, oldValue);\n          }\n          // TODO: backAttribs does not restore removed attributes (it is missing attributes that\n          // are in oldAttribs but not in attribs). I don't know if that is intentional.\n          return backAttribs.toString();\n        });\n        consumeAttribRuns(csOp.chars, (len: number, attribs: string, endsLine: number) => {\n          builder.keep(len, endsLine ? 1 : 0, undoBackToAttribs(attribs));\n        });\n      } else {\n        skip(csOp.chars, csOp.lines);\n        builder.keep(csOp.chars, csOp.lines);\n      }\n    } else if (csOp.opcode === '+') {\n      builder.remove(csOp.chars, csOp.lines);\n    } else if (csOp.opcode === '-') {\n      const textBank = nextText(csOp.chars);\n      let textBankIndex = 0;\n      consumeAttribRuns(csOp.chars, (len: number, attribs: string) => {\n        builder.insert(textBank.substr(textBankIndex, len), attribs);\n        textBankIndex += len;\n      });\n    }\n  }\n\n  return checkRep(builder.toString());\n};\n\n// %CLIENT FILE ENDS HERE%\nexport const follow = (cs1: string, cs2:string, reverseInsertOrder: boolean, pool: AttributePool) => {\n  const unpacked1 = unpack(cs1);\n  const unpacked2 = unpack(cs2);\n  const len1 = unpacked1.oldLen;\n  const len2 = unpacked2.oldLen;\n  assert(len1 === len2, 'mismatched follow - cannot transform cs1 on top of cs2');\n  const chars1 = new StringIterator(unpacked1.charBank);\n  const chars2 = new StringIterator(unpacked2.charBank);\n\n  const oldLen = unpacked1.newLen;\n  let oldPos = 0;\n  let newLen = 0;\n\n  const hasInsertFirst = attributeTester(['insertorder', 'first'], pool);\n\n  const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1: Op, op2: Op) => {\n    const opOut = new Op();\n    if (op1.opcode === '+' || op2.opcode === '+') {\n      let whichToDo;\n      if (op2.opcode !== '+') {\n        whichToDo = 1;\n      } else if (op1.opcode !== '+') {\n        whichToDo = 2;\n      } else {\n        // both +\n        const firstChar1 = chars1.peek(1);\n        const firstChar2 = chars2.peek(1);\n        const insertFirst1 = hasInsertFirst(op1.attribs);\n        const insertFirst2 = hasInsertFirst(op2.attribs);\n        if (insertFirst1 && !insertFirst2) {\n          whichToDo = 1;\n        } else if (insertFirst2 && !insertFirst1) {\n          whichToDo = 2;\n        } else if (firstChar1 === '\\n' && firstChar2 !== '\\n') {\n          // insert string that doesn't start with a newline first so as not to break up lines\n          whichToDo = 2;\n        } else if (firstChar1 !== '\\n' && firstChar2 === '\\n') {\n          whichToDo = 1;\n        } else if (reverseInsertOrder) {\n          // break symmetry:\n          whichToDo = 2;\n        } else {\n          whichToDo = 1;\n        }\n      }\n      if (whichToDo === 1) {\n        chars1.skip(op1.chars);\n        opOut.opcode = '=';\n        opOut.lines = op1.lines;\n        opOut.chars = op1.chars;\n        opOut.attribs = '';\n        op1.opcode = '';\n      } else {\n        // whichToDo == 2\n        chars2.skip(op2.chars);\n        copyOp(op2, opOut);\n        op2.opcode = '';\n      }\n    } else if (op1.opcode === '-') {\n      if (!op2.opcode) {\n        op1.opcode = '';\n      } else if (op1.chars <= op2.chars) {\n        op2.chars -= op1.chars;\n        op2.lines -= op1.lines;\n        op1.opcode = '';\n        if (!op2.chars) {\n          op2.opcode = '';\n        }\n      } else {\n        op1.chars -= op2.chars;\n        op1.lines -= op2.lines;\n        op2.opcode = '';\n      }\n    } else if (op2.opcode === '-') {\n      copyOp(op2, opOut);\n      if (!op1.opcode) {\n        op2.opcode = '';\n      } else if (op2.chars <= op1.chars) {\n        // delete part or all of a keep\n        op1.chars -= op2.chars;\n        op1.lines -= op2.lines;\n        op2.opcode = '';\n        if (!op1.chars) {\n          op1.opcode = '';\n        }\n      } else {\n        // delete all of a keep, and keep going\n        opOut.lines = op1.lines;\n        opOut.chars = op1.chars;\n        op2.lines -= op1.lines;\n        op2.chars -= op1.chars;\n        op1.opcode = '';\n      }\n    } else if (!op1.opcode) {\n      copyOp(op2, opOut);\n      op2.opcode = '';\n    } else if (!op2.opcode) {\n      // @NOTE: Critical bugfix for EPL issue #1625. We do not copy op1 here\n      // in order to prevent attributes from leaking into result changesets.\n      // copyOp(op1, opOut);\n      op1.opcode = '';\n    } else {\n      // both keeps\n      opOut.opcode = '=';\n      opOut.attribs = followAttributes(op1.attribs, op2.attribs, pool);\n      if (op1.chars <= op2.chars) {\n        opOut.chars = op1.chars;\n        opOut.lines = op1.lines;\n        op2.chars -= op1.chars;\n        op2.lines -= op1.lines;\n        op1.opcode = '';\n        if (!op2.chars) {\n          op2.opcode = '';\n        }\n      } else {\n        opOut.chars = op2.chars;\n        opOut.lines = op2.lines;\n        op1.chars -= op2.chars;\n        op1.lines -= op2.lines;\n        op2.opcode = '';\n      }\n    }\n    switch (opOut.opcode) {\n      case '=':\n        oldPos += opOut.chars;\n        newLen += opOut.chars;\n        break;\n      case '-':\n        oldPos += opOut.chars;\n        break;\n      case '+':\n        newLen += opOut.chars;\n        break;\n    }\n    return opOut;\n  });\n  newLen += oldLen - oldPos;\n\n  return pack(oldLen, newLen, newOps, unpacked2.charBank);\n};\n\nconst followAttributes = (att1: string, att2: string, pool: AttributePool) => {\n  // The merge of two sets of attribute changes to the same text\n  // takes the lexically-earlier value if there are two values\n  // for the same key.  Otherwise, all key/value changes from\n  // both attribute sets are taken.  This operation is the \"follow\",\n  // so a set of changes is produced that can be applied to att1\n  // to produce the merged set.\n  if ((!att2) || (!pool)) return '';\n  if (!att1) return att2;\n  const atts = new Map();\n  att2.replace(/\\*([0-9a-z]+)/g, (_, a) => {\n    const [key, val] = pool.getAttrib(parseNum(a));\n    atts.set(key, val);\n    return '';\n  });\n  att1.replace(/\\*([0-9a-z]+)/g, (_, a) => {\n    const [key, val] = pool.getAttrib(parseNum(a));\n    if (atts.has(key) && val <= atts.get(key)) atts.delete(key);\n    return '';\n  });\n  // we've only removed attributes, so they're already sorted\n  const buf = new StringAssembler();\n  for (const att of atts) {\n    buf.append('*');\n    buf.append(numToString(pool.putAttrib(att)));\n  }\n  return buf.toString();\n};\n\nexport const exportedForTestingOnly = {\n  TextLinesMutator,\n  followAttributes,\n  toSplices,\n};\n"
  },
  {
    "path": "src/static/js/ChangesetUtils.ts",
    "content": "'use strict';\n\n/**\n * This module contains several helper Functions to build Changesets\n * based on a SkipList\n */\n\nimport {RepModel} from \"./types/RepModel\";\nimport {ChangeSetBuilder} from \"./types/ChangeSetBuilder\";\nimport {Attribute} from \"./types/Attribute\";\nimport AttributePool from \"./AttributePool\";\nimport {Builder} from \"./Builder\";\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nexport const buildRemoveRange = (rep: RepModel, builder: ChangeSetBuilder, start: [number,number], end: [number, number]) => {\n  const startLineOffset = rep.lines.offsetOfIndex(start[0]);\n  const endLineOffset = rep.lines.offsetOfIndex(end[0]);\n\n  if (end[0] > start[0]) {\n    builder.remove(endLineOffset - startLineOffset - start[1], end[0] - start[0]);\n    builder.remove(end[1]);\n  } else {\n    builder.remove(end[1] - start[1]);\n  }\n};\n\nexport const buildKeepRange = (rep: RepModel, builder: ChangeSetBuilder, start: [number, number], end:[number, number], attribs?: Attribute[], pool?: AttributePool) => {\n  const startLineOffset = rep.lines.offsetOfIndex(start[0]);\n  const endLineOffset = rep.lines.offsetOfIndex(end[0]);\n\n  if (end[0] > start[0]) {\n    builder.keep(endLineOffset - startLineOffset - start[1], end[0] - start[0], attribs, pool);\n    builder.keep(end[1], 0, attribs, pool);\n  } else {\n    builder.keep(end[1] - start[1], 0, attribs, pool);\n  }\n};\n\nexport const buildKeepToStartOfRange = (rep: RepModel, builder: Builder, start: [number, number]) => {\n  const startLineOffset = rep.lines.offsetOfIndex(start[0]);\n\n  builder.keep(startLineOffset, start[0]);\n  builder.keep(start[1]);\n};\n\n/**\n * Parses a number from string base 36.\n *\n * @param {string} str - string of the number in base 36\n * @returns {number} number\n */\nexport const parseNum = (str: string): number => parseInt(str, 36);\n\n/**\n * Writes a number in base 36 and puts it in a string.\n *\n * @param {number} num - number\n * @returns {string} string\n */\nexport const numToString = (num: number): string => num.toString(36).toLowerCase();\n"
  },
  {
    "path": "src/static/js/ChatMessage.ts",
    "content": "'use strict';\n\nimport padUtils from './pad_utils'\n\n/**\n * Represents a chat message stored in the database and transmitted among users. Plugins can extend\n * the object with additional properties.\n *\n * Supports serialization to JSON.\n */\nexport class ChatMessage {\n  customMetadata: any\n  text: string|null\n  public authorId: string|null\n  displayName: string|null\n  time: number|null\n  static fromObject(obj: ChatMessage) {\n    // The userId property was renamed to authorId, and userName was renamed to displayName. Accept\n    // the old names in case the db record was written by an older version of Etherpad.\n    obj = Object.assign({}, obj); // Don't mutate the caller's object.\n    if ('userId' in obj && !('authorId' in obj)) { // @ts-ignore\n      obj.authorId = obj.userId;\n    }\n    // @ts-ignore\n    delete obj.userId;\n    if ('userName' in obj && !('displayName' in obj)) { // @ts-ignore\n      obj.displayName = obj.userName;\n    }\n    // @ts-ignore\n    delete obj.userName;\n    return Object.assign(new ChatMessage(), obj);\n  }\n\n  /**\n   * @param {?string} [text] - Initial value of the `text` property.\n   * @param {?string} [authorId] - Initial value of the `authorId` property.\n   * @param {?number} [time] - Initial value of the `time` property.\n   */\n  constructor(text: string | null = null, authorId: string | null = null, time: number | null = null) {\n    /**\n     * The raw text of the user's chat message (before any rendering or processing).\n     *\n     * @type {?string}\n     */\n    this.text = text;\n\n    /**\n     * The user's author ID.\n     *\n     * @type {?string}\n     */\n    this.authorId = authorId;\n\n    /**\n     * The message's timestamp, as milliseconds since epoch.\n     *\n     * @type {?number}\n     */\n    this.time = time;\n\n    /**\n     * The user's display name.\n     *\n     * @type {?string}\n     */\n    this.displayName = null;\n  }\n\n  /**\n   * Alias of `authorId`, for compatibility with old plugins.\n   *\n   * @deprecated Use `authorId` instead.\n   * @type {string}\n   */\n  get userId() {\n    padUtils.warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead');\n    return this.authorId;\n  }\n  set userId(val) {\n    padUtils.warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead');\n    this.authorId = val;\n  }\n\n  /**\n   * Alias of `displayName`, for compatibility with old plugins.\n   *\n   * @deprecated Use `displayName` instead.\n   * @type {string}\n   */\n  get userName() {\n    padUtils.warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead');\n    return this.displayName;\n  }\n  set userName(val) {\n    padUtils.warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead');\n    this.displayName = val;\n  }\n\n  // TODO: Delete this method once users are unlikely to roll back to a version of Etherpad that\n  // doesn't support authorId and displayName.\n  toJSON() {\n    const {authorId, displayName, ...obj} = this;\n    // @ts-ignore\n    obj.userId = authorId;\n    // @ts-ignore\n    obj.userName = displayName;\n    return obj;\n  }\n}\n\nexport default ChatMessage\n"
  },
  {
    "path": "src/static/js/MergingOpAssembler.ts",
    "content": "import {OpAssembler} from \"./OpAssembler\";\nimport Op from \"./Op\";\nimport {clearOp, copyOp} from \"./Changeset\";\n\nexport class MergingOpAssembler {\n  private assem: OpAssembler;\n  private readonly bufOp: Op;\n  private bufOpAdditionalCharsAfterNewline: number;\n\n  constructor() {\n    this.assem = new OpAssembler()\n    this.bufOp = new Op()\n    // If we get, for example, insertions [xxx\\n,yyy], those don't merge,\n    // but if we get [xxx\\n,yyy,zzz\\n], that merges to [xxx\\nyyyzzz\\n].\n    // This variable stores the length of yyy and any other newline-less\n    // ops immediately after it.\n    this.bufOpAdditionalCharsAfterNewline = 0;\n  }\n\n  /**\n   * @param {boolean} [isEndDocument]\n   */\n  flush = (isEndDocument?: boolean) => {\n    if (!this.bufOp.opcode) return;\n    if (isEndDocument && this.bufOp.opcode === '=' && !this.bufOp.attribs) {\n      // final merged keep, leave it implicit\n    } else {\n      this.assem.append(this.bufOp);\n      if (this.bufOpAdditionalCharsAfterNewline) {\n        this.bufOp.chars = this.bufOpAdditionalCharsAfterNewline;\n        this.bufOp.lines = 0;\n        this.assem.append(this.bufOp);\n        this.bufOpAdditionalCharsAfterNewline = 0;\n      }\n    }\n    this.bufOp.opcode = '';\n  }\n\n  append = (op: Op) => {\n    if (op.chars <= 0) return;\n    if (this.bufOp.opcode === op.opcode && this.bufOp.attribs === op.attribs) {\n      if (op.lines > 0) {\n        // bufOp and additional chars are all mergeable into a multi-line op\n        this.bufOp.chars += this.bufOpAdditionalCharsAfterNewline + op.chars;\n        this.bufOp.lines += op.lines;\n        this.bufOpAdditionalCharsAfterNewline = 0;\n      } else if (this.bufOp.lines === 0) {\n        // both bufOp and op are in-line\n        this.bufOp.chars += op.chars;\n      } else {\n        // append in-line text to multi-line bufOp\n        this.bufOpAdditionalCharsAfterNewline += op.chars;\n      }\n    } else {\n      this.flush();\n      copyOp(op, this.bufOp);\n    }\n  }\n\n  endDocument = () => {\n    this.flush(true);\n  };\n\n  toString = () => {\n    this.flush();\n    return this.assem.toString();\n  };\n\n  clear = () => {\n    this.assem.clear();\n    clearOp(this.bufOp);\n  };\n}\n"
  },
  {
    "path": "src/static/js/Op.ts",
    "content": "import {numToString} from \"./ChangesetUtils\";\n\nexport type OpCode = ''|'='|'+'|'-';\n\n\n/**\n * An operation to apply to a shared document.\n */\nexport default class Op {\n  opcode: ''|'='|'+'|'-'\n  chars: number\n  lines: number\n  attribs: string\n  /**\n   * @param {(''|'='|'+'|'-')} [opcode=''] - Initial value of the `opcode` property.\n   */\n  constructor(opcode:''|'='|'+'|'-' = '') {\n    /**\n     * The operation's operator:\n     *   - '=': Keep the next `chars` characters (containing `lines` newlines) from the base\n     *     document.\n     *   - '-': Remove the next `chars` characters (containing `lines` newlines) from the base\n     *     document.\n     *   - '+': Insert `chars` characters (containing `lines` newlines) at the current position in\n     *     the document. The inserted characters come from the changeset's character bank.\n     *   - '' (empty string): Invalid operator used in some contexts to signifiy the lack of an\n     *     operation.\n     *\n     * @type {(''|'='|'+'|'-')}\n     * @public\n     */\n    this.opcode = opcode;\n\n    /**\n     * The number of characters to keep, insert, or delete.\n     *\n     * @type {number}\n     * @public\n     */\n    this.chars = 0;\n\n    /**\n     * The number of characters among the `chars` characters that are newlines. If non-zero, the\n     * last character must be a newline.\n     *\n     * @type {number}\n     * @public\n     */\n    this.lines = 0;\n\n    /**\n     * Identifiers of attributes to apply to the text, represented as a repeated (zero or more)\n     * sequence of asterisk followed by a non-negative base-36 (lower-case) integer. For example,\n     * '*2*1o' indicates that attributes 2 and 60 apply to the text affected by the operation. The\n     * identifiers come from the document's attribute pool.\n     *\n     * For keep ('=') operations, the attributes are merged with the base text's existing\n     * attributes:\n     *   - A keep op attribute with a non-empty value replaces an existing base text attribute that\n     *     has the same key.\n     *   - A keep op attribute with an empty value is interpreted as an instruction to remove an\n     *     existing base text attribute that has the same key, if one exists.\n     *\n     * This is the empty string for remove ('-') operations.\n     *\n     * @type {string}\n     * @public\n     */\n    this.attribs = '';\n  }\n\n  toString() {\n    if (!this.opcode) throw new TypeError('null op');\n    if (typeof this.attribs !== 'string') throw new TypeError('attribs must be a string');\n    const l = this.lines ? `|${numToString(this.lines)}` : '';\n    return this.attribs + l + this.opcode + numToString(this.chars);\n  }\n}\n"
  },
  {
    "path": "src/static/js/OpAssembler.ts",
    "content": "import Op from \"./Op\";\nimport {assert} from './Changeset'\n\n/**\n * @returns {OpAssembler}\n */\nexport class OpAssembler {\n  private serialized: string;\n  constructor() {\n    this.serialized = ''\n\n  }\n  append = (op: Op) => {\n    assert(op instanceof Op, 'argument must be an instance of Op');\n    this.serialized += op.toString();\n  }\n  toString = () => this.serialized\n  clear = () => {\n    this.serialized = '';\n  }\n}\n"
  },
  {
    "path": "src/static/js/OpIter.ts",
    "content": "import Op from \"./Op\";\nimport {clearOp, copyOp, deserializeOps} from \"./Changeset\";\n\n/**\n * Iterator over a changeset's operations.\n *\n * Note: This class does NOT implement the ECMAScript iterable or iterator protocols.\n *\n * @deprecated Use `deserializeOps` instead.\n */\nexport class OpIter {\n  private gen\n  private _next: IteratorResult<Op, void>\n  /**\n   * @param {string} ops - String encoding the change operations to iterate over.\n   */\n  constructor(ops: string) {\n    this.gen = deserializeOps(ops);\n    this._next = this.gen.next();\n  }\n\n  /**\n   * @returns {boolean} Whether there are any remaining operations.\n   */\n  hasNext(): boolean {\n    return !this._next.done;\n  }\n\n  /**\n   * Returns the next operation object and advances the iterator.\n   *\n   * Note: This does NOT implement the ECMAScript iterator protocol.\n   *\n   * @param {Op} [opOut] - Deprecated. Operation object to recycle for the return value.\n   * @returns {Op} The next operation, or an operation with a falsy `opcode` property if there are\n   *     no more operations.\n   */\n  next(opOut: Op = new Op()): Op {\n    if (this.hasNext()) {\n      copyOp(this._next.value!, opOut);\n      this._next = this.gen.next();\n    } else {\n      clearOp(opOut);\n    }\n    return opOut;\n  }\n}\n"
  },
  {
    "path": "src/static/js/SmartOpAssembler.ts",
    "content": "import {MergingOpAssembler} from \"./MergingOpAssembler\";\nimport {StringAssembler} from \"./StringAssembler\";\nimport padutils from \"./pad_utils\";\nimport Op from \"./Op\";\nimport { Attribute } from \"./types/Attribute\";\nimport AttributePool from \"./AttributePool\";\nimport {opsFromText} from \"./Changeset\";\n\n/**\n * Creates an object that allows you to append operations (type Op) and also compresses them if\n * possible. Like MergingOpAssembler, but able to produce conforming exportss from slightly looser\n * input, at the cost of speed. Specifically:\n *   - merges consecutive operations that can be merged\n *   - strips final \"=\"\n *   - ignores 0-length changes\n *   - reorders consecutive + and - (which MergingOpAssembler doesn't do)\n *\n * @typedef {object} SmartOpAssembler\n * @property {Function} append -\n * @property {Function} appendOpWithText -\n * @property {Function} clear -\n * @property {Function} endDocument -\n * @property {Function} getLengthChange -\n * @property {Function} toString -\n */\nexport class SmartOpAssembler {\n  private minusAssem: MergingOpAssembler;\n  private plusAssem: MergingOpAssembler;\n  private keepAssem: MergingOpAssembler;\n  private lastOpcode: string;\n  private lengthChange: number;\n  private assem: StringAssembler;\n\n  constructor() {\n    this.minusAssem = new MergingOpAssembler()\n    this.plusAssem = new MergingOpAssembler()\n    this.keepAssem = new MergingOpAssembler()\n    this.assem = new StringAssembler()\n    this.lastOpcode = ''\n    this.lengthChange = 0\n  }\n\n  flushKeeps = () => {\n    this.assem.append(this.keepAssem.toString());\n    this.keepAssem.clear();\n  };\n\n  flushPlusMinus = () => {\n    this.assem.append(this.minusAssem.toString());\n    this.minusAssem.clear();\n    this.assem.append(this.plusAssem.toString());\n    this.plusAssem.clear();\n  };\n\n  append = (op: Op) => {\n    if (!op.opcode) return;\n    if (!op.chars) return;\n\n    if (op.opcode === '-') {\n      if (this.lastOpcode === '=') {\n        this.flushKeeps();\n      }\n      this.minusAssem.append(op);\n      this.lengthChange -= op.chars;\n    } else if (op.opcode === '+') {\n      if (this.lastOpcode === '=') {\n        this.flushKeeps();\n      }\n      this.plusAssem.append(op);\n      this.lengthChange += op.chars;\n    } else if (op.opcode === '=') {\n      if (this.lastOpcode !== '=') {\n        this.flushPlusMinus();\n      }\n      this.keepAssem.append(op);\n    }\n    this.lastOpcode = op.opcode;\n  };\n\n  /**\n   * Generates operations from the given text and attributes.\n   *\n   * @deprecated Use `opsFromText` instead.\n   * @param {('-'|'+'|'=')} opcode - The operator to use.\n   * @param {string} text - The text to remove/add/keep.\n   * @param {(string|Iterable<Attribute>)} attribs - The attributes to apply to the operations.\n   * @param {?AttributePool.ts} pool - Attribute pool. Only required if `attribs` is an iterable of\n   *     attribute key, value pairs.\n   */\n  appendOpWithText = (opcode: '-'|'+'|'=', text: string, attribs: Attribute[]|string, pool?: AttributePool) => {\n    padutils.warnDeprecated('Changeset.smartOpAssembler().appendOpWithText() is deprecated; ' +\n      'use opsFromText() instead.');\n    for (const op of opsFromText(opcode, text, attribs, pool)) this.append(op);\n  };\n\n  toString = () => {\n    this.flushPlusMinus();\n    this.flushKeeps();\n    return this.assem.toString();\n  };\n\n  clear = () => {\n    this.minusAssem.clear();\n    this.plusAssem.clear();\n    this.keepAssem.clear();\n    this.assem.clear();\n    this.lengthChange = 0;\n  };\n\n  endDocument = () => {\n    this.keepAssem.endDocument();\n  };\n\n  getLengthChange = () => this.lengthChange;\n}\n"
  },
  {
    "path": "src/static/js/StringAssembler.ts",
    "content": "/**\n * @returns {StringAssembler}\n */\nexport class StringAssembler {\n  private str = ''\n  clear = ()=> {\n    this.str = '';\n  }\n  /**\n   * @param {string} x -\n   */\n  append(x: string) {\n    this.str += String(x);\n  }\n  toString() {\n    return this.str\n  }\n}\n"
  },
  {
    "path": "src/static/js/StringIterator.ts",
    "content": "import {assert} from \"./Changeset\";\n\n/**\n * A custom made String Iterator\n *\n * @typedef {object} StringIterator\n * @property {Function} newlines -\n * @property {Function} peek -\n * @property {Function} remaining -\n * @property {Function} skip -\n * @property {Function} take -\n */\n\n/**\n * @param {string} str - String to iterate over\n * @returns {StringIterator}\n */\nexport class StringIterator {\n  private curIndex: number;\n  private newLines: number;\n  private str: String\n\n  constructor(str: string) {\n    this.curIndex = 0;\n    this.str = str\n    this.newLines = str.split('\\n').length - 1;\n  }\n  remaining = () => this.str.length - this.curIndex;\n\n  getnewLines = () => this.newLines;\n\n  assertRemaining = (n: number) => {\n    assert(n <= this.remaining(), `!(${n} <= ${this.remaining()})`);\n  }\n\n  take = (n: number) => {\n    this.assertRemaining(n);\n    const s = this.str.substring(this.curIndex, this.curIndex+n);\n    this.newLines -= s.split('\\n').length - 1;\n    this.curIndex += n;\n    return s;\n  }\n\n  peek = (n: number) => {\n    this.assertRemaining(n);\n    return this.str.substring(this.curIndex, this.curIndex+n);\n  }\n\n  skip = (n: number) => {\n    this.assertRemaining(n);\n    this.curIndex += n;\n  }\n\n}\n"
  },
  {
    "path": "src/static/js/TextLinesMutator.ts",
    "content": "import {splitTextLines} from \"./Changeset\";\n\n/**\n * Class to iterate and modify texts which have several lines. It is used for applying Changesets on\n * arrays of lines.\n *\n * Mutation operations have the same constraints as exports operations with respect to newlines, but\n * not the other additional constraints (i.e. ins/del ordering, forbidden no-ops, non-mergeability,\n * final newline). Can be used to mutate lists of strings where the last char of each string is not\n * actually a newline, but for the purposes of N and L values, the caller should pretend it is, and\n * for things to work right in that case, the input to the `insert` method should be a single line\n * with no newlines.\n */\nclass TextLinesMutator {\n  private _lines: string[];\n  private _curSplice: [number, number?];\n  private _inSplice: boolean;\n  private _curLine: number;\n  private _curCol: number;\n  /**\n   * @param {(string[]|StringArrayLike)} lines - Lines to mutate (in place).\n   */\n  constructor(lines: string[]) {\n    this._lines = lines;\n    /**\n     * this._curSplice holds values that will be passed as arguments to this._lines.splice() to\n     * insert, delete, or change lines:\n     *   - this._curSplice[0] is an index into the this._lines array.\n     *   - this._curSplice[1] is the number of lines that will be removed from the this._lines array\n     *     starting at the index.\n     *   - The other elements represent mutated (changed by ops) lines or new lines (added by ops)\n     *     to insert at the index.\n     *\n     * @type {[number, number?, ...string[]?]}\n     */\n    this._curSplice = [0, 0];\n    this._inSplice = false;\n    // position in lines after curSplice is applied:\n    this._curLine = 0;\n    this._curCol = 0;\n    // invariant: if (inSplice) then (curLine is in curSplice[0] + curSplice.length - {2,3}) &&\n    //            curLine >= curSplice[0]\n    // invariant: if (inSplice && (curLine >= curSplice[0] + curSplice.length - 2)) then\n    //            curCol == 0\n  }\n\n  /**\n   * Get a line from `lines` at given index.\n   *\n   * @param {number} idx - an index\n   * @returns {string}\n   */\n  _linesGet(idx: number) {\n    if ('get' in this._lines) {\n      // @ts-ignore\n      return this._lines.get(idx) as string;\n    } else {\n      return this._lines[idx];\n    }\n  }\n\n  /**\n   * Return a slice from `lines`.\n   *\n   * @param {number} start - the start index\n   * @param {number} end - the end index\n   * @returns {string[]}\n   */\n  _linesSlice(start: number | undefined, end: number | undefined) {\n    // can be unimplemented if removeLines's return value not needed\n    if (this._lines.slice) {\n      return this._lines.slice(start, end);\n    } else {\n      return [];\n    }\n  }\n\n  /**\n   * Return the length of `lines`.\n   *\n   * @returns {number}\n   */\n  _linesLength() {\n    if (typeof this._lines.length === 'number') {\n      return this._lines.length;\n    } else {\n      // @ts-ignore\n      return this._lines.length();\n    }\n  }\n\n  /**\n   * Starts a new splice.\n   */\n  _enterSplice() {\n    this._curSplice[0] = this._curLine;\n    this._curSplice[1] = 0;\n    // TODO(doc) when is this the case?\n    //           check all enterSplice calls and changes to curCol\n    if (this._curCol > 0) this._putCurLineInSplice();\n    this._inSplice = true;\n  }\n\n  /**\n   * Changes the lines array according to the values in curSplice and resets curSplice. Called via\n   * close or TODO(doc).\n   */\n  _leaveSplice() {\n    this._lines.splice(...this._curSplice);\n    this._curSplice.length = 2;\n    this._curSplice[0] = this._curSplice[1] = 0;\n    this._inSplice = false;\n  }\n\n  /**\n   * Indicates if curLine is already in the splice. This is necessary because the last element in\n   * curSplice is curLine when this line is currently worked on (e.g. when skipping or inserting).\n   *\n   * @returns {boolean} true if curLine is in splice\n   */\n  _isCurLineInSplice() {\n    // The value of `this._curSplice[1]` does not matter when determining the return value because\n    // `this._curLine` refers to the line number *after* the splice is applied (so after those lines\n    // are deleted).\n    return this._curLine - this._curSplice[0] < this._curSplice.length - 2;\n  }\n\n  /**\n   * Incorporates current line into the splice and marks its old position to be deleted.\n   *\n   * @returns {number} the index of the added line in curSplice\n   */\n  _putCurLineInSplice() {\n    if (!this._isCurLineInSplice()) {\n      // @ts-ignore\n      this._curSplice.push(this._linesGet(this._curSplice[0] + this._curSplice[1]));\n      // @ts-ignore\n      this._curSplice[1]++;\n    }\n    // TODO should be the same as this._curSplice.length - 1\n    return 2 + this._curLine - this._curSplice[0];\n  }\n\n  /**\n   * It will skip some newlines by putting them into the splice.\n   *\n   * @param {number} L -\n   * @param {boolean} includeInSplice - Indicates that attributes are present.\n   */\n  skipLines(L: number, includeInSplice?: any) {\n    if (!L) return;\n    if (includeInSplice) {\n      if (!this._inSplice) this._enterSplice();\n      // TODO(doc) should this count the number of characters that are skipped to check?\n      for (let i = 0; i < L; i++) {\n        this._curCol = 0;\n        this._putCurLineInSplice();\n        this._curLine++;\n      }\n    } else {\n      if (this._inSplice) {\n        if (L > 1) {\n          // TODO(doc) figure out why single lines are incorporated into splice instead of ignored\n          this._leaveSplice();\n        } else {\n          this._putCurLineInSplice();\n        }\n      }\n      this._curLine += L;\n      this._curCol = 0;\n    }\n    // tests case foo in remove(), which isn't otherwise covered in current impl\n  }\n\n  /**\n   * Skip some characters. Can contain newlines.\n   *\n   * @param {number} N - number of characters to skip\n   * @param {number} L - number of newlines to skip\n   * @param {boolean} includeInSplice - indicates if attributes are present\n   */\n  skip(N: number, L: number, includeInSplice?: any) {\n    if (!N) return;\n    if (L) {\n      this.skipLines(L, includeInSplice);\n    } else {\n      if (includeInSplice && !this._inSplice) this._enterSplice();\n      if (this._inSplice) {\n        // although the line is put into splice curLine is not increased, because\n        // only some chars are skipped, not the whole line\n        this._putCurLineInSplice();\n      }\n      this._curCol += N;\n    }\n  }\n\n  /**\n   * Remove whole lines from lines array.\n   *\n   * @param {number} L - number of lines to remove\n   * @returns {string}\n   */\n  removeLines(L: number) {\n    if (!L) return '';\n    if (!this._inSplice) this._enterSplice();\n\n    /**\n     * Gets a string of joined lines after the end of the splice.\n     *\n     * @param {number} k - number of lines\n     * @returns {string} joined lines\n     */\n    const nextKLinesText = (k: number) => {\n      // @ts-ignore\n      const m = this._curSplice[0] + this._curSplice[1];\n      return this._linesSlice(m, m + k).join('');\n    };\n\n    let removed = '';\n    if (this._isCurLineInSplice()) {\n      if (this._curCol === 0) {\n        // @ts-ignore\n        removed = this._curSplice[this._curSplice.length - 1];\n        this._curSplice.length--;\n        removed += nextKLinesText(L - 1);\n        // @ts-ignore\n        this._curSplice[1] += L - 1;\n      } else {\n        removed = nextKLinesText(L - 1);\n        // @ts-ignore\n        this._curSplice[1] += L - 1;\n        const sline = this._curSplice.length - 1;\n        // @ts-ignore\n        removed = this._curSplice[sline].substring(this._curCol) + removed;\n        // @ts-ignore\n        this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) +\n          // @ts-ignore\n          this._linesGet(this._curSplice[0] + this._curSplice[1]);\n        // @ts-ignore\n        this._curSplice[1] += 1;\n      }\n    } else {\n      removed = nextKLinesText(L);\n      this._curSplice[1]! += L;\n    }\n    return removed;\n  }\n\n  /**\n   * Remove text from lines array.\n   *\n   * @param {number} N - characters to delete\n   * @param {number} L - lines to delete\n   * @returns {string}\n   */\n  remove(N: number, L: any) {\n    if (!N) return '';\n    if (L) return this.removeLines(L);\n    if (!this._inSplice) this._enterSplice();\n    // although the line is put into splice, curLine is not increased, because\n    // only some chars are removed not the whole line\n    const sline = this._putCurLineInSplice();\n    // @ts-ignore\n    const removed = this._curSplice[sline].substring(this._curCol, this._curCol + N);\n    // @ts-ignore\n    this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) +\n      // @ts-ignore\n      this._curSplice[sline].substring(this._curCol + N);\n    return removed;\n  }\n\n  /**\n   * Inserts text into lines array.\n   *\n   * @param {string} text - the text to insert\n   * @param {number} L - number of newlines in text\n   */\n  insert(text: string | any[], L: any) {\n    if (!text) return;\n    if (!this._inSplice) this._enterSplice();\n    if (L) {\n      // @ts-ignore\n      const newLines = splitTextLines(text);\n      if (this._isCurLineInSplice()) {\n        const sline = this._curSplice.length - 1;\n        /** @type {string} */\n        const theLine = this._curSplice[sline];\n        const lineCol = this._curCol;\n        // Insert the chars up to `curCol` and the first new line.\n        // @ts-ignore\n        this._curSplice[sline] = theLine.substring(0, lineCol) + newLines[0];\n        this._curLine++;\n        newLines!.splice(0, 1);\n        // insert the remaining new lines\n        // @ts-ignore\n        this._curSplice.push(...newLines);\n        this._curLine += newLines!.length;\n        // insert the remaining chars from the \"old\" line (e.g. the line we were in\n        // when we started to insert new lines)\n        // @ts-ignore\n        this._curSplice.push(theLine.substring(lineCol));\n        this._curCol = 0; // TODO(doc) why is this not set to the length of last line?\n      } else {\n        this._curSplice.push(...newLines);\n        this._curLine += newLines!.length;\n      }\n    } else {\n      // There are no additional lines. Although the line is put into splice, curLine is not\n      // increased because there may be more chars in the line (newline is not reached).\n      const sline = this._putCurLineInSplice();\n      if (!this._curSplice[sline]) {\n        const err = new Error(\n          'curSplice[sline] not populated, actual curSplice contents is ' +\n          `${JSON.stringify(this._curSplice)}. Possibly related to ` +\n          'https://github.com/ether/etherpad-lite/issues/2802');\n        console.error(err.stack || err.toString());\n      }\n      // @ts-ignore\n      this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + text +\n        // @ts-ignore\n        this._curSplice[sline].substring(this._curCol);\n      this._curCol += text.length;\n    }\n  }\n\n  /**\n   * Checks if curLine (the line we are in when curSplice is applied) is the last line in `lines`.\n   *\n   * @returns {boolean} indicates if there are lines left\n   */\n  hasMore() {\n    let docLines = this._linesLength();\n    if (this._inSplice) {\n      // @ts-ignore\n      docLines += this._curSplice.length - 2 - this._curSplice[1];\n    }\n    return this._curLine < docLines;\n  }\n\n  /**\n   * Closes the splice\n   */\n  close() {\n    if (this._inSplice) this._leaveSplice();\n  }\n}\n\nexport default TextLinesMutator\n"
  },
  {
    "path": "src/static/js/ace.ts",
    "content": "// @ts-nocheck\n'use strict';\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// requires: top\n// requires: undefined\n\nconst hooks = require('./pluginfw/hooks');\nconst makeCSSManager = require('./cssmanager').makeCSSManager;\nconst pluginUtils = require('./pluginfw/shared');\nconst ace2_inner = require('ep_etherpad-lite/static/js/ace2_inner')\nconst debugLog = (...args) => {};\nconst cl_plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins')\nconst rJQuery = require('ep_etherpad-lite/static/js/rjquery')\n// The inner and outer iframe's locations are about:blank, so relative URLs are relative to that.\n// Firefox and Chrome seem to do what the developer intends if given a relative URL, but Safari\n// errors out unless given an absolute URL for a JavaScript-created element.\nconst absUrl = (url) => new URL(url, window.location.href).href;\n\nconst eventFired = async (obj, event, cleanups = [], predicate = () => true) => {\n  if (typeof cleanups === 'function') {\n    predicate = cleanups;\n    cleanups = [];\n  }\n  await new Promise((resolve, reject) => {\n    let cleanup;\n    const successCb = () => {\n      if (!predicate()) return;\n      debugLog(`Ace2Editor.init() ${event} event on`, obj);\n      cleanup();\n      resolve();\n    };\n    const errorCb = () => {\n      const err = new Error(`Ace2Editor.init() error event while waiting for ${event} event`);\n      debugLog(`${err} on object`, obj);\n      cleanup();\n      reject(err);\n    };\n    cleanup = () => {\n      cleanup = () => {};\n      obj.removeEventListener(event, successCb);\n      obj.removeEventListener('error', errorCb);\n    };\n    cleanups.push(cleanup);\n    obj.addEventListener(event, successCb);\n    obj.addEventListener('error', errorCb);\n  });\n};\n\n// Resolves when the frame's document is ready to be mutated. Browsers seem to be quirky about\n// iframe ready events so this function throws the kitchen sink at the problem. Maybe one day we'll\n// find a concise general solution.\nconst frameReady = async (frame) => {\n  // Can't do `const doc = frame.contentDocument;` because Firefox seems to asynchronously replace\n  // the document object after the frame is first created for some reason. ¯\\_(ツ)_/¯\n  const doc = () => frame.contentDocument;\n  const cleanups = [];\n  try {\n    await Promise.race([\n      eventFired(frame, 'load', cleanups),\n      eventFired(frame.contentWindow, 'load', cleanups),\n      eventFired(doc(), 'load', cleanups),\n      eventFired(doc(), 'DOMContentLoaded', cleanups),\n      eventFired(doc(), 'readystatechange', cleanups, () => doc.readyState === 'complete'),\n    ]);\n  } finally {\n    for (const cleanup of cleanups) cleanup();\n  }\n};\n\nconst Ace2Editor = function () {\n  let info = {editor: this};\n  let loaded = false;\n\n  let actionsPendingInit = [];\n\n  const pendingInit = (func) => function (...args) {\n    const action = () => func.apply(this, args);\n    if (loaded) return action();\n    actionsPendingInit.push(action);\n  };\n\n  const doActionsPendingInit = () => {\n    for (const fn of actionsPendingInit) fn();\n    actionsPendingInit = [];\n  };\n\n  // The following functions (prefixed by 'ace_')  are exposed by editor, but\n  // execution is delayed until init is complete\n  const aceFunctionsPendingInit = [\n    'importText',\n    'importAText',\n    'focus',\n    'setEditable',\n    'setOnKeyPress',\n    'setOnKeyDown',\n    'setNotifyDirty',\n    'setProperty',\n    'setBaseText',\n    'setBaseAttributedText',\n    'applyChangesToBase',\n    'applyPreparedChangesetToBase',\n    'setUserChangeNotificationCallback',\n    'setAuthorInfo',\n    'callWithAce',\n    'execCommand',\n    'replaceRange',\n  ];\n\n  for (const fnName of aceFunctionsPendingInit) {\n    // Note: info[`ace_${fnName}`] does not exist yet, so it can't be passed directly to\n    // pendingInit(). A simple wrapper is used to defer the info[`ace_${fnName}`] lookup until\n    // method invocation.\n    this[fnName] = pendingInit(function (...args) {\n      info[`ace_${fnName}`].apply(this, args);\n    });\n  }\n\n  this.exportText = () => loaded ? info.ace_exportText() : '(awaiting init)\\n';\n\n  this.getInInternationalComposition =\n      () => loaded ? info.ace_getInInternationalComposition() : null;\n\n  // prepareUserChangeset:\n  // Returns null if no new changes or ACE not ready.  Otherwise, bundles up all user changes\n  // to the latest base text into a Changeset, which is returned (as a string if encodeAsString).\n  // If this method returns a truthy value, then applyPreparedChangesetToBase can be called at some\n  // later point to consider these changes part of the base, after which prepareUserChangeset must\n  // be called again before applyPreparedChangesetToBase. Multiple consecutive calls to\n  // prepareUserChangeset will return an updated changeset that takes into account the latest user\n  // changes, and modify the changeset to be applied by applyPreparedChangesetToBase accordingly.\n  this.prepareUserChangeset = () => loaded ? info.ace_prepareUserChangeset() : null;\n\n  const addStyleTagsFor = (doc, files) => {\n    for (const file of files) {\n      const link = doc.createElement('link');\n      link.rel = 'stylesheet';\n      link.type = 'text/css';\n      link.href = absUrl(encodeURI(file));\n      doc.head.appendChild(link);\n    }\n  };\n\n  this.destroy = pendingInit(() => {\n    info.ace_dispose();\n    info.frame.parentNode.removeChild(info.frame);\n    info = null; // prevent IE 6 closure memory leaks\n  });\n\n  this.init = async function (containerId, initialCode) {\n    debugLog('Ace2Editor.init()');\n    this.importText(initialCode);\n\n    const includedCSS = [\n      `../static/css/iframe_editor.css?v=${clientVars.randomVersionString}`,\n      `../static/css/pad.css?v=${clientVars.randomVersionString}`,\n      ...hooks.callAll('aceEditorCSS').map(\n          // Allow urls to external CSS - http(s):// and //some/path.css\n          (p) => /\\/\\//.test(p) ? p : `../static/plugins/${p}`),\n      `../static/skins/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`,\n    ];\n\n    const skinVariants = clientVars.skinVariants.split(' ').filter((x) => x !== '');\n\n    const outerFrame = document.createElement('iframe');\n    outerFrame.name = 'ace_outer';\n    outerFrame.frameBorder = 0; // for IE\n    outerFrame.title = 'Ether';\n    // Some browsers do strange things unless the iframe has a src or srcdoc property:\n    //   - Firefox replaces the frame's contentWindow.document object with a different object after\n    //     the frame is created. This can be worked around by waiting for the window's load event\n    //     before continuing.\n    //   - Chrome never fires any events on the frame or document. Eventually the document's\n    //     readyState becomes 'complete' even though it never fires a readystatechange event.\n    //   - Safari behaves like Chrome.\n    // srcdoc is avoided because Firefox's Content Security Policy engine does not properly handle\n    // 'self' with nested srcdoc iframes: https://bugzilla.mozilla.org/show_bug.cgi?id=1721296\n    outerFrame.src = '../static/empty.html';\n    info.frame = outerFrame;\n    document.getElementById(containerId).appendChild(outerFrame);\n    const outerWindow = outerFrame.contentWindow;\n\n    debugLog('Ace2Editor.init() waiting for outer frame');\n    await frameReady(outerFrame);\n    debugLog('Ace2Editor.init() outer frame ready');\n\n    // Firefox might replace the outerWindow.document object after iframe creation so this variable\n    // is assigned after the Window's load event.\n    const outerDocument = outerWindow.document;\n\n    // <html> tag\n    outerDocument.documentElement.classList.add('outer-editor', 'outerdoc', ...skinVariants);\n\n    // <head> tag\n    addStyleTagsFor(outerDocument, includedCSS);\n    const outerStyle = outerDocument.createElement('style');\n    outerStyle.type = 'text/css';\n    outerStyle.title = 'dynamicsyntax';\n    outerDocument.head.appendChild(outerStyle);\n\n    // <body> tag\n    outerDocument.body.id = 'outerdocbody';\n    outerDocument.body.classList.add('outerdocbody', ...pluginUtils.clientPluginNames());\n    const sideDiv = outerDocument.createElement('div');\n    sideDiv.id = 'sidediv';\n    sideDiv.classList.add('sidediv');\n    outerDocument.body.appendChild(sideDiv);\n    const sideDivInner = outerDocument.createElement('div');\n    sideDivInner.id = 'sidedivinner';\n    sideDivInner.classList.add('sidedivinner');\n    sideDiv.appendChild(sideDivInner);\n    const lineMetricsDiv = outerDocument.createElement('div');\n    lineMetricsDiv.id = 'linemetricsdiv';\n    lineMetricsDiv.appendChild(outerDocument.createTextNode('x'));\n    outerDocument.body.appendChild(lineMetricsDiv);\n\n    const innerFrame = outerDocument.createElement('iframe');\n    innerFrame.name = 'ace_inner';\n    innerFrame.title = 'pad';\n    innerFrame.scrolling = 'no';\n    innerFrame.frameBorder = 0;\n    innerFrame.allowTransparency = true; // for IE\n    // The iframe MUST have a src or srcdoc property to avoid browser quirks. See the comment above\n    // outerFrame.srcdoc.\n    innerFrame.src = 'empty.html';\n    outerDocument.body.insertBefore(innerFrame, outerDocument.body.firstChild);\n    const innerWindow = innerFrame.contentWindow;\n\n    debugLog('Ace2Editor.init() waiting for inner frame');\n    await frameReady(innerFrame);\n    debugLog('Ace2Editor.init() inner frame ready');\n\n    // Firefox might replace the innerWindow.document object after iframe creation so this variable\n    // is assigned after the Window's load event.\n    const innerDocument = innerWindow.document;\n\n    // <html> tag\n    innerDocument.documentElement.classList.add('inner-editor', ...skinVariants);\n\n    // <head> tag\n    addStyleTagsFor(innerDocument, includedCSS);\n    //const requireKernel = innerDocument.createElement('script');\n    //requireKernel.type = 'text/javascript';\n    //requireKernel.src =\n     //   absUrl(`../static/js/require-kernel.js?v=${clientVars.randomVersionString}`);\n    //innerDocument.head.appendChild(requireKernel);\n    // Pre-fetch modules to improve load performance.\n    /*for (const module of ['ace2_inner', 'ace2_common']) {\n      const script = innerDocument.createElement('script');\n      script.type = 'text/javascript';\n      script.src = absUrl(`../javascripts/lib/ep_etherpad-lite/static/js/${module}.js` +\n                          `?callback=require.define&v=${clientVars.randomVersionString}`);\n      innerDocument.head.appendChild(script);\n    }*/\n    const innerStyle = innerDocument.createElement('style');\n    innerStyle.type = 'text/css';\n    innerStyle.title = 'dynamicsyntax';\n    innerDocument.head.appendChild(innerStyle);\n    const headLines = [];\n    hooks.callAll('aceInitInnerdocbodyHead', {iframeHTML: headLines});\n    innerDocument.head.appendChild(\n        innerDocument.createRange().createContextualFragment(headLines.join('\\n')));\n\n    // <body> tag\n    innerDocument.body.id = 'innerdocbody';\n    innerDocument.body.classList.add('innerdocbody');\n    innerDocument.body.setAttribute('spellcheck', 'false');\n    innerDocument.body.appendChild(innerDocument.createTextNode('\\u00A0')); // &nbsp;\n/*\n    debugLog('Ace2Editor.init() waiting for require kernel load');\n    await eventFired(requireKernel, 'load');\n    debugLog('Ace2Editor.init() require kernel loaded');\n    const require = innerWindow.require;\n    require.setRootURI(absUrl('../javascripts/src'));\n    require.setLibraryURI(absUrl('../javascripts/lib'));\n    require.setGlobalKeyPath('require');\n*/\n    // intentially moved before requiring client_plugins to save a 307\n    innerWindow.Ace2Inner = ace2_inner;\n    innerWindow.plugins = cl_plugins;\n\n    innerWindow.$ = innerWindow.jQuery = rJQuery.jQuery;\n\n    debugLog('Ace2Editor.init() waiting for plugins');\n    /*await new Promise((resolve, reject) => innerWindow.plugins.ensure(\n        (err) => err != null ? reject(err) : resolve()));*/\n    debugLog('Ace2Editor.init() waiting for Ace2Inner.init()');\n    await innerWindow.Ace2Inner.init(info, {\n      inner: makeCSSManager(innerStyle.sheet),\n      outer: makeCSSManager(outerStyle.sheet),\n      parent: makeCSSManager(document.querySelector('style[title=\"dynamicsyntax\"]').sheet),\n    });\n    debugLog('Ace2Editor.init() Ace2Inner.init() returned');\n    loaded = true;\n    doActionsPendingInit();\n    debugLog('Ace2Editor.init() done');\n  };\n};\n\nexports.Ace2Editor = Ace2Editor;\n"
  },
  {
    "path": "src/static/js/ace2_common.ts",
    "content": "'use strict';\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\nimport {MapArrayType} from \"../../node/types/MapType\";\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexport const isNodeText = (node: {\n  nodeType: number\n}) => (node.nodeType === 3);\n\nexport const getAssoc = (obj: MapArrayType<any>, name: string) => obj[`_magicdom_${name}`];\n\nexport const setAssoc = (obj: MapArrayType<any>, name: string, value: string) => {\n  // note that in IE designMode, properties of a node can get\n  // copied to new nodes that are spawned during editing; also,\n  // properties representable in HTML text can survive copy-and-paste\n  obj[`_magicdom_${name}`] = value;\n};\n\n// \"func\" is a function over 0..(numItems-1) that is monotonically\n// \"increasing\" with index (false, then true).  Finds the boundary\n// between false and true, a number between 0 and numItems inclusive.\n\n\nexport const binarySearch = (numItems: number, func: (num: number)=>boolean) => {\n  if (numItems < 1) return 0;\n  if (func(0)) return 0;\n  if (!func(numItems - 1)) return numItems;\n  let low = 0; // func(low) is always false\n  let high = numItems - 1; // func(high) is always true\n  while ((high - low) > 1) {\n    const x = Math.floor((low + high) / 2); // x != low, x != high\n    if (func(x)) high = x;\n    else low = x;\n  }\n  return high;\n};\n\nexport const binarySearchInfinite = (expectedLength: number, func: (num: number)=>boolean) => {\n  let i = 0;\n  while (!func(i)) i += expectedLength;\n  return binarySearch(i, func);\n};\n\nexport const noop = () => {};\n"
  },
  {
    "path": "src/static/js/ace2_inner.ts",
    "content": "// @ts-nocheck\nimport {Builder} from \"./Builder\";\n\n/**\n * Copyright 2009 Google Inc.\n * Copyright 2020 John McLear - The Etherpad Foundation.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nlet documentAttributeManager;\n\nimport AttributeMap from './AttributeMap';\nconst browser = require('./vendors/browser');\nimport padutils from './pad_utils'\nconst Ace2Common = require('./ace2_common');\nconst $ = require('./rjquery').$;\nimport {characterRangeFollow, checkRep, cloneAText, compose, deserializeOps, filterAttribNumbers, inverse, isIdentity, makeAText, makeAttribution, mapAttribNumbers, moveOpsToNewPool, mutateAttributionLines, mutateTextLines, oldLen, opsFromAText, pack, splitAttributionLines} from './Changeset'\n\n\nconst isNodeText = Ace2Common.isNodeText;\nconst getAssoc = Ace2Common.getAssoc;\nconst setAssoc = Ace2Common.setAssoc;\nconst noop = Ace2Common.noop;\nconst hooks = require('./pluginfw/hooks');\nimport SkipList from \"./skiplist\";\nimport Scroll from './scroll'\nimport AttribPool from './AttributePool'\nimport {SmartOpAssembler} from \"./SmartOpAssembler\";\nimport Op from \"./Op\";\nimport {buildKeepRange, buildKeepToStartOfRange, buildRemoveRange} from './ChangesetUtils'\n\nfunction Ace2Inner(editorInfo, cssManagers) {\n  const makeChangesetTracker = require('./changesettracker').makeChangesetTracker;\n  const colorutils = require('./colorutils').colorutils;\n  const makeContentCollector = require('./contentcollector').makeContentCollector;\n  const domline = require('./domline').domline;\n  const linestylefilter = require('./linestylefilter').linestylefilter;\n  const undoModule = require('./undomodule').undoModule;\n  const AttributeManager = require('./AttributeManager');\n  const DEBUG = false;\n\n  const THE_TAB = '    '; // 4\n  const MAX_LIST_LEVEL = 16;\n\n  const FORMATTING_STYLES = ['bold', 'italic', 'underline', 'strikethrough'];\n  const SELECT_BUTTON_CLASS = 'selected';\n\n  let thisAuthor = '';\n\n  let disposed = false;\n  const outerWin = document.getElementsByName(\"ace_outer\")[0]\n  const targetDoc = outerWin.contentWindow.document.getElementsByName(\"ace_inner\")[0].contentWindow.document\n  const targetBody = targetDoc.body\n\n  const focus = () => {\n    targetBody.focus();\n  };\n\n  const outerDoc = outerWin.contentWindow.document;\n\n  const sideDiv = outerDoc.getElementById('sidediv');\n  const lineMetricsDiv = outerDoc.getElementById('linemetricsdiv');\n  const sideDivInner = outerDoc.getElementById('sidedivinner');\n  const appendNewSideDivLine = () => {\n    const lineDiv = outerDoc.createElement('div');\n    sideDivInner.appendChild(lineDiv);\n    const lineSpan = outerDoc.createElement('span');\n    lineSpan.classList.add('line-number');\n    lineSpan.appendChild(outerDoc.createTextNode(sideDivInner.children.length));\n    lineDiv.appendChild(lineSpan);\n  };\n  appendNewSideDivLine();\n\n  const scroll = new Scroll(outerWin);\n\n  let outsideKeyDown = noop;\n  let outsideKeyPress = (e) => true;\n  let outsideNotifyDirty = noop;\n\n  /**\n   * Document representation.\n   */\n  const rep = {\n    /**\n     * The contents of the document. Each entry in this skip list is an object representing a\n     * line (actually paragraph) of text. The line objects are created by createDomLineEntry().\n     */\n    lines: new SkipList(),\n    /**\n     * Start of the selection. Represented as an array of two non-negative numbers that point to the\n     * first character of the selection: [zeroBasedLineNumber, zeroBasedColumnNumber]. Notes:\n     *   - There is an implicit newline character (not actually stored) at the end of every line.\n     *     Because of this, a selection that starts at the end of a line (column number equals the\n     *     number of characters in the line, not including the implicit newline) is not equivalent\n     *     to a selection that starts at the beginning of the next line. The same goes for the\n     *     selection end.\n     *   - If there are N lines, [N, 0] is valid for the start of the selection. [N, 0] indicates\n     *     that the selection starts just after the implicit newline at the end of the document's\n     *     last line (if the document has any lines). The same goes for the end of the selection.\n     *   - If a line starts with a line marker, a selection that starts at the beginning of the line\n     *     may start either immediately before (column = 0) or immediately after (column = 1) the\n     *     line marker, and the two are considered to be semantically equivalent. For safety, all\n     *     code should be written to accept either but only produce selections that start after the\n     *     line marker (the column number should be 1, not 0, when there is a line marker). The same\n     *     goes for the end of the selection.\n     */\n    selStart: null,\n    /**\n     * End of the selection. Represented as an array of two non-negative numbers that point to the\n     * character just after the end of the selection: [zeroBasedLineNumber, zeroBasedColumnNumber].\n     * See the above notes for selStart.\n     */\n    selEnd: null,\n    /**\n     * Whether the selection extends \"backwards\", so that the focus point (controlled with the arrow\n     * keys) is at the beginning. This is not supported in IE, though native IE selections have that\n     * behavior (which we try not to interfere with). Must be false if selection is collapsed!\n     */\n    selFocusAtStart: false,\n    alltext: '',\n    alines: [],\n    apool: new AttribPool(),\n  };\n\n  // lines, alltext, alines, and DOM are set up in init()\n  if (undoModule.enabled) {\n    undoModule.apool = rep.apool;\n  }\n\n  let isEditable = true;\n  let doesWrap = true;\n  let hasLineNumbers = true;\n  let isStyled = true;\n\n  let console = (DEBUG && window.console);\n\n  if (!window.console) {\n    const names = [\n      'log',\n      'debug',\n      'info',\n      'warn',\n      'error',\n      'assert',\n      'dir',\n      'dirxml',\n      'group',\n      'groupEnd',\n      'time',\n      'timeEnd',\n      'count',\n      'trace',\n      'profile',\n      'profileEnd',\n    ];\n    console = {};\n    for (const name of names) console[name] = noop;\n  }\n\n  const scheduler = window; // hack for opera required\n\n  const performDocumentReplaceRange = (start, end, newText) => {\n    if (start === undefined) start = rep.selStart;\n    if (end === undefined) end = rep.selEnd;\n\n    // start[0]: <--- start[1] --->CCCCCCCCCCC\\n\n    //           CCCCCCCCCCCCCCCCCCCC\\n\n    //           CCCC\\n\n    // end[0]:   <CCC end[1] CCC>-------\\n\n    const builder = new Builder(rep.lines.totalWidth());\n    buildKeepToStartOfRange(rep, builder, start);\n    buildRemoveRange(rep, builder, start, end);\n    builder.insert(newText, [\n      ['author', thisAuthor],\n    ], rep.apool);\n    const cs = builder.toString();\n\n    performDocumentApplyChangeset(cs);\n  };\n\n  const changesetTracker = makeChangesetTracker(scheduler, rep.apool, {\n    withCallbacks: (operationName, f) => {\n      inCallStackIfNecessary(operationName, () => {\n        fastIncorp(1);\n        f(\n            {\n              setDocumentAttributedText: (atext) => {\n                setDocAText(atext);\n              },\n              applyChangesetToDocument: (changeset, preferInsertionAfterCaret) => {\n                const oldEventType = currentCallStack.editEvent.eventType;\n                currentCallStack.startNewEvent('nonundoable');\n\n                performDocumentApplyChangeset(changeset, preferInsertionAfterCaret);\n\n                currentCallStack.startNewEvent(oldEventType);\n              },\n            });\n      });\n    },\n  });\n\n  const authorInfos = {}; // presence of key determines if author is present in doc\n  const getAuthorInfos = () => authorInfos;\n  editorInfo.ace_getAuthorInfos = getAuthorInfos;\n\n  const setAuthorStyle = (author, info) => {\n    const authorSelector = getAuthorColorClassSelector(getAuthorClassName(author));\n\n    const authorStyleSet = hooks.callAll('aceSetAuthorStyle', {\n      dynamicCSS: cssManagers.inner,\n      outerDynamicCSS: cssManagers.outer,\n      parentDynamicCSS: cssManagers.parent,\n      info,\n      author,\n      authorSelector,\n    });\n\n    // Prevent default behaviour if any hook says so\n    if (authorStyleSet.some((it) => it)) {\n      return;\n    }\n\n    if (!info) {\n      cssManagers.inner.removeSelectorStyle(authorSelector);\n      cssManagers.parent.removeSelectorStyle(authorSelector);\n    } else if (info.bgcolor) {\n      let bgcolor = info.bgcolor;\n      if ((typeof info.fade) === 'number') {\n        bgcolor = fadeColor(bgcolor, info.fade);\n      }\n      const textColor =\n          colorutils.textColorFromBackgroundColor(bgcolor, window.clientVars.skinName);\n      const styles = [\n        cssManagers.inner.selectorStyle(authorSelector),\n        cssManagers.parent.selectorStyle(authorSelector),\n      ];\n      for (const style of styles) {\n        style.backgroundColor = bgcolor;\n        style.color = textColor;\n        style['padding-top'] = '3px';\n        style['padding-bottom'] = '4px';\n      }\n    }\n  };\n\n  const setAuthorInfo = (author, info) => {\n    if (!author) return; // author ID not set for some reason\n    if ((typeof author) !== 'string') {\n      // Potentially caused by: https://github.com/ether/etherpad-lite/issues/2802\");\n      throw new Error(`setAuthorInfo: author (${author}) is not a string`);\n    }\n    if (!info) {\n      delete authorInfos[author];\n    } else {\n      authorInfos[author] = info;\n    }\n    setAuthorStyle(author, info);\n  };\n\n  const getAuthorClassName = (author) => `author-${author.replace(/[^a-y0-9]/g, (c) => {\n    if (c === '.') return '-';\n    return `z${c.charCodeAt(0)}z`;\n  })}`;\n\n  const className2Author = (className) => {\n    if (className.substring(0, 7) === 'author-') {\n      return className.substring(7).replace(/[a-y0-9]+|-|z.+?z/g, (cc) => {\n        if (cc === '-') { return '.'; } else if (cc.charAt(0) === 'z') {\n          return String.fromCharCode(Number(cc.slice(1, -1)));\n        } else {\n          return cc;\n        }\n      });\n    }\n    return null;\n  };\n\n  const getAuthorColorClassSelector = (oneClassName) => `.authorColors .${oneClassName}`;\n\n  const fadeColor = (colorCSS, fadeFrac) => {\n    let color = colorutils.css2triple(colorCSS);\n    color = colorutils.blend(color, [1, 1, 1], fadeFrac);\n    return colorutils.triple2css(color);\n  };\n\n  editorInfo.ace_getRep = () => rep;\n\n  editorInfo.ace_getAuthor = () => thisAuthor;\n\n  const _nonScrollableEditEvents = {\n    applyChangesToBase: 1,\n  };\n\n  for (const eventType of hooks.callAll('aceRegisterNonScrollableEditEvents')) {\n    _nonScrollableEditEvents[eventType] = 1;\n  }\n\n  const isScrollableEditEvent = (eventType) => !_nonScrollableEditEvents[eventType];\n\n  let currentCallStack = null;\n\n  const inCallStack = (type, action) => {\n    if (disposed) return;\n\n    const newEditEvent = (eventType) => ({\n      eventType,\n      backset: null,\n    });\n\n    const submitOldEvent = (evt) => {\n      if (rep.selStart && rep.selEnd) {\n        const selStartChar = rep.lines.offsetOfIndex(rep.selStart[0]) + rep.selStart[1];\n        const selEndChar = rep.lines.offsetOfIndex(rep.selEnd[0]) + rep.selEnd[1];\n        evt.selStart = selStartChar;\n        evt.selEnd = selEndChar;\n        evt.selFocusAtStart = rep.selFocusAtStart;\n      }\n      if (undoModule.enabled) {\n        let undoWorked = false;\n        try {\n          if (isPadLoading(evt.eventType)) {\n            undoModule.clearHistory();\n          } else if (evt.eventType === 'nonundoable') {\n            if (evt.changeset) {\n              undoModule.reportExternalChange(evt.changeset);\n            }\n          } else {\n            undoModule.reportEvent(evt);\n          }\n          undoWorked = true;\n        } finally {\n          if (!undoWorked) {\n            undoModule.enabled = false; // for safety\n          }\n        }\n      }\n    };\n\n    const startNewEvent = (eventType, dontSubmitOld) => {\n      const oldEvent = currentCallStack.editEvent;\n      if (!dontSubmitOld) {\n        submitOldEvent(oldEvent);\n      }\n      currentCallStack.editEvent = newEditEvent(eventType);\n      return oldEvent;\n    };\n\n    currentCallStack = {\n      type,\n      docTextChanged: false,\n      selectionAffected: false,\n      userChangedSelection: false,\n      domClean: false,\n      isUserChange: false,\n      // is this a \"user change\" type of call-stack\n      repChanged: false,\n      editEvent: newEditEvent(type),\n      startNewEvent,\n    };\n    let cleanExit = false;\n    let result;\n    try {\n      result = action();\n\n      hooks.callAll('aceEditEvent', {\n        callstack: currentCallStack,\n        editorInfo,\n        rep,\n        documentAttributeManager,\n      });\n\n      cleanExit = true;\n    } finally {\n      const cs = currentCallStack;\n      if (cleanExit) {\n        submitOldEvent(cs.editEvent);\n        if (cs.domClean && cs.type !== 'setup') {\n          if (cs.selectionAffected) {\n            updateBrowserSelectionFromRep();\n          }\n          if ((cs.docTextChanged || cs.userChangedSelection) && isScrollableEditEvent(cs.type)) {\n            scrollSelectionIntoView();\n          }\n          if (cs.docTextChanged && cs.type.indexOf('importText') < 0) {\n            outsideNotifyDirty();\n          }\n        }\n      } else if (currentCallStack.type === 'idleWorkTimer') {\n        idleWorkTimer.atLeast(1000);\n      }\n      currentCallStack = null;\n    }\n    return result;\n  };\n  editorInfo.ace_inCallStack = inCallStack;\n\n  const inCallStackIfNecessary = (type, action) => {\n    if (!currentCallStack) {\n      inCallStack(type, action);\n    } else {\n      action();\n    }\n  };\n  editorInfo.ace_inCallStackIfNecessary = inCallStackIfNecessary;\n\n  const dispose = () => {\n    disposed = true;\n    if (idleWorkTimer) idleWorkTimer.never();\n    teardown();\n  };\n\n  const setWraps = (newVal) => {\n    doesWrap = newVal;\n    targetBody.classList.toggle('doesWrap', doesWrap);\n    scheduler.setTimeout(() => {\n      inCallStackIfNecessary('setWraps', () => {\n        fastIncorp(7);\n        recreateDOM();\n        fixView();\n      });\n    }, 0);\n  };\n\n  const setStyled = (newVal) => {\n    const oldVal = isStyled;\n    isStyled = !!newVal;\n\n    if (newVal !== oldVal) {\n      if (!newVal) {\n        // clear styles\n        inCallStackIfNecessary('setStyled', () => {\n          fastIncorp(12);\n          const clearStyles = [];\n          for (const k of Object.keys(STYLE_ATTRIBS)) {\n            clearStyles.push([k, '']);\n          }\n          performDocumentApplyAttributesToCharRange(0, rep.alltext.length, clearStyles);\n        });\n      }\n    }\n  };\n\n  const setTextFace = (face) => {\n    targetBody.style.fontFamily = face;\n    lineMetricsDiv.style.fontFamily = face;\n  };\n\n  const recreateDOM = () => {\n    // precond: normalized\n    recolorLinesInRange(0, rep.alltext.length);\n  };\n\n  const setEditable = (newVal) => {\n    isEditable = newVal;\n    targetBody.contentEditable = isEditable ? 'true' : 'false';\n    targetBody.classList.toggle('static', !isEditable);\n  };\n\n  const enforceEditability = () => setEditable(isEditable);\n\n  const importText = (text, undoable, dontProcess) => {\n    let lines;\n    if (dontProcess) {\n      if (text.charAt(text.length - 1) !== '\\n') {\n        throw new Error('new raw text must end with newline');\n      }\n      if (/[\\r\\t\\xa0]/.exec(text)) {\n        throw new Error('new raw text must not contain CR, tab, or nbsp');\n      }\n      lines = text.substring(0, text.length - 1).split('\\n');\n    } else {\n      lines = text.split('\\n').map(textify);\n    }\n    let newText = '\\n';\n    if (lines.length > 0) {\n      newText = `${lines.join('\\n')}\\n`;\n    }\n\n\n    inCallStackIfNecessary(`importText${undoable ? 'Undoable' : ''}`, () => {\n      setDocText(newText);\n    });\n\n    if (dontProcess && rep.alltext !== text) {\n      throw new Error('mismatch error setting raw text in importText');\n    }\n  };\n\n  const importAText = (atext, apoolJsonObj, undoable) => {\n    atext = cloneAText(atext);\n    if (apoolJsonObj) {\n      const wireApool = (new AttribPool()).fromJsonable(apoolJsonObj);\n      atext.attribs = moveOpsToNewPool(atext.attribs, wireApool, rep.apool);\n    }\n    inCallStackIfNecessary(`importText${undoable ? 'Undoable' : ''}`, () => {\n      setDocAText(atext);\n    });\n  };\n\n  const setDocAText = (atext) => {\n    if (atext.text === '') {\n      /*\n       * The server is fine with atext.text being an empty string, but the front\n       * end is not, and crashes.\n       *\n       * It is not clear if this is a problem in the server or in the client\n       * code, and this is a client-side hack fix. The underlying problem needs\n       * to be investigated.\n       *\n       * See for reference:\n       * - https://github.com/ether/etherpad-lite/issues/3861\n       */\n      atext.text = '\\n';\n    }\n\n    fastIncorp(8);\n\n    const oldLen = rep.lines.totalWidth();\n    const numLines = rep.lines.length();\n    const upToLastLine = rep.lines.offsetOfIndex(numLines - 1);\n    const lastLineLength = rep.lines.atIndex(numLines - 1).text.length;\n    const assem = new SmartOpAssembler();\n    const o = new Op('-');\n    o.chars = upToLastLine;\n    o.lines = numLines - 1;\n    assem.append(o);\n    o.chars = lastLineLength;\n    o.lines = 0;\n    assem.append(o);\n    for (const op of opsFromAText(atext)) assem.append(op);\n    const newLen = oldLen + assem.getLengthChange();\n    const changeset = checkRep(\n        pack(oldLen, newLen, assem.toString(), atext.text.slice(0, -1)));\n    performDocumentApplyChangeset(changeset);\n\n    performSelectionChange(\n        [0, rep.lines.atIndex(0).lineMarker], [0, rep.lines.atIndex(0).lineMarker]);\n\n    idleWorkTimer.atMost(100);\n\n    if (rep.alltext !== atext.text) {\n      throw new Error('mismatch error setting raw text in setDocAText');\n    }\n  };\n\n  const setDocText = (text) => {\n    setDocAText(makeAText(text));\n  };\n\n  const getDocText = () => {\n    const alltext = rep.alltext;\n    let len = alltext.length;\n    if (len > 0) len--; // final extra newline\n    return alltext.substring(0, len);\n  };\n\n  const exportText = () => {\n    if (currentCallStack && !currentCallStack.domClean) {\n      inCallStackIfNecessary('exportText', () => {\n        fastIncorp(2);\n      });\n    }\n    return getDocText();\n  };\n\n  const editorChangedSize = () => fixView();\n\n  const setOnKeyPress = (handler) => {\n    outsideKeyPress = handler;\n  };\n\n  const setOnKeyDown = (handler) => {\n    outsideKeyDown = handler;\n  };\n\n  const setNotifyDirty = (handler) => {\n    outsideNotifyDirty = handler;\n  };\n\n  const CMDS = {\n    clearauthorship: (prompt) => {\n      if ((!(rep.selStart && rep.selEnd)) || isCaret()) {\n        if (prompt) {\n          prompt();\n        } else {\n          performDocumentApplyAttributesToCharRange(0, rep.alltext.length, [\n            ['author', ''],\n          ]);\n        }\n      } else {\n        setAttributeOnSelection('author', '');\n      }\n    },\n  };\n\n  const execCommand = (cmd, ...args) => {\n    cmd = cmd.toLowerCase();\n    if (CMDS[cmd]) {\n      inCallStackIfNecessary(cmd, () => {\n        fastIncorp(9);\n        CMDS[cmd](...args);\n      });\n    }\n  };\n\n  const replaceRange = (start, end, text) => {\n    inCallStackIfNecessary('replaceRange', () => {\n      fastIncorp(9);\n      performDocumentReplaceRange(start, end, text);\n    });\n  };\n\n  editorInfo.ace_callWithAce = (fn, callStack, normalize) => {\n    let wrapper = () => fn(editorInfo);\n\n    if (normalize !== undefined) {\n      const wrapper1 = wrapper;\n      wrapper = () => {\n        editorInfo.ace_fastIncorp(9);\n        wrapper1();\n      };\n    }\n\n    if (callStack !== undefined) {\n      return editorInfo.ace_inCallStack(callStack, wrapper);\n    } else {\n      return wrapper();\n    }\n  };\n\n  /**\n   * This methed exposes a setter for some ace properties\n   * @param key the name of the parameter\n   * @param value the value to set to\n   */\n  editorInfo.ace_setProperty = (key, value) => {\n    // These properties are exposed\n    const setters = {\n      wraps: setWraps,\n      showsauthorcolors: (val) => targetBody.classList.toggle('authorColors', !!val),\n      showsuserselections: (val) => targetBody.classList.toggle('userSelections', !!val),\n      showslinenumbers: (value) => {\n        hasLineNumbers = !!value;\n        sideDiv.parentNode.classList.toggle('line-numbers-hidden', !hasLineNumbers);\n        fixView();\n      },\n      userauthor: (value) => {\n        thisAuthor = String(value);\n        documentAttributeManager.author = thisAuthor;\n      },\n      styled: setStyled,\n      textface: setTextFace,\n      rtlistrue: (value) => {\n        targetBody.classList.toggle('rtl', value);\n        targetBody.classList.toggle('ltr', !value);\n        document.documentElement.dir = value ? 'rtl' : 'ltr';\n      },\n    };\n\n    const setter = setters[key.toLowerCase()];\n\n    // check if setter is present\n    if (setter !== undefined) {\n      setter(value);\n    }\n  };\n\n  editorInfo.ace_setBaseText = (txt) => {\n    changesetTracker.setBaseText(txt);\n  };\n  editorInfo.ace_setBaseAttributedText = (atxt, apoolJsonObj) => {\n    changesetTracker.setBaseAttributedText(atxt, apoolJsonObj);\n  };\n  editorInfo.ace_applyChangesToBase = (c, optAuthor, apoolJsonObj) => {\n    changesetTracker.applyChangesToBase(c, optAuthor, apoolJsonObj);\n  };\n  editorInfo.ace_prepareUserChangeset = () => changesetTracker.prepareUserChangeset();\n  editorInfo.ace_applyPreparedChangesetToBase = () => {\n    changesetTracker.applyPreparedChangesetToBase();\n  };\n  editorInfo.ace_setUserChangeNotificationCallback = (f) => {\n    changesetTracker.setUserChangeNotificationCallback(f);\n  };\n  editorInfo.ace_setAuthorInfo = (author, info) => {\n    setAuthorInfo(author, info);\n  };\n\n  editorInfo.ace_getDocument = () => document;\n\n  const now = () => Date.now();\n\n  const newTimeLimit = (ms) => {\n    const startTime = now();\n    let exceededAlready = false;\n    let printedTrace = false;\n    const isTimeUp = () => {\n      if (exceededAlready) {\n        if ((!printedTrace)) {\n          printedTrace = true;\n        }\n        return true;\n      }\n      const elapsed = now() - startTime;\n      if (elapsed > ms) {\n        exceededAlready = true;\n        return true;\n      } else {\n        return false;\n      }\n    };\n\n    isTimeUp.elapsed = () => now() - startTime;\n    return isTimeUp;\n  };\n\n\n  const makeIdleAction = (func) => {\n    let scheduledTimeout = null;\n    let scheduledTime = 0;\n\n    const unschedule = () => {\n      if (scheduledTimeout) {\n        scheduler.clearTimeout(scheduledTimeout);\n        scheduledTimeout = null;\n      }\n    };\n\n    const reschedule = (time) => {\n      unschedule();\n      scheduledTime = time;\n      let delay = time - now();\n      if (delay < 0) delay = 0;\n      scheduledTimeout = scheduler.setTimeout(callback, delay);\n    };\n\n    const callback = () => {\n      scheduledTimeout = null;\n      // func may reschedule the action\n      func();\n    };\n\n    return {\n      atMost: (ms) => {\n        const latestTime = now() + ms;\n        if ((!scheduledTimeout) || scheduledTime > latestTime) {\n          reschedule(latestTime);\n        }\n      },\n      // atLeast(ms) will schedule the action if not scheduled yet.\n      // In other words, \"infinity\" is replaced by ms, even though\n      // it is technically larger.\n      atLeast: (ms) => {\n        const earliestTime = now() + ms;\n        if ((!scheduledTimeout) || scheduledTime < earliestTime) {\n          reschedule(earliestTime);\n        }\n      },\n      never: () => {\n        unschedule();\n      },\n    };\n  };\n\n  const fastIncorp = (n) => {\n    // normalize but don't do any lexing or anything\n    incorporateUserChanges();\n  };\n  editorInfo.ace_fastIncorp = fastIncorp;\n\n  const idleWorkTimer = makeIdleAction(() => {\n    if (inInternationalComposition) {\n      // don't do idle input incorporation during international input composition\n      idleWorkTimer.atLeast(500);\n      return;\n    }\n\n    inCallStackIfNecessary('idleWorkTimer', () => {\n      const isTimeUp = newTimeLimit(250);\n\n      let finishedImportantWork = false;\n      let finishedWork = false;\n\n      try {\n        incorporateUserChanges();\n\n        if (isTimeUp()) return;\n\n        updateLineNumbers(); // update line numbers if any time left\n        if (isTimeUp()) return;\n        finishedImportantWork = true;\n        finishedWork = true;\n      } finally {\n        if (finishedWork) {\n          idleWorkTimer.atMost(1000);\n        } else if (finishedImportantWork) {\n          // if we've finished highlighting the view area,\n          // more highlighting could be counter-productive,\n          // e.g. if the user just opened a triple-quote and will soon close it.\n          idleWorkTimer.atMost(500);\n        } else {\n          let timeToWait = Math.round(isTimeUp.elapsed() / 2);\n          if (timeToWait < 100) timeToWait = 100;\n          idleWorkTimer.atMost(timeToWait);\n        }\n      }\n    });\n  });\n\n  let _nextId = 1;\n\n  const uniqueId = (n) => {\n    // not actually guaranteed to be unique, e.g. if user copy-pastes\n    // nodes with ids\n    const nid = n.id;\n    if (nid) return nid;\n    return (n.id = `magicdomid${_nextId++}`);\n  };\n\n\n  const recolorLinesInRange = (startChar, endChar) => {\n    if (endChar <= startChar) return;\n    if (startChar < 0 || startChar >= rep.lines.totalWidth()) return;\n    let lineEntry = rep.lines.atOffset(startChar); // rounds down to line boundary\n    let lineStart = rep.lines.offsetOfEntry(lineEntry);\n    let lineIndex = rep.lines.indexOfEntry(lineEntry);\n    let selectionNeedsResetting = false;\n    let firstLine = null;\n\n    // tokenFunc function; accesses current value of lineEntry and curDocChar,\n    // also mutates curDocChar\n    const tokenFunc = (tokenText, tokenClass) => {\n      lineEntry.domInfo.appendSpan(tokenText, tokenClass);\n    };\n\n    while (lineEntry && lineStart < endChar) {\n      const lineEnd = lineStart + lineEntry.width;\n      lineEntry.domInfo.clearSpans();\n      getSpansForLine(lineEntry, tokenFunc, lineStart);\n      lineEntry.domInfo.finishUpdate();\n\n      markNodeClean(lineEntry.lineNode);\n\n      if (rep.selStart && rep.selStart[0] === lineIndex ||\n          rep.selEnd && rep.selEnd[0] === lineIndex) {\n        selectionNeedsResetting = true;\n      }\n\n      if (firstLine == null) firstLine = lineIndex;\n      lineStart = lineEnd;\n      lineEntry = rep.lines.next(lineEntry);\n      lineIndex++;\n    }\n    if (selectionNeedsResetting) {\n      currentCallStack.selectionAffected = true;\n    }\n  };\n\n  // like getSpansForRange, but for a line, and the func takes (text,class)\n  // instead of (width,class); excludes the trailing '\\n' from\n  // consideration by func\n\n\n  const getSpansForLine = (lineEntry, textAndClassFunc, lineEntryOffsetHint) => {\n    let lineEntryOffset = lineEntryOffsetHint;\n    if ((typeof lineEntryOffset) !== 'number') {\n      lineEntryOffset = rep.lines.offsetOfEntry(lineEntry);\n    }\n    const text = lineEntry.text;\n    if (text.length === 0) {\n      // allow getLineStyleFilter to set line-div styles\n      const func = linestylefilter.getLineStyleFilter(\n          0, '', textAndClassFunc, rep.apool);\n      func('', '');\n    } else {\n      let filteredFunc = linestylefilter.getFilterStack(text, textAndClassFunc, browser);\n      const lineNum = rep.lines.indexOfEntry(lineEntry);\n      const aline = rep.alines[lineNum];\n      filteredFunc = linestylefilter.getLineStyleFilter(\n          text.length, aline, filteredFunc, rep.apool);\n      filteredFunc(text, '');\n    }\n  };\n\n  let observedChanges;\n\n  const clearObservedChanges = () => {\n    observedChanges = {\n      cleanNodesNearChanges: {},\n    };\n  };\n  clearObservedChanges();\n\n  const getCleanNodeByKey = (key) => {\n    let n = targetDoc.getElementById(key);\n    // copying and pasting can lead to duplicate ids\n    while (n && isNodeDirty(n)) {\n      n.id = '';\n      n = targetDoc.getElementById(key);\n    }\n    return n;\n  };\n\n  const observeChangesAroundNode = (node) => {\n    // Around this top-level DOM node, look for changes to the document\n    // (from how it looks in our representation) and record them in a way\n    // that can be used to \"normalize\" the document (apply the changes to our\n    // representation, and put the DOM in a canonical form).\n    let cleanNode;\n    let hasAdjacentDirtyness;\n    if (!isNodeDirty(node)) {\n      cleanNode = node;\n      const prevSib = cleanNode.previousSibling;\n      const nextSib = cleanNode.nextSibling;\n      hasAdjacentDirtyness = ((prevSib && isNodeDirty(prevSib)) ||\n         (nextSib && isNodeDirty(nextSib)));\n    } else {\n      // node is dirty, look for clean node above\n      let upNode = node.previousSibling;\n      while (upNode && isNodeDirty(upNode)) {\n        upNode = upNode.previousSibling;\n      }\n      if (upNode) {\n        cleanNode = upNode;\n      } else {\n        let downNode = node.nextSibling;\n        while (downNode && isNodeDirty(downNode)) {\n          downNode = downNode.nextSibling;\n        }\n        if (downNode) {\n          cleanNode = downNode;\n        }\n      }\n      if (!cleanNode) {\n        // Couldn't find any adjacent clean nodes!\n        // Since top and bottom of doc is dirty, the dirty area will be detected.\n        return;\n      }\n      hasAdjacentDirtyness = true;\n    }\n\n    if (hasAdjacentDirtyness) {\n      // previous or next line is dirty\n      observedChanges.cleanNodesNearChanges[`$${uniqueId(cleanNode)}`] = true;\n    } else {\n      // next and prev lines are clean (if they exist)\n      const lineKey = uniqueId(cleanNode);\n      const prevSib = cleanNode.previousSibling;\n      const nextSib = cleanNode.nextSibling;\n      const actualPrevKey = ((prevSib && uniqueId(prevSib)) || null);\n      const actualNextKey = ((nextSib && uniqueId(nextSib)) || null);\n      const repPrevEntry = rep.lines.prev(rep.lines.atKey(lineKey));\n      const repNextEntry = rep.lines.next(rep.lines.atKey(lineKey));\n      const repPrevKey = ((repPrevEntry && repPrevEntry.key) || null);\n      const repNextKey = ((repNextEntry && repNextEntry.key) || null);\n      if (actualPrevKey !== repPrevKey || actualNextKey !== repNextKey) {\n        observedChanges.cleanNodesNearChanges[`$${uniqueId(cleanNode)}`] = true;\n      }\n    }\n  };\n\n  const observeChangesAroundSelection = () => {\n    if (currentCallStack.observedSelection) return;\n    currentCallStack.observedSelection = true;\n\n    const selection = getSelection();\n\n    if (selection) {\n      const node1 = topLevel(selection.startPoint.node);\n      const node2 = topLevel(selection.endPoint.node);\n      if (node1) observeChangesAroundNode(node1);\n      if (node2 && node1 !== node2) {\n        observeChangesAroundNode(node2);\n      }\n    }\n  };\n\n  const observeSuspiciousNodes = () => {\n    // inspired by Firefox bug #473255, where pasting formatted text\n    // causes the cursor to jump away, making the new HTML never found.\n    if (targetBody.getElementsByTagName) {\n      const elts = targetBody.getElementsByTagName('style');\n      for (const elt of elts) {\n        const n = topLevel(elt);\n        if (n && n.parentNode === targetBody) {\n          observeChangesAroundNode(n);\n        }\n      }\n    }\n  };\n\n  const incorporateUserChanges = () => {\n    if (currentCallStack.domClean) return false;\n\n    currentCallStack.isUserChange = true;\n\n    if (DEBUG && window.DONT_INCORP || window.DEBUG_DONT_INCORP) return false;\n\n    // returns true if dom changes were made\n    if (!targetBody.firstChild) {\n      targetBody.innerHTML = '<div><!-- --></div>';\n    }\n\n    observeChangesAroundSelection();\n    observeSuspiciousNodes();\n    let dirtyRanges = getDirtyRanges();\n    let dirtyRangesCheckOut = true;\n    let j = 0;\n    let a, b;\n    let scrollToTheLeftNeeded = false;\n\n    while (j < dirtyRanges.length) {\n      a = dirtyRanges[j][0];\n      b = dirtyRanges[j][1];\n      if (!((a === 0 || getCleanNodeByKey(rep.lines.atIndex(a - 1).key)) &&\n          (b === rep.lines.length() || getCleanNodeByKey(rep.lines.atIndex(b).key)))) {\n        dirtyRangesCheckOut = false;\n        break;\n      }\n      j++;\n    }\n    if (!dirtyRangesCheckOut) {\n      for (const bodyNode of targetBody.childNodes) {\n        if ((bodyNode.tagName) && ((!bodyNode.id) || (!rep.lines.containsKey(bodyNode.id)))) {\n          observeChangesAroundNode(bodyNode);\n        }\n      }\n      dirtyRanges = getDirtyRanges();\n    }\n\n    clearObservedChanges();\n\n    const selection = getSelection();\n\n    let selStart, selEnd; // each one, if truthy, has [line,char] needed to set selection\n    let i = 0;\n    const splicesToDo = [];\n    let netNumLinesChangeSoFar = 0;\n    const toDeleteAtEnd = [];\n    const domInsertsNeeded = []; // each entry is [nodeToInsertAfter, [info1, info2, ...]]\n    while (i < dirtyRanges.length) {\n      const range = dirtyRanges[i];\n      a = range[0];\n      b = range[1];\n      let firstDirtyNode = (((a === 0) && targetBody.firstChild) ||\n          getCleanNodeByKey(rep.lines.atIndex(a - 1).key).nextSibling);\n      firstDirtyNode = (firstDirtyNode && isNodeDirty(firstDirtyNode) && firstDirtyNode);\n\n      let lastDirtyNode = (((b === rep.lines.length()) && targetBody.lastChild) ||\n          getCleanNodeByKey(rep.lines.atIndex(b).key).previousSibling);\n\n      lastDirtyNode = (lastDirtyNode && isNodeDirty(lastDirtyNode) && lastDirtyNode);\n      if (firstDirtyNode && lastDirtyNode) {\n        const cc = makeContentCollector(isStyled, browser, rep.apool, className2Author);\n        cc.notifySelection(selection);\n        const dirtyNodes = [];\n        for (let n = firstDirtyNode; n &&\n            !(n.previousSibling && n.previousSibling === lastDirtyNode);\n          n = n.nextSibling) {\n          cc.collectContent(n);\n          dirtyNodes.push(n);\n        }\n        cc.notifyNextNode(lastDirtyNode.nextSibling);\n        let lines = cc.getLines();\n        if ((lines.length <= 1 || lines[lines.length - 1] !== '') && lastDirtyNode.nextSibling) {\n          // dirty region doesn't currently end a line, even taking the following node\n          // (or lack of node) into account, so include the following clean node.\n          // It could be SPAN or a DIV; basically this is any case where the contentCollector\n          // decides it isn't done.\n          // Note that this clean node might need to be there for the next dirty range.\n          b++;\n          const cleanLine = lastDirtyNode.nextSibling;\n          cc.collectContent(cleanLine);\n          toDeleteAtEnd.push(cleanLine);\n          cc.notifyNextNode(cleanLine.nextSibling);\n        }\n\n        const ccData = cc.finish();\n        const ss = ccData.selStart;\n        const se = ccData.selEnd;\n        lines = ccData.lines;\n        const lineAttribs = ccData.lineAttribs;\n        const linesWrapped = ccData.linesWrapped;\n\n        if (linesWrapped > 0) {\n          // Chrome decides in its infinite wisdom that it's okay to put the browser's visisble\n          // window in the middle of the span. An outcome of this is that the first chars of the\n          // string are no longer visible to the user.. Yay chrome.. Move the browser's visible area\n          // to the left hand side of the span. Firefox isn't quite so bad, but it's still pretty\n          // quirky.\n          scrollToTheLeftNeeded = true;\n        }\n\n        if (ss[0] >= 0) selStart = [ss[0] + a + netNumLinesChangeSoFar, ss[1]];\n        if (se[0] >= 0) selEnd = [se[0] + a + netNumLinesChangeSoFar, se[1]];\n\n        const entries = [];\n        const nodeToAddAfter = lastDirtyNode;\n        const lineNodeInfos = [];\n        for (const lineString of lines) {\n          const newEntry = createDomLineEntry(lineString);\n          entries.push(newEntry);\n          lineNodeInfos.push(newEntry.domInfo);\n        }\n        domInsertsNeeded.push([nodeToAddAfter, lineNodeInfos]);\n        for (const n of dirtyNodes) toDeleteAtEnd.push(n);\n        const spliceHints = {};\n        if (selStart) spliceHints.selStart = selStart;\n        if (selEnd) spliceHints.selEnd = selEnd;\n        splicesToDo.push([a + netNumLinesChangeSoFar, b - a, entries, lineAttribs, spliceHints]);\n        netNumLinesChangeSoFar += (lines.length - (b - a));\n      } else if (b > a) {\n        splicesToDo.push([a + netNumLinesChangeSoFar, b - a, [], []]);\n      }\n      i++;\n    }\n\n    const domChanges = (splicesToDo.length > 0);\n\n    for (const splice of splicesToDo) doIncorpLineSplice(...splice);\n    for (const ins of domInsertsNeeded) insertDomLines(...ins);\n    for (const n of toDeleteAtEnd) n.remove();\n\n    // needed to stop chrome from breaking the ui when long strings without spaces are pasted\n    if (scrollToTheLeftNeeded) {\n      $('#innerdocbody').scrollLeft(0);\n    }\n\n    // if the nodes that define the selection weren't encountered during\n    // content collection, figure out where those nodes are now.\n    if (selection && !selStart) {\n      const selStartFromHook = hooks.callAll('aceStartLineAndCharForPoint', {\n        callstack: currentCallStack,\n        editorInfo,\n        rep,\n        root: targetBody,\n        point: selection.startPoint,\n        documentAttributeManager,\n      });\n      selStart = (selStartFromHook == null || selStartFromHook.length === 0)\n        ? getLineAndCharForPoint(selection.startPoint) : selStartFromHook;\n    }\n    if (selection && !selEnd) {\n      const selEndFromHook = hooks.callAll('aceEndLineAndCharForPoint', {\n        callstack: currentCallStack,\n        editorInfo,\n        rep,\n        root: targetBody,\n        point: selection.endPoint,\n        documentAttributeManager,\n      });\n      selEnd = (selEndFromHook == null ||\n         selEndFromHook.length === 0)\n        ? getLineAndCharForPoint(selection.endPoint) : selEndFromHook;\n    }\n\n    // selection from content collection can, in various ways, extend past final\n    // BR in firefox DOM, so cap the line\n    const numLines = rep.lines.length();\n    if (selStart && selStart[0] >= numLines) {\n      selStart[0] = numLines - 1;\n      selStart[1] = rep.lines.atIndex(selStart[0]).text.length;\n    }\n    if (selEnd && selEnd[0] >= numLines) {\n      selEnd[0] = numLines - 1;\n      selEnd[1] = rep.lines.atIndex(selEnd[0]).text.length;\n    }\n\n    // update rep if we have a new selection\n    // NOTE: IE loses the selection when you click stuff in e.g. the\n    // editbar, so removing the selection when it's lost is not a good\n    // idea.\n    if (selection) repSelectionChange(selStart, selEnd, selection && selection.focusAtStart);\n    // update browser selection\n    if (selection && (domChanges || isCaret())) {\n      // if no DOM changes (not this case), want to treat range selection delicately,\n      // e.g. in IE not lose which end of the selection is the focus/anchor;\n      // on the other hand, we may have just noticed a press of PageUp/PageDown\n      currentCallStack.selectionAffected = true;\n    }\n\n    currentCallStack.domClean = true;\n\n    fixView();\n\n    return domChanges;\n  };\n\n  const STYLE_ATTRIBS = {\n    bold: true,\n    italic: true,\n    underline: true,\n    strikethrough: true,\n    list: true,\n  };\n\n  const isStyleAttribute = (aname) => !!STYLE_ATTRIBS[aname];\n\n  const isDefaultLineAttribute =\n      (aname) => AttributeManager.DEFAULT_LINE_ATTRIBUTES.indexOf(aname) !== -1;\n\n  const insertDomLines = (nodeToAddAfter, infoStructs) => {\n    let lastEntry;\n    let lineStartOffset;\n    for (const info of infoStructs) {\n      const node = info.node;\n      const key = uniqueId(node);\n      let entry;\n      if (lastEntry) {\n        // optimization to avoid recalculation\n        const next = rep.lines.next(lastEntry);\n        if (next && next.key === key) {\n          entry = next;\n          lineStartOffset += lastEntry.width;\n        }\n      }\n      if (!entry) {\n        entry = rep.lines.atKey(key);\n        lineStartOffset = rep.lines.offsetOfKey(key);\n      }\n      lastEntry = entry;\n      getSpansForLine(entry, (tokenText, tokenClass) => {\n        info.appendSpan(tokenText, tokenClass);\n      }, lineStartOffset);\n      info.prepareForAdd();\n      entry.lineMarker = info.lineMarker;\n      if (!nodeToAddAfter) {\n        targetBody.insertBefore(node, targetBody.firstChild);\n      } else {\n        targetBody.insertBefore(node, nodeToAddAfter.nextSibling);\n      }\n      nodeToAddAfter = node;\n      info.notifyAdded();\n      markNodeClean(node);\n    }\n  };\n\n  const isCaret = () => (rep.selStart && rep.selEnd &&\n                         rep.selStart[0] === rep.selEnd[0] && rep.selStart[1] === rep.selEnd[1]);\n  editorInfo.ace_isCaret = isCaret;\n\n  // prereq: isCaret()\n  const caretLine = () => rep.selStart[0];\n\n  editorInfo.ace_caretLine = caretLine;\n\n  const caretColumn = () => rep.selStart[1];\n\n  editorInfo.ace_caretColumn = caretColumn;\n\n  const caretDocChar = () => rep.lines.offsetOfIndex(caretLine()) + caretColumn();\n\n  editorInfo.ace_caretDocChar = caretDocChar;\n\n  const handleReturnIndentation = () => {\n    // on return, indent to level of previous line\n    if (isCaret() && caretColumn() === 0 && caretLine() > 0) {\n      const lineNum = caretLine();\n      const thisLine = rep.lines.atIndex(lineNum);\n      const prevLine = rep.lines.prev(thisLine);\n      const prevLineText = prevLine.text;\n      let theIndent = /^ *(?:)/.exec(prevLineText)[0];\n      const shouldIndent = window.clientVars.indentationOnNewLine;\n      if (shouldIndent && /[[(:{]\\s*$/.exec(prevLineText)) {\n        theIndent += THE_TAB;\n      }\n      const cs = new Builder(rep.lines.totalWidth()).keep(\n          rep.lines.offsetOfIndex(lineNum), lineNum).insert(\n          theIndent, [\n            ['author', thisAuthor],\n          ], rep.apool).toString();\n      performDocumentApplyChangeset(cs);\n      performSelectionChange([lineNum, theIndent.length], [lineNum, theIndent.length]);\n    }\n  };\n\n  const getPointForLineAndChar = (lineAndChar) => {\n    const line = lineAndChar[0];\n    let charsLeft = lineAndChar[1];\n    const lineEntry = rep.lines.atIndex(line);\n    charsLeft -= lineEntry.lineMarker;\n    if (charsLeft < 0) {\n      charsLeft = 0;\n    }\n    const lineNode = lineEntry.lineNode;\n    let n = lineNode;\n    let after = false;\n    if (charsLeft === 0) {\n      return {\n        node: lineNode,\n        index: 0,\n        maxIndex: 1,\n      };\n    }\n    while (!(n === lineNode && after)) {\n      if (after) {\n        if (n.nextSibling) {\n          n = n.nextSibling;\n          after = false;\n        } else { n = n.parentNode; }\n      } else if (isNodeText(n)) {\n        const len = n.nodeValue.length;\n        if (charsLeft <= len) {\n          return {\n            node: n,\n            index: charsLeft,\n            maxIndex: len,\n          };\n        }\n        charsLeft -= len;\n        after = true;\n      } else if (n.firstChild) { n = n.firstChild; } else { after = true; }\n    }\n    return {\n      node: lineNode,\n      index: 1,\n      maxIndex: 1,\n    };\n  };\n\n  const nodeText = (n) => n.textContent || n.nodeValue || '';\n\n  const getLineAndCharForPoint = (point) => {\n    // Turn DOM node selection into [line,char] selection.\n    // This method has to work when the DOM is not pristine,\n    // assuming the point is not in a dirty node.\n    if (point.node === targetBody) {\n      if (point.index === 0) {\n        return [0, 0];\n      } else {\n        const N = rep.lines.length();\n        const ln = rep.lines.atIndex(N - 1);\n        return [N - 1, ln.text.length];\n      }\n    } else {\n      let n = point.node;\n      let col = 0;\n      // if this part fails, it probably means the selection node\n      // was dirty, and we didn't see it when collecting dirty nodes.\n      if (isNodeText(n)) {\n        col = point.index;\n      } else if (point.index > 0) {\n        col = nodeText(n).length;\n      }\n      let parNode, prevSib;\n      while ((parNode = n.parentNode) !== targetBody) {\n        if ((prevSib = n.previousSibling)) {\n          n = prevSib;\n          col += nodeText(n).length;\n        } else {\n          n = parNode;\n        }\n      }\n      if (n.firstChild && isBlockElement(n.firstChild)) {\n        col += 1; // lineMarker\n      }\n      const lineEntry = rep.lines.atKey(n.id);\n      const lineNum = rep.lines.indexOfEntry(lineEntry);\n      return [lineNum, col];\n    }\n  };\n  editorInfo.ace_getLineAndCharForPoint = getLineAndCharForPoint;\n\n  const createDomLineEntry = (lineString) => {\n    const info = doCreateDomLine(lineString.length > 0);\n    const newNode = info.node;\n    return {\n      key: uniqueId(newNode),\n      text: lineString,\n      lineNode: newNode,\n      domInfo: info,\n      lineMarker: 0,\n    };\n  };\n\n  const performDocumentApplyChangeset = (changes, insertsAfterSelection) => {\n    const domAndRepSplice = (startLine, deleteCount, newLineStrings) => {\n      const keysToDelete = [];\n      if (deleteCount > 0) {\n        let entryToDelete = rep.lines.atIndex(startLine);\n        for (let i = 0; i < deleteCount; i++) {\n          keysToDelete.push(entryToDelete.key);\n          entryToDelete = rep.lines.next(entryToDelete);\n        }\n      }\n\n      const lineEntries = newLineStrings.map(createDomLineEntry);\n\n      doRepLineSplice(startLine, deleteCount, lineEntries);\n\n      let nodeToAddAfter;\n      if (startLine > 0) {\n        nodeToAddAfter = getCleanNodeByKey(rep.lines.atIndex(startLine - 1).key);\n      } else { nodeToAddAfter = null; }\n\n      insertDomLines(nodeToAddAfter, lineEntries.map((entry) => entry.domInfo));\n\n      for (const k of keysToDelete) {\n        const n = targetDoc.getElementById(k);\n        n.parentNode.removeChild(n);\n      }\n\n      if (\n        (rep.selStart &&\n          rep.selStart[0] >= startLine &&\n          rep.selStart[0] <= startLine + deleteCount) ||\n         (rep.selEnd && rep.selEnd[0] >= startLine && rep.selEnd[0] <= startLine + deleteCount)) {\n        currentCallStack.selectionAffected = true;\n      }\n    };\n\n    doRepApplyChangeset(changes, insertsAfterSelection);\n\n    let requiredSelectionSetting = null;\n    if (rep.selStart && rep.selEnd) {\n      const selStartChar = rep.lines.offsetOfIndex(rep.selStart[0]) + rep.selStart[1];\n      const selEndChar = rep.lines.offsetOfIndex(rep.selEnd[0]) + rep.selEnd[1];\n      const result =\n          characterRangeFollow(changes, selStartChar, selEndChar, insertsAfterSelection);\n      requiredSelectionSetting = [result[0], result[1], rep.selFocusAtStart];\n    }\n\n    const linesMutatee = {\n      splice: (start, numRemoved, ...args) => {\n        domAndRepSplice(start, numRemoved, args.map((s) => s.slice(0, -1)));\n      },\n      get: (i) => `${rep.lines.atIndex(i).text}\\n`,\n      length: () => rep.lines.length(),\n    };\n\n    mutateTextLines(changes, linesMutatee);\n\n    if (requiredSelectionSetting) {\n      performSelectionChange(\n          lineAndColumnFromChar(requiredSelectionSetting[0]),\n          lineAndColumnFromChar(requiredSelectionSetting[1]),\n          requiredSelectionSetting[2]);\n    }\n  };\n\n  const doRepApplyChangeset = (changes, insertsAfterSelection) => {\n    checkRep(changes);\n\n    if (oldLen(changes) !== rep.alltext.length) {\n      const errMsg = `${oldLen(changes)}/${rep.alltext.length}`;\n      throw new Error(`doRepApplyChangeset length mismatch: ${errMsg}`);\n    }\n\n    const editEvent = currentCallStack.editEvent;\n    if (editEvent.eventType === 'nonundoable') {\n      if (!editEvent.changeset) {\n        editEvent.changeset = changes;\n      } else {\n        editEvent.changeset = compose(editEvent.changeset, changes, rep.apool);\n      }\n    } else {\n      const inverseChangeset = inverse(changes, {\n        get: (i) => `${rep.lines.atIndex(i).text}\\n`,\n        length: () => rep.lines.length(),\n      }, rep.alines, rep.apool);\n\n      if (!editEvent.backset) {\n        editEvent.backset = inverseChangeset;\n      } else {\n        editEvent.backset = compose(inverseChangeset, editEvent.backset, rep.apool);\n      }\n    }\n\n    mutateAttributionLines(changes, rep.alines, rep.apool);\n\n    if (changesetTracker.isTracking()) {\n      changesetTracker.composeUserChangeset(changes);\n    }\n  };\n\n  /**\n   * Converts the position of a char (index in String) into a [row, col] tuple\n   */\n  const lineAndColumnFromChar = (x) => {\n    const lineEntry = rep.lines.atOffset(x);\n    const lineStart = rep.lines.offsetOfEntry(lineEntry);\n    const lineNum = rep.lines.indexOfEntry(lineEntry);\n    return [lineNum, x - lineStart];\n  };\n\n  const performDocumentReplaceCharRange = (startChar, endChar, newText) => {\n    if (startChar === endChar && newText.length === 0) {\n      return;\n    }\n    // Requires that the replacement preserve the property that the\n    // internal document text ends in a newline.  Given this, we\n    // rewrite the splice so that it doesn't touch the very last\n    // char of the document.\n    if (endChar === rep.alltext.length) {\n      if (startChar === endChar) {\n        // an insert at end\n        startChar--;\n        endChar--;\n        newText = `\\n${newText.substring(0, newText.length - 1)}`;\n      } else if (newText.length === 0) {\n        // a delete at end\n        startChar--;\n        endChar--;\n      } else {\n        // a replace at end\n        endChar--;\n        newText = newText.substring(0, newText.length - 1);\n      }\n    }\n    performDocumentReplaceRange(\n        lineAndColumnFromChar(startChar), lineAndColumnFromChar(endChar), newText);\n  };\n\n  const performDocumentApplyAttributesToCharRange = (start, end, attribs) => {\n    end = Math.min(end, rep.alltext.length - 1);\n    documentAttributeManager.setAttributesOnRange(\n        lineAndColumnFromChar(start), lineAndColumnFromChar(end), attribs);\n  };\n\n  editorInfo.ace_performDocumentApplyAttributesToCharRange =\n      performDocumentApplyAttributesToCharRange;\n\n  const setAttributeOnSelection = (attributeName, attributeValue) => {\n    if (!(rep.selStart && rep.selEnd)) return;\n\n    documentAttributeManager.setAttributesOnRange(rep.selStart, rep.selEnd, [\n      [attributeName, attributeValue],\n    ]);\n  };\n  editorInfo.ace_setAttributeOnSelection = setAttributeOnSelection;\n\n  const getAttributeOnSelection = (attributeName, prevChar) => {\n    if (!(rep.selStart && rep.selEnd)) return;\n    const isNotSelection = (rep.selStart[0] === rep.selEnd[0] && rep.selEnd[1] === rep.selStart[1]);\n    if (isNotSelection) {\n      if (prevChar) {\n        // If it's not the start of the line\n        if (rep.selStart[1] !== 0) {\n          rep.selStart[1]--;\n        }\n      }\n    }\n\n    const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString();\n    const withItRegex = new RegExp(`${withIt.replace(/\\*/g, '\\\\*')}(\\\\*|$)`);\n    const hasIt = (attribs) => withItRegex.test(attribs);\n\n    const rangeHasAttrib = (selStart, selEnd) => {\n      // if range is collapsed -> no attribs in range\n      if (selStart[1] === selEnd[1] && selStart[0] === selEnd[0]) return false;\n\n      if (selStart[0] !== selEnd[0]) { // -> More than one line selected\n        let hasAttrib = true;\n\n        // from selStart to the end of the first line\n        hasAttrib = hasAttrib &&\n            rangeHasAttrib(selStart, [selStart[0], rep.lines.atIndex(selStart[0]).text.length]);\n\n        // for all lines in between\n        for (let n = selStart[0] + 1; n < selEnd[0]; n++) {\n          hasAttrib = hasAttrib && rangeHasAttrib([n, 0], [n, rep.lines.atIndex(n).text.length]);\n        }\n\n        // for the last, potentially partial, line\n        hasAttrib = hasAttrib && rangeHasAttrib([selEnd[0], 0], [selEnd[0], selEnd[1]]);\n\n        return hasAttrib;\n      }\n\n      // Logic tells us we now have a range on a single line\n\n      const lineNum = selStart[0];\n      const start = selStart[1];\n      const end = selEnd[1];\n      let hasAttrib = true;\n\n      let indexIntoLine = 0;\n      for (const op of deserializeOps(rep.alines[lineNum])) {\n        const opStartInLine = indexIntoLine;\n        const opEndInLine = opStartInLine + op.chars;\n        if (!hasIt(op.attribs)) {\n          // does op overlap selection?\n          if (!(opEndInLine <= start || opStartInLine >= end)) {\n            // since it's overlapping but hasn't got the attrib -> range hasn't got it\n            hasAttrib = false;\n            break;\n          }\n        }\n        indexIntoLine = opEndInLine;\n      }\n\n      return hasAttrib;\n    };\n    return rangeHasAttrib(rep.selStart, rep.selEnd);\n  };\n\n  editorInfo.ace_getAttributeOnSelection = getAttributeOnSelection;\n\n  const toggleAttributeOnSelection = (attributeName) => {\n    if (!(rep.selStart && rep.selEnd)) return;\n\n    let selectionAllHasIt = true;\n    const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString();\n    const withItRegex = new RegExp(`${withIt.replace(/\\*/g, '\\\\*')}(\\\\*|$)`);\n\n    const hasIt = (attribs) => withItRegex.test(attribs);\n\n    const selStartLine = rep.selStart[0];\n    const selEndLine = rep.selEnd[0];\n    for (let n = selStartLine; n <= selEndLine; n++) {\n      let indexIntoLine = 0;\n      let selectionStartInLine = 0;\n      if (documentAttributeManager.lineHasMarker(n)) {\n        selectionStartInLine = 1; // ignore \"*\" used as line marker\n      }\n      let selectionEndInLine = rep.lines.atIndex(n).text.length; // exclude newline\n      if (n === selStartLine) {\n        selectionStartInLine = rep.selStart[1];\n      }\n      if (n === selEndLine) {\n        selectionEndInLine = rep.selEnd[1];\n      }\n      for (const op of deserializeOps(rep.alines[n])) {\n        const opStartInLine = indexIntoLine;\n        const opEndInLine = opStartInLine + op.chars;\n        if (!hasIt(op.attribs)) {\n          // does op overlap selection?\n          if (!(opEndInLine <= selectionStartInLine || opStartInLine >= selectionEndInLine)) {\n            selectionAllHasIt = false;\n            break;\n          }\n        }\n        indexIntoLine = opEndInLine;\n      }\n      if (!selectionAllHasIt) {\n        break;\n      }\n    }\n\n\n    const attributeValue = selectionAllHasIt ? '' : 'true';\n    documentAttributeManager.setAttributesOnRange(\n        rep.selStart, rep.selEnd, [[attributeName, attributeValue]]);\n    if (attribIsFormattingStyle(attributeName)) {\n      updateStyleButtonState(attributeName, !selectionAllHasIt); // italic, bold, ...\n    }\n  };\n  editorInfo.ace_toggleAttributeOnSelection = toggleAttributeOnSelection;\n\n  const performDocumentReplaceSelection = (newText) => {\n    if (!(rep.selStart && rep.selEnd)) return;\n    performDocumentReplaceRange(rep.selStart, rep.selEnd, newText);\n  };\n\n  // Change the abstract representation of the document to have a different set of lines.\n  // Must be called after rep.alltext is set.\n  const doRepLineSplice = (startLine, deleteCount, newLineEntries) => {\n    for (const entry of newLineEntries) entry.width = entry.text.length + 1;\n\n    const startOldChar = rep.lines.offsetOfIndex(startLine);\n    const endOldChar = rep.lines.offsetOfIndex(startLine + deleteCount);\n\n    rep.lines.splice(startLine, deleteCount, newLineEntries);\n    currentCallStack.docTextChanged = true;\n    currentCallStack.repChanged = true;\n    const newText = newLineEntries.map((e) => `${e.text}\\n`).join('');\n\n    rep.alltext = rep.alltext.substring(0, startOldChar) +\n       newText + rep.alltext.substring(endOldChar, rep.alltext.length);\n  };\n\n  const doIncorpLineSplice = (startLine, deleteCount, newLineEntries, lineAttribs, hints) => {\n    const startOldChar = rep.lines.offsetOfIndex(startLine);\n    const endOldChar = rep.lines.offsetOfIndex(startLine + deleteCount);\n\n    const oldRegionStart = rep.lines.offsetOfIndex(startLine);\n\n    let selStartHintChar, selEndHintChar;\n    if (hints && hints.selStart) {\n      selStartHintChar =\n          rep.lines.offsetOfIndex(hints.selStart[0]) + hints.selStart[1] - oldRegionStart;\n    }\n    if (hints && hints.selEnd) {\n      selEndHintChar = rep.lines.offsetOfIndex(hints.selEnd[0]) + hints.selEnd[1] - oldRegionStart;\n    }\n\n    const newText = newLineEntries.map((e) => `${e.text}\\n`).join('');\n    const oldText = rep.alltext.substring(startOldChar, endOldChar);\n    const oldAttribs = rep.alines.slice(startLine, startLine + deleteCount).join('');\n    const newAttribs = `${lineAttribs.join('|1+1')}|1+1`; // not valid in a changeset\n    const analysis =\n        analyzeChange(oldText, newText, oldAttribs, newAttribs, selStartHintChar, selEndHintChar);\n    const commonStart = analysis[0];\n    let commonEnd = analysis[1];\n    let shortOldText = oldText.substring(commonStart, oldText.length - commonEnd);\n    let shortNewText = newText.substring(commonStart, newText.length - commonEnd);\n    let spliceStart = startOldChar + commonStart;\n    let spliceEnd = endOldChar - commonEnd;\n    let shiftFinalNewlineToBeforeNewText = false;\n\n    // adjust the splice to not involve the final newline of the document;\n    // be very defensive\n    if (shortOldText.charAt(shortOldText.length - 1) === '\\n' &&\n        shortNewText.charAt(shortNewText.length - 1) === '\\n') {\n      // replacing text that ends in newline with text that also ends in newline\n      // (still, after analysis, somehow)\n      shortOldText = shortOldText.slice(0, -1);\n      shortNewText = shortNewText.slice(0, -1);\n      spliceEnd--;\n      commonEnd++;\n    }\n    if (shortOldText.length === 0 &&\n        spliceStart === rep.alltext.length &&\n        shortNewText.length > 0) {\n      // inserting after final newline, bad\n      spliceStart--;\n      spliceEnd--;\n      shortNewText = `\\n${shortNewText.slice(0, -1)}`;\n      shiftFinalNewlineToBeforeNewText = true;\n    }\n    if (spliceEnd === rep.alltext.length &&\n      shortOldText.length > 0 &&\n      shortNewText.length === 0) {\n      // deletion at end of rep.alltext\n      if (rep.alltext.charAt(spliceStart - 1) === '\\n') {\n        // (if not then what the heck?  it will definitely lead\n        // to a rep.alltext without a final newline)\n        spliceStart--;\n        spliceEnd--;\n      }\n    }\n\n    if (!(shortOldText.length === 0 && shortNewText.length === 0)) {\n      const oldDocText = rep.alltext;\n      const oldLen = oldDocText.length;\n\n      const spliceStartLine = rep.lines.indexOfOffset(spliceStart);\n      const spliceStartLineStart = rep.lines.offsetOfIndex(spliceStartLine);\n\n      const startBuilder = () => {\n        const builder = new Builder(oldLen);\n        builder.keep(spliceStartLineStart, spliceStartLine);\n        builder.keep(spliceStart - spliceStartLineStart);\n        return builder;\n      };\n\n      const eachAttribRun = (attribs, func /* (startInNewText, endInNewText, attribs)*/) => {\n        let textIndex = 0;\n        const newTextStart = commonStart;\n        const newTextEnd = newText.length - commonEnd - (shiftFinalNewlineToBeforeNewText ? 1 : 0);\n        for (const op of deserializeOps(attribs)) {\n          const nextIndex = textIndex + op.chars;\n          if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) {\n            func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs);\n          }\n          textIndex = nextIndex;\n        }\n      };\n\n      const justApplyStyles = (shortNewText === shortOldText);\n      let theChangeset;\n\n      if (justApplyStyles) {\n        // create changeset that clears the incorporated styles on\n        // the existing text.  we compose this with the\n        // changeset the applies the styles found in the DOM.\n        // This allows us to incorporate, e.g., Safari's native \"unbold\".\n        const incorpedAttribClearer = cachedStrFunc(\n            (oldAtts) => mapAttribNumbers(oldAtts, (n) => {\n              const k = rep.apool.getAttribKey(n);\n              if (isStyleAttribute(k)) {\n                return rep.apool.putAttrib([k, '']);\n              }\n              return false;\n            }));\n\n        const builder1 = startBuilder();\n        if (shiftFinalNewlineToBeforeNewText) {\n          builder1.keep(1, 1);\n        }\n        eachAttribRun(oldAttribs, (start, end, attribs) => {\n          builder1.keepText(newText.substring(start, end), incorpedAttribClearer(attribs));\n        });\n        const clearer = builder1.toString();\n\n        const builder2 = startBuilder();\n        if (shiftFinalNewlineToBeforeNewText) {\n          builder2.keep(1, 1);\n        }\n        eachAttribRun(newAttribs, (start, end, attribs) => {\n          builder2.keepText(newText.substring(start, end), attribs);\n        });\n        const styler = builder2.toString();\n\n        theChangeset = compose(clearer, styler, rep.apool);\n      } else {\n        const builder = startBuilder();\n\n        const spliceEndLine = rep.lines.indexOfOffset(spliceEnd);\n        const spliceEndLineStart = rep.lines.offsetOfIndex(spliceEndLine);\n        if (spliceEndLineStart > spliceStart) {\n          builder.remove(spliceEndLineStart - spliceStart, spliceEndLine - spliceStartLine);\n          builder.remove(spliceEnd - spliceEndLineStart);\n        } else {\n          builder.remove(spliceEnd - spliceStart);\n        }\n\n        let isNewTextMultiauthor = false;\n        const authorizer = cachedStrFunc((oldAtts) => {\n          const attribs = AttributeMap.fromString(oldAtts, rep.apool);\n          if (!isNewTextMultiauthor || !attribs.has('author')) attribs.set('author', thisAuthor);\n          return attribs.toString();\n        });\n\n        let foundDomAuthor = '';\n        eachAttribRun(newAttribs, (start, end, attribs) => {\n          const a = AttributeMap.fromString(attribs, rep.apool).get('author');\n          if (a && a !== foundDomAuthor) {\n            if (!foundDomAuthor) {\n              foundDomAuthor = a;\n            } else {\n              isNewTextMultiauthor = true; // multiple authors in DOM!\n            }\n          }\n        });\n\n        if (shiftFinalNewlineToBeforeNewText) {\n          builder.insert('\\n', authorizer(''));\n        }\n\n        eachAttribRun(newAttribs, (start, end, attribs) => {\n          builder.insert(newText.substring(start, end), authorizer(attribs));\n        });\n        theChangeset = builder.toString();\n      }\n\n      doRepApplyChangeset(theChangeset);\n    }\n\n    // do this no matter what, because we need to get the right\n    // line keys into the rep.\n    doRepLineSplice(startLine, deleteCount, newLineEntries);\n  };\n\n  const cachedStrFunc = (func) => {\n    const cache = {};\n    return (s) => {\n      if (!cache[s]) {\n        cache[s] = func(s);\n      }\n      return cache[s];\n    };\n  };\n\n  const analyzeChange = (\n    oldText, newText, oldAttribs, newAttribs, optSelStartHint, optSelEndHint) => {\n    // we need to take into account both the styles attributes & attributes defined by\n    // the plugins, so basically we can ignore only the default line attribs used by\n    // Etherpad\n    const incorpedAttribFilter = (anum) => !isDefaultLineAttribute(rep.apool.getAttribKey(anum));\n\n    const attribRuns = (attribs) => {\n      const lengs = [];\n      const atts = [];\n      for (const op of deserializeOps(attribs)) {\n        lengs.push(op.chars);\n        atts.push(op.attribs);\n      }\n      return [lengs, atts];\n    };\n\n    const attribIterator = (runs, backward) => {\n      const lengs = runs[0];\n      const atts = runs[1];\n      let i = (backward ? lengs.length - 1 : 0);\n      let j = 0;\n      const next = () => {\n        while (j >= lengs[i]) {\n          if (backward) i--;\n          else i++;\n          j = 0;\n        }\n        const a = atts[i];\n        j++;\n        return a;\n      };\n      return next;\n    };\n\n    const oldLen = oldText.length;\n    const newLen = newText.length;\n    const minLen = Math.min(oldLen, newLen);\n\n    const oldARuns = attribRuns(filterAttribNumbers(oldAttribs, incorpedAttribFilter));\n    const newARuns = attribRuns(filterAttribNumbers(newAttribs, incorpedAttribFilter));\n\n    let commonStart = 0;\n    const oldStartIter = attribIterator(oldARuns, false);\n    const newStartIter = attribIterator(newARuns, false);\n    while (commonStart < minLen) {\n      if (oldText.charAt(commonStart) === newText.charAt(commonStart) &&\n      oldStartIter() === newStartIter()) {\n        commonStart++;\n      } else { break; }\n    }\n\n    let commonEnd = 0;\n    const oldEndIter = attribIterator(oldARuns, true);\n    const newEndIter = attribIterator(newARuns, true);\n    while (commonEnd < minLen) {\n      if (commonEnd === 0) {\n        // assume newline in common\n        oldEndIter();\n        newEndIter();\n        commonEnd++;\n      } else if (\n        oldText.charAt(oldLen - 1 - commonEnd) === newText.charAt(newLen - 1 - commonEnd) &&\n          oldEndIter() === newEndIter()) {\n        commonEnd++;\n      } else { break; }\n    }\n\n    let hintedCommonEnd = -1;\n    if ((typeof optSelEndHint) === 'number') {\n      hintedCommonEnd = newLen - optSelEndHint;\n    }\n\n\n    if (commonStart + commonEnd > oldLen) {\n      // ambiguous insertion\n      const minCommonEnd = oldLen - commonStart;\n      const maxCommonEnd = commonEnd;\n      if (hintedCommonEnd >= minCommonEnd && hintedCommonEnd <= maxCommonEnd) {\n        commonEnd = hintedCommonEnd;\n      } else {\n        commonEnd = minCommonEnd;\n      }\n      commonStart = oldLen - commonEnd;\n    }\n    if (commonStart + commonEnd > newLen) {\n      // ambiguous deletion\n      const minCommonEnd = newLen - commonStart;\n      const maxCommonEnd = commonEnd;\n      if (hintedCommonEnd >= minCommonEnd && hintedCommonEnd <= maxCommonEnd) {\n        commonEnd = hintedCommonEnd;\n      } else {\n        commonEnd = minCommonEnd;\n      }\n      commonStart = newLen - commonEnd;\n    }\n\n    return [commonStart, commonEnd];\n  };\n\n  const equalLineAndChars = (a, b) => {\n    if (!a) return !b;\n    if (!b) return !a;\n    return (a[0] === b[0] && a[1] === b[1]);\n  };\n\n  const performSelectionChange = (selectStart, selectEnd, focusAtStart) => {\n    if (repSelectionChange(selectStart, selectEnd, focusAtStart)) {\n      currentCallStack.selectionAffected = true;\n    }\n  };\n  editorInfo.ace_performSelectionChange = performSelectionChange;\n\n  // Change the abstract representation of the document to have a different selection.\n  // Should not rely on the line representation.  Should not affect the DOM.\n\n\n  const repSelectionChange = (selectStart, selectEnd, focusAtStart) => {\n    focusAtStart = !!focusAtStart;\n\n    const newSelFocusAtStart = (focusAtStart && ((!selectStart) ||\n        (!selectEnd) ||\n        (selectStart[0] !== selectEnd[0]) ||\n        (selectStart[1] !== selectEnd[1])));\n\n    if ((!equalLineAndChars(rep.selStart, selectStart)) ||\n        (!equalLineAndChars(rep.selEnd, selectEnd)) ||\n        (rep.selFocusAtStart !== newSelFocusAtStart)) {\n      rep.selStart = selectStart;\n      rep.selEnd = selectEnd;\n      rep.selFocusAtStart = newSelFocusAtStart;\n      currentCallStack.repChanged = true;\n\n      // select the formatting buttons when there is the style applied on selection\n      selectFormattingButtonIfLineHasStyleApplied(rep);\n\n      hooks.callAll('aceSelectionChanged', {\n        rep,\n        callstack: currentCallStack,\n        documentAttributeManager,\n      });\n\n      // we scroll when user places the caret at the last line of the pad\n      // when this settings is enabled\n      const docTextChanged = currentCallStack.docTextChanged;\n      if (!docTextChanged) {\n        const isScrollableEvent = !isPadLoading(currentCallStack.type) &&\n            isScrollableEditEvent(currentCallStack.type);\n        const innerHeight = getInnerHeight();\n        scroll.scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary(\n            rep, isScrollableEvent, innerHeight * 2);\n      }\n\n      return true;\n    }\n    return false;\n  };\n\n  const isPadLoading = (t) => t === 'setup' || t === 'setBaseText' || t === 'importText';\n\n  const updateStyleButtonState = (attribName, hasStyleOnRepSelection) => {\n    const $formattingButton = window.$(`[data-key=\"${attribName}\"]`).find('a');\n    $formattingButton.toggleClass(SELECT_BUTTON_CLASS, hasStyleOnRepSelection);\n  };\n\n  const attribIsFormattingStyle = (attribName) => FORMATTING_STYLES.indexOf(attribName) !== -1;\n\n  const selectFormattingButtonIfLineHasStyleApplied = (rep) => {\n    for (const style of FORMATTING_STYLES) {\n      const hasStyleOnRepSelection =\n          documentAttributeManager.hasAttributeOnSelectionOrCaretPosition(style);\n      updateStyleButtonState(style, hasStyleOnRepSelection);\n    }\n  };\n\n  const doCreateDomLine =\n      (nonEmpty) => domline.createDomLine(nonEmpty, doesWrap, browser, document);\n\n  const textify =\n      (str) => str.replace(/[\\n\\r ]/g, ' ').replace(/\\xa0/g, ' ').replace(/\\t/g, '        ');\n\n  const _blockElems = {\n    div: 1,\n    p: 1,\n    pre: 1,\n    li: 1,\n    ol: 1,\n    ul: 1,\n  };\n\n  for (const element of hooks.callAll('aceRegisterBlockElements')) _blockElems[element] = 1;\n\n  const isBlockElement = (n) => !!_blockElems[(n.tagName || '').toLowerCase()];\n  editorInfo.ace_isBlockElement = isBlockElement;\n\n  const getDirtyRanges = () => {\n    // based on observedChanges, return a list of ranges of original lines\n    // that need to be removed or replaced with new user content to incorporate\n    // the user's changes into the line representation.  ranges may be zero-length,\n    // indicating inserted content.  for example, [0,0] means content was inserted\n    // at the top of the document, while [3,4] means line 3 was deleted, modified,\n    // or replaced with one or more new lines of content. ranges do not touch.\n\n    const cleanNodeForIndexCache = {};\n    const N = rep.lines.length(); // old number of lines\n\n\n    const cleanNodeForIndex = (i) => {\n      // if line (i) in the un-updated line representation maps to a clean node\n      // in the document, return that node.\n      // if (i) is out of bounds, return true. else return false.\n      if (cleanNodeForIndexCache[i] === undefined) {\n        let result;\n        if (i < 0 || i >= N) {\n          result = true; // truthy, but no actual node\n        } else {\n          const key = rep.lines.atIndex(i).key;\n          result = (getCleanNodeByKey(key) || false);\n        }\n        cleanNodeForIndexCache[i] = result;\n      }\n      return cleanNodeForIndexCache[i];\n    };\n    const isConsecutiveCache = {};\n\n    const isConsecutive = (i) => {\n      if (isConsecutiveCache[i] === undefined) {\n        isConsecutiveCache[i] = (() => {\n          // returns whether line (i) and line (i-1), assumed to be map to clean DOM nodes,\n          // or document boundaries, are consecutive in the changed DOM\n          const a = cleanNodeForIndex(i - 1);\n          const b = cleanNodeForIndex(i);\n          if ((!a) || (!b)) return false; // violates precondition\n          if ((a === true) && (b === true)) return !targetBody.firstChild;\n          if ((a === true) && b.previousSibling) return false;\n          if ((b === true) && a.nextSibling) return false;\n          if ((a === true) || (b === true)) return true;\n          return a.nextSibling === b;\n        })();\n      }\n      return isConsecutiveCache[i];\n    };\n\n    // returns whether line (i) in the un-updated representation maps to a clean node,\n    // or is outside the bounds of the document\n    const isClean = (i) => !!cleanNodeForIndex(i);\n\n    // list of pairs, each representing a range of lines that is clean and consecutive\n    // in the changed DOM.  lines (-1) and (N) are always clean, but may or may not\n    // be consecutive with lines in the document.  pairs are in sorted order.\n    const cleanRanges = [\n      [-1, N + 1],\n    ];\n\n    // returns index of cleanRange containing i, or -1 if none\n    const rangeForLine = (i) => {\n      for (const [idx, r] of cleanRanges.entries()) {\n        if (i < r[0]) return -1;\n        if (i < r[1]) return idx;\n      }\n      return -1;\n    };\n\n    const removeLineFromRange = (rng, line) => {\n      // rng is index into cleanRanges, line is line number\n      // precond: line is in rng\n      const a = cleanRanges[rng][0];\n      const b = cleanRanges[rng][1];\n      if ((a + 1) === b) cleanRanges.splice(rng, 1);\n      else if (line === a) cleanRanges[rng][0]++;\n      else if (line === (b - 1)) cleanRanges[rng][1]--;\n      else cleanRanges.splice(rng, 1, [a, line], [line + 1, b]);\n    };\n\n    const splitRange = (rng, pt) => {\n      // precond: pt splits cleanRanges[rng] into two non-empty ranges\n      const a = cleanRanges[rng][0];\n      const b = cleanRanges[rng][1];\n      cleanRanges.splice(rng, 1, [a, pt], [pt, b]);\n    };\n\n    const correctedLines = {};\n\n    const correctlyAssignLine = (line) => {\n      if (correctedLines[line]) return true;\n      correctedLines[line] = true;\n      // \"line\" is an index of a line in the un-updated rep.\n      // returns whether line was already correctly assigned (i.e. correctly\n      // clean or dirty, according to cleanRanges, and if clean, correctly\n      // attached or not attached (i.e. in the same range as) the prev and next lines).\n      const rng = rangeForLine(line);\n      const lineClean = isClean(line);\n      if (rng < 0) {\n        if (lineClean) {\n          // somehow lost clean line\n        }\n        return true;\n      }\n      if (!lineClean) {\n        // a clean-range includes this dirty line, fix it\n        removeLineFromRange(rng, line);\n        return false;\n      } else {\n        // line is clean, but could be wrongly connected to a clean line\n        // above or below\n        const a = cleanRanges[rng][0];\n        const b = cleanRanges[rng][1];\n        let didSomething = false;\n        // we'll leave non-clean adjacent nodes in the clean range for the caller to\n        // detect and deal with.  we deal with whether the range should be split\n        // just above or just below this line.\n        if (a < line && isClean(line - 1) && !isConsecutive(line)) {\n          splitRange(rng, line);\n          didSomething = true;\n        }\n        if (b > (line + 1) && isClean(line + 1) && !isConsecutive(line + 1)) {\n          splitRange(rng, line + 1);\n          didSomething = true;\n        }\n        return !didSomething;\n      }\n    };\n\n    const detectChangesAroundLine = (line, reqInARow) => {\n      // make sure cleanRanges is correct about line number \"line\" and the surrounding\n      // lines; only stops checking at end of document or after no changes need\n      // making for several consecutive lines. note that iteration is over old lines,\n      // so this operation takes time proportional to the number of old lines\n      // that are changed or missing, not the number of new lines inserted.\n      let correctInARow = 0;\n      let currentIndex = line;\n      while (correctInARow < reqInARow && currentIndex >= 0) {\n        if (correctlyAssignLine(currentIndex)) {\n          correctInARow++;\n        } else { correctInARow = 0; }\n        currentIndex--;\n      }\n      correctInARow = 0;\n      currentIndex = line;\n      while (correctInARow < reqInARow && currentIndex < N) {\n        if (correctlyAssignLine(currentIndex)) {\n          correctInARow++;\n        } else { correctInARow = 0; }\n        currentIndex++;\n      }\n    };\n\n    if (N === 0) {\n      if (!isConsecutive(0)) {\n        splitRange(0, 0);\n      }\n    } else {\n      detectChangesAroundLine(0, 1);\n      detectChangesAroundLine(N - 1, 1);\n\n      for (const k of Object.keys(observedChanges.cleanNodesNearChanges)) {\n        const key = k.substring(1);\n        if (rep.lines.containsKey(key)) {\n          const line = rep.lines.indexOfKey(key);\n          detectChangesAroundLine(line, 2);\n        }\n      }\n    }\n\n    const dirtyRanges = [];\n    for (let r = 0; r < cleanRanges.length - 1; r++) {\n      dirtyRanges.push([cleanRanges[r][1], cleanRanges[r + 1][0]]);\n    }\n\n    return dirtyRanges;\n  };\n\n  const markNodeClean = (n) => {\n    // clean nodes have knownHTML that matches their innerHTML\n    setAssoc(n, 'dirtiness', {nodeId: uniqueId(n), knownHTML: n.innerHTML});\n  };\n\n  const isNodeDirty = (n) => {\n    if (n.parentNode !== targetBody) return true;\n    const data = getAssoc(n, 'dirtiness');\n    if (!data) return true;\n    if (n.id !== data.nodeId) return true;\n    if (n.innerHTML !== data.knownHTML) return true;\n    return false;\n  };\n\n  const handleClick = (evt) => {\n    inCallStackIfNecessary('handleClick', () => {\n      idleWorkTimer.atMost(200);\n    });\n\n    const isLink = (n) => (n.tagName || '').toLowerCase() === 'a' && n.href;\n\n    // only want to catch left-click\n    if ((evt.button !== 2) && (evt.button !== 3)) {\n      // find A tag with HREF\n      let n = evt.target;\n      while (n && n.parentNode && !isLink(n)) {\n        n = n.parentNode;\n      }\n      if (n && isLink(n)) {\n        try {\n          window.open(n.href, '_blank', 'noopener,noreferrer');\n          if (evt.ctrlKey) window.focus();\n        } catch (e) {\n          // absorb \"user canceled\" error in IE for certain prompts\n        }\n        evt.preventDefault();\n      }\n    }\n\n    hideEditBarDropdowns();\n  };\n\n  const hideEditBarDropdowns = () => {\n    window.padeditbar.toggleDropDown('none');\n  };\n\n  const renumberList = (lineNum) => {\n    // 1-check we are in a list\n    let type = getLineListType(lineNum);\n    if (!type) {\n      return null;\n    }\n    type = /([a-z]+)[0-9]+/.exec(type);\n    if (type[1] === 'indent') {\n      return null;\n    }\n\n    // 2-find the first line of the list\n    while (lineNum - 1 >= 0 && (type = getLineListType(lineNum - 1))) {\n      type = /([a-z]+)[0-9]+/.exec(type);\n      if (type[1] === 'indent') break;\n      lineNum--;\n    }\n\n    // 3-renumber every list item of the same level from the beginning, level 1\n    // IMPORTANT: never skip a level because there imbrication may be arbitrary\n    const builder = new Builder(rep.lines.totalWidth());\n    let loc = [0, 0];\n    const applyNumberList = (line, level) => {\n      // init\n      let position = 1;\n      let curLevel = level;\n      let listType;\n      // loop over the lines\n      while ((listType = getLineListType(line))) {\n        // apply new num\n        listType = /([a-z]+)([0-9]+)/.exec(listType);\n        curLevel = Number(listType[2]);\n        if (isNaN(curLevel) || listType[0] === 'indent') {\n          return line;\n        } else if (curLevel === level) {\n          buildKeepRange(rep, builder, loc, (loc = [line, 0]));\n          buildKeepRange(rep, builder, loc, (loc = [line, 1]), [\n            ['start', position],\n          ], rep.apool);\n\n          position++;\n          line++;\n        } else if (curLevel < level) {\n          return line;// back to parent\n        } else {\n          line = applyNumberList(line, level + 1);// recursive call\n        }\n      }\n      return line;\n    };\n\n    applyNumberList(lineNum, 1);\n    const cs = builder.toString();\n    if (!isIdentity(cs)) {\n      performDocumentApplyChangeset(cs);\n    }\n\n    // 4-apply the modifications\n  };\n  editorInfo.ace_renumberList = renumberList;\n\n  const setLineListType = (lineNum, listType) => {\n    if (listType === '') {\n      documentAttributeManager.removeAttributeOnLine(lineNum, listAttributeName);\n      documentAttributeManager.removeAttributeOnLine(lineNum, 'start');\n    } else {\n      documentAttributeManager.setAttributeOnLine(lineNum, listAttributeName, listType);\n    }\n\n    // if the list has been removed, it is necessary to renumber\n    // starting from the *next* line because the list may have been\n    // separated. If it returns null, it means that the list was not cut, try\n    // from the current one.\n    if (renumberList(lineNum + 1) == null) {\n      renumberList(lineNum);\n    }\n  };\n\n  const doReturnKey = () => {\n    if (!(rep.selStart && rep.selEnd)) {\n      return;\n    }\n\n    const lineNum = rep.selStart[0];\n    let listType = getLineListType(lineNum);\n\n    if (listType) {\n      const text = rep.lines.atIndex(lineNum).text;\n      listType = /([a-z]+)([0-9]+)/.exec(listType);\n      const type = listType[1];\n      const level = Number(listType[2]);\n\n      // detect empty list item; exclude indentation\n      if (text === '*' && type !== 'indent') {\n        // if not already on the highest level\n        if (level > 1) {\n          setLineListType(lineNum, type + (level - 1));// automatically decrease the level\n        } else {\n          setLineListType(lineNum, '');// remove the list\n          renumberList(lineNum + 1);// trigger renumbering of list that may be right after\n        }\n      } else if (lineNum + 1 <= rep.lines.length()) {\n        performDocumentReplaceSelection('\\n');\n        setLineListType(lineNum + 1, type + level);\n      }\n    } else {\n      performDocumentReplaceSelection('\\n');\n      handleReturnIndentation();\n    }\n  };\n  editorInfo.ace_doReturnKey = doReturnKey;\n\n  const doIndentOutdent = (isOut) => {\n    if (!((rep.selStart && rep.selEnd) ||\n          (rep.selStart[0] === rep.selEnd[0] &&\n           rep.selStart[1] === rep.selEnd[1] &&\n           rep.selEnd[1] > 1)) &&\n        isOut !== true) {\n      return false;\n    }\n\n    const firstLine = rep.selStart[0];\n    const lastLine = Math.max(firstLine, rep.selEnd[0] - ((rep.selEnd[1] === 0) ? 1 : 0));\n    const mods = [];\n    for (let n = firstLine; n <= lastLine; n++) {\n      let listType = getLineListType(n);\n      let t = 'indent';\n      let level = 0;\n      if (listType) {\n        listType = /([a-z]+)([0-9]+)/.exec(listType);\n        if (listType) {\n          t = listType[1];\n          level = Number(listType[2]);\n        }\n      }\n      const newLevel = Math.max(0, Math.min(MAX_LIST_LEVEL, level + (isOut ? -1 : 1)));\n      if (level !== newLevel) {\n        mods.push([n, (newLevel > 0) ? t + newLevel : '']);\n      }\n    }\n\n    for (const mod of mods) setLineListType(mod[0], mod[1]);\n    return true;\n  };\n  editorInfo.ace_doIndentOutdent = doIndentOutdent;\n\n  const doTabKey = (shiftDown) => {\n    if (!doIndentOutdent(shiftDown)) {\n      performDocumentReplaceSelection(THE_TAB);\n    }\n  };\n\n  const doDeleteKey = (optEvt) => {\n    const evt = optEvt || {};\n    let handled = false;\n    if (rep.selStart) {\n      if (isCaret()) {\n        const lineNum = caretLine();\n        const col = caretColumn();\n        const lineEntry = rep.lines.atIndex(lineNum);\n        const lineText = lineEntry.text;\n        const lineMarker = lineEntry.lineMarker;\n        if (evt.metaKey && col > lineMarker) {\n          // cmd-backspace deletes to start of line (if not already at start)\n          performDocumentReplaceRange([lineNum, lineMarker], [lineNum, col], '');\n          handled = true;\n        } else if (/^ +$/.exec(lineText.substring(lineMarker, col))) {\n          const col2 = col - lineMarker;\n          const tabSize = THE_TAB.length;\n          const toDelete = ((col2 - 1) % tabSize) + 1;\n          performDocumentReplaceRange([lineNum, col - toDelete], [lineNum, col], '');\n          handled = true;\n        }\n      }\n      if (!handled) {\n        if (isCaret()) {\n          const theLine = caretLine();\n          const lineEntry = rep.lines.atIndex(theLine);\n          if (caretColumn() <= lineEntry.lineMarker) {\n            // delete at beginning of line\n            const prevLineListType = (theLine > 0 ? getLineListType(theLine - 1) : '');\n            const thisLineListType = getLineListType(theLine);\n            const prevLineEntry = (theLine > 0 && rep.lines.atIndex(theLine - 1));\n            const prevLineBlank = (prevLineEntry &&\n                prevLineEntry.text.length === prevLineEntry.lineMarker);\n\n            const thisLineHasMarker = documentAttributeManager.lineHasMarker(theLine);\n\n            if (thisLineListType) {\n              // this line is a list\n              if (prevLineBlank && !prevLineListType) {\n                // previous line is blank, remove it\n                performDocumentReplaceRange(\n                    [theLine - 1, prevLineEntry.text.length], [theLine, 0], '');\n              } else {\n                // delistify\n                performDocumentReplaceRange([theLine, 0], [theLine, lineEntry.lineMarker], '');\n              }\n            } else if (thisLineHasMarker && prevLineEntry) {\n              // If the line has any attributes assigned, remove them by removing the marker '*'\n              performDocumentReplaceRange(\n                  [theLine - 1, prevLineEntry.text.length], [theLine, lineEntry.lineMarker], '');\n            } else if (theLine > 0) {\n              // remove newline\n              performDocumentReplaceRange(\n                  [theLine - 1, prevLineEntry.text.length], [theLine, 0], '');\n            }\n          } else {\n            const docChar = caretDocChar();\n            if (docChar > 0) {\n              if (evt.metaKey || evt.ctrlKey || evt.altKey) {\n                // delete as many unicode \"letters or digits\" in a row as possible;\n                // always delete one char, delete further even if that first char\n                // isn't actually a word char.\n                let deleteBackTo = docChar - 1;\n                while (deleteBackTo > lineEntry.lineMarker &&\n                  isWordChar(rep.alltext.charAt(deleteBackTo - 1))) {\n                  deleteBackTo--;\n                }\n                performDocumentReplaceCharRange(deleteBackTo, docChar, '');\n              } else {\n                // normal delete\n                performDocumentReplaceCharRange(docChar - 1, docChar, '');\n              }\n            }\n          }\n        } else {\n          performDocumentReplaceSelection('');\n        }\n      }\n    }\n    // if the list has been removed, it is necessary to renumber\n    // starting from the *next* line because the list may have been\n    // separated. If it returns null, it means that the list was not cut, try\n    // from the current one.\n    const line = caretLine();\n    if (line !== -1 && renumberList(line + 1) == null) {\n      renumberList(line);\n    }\n  };\n\n  const isWordChar = (c) => padutils.wordCharRegex.test(c);\n  editorInfo.ace_isWordChar = isWordChar;\n\n  const handleKeyEvent = (evt) => {\n    if (!isEditable) return;\n    const {type, charCode, keyCode, which, shiftKey} = evt;\n\n    // If DOM3 support exists, ensure that the left ALT key was pressed. This\n    // allows keyboard layouts with special meaning for right-alt-char to\n    // continue working on Firefox / macOS.\n    let altKey = evt.altKey;\n    if (evt.originalEvent.location !== undefined) {\n        altKey = altKey && evt.originalEvent.location === evt.originalEvent.DOM_KEY_LOCATION_LEFT;\n    }\n\n    // Don't take action based on modifier keys going up and down.\n    // Modifier keys do not generate \"keypress\" events.\n    // 224 is the command-key under Mac Firefox.\n    // 91 is the Windows key in IE; it is ASCII for open-bracket but isn't the keycode for that key\n    // 20 is capslock in IE.\n    const isModKey = !charCode && (type === 'keyup' || type === 'keydown') &&\n        (keyCode === 16 || keyCode === 17 || keyCode === 18 ||\n         keyCode === 20 || keyCode === 224 || keyCode === 91);\n    if (isModKey) return;\n\n    // If the key is a keypress and the browser is opera and the key is enter,\n    // do nothign at all as this fires twice.\n    if (keyCode === 13 && browser.opera && type === 'keypress') {\n      // This stops double enters in Opera but double Tabs still show on single\n      // tab keypress, adding keyCode == 9 to this doesn't help as the event is fired twice\n      return;\n    }\n\n    const isTypeForSpecialKey = browser.safari || browser.chrome || browser.firefox\n      ? type === 'keydown' : type === 'keypress';\n    const isTypeForCmdKey = browser.safari || browser.chrome || browser.firefox\n      ? type === 'keydown' : type === 'keypress';\n\n    let stopped = false;\n\n    inCallStackIfNecessary('handleKeyEvent', function () {\n      if (type === 'keypress' || (isTypeForSpecialKey && keyCode === 13 /* return*/)) {\n        // in IE, special keys don't send keypress, the keydown does the action\n        if (!outsideKeyPress(evt)) {\n          evt.preventDefault();\n          stopped = true;\n        }\n      } else if (evt.key === 'Dead') {\n        // If it's a dead key we don't want to do any Etherpad behavior.\n        stopped = true;\n        return true;\n      } else if (type === 'keydown') {\n        outsideKeyDown(evt);\n      }\n      let specialHandled = false;\n      if (!stopped) {\n        const specialHandledInHook = hooks.callAll('aceKeyEvent', {\n          callstack: currentCallStack,\n          editorInfo,\n          rep,\n          documentAttributeManager,\n          evt,\n        });\n\n        // if any hook returned true, set specialHandled with true\n        if (specialHandledInHook) {\n          specialHandled = specialHandledInHook.indexOf(true) !== -1;\n        }\n\n        const padShortcutEnabled = window.clientVars.padShortcutEnabled;\n        if (!specialHandled && isTypeForSpecialKey &&\n            altKey && keyCode === 120 &&\n            padShortcutEnabled.altF9) {\n          // Alt F9 focuses on the File Menu and/or editbar.\n          // Note that while most editors use Alt F10 this is not desirable\n          // As ubuntu cannot use Alt F10....\n          // Focus on the editbar.\n          // -- TODO: Move Focus back to previous state (we know it so we can use it)\n          const firstEditbarElement = window.$('#editbar')\n              .children('ul').first().children().first()\n              .children().first().children().first();\n          $(this).trigger('blur');\n          firstEditbarElement.trigger('focus');\n          evt.preventDefault();\n        }\n        if (!specialHandled && type === 'keydown' &&\n            altKey && keyCode === 67 &&\n            padShortcutEnabled.altC) {\n          // Alt c focuses on the Chat window\n          $(this).trigger('blur');\n          window.chat.show();\n          window.$('#chatinput').trigger('focus');\n          evt.preventDefault();\n        }\n        if (!specialHandled && type === 'keydown' &&\n            evt.ctrlKey && shiftKey && keyCode === 50 &&\n            padShortcutEnabled.cmdShift2) {\n          // Control-Shift-2 shows a gritter popup showing a line author\n          const lineNumber = rep.selEnd[0];\n          const alineAttrs = rep.alines[lineNumber];\n          const apool = rep.apool;\n\n          // TODO: support selection ranges\n          // TODO: Still work when authorship colors have been cleared\n          // TODO: i18n\n          // TODO: There appears to be a race condition or so.\n          const authorIds = new Set();\n          if (alineAttrs) {\n            for (const op of deserializeOps(alineAttrs)) {\n              const authorId = AttributeMap.fromString(op.attribs, apool).get('author');\n              if (authorId) authorIds.add(authorId);\n            }\n          }\n          const idToName = new Map(window.pad.userList().map((a) => [a.userId, a.name]));\n          const myId = window.clientVars.userId;\n          const authors =\n              [...authorIds].map((id) => id === myId ? 'me' : idToName.get(id) || 'unknown');\n\n          window.$.gritter.add({\n            title: 'Line Authors',\n            text:\n                authors.length === 0 ? 'No author information is available'\n                : authors.length === 1 ? `The author of this line is ${authors[0]}`\n                : `The authors of this line are ${authors.join(' & ')}`,\n            sticky: false,\n            time: '4000',\n          });\n        }\n        if (!specialHandled && isTypeForSpecialKey &&\n            keyCode === 8 &&\n            padShortcutEnabled.delete) {\n          // \"delete\" key; in mozilla, if we're at the beginning of a line, normalize now,\n          // or else deleting a blank line can take two delete presses.\n          // --\n          // we do deletes completely customly now:\n          //  - allows consistent (and better) meta-delete behavior\n          //  - normalizing and then allowing default behavior confused IE\n          //  - probably eliminates a few minor quirks\n          fastIncorp(3);\n          evt.preventDefault();\n          doDeleteKey(evt);\n          specialHandled = true;\n        }\n        if (!specialHandled && isTypeForSpecialKey &&\n            keyCode === 13 &&\n            padShortcutEnabled.return) {\n          // return key, handle specially;\n          // note that in mozilla we need to do an incorporation for proper return behavior anyway.\n          fastIncorp(4);\n          evt.preventDefault();\n          doReturnKey();\n          scheduler.setTimeout(() => {\n            outerWin.scrollBy(-100, 0);\n          }, 0);\n          specialHandled = true;\n        }\n        if (!specialHandled && isTypeForSpecialKey &&\n            keyCode === 27 &&\n            padShortcutEnabled.esc) {\n          // prevent esc key;\n          // in mozilla versions 14-19 avoid reconnecting pad.\n\n          fastIncorp(4);\n          evt.preventDefault();\n          specialHandled = true;\n\n          // close all gritters when the user hits escape key\n          window.$.gritter.removeAll();\n        }\n        if (!specialHandled && isTypeForCmdKey &&\n            /* Do a saved revision on ctrl S */\n            (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 's' &&\n            !evt.altKey &&\n            padShortcutEnabled.cmdS) {\n          evt.preventDefault();\n          const originalBackground = window.$('#revisionlink').css('background');\n          window.$('#revisionlink').css({background: 'lightyellow'});\n          scheduler.setTimeout(() => {\n            window.$('#revisionlink').css({background: originalBackground});\n          }, 1000);\n\n          window.pad.collabClient.sendMessage({type: 'SAVE_REVISION'});\n          specialHandled = true;\n        }\n        if (!specialHandled && isTypeForSpecialKey &&\n            // tab\n            keyCode === 9 &&\n            !(evt.metaKey || evt.ctrlKey) &&\n            padShortcutEnabled.tab) {\n          fastIncorp(5);\n          evt.preventDefault();\n          doTabKey(evt.shiftKey);\n          specialHandled = true;\n        }\n        if (!specialHandled && isTypeForCmdKey &&\n            // cmd-Z (undo)\n            (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'z' &&\n            !evt.altKey &&\n            padShortcutEnabled.cmdZ) {\n          fastIncorp(6);\n          evt.preventDefault();\n          if (evt.shiftKey) {\n            doUndoRedo('redo');\n          } else {\n            doUndoRedo('undo');\n          }\n          specialHandled = true;\n        }\n        if (!specialHandled && isTypeForCmdKey &&\n            // cmd-Y (redo)\n            (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'y' &&\n            padShortcutEnabled.cmdY) {\n          fastIncorp(10);\n          evt.preventDefault();\n          doUndoRedo('redo');\n          specialHandled = true;\n        }\n        if (!specialHandled && isTypeForCmdKey &&\n            // cmd-B (bold)\n            (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'b' &&\n            padShortcutEnabled.cmdB) {\n          fastIncorp(13);\n          evt.preventDefault();\n          toggleAttributeOnSelection('bold');\n          specialHandled = true;\n        }\n        if (!specialHandled && isTypeForCmdKey &&\n            // cmd-I (italic)\n            (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'i' &&\n            padShortcutEnabled.cmdI) {\n          fastIncorp(14);\n          evt.preventDefault();\n          toggleAttributeOnSelection('italic');\n          specialHandled = true;\n        }\n        if (!specialHandled && isTypeForCmdKey &&\n            // cmd-U (underline)\n            (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'u' &&\n            padShortcutEnabled.cmdU) {\n          fastIncorp(15);\n          evt.preventDefault();\n          toggleAttributeOnSelection('underline');\n          specialHandled = true;\n        }\n        if (!specialHandled && isTypeForCmdKey &&\n            // cmd-5 (strikethrough)\n            (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === '5' &&\n            evt.altKey !== true &&\n            padShortcutEnabled.cmd5) {\n          fastIncorp(13);\n          evt.preventDefault();\n          toggleAttributeOnSelection('strikethrough');\n          specialHandled = true;\n        }\n        if (!specialHandled && isTypeForCmdKey &&\n            // cmd-shift-L (unorderedlist)\n            (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'l' &&\n            evt.shiftKey &&\n            padShortcutEnabled.cmdShiftL) {\n          fastIncorp(9);\n          evt.preventDefault();\n          doInsertUnorderedList();\n          specialHandled = true;\n        }\n        if (!specialHandled && isTypeForCmdKey &&\n            // cmd-shift-N and cmd-shift-1 (orderedlist)\n            (evt.metaKey || evt.ctrlKey) && evt.shiftKey &&\n            ((String.fromCharCode(which).toLowerCase() === 'n' && padShortcutEnabled.cmdShiftN) ||\n             (String.fromCharCode(which) === '1' && padShortcutEnabled.cmdShift1))) {\n          fastIncorp(9);\n          evt.preventDefault();\n          doInsertOrderedList();\n          specialHandled = true;\n        }\n        if (!specialHandled && isTypeForCmdKey &&\n            // cmd-shift-C (clearauthorship)\n            (evt.metaKey || evt.ctrlKey) && evt.shiftKey &&\n            String.fromCharCode(which).toLowerCase() === 'c' &&\n            padShortcutEnabled.cmdShiftC) {\n          fastIncorp(9);\n          evt.preventDefault();\n          CMDS.clearauthorship();\n        }\n        if (!specialHandled && isTypeForCmdKey &&\n            // cmd-H (backspace)\n            (evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'h' &&\n            padShortcutEnabled.cmdH) {\n          fastIncorp(20);\n          evt.preventDefault();\n          doDeleteKey();\n          specialHandled = true;\n        }\n        if (evt.ctrlKey === true && evt.which === 36 &&\n            // Control Home send to Y = 0\n            padShortcutEnabled.ctrlHome) {\n          scroll.setScrollY(0);\n        }\n        if ((evt.which === 33 || evt.which === 34) && type === 'keydown' && !evt.ctrlKey) {\n          // This is required, browsers will try to do normal default behavior on\n          // page up / down and the default behavior SUCKS\n          evt.preventDefault();\n          const oldVisibleLineRange = scroll.getVisibleLineRange(rep);\n          let topOffset = rep.selStart[0] - oldVisibleLineRange[0];\n          if (topOffset < 0) {\n            topOffset = 0;\n          }\n\n          const isPageDown = evt.which === 34;\n          const isPageUp = evt.which === 33;\n\n          scheduler.setTimeout(() => {\n            // the visible lines IE 1,10\n            const newVisibleLineRange = scroll.getVisibleLineRange(rep);\n            // total count of lines in pad IE 10\n            const linesCount = rep.lines.length();\n            // How many lines are in the viewport right now?\n            const numberOfLinesInViewport = newVisibleLineRange[1] - newVisibleLineRange[0];\n\n            if (isPageUp && padShortcutEnabled.pageUp) {\n              // move to the bottom line +1 in the viewport (essentially skipping over a page)\n              rep.selEnd[0] -= numberOfLinesInViewport;\n              // move to the bottom line +1 in the viewport (essentially skipping over a page)\n              rep.selStart[0] -= numberOfLinesInViewport;\n            }\n\n            // if we hit page down\n            if (isPageDown && padShortcutEnabled.pageDown) {\n              // If the new viewpoint position is actually further than where we are right now\n              if (rep.selEnd[0] >= oldVisibleLineRange[0]) {\n                // dont go further in the page down than what's visible IE go from 0 to 50\n                //  if 50 is visible on screen but dont go below that else we miss content\n                rep.selStart[0] = oldVisibleLineRange[1] - 1;\n                // dont go further in the page down than what's visible IE go from 0 to 50\n                // if 50 is visible on screen but dont go below that else we miss content\n                rep.selEnd[0] = oldVisibleLineRange[1] - 1;\n              }\n            }\n\n            // ensure min and max\n            if (rep.selEnd[0] < 0) {\n              rep.selEnd[0] = 0;\n            }\n            if (rep.selStart[0] < 0) {\n              rep.selStart[0] = 0;\n            }\n            if (rep.selEnd[0] >= linesCount) {\n              rep.selEnd[0] = linesCount - 1;\n            }\n            updateBrowserSelectionFromRep();\n            // get the current caret selection, can't use rep. here because that only gives\n            // us the start position not the current\n            const myselection = targetDoc.getSelection();\n            // get the carets selection offset in px IE 214\n            let caretOffsetTop = myselection.focusNode.parentNode.offsetTop ||\n                myselection.focusNode.offsetTop;\n\n            // sometimes the first selection is -1 which causes problems\n            // (Especially with ep_page_view)\n            // so use focusNode.offsetTop value.\n            if (caretOffsetTop === -1) caretOffsetTop = myselection.focusNode.offsetTop;\n            // set the scrollY offset of the viewport on the document\n            scroll.setScrollY(caretOffsetTop);\n          }, 200);\n        }\n      }\n\n      if (type === 'keydown') {\n        idleWorkTimer.atLeast(500);\n      } else if (type === 'keypress') {\n        // OPINION ASKED.  What's going on here? :D\n        if (!specialHandled) {\n          idleWorkTimer.atMost(0);\n        } else {\n          idleWorkTimer.atLeast(500);\n        }\n      } else if (type === 'keyup') {\n        const wait = 0;\n        idleWorkTimer.atLeast(wait);\n        idleWorkTimer.atMost(wait);\n      }\n\n      // Is part of multi-keystroke international character on Firefox Mac\n      const isFirefoxHalfCharacter =\n          (browser.firefox && evt.altKey && charCode === 0 && keyCode === 0);\n\n      // Is part of multi-keystroke international character on Safari Mac\n      const isSafariHalfCharacter =\n          (browser.safari && evt.altKey && keyCode === 229);\n\n      if (thisKeyDoesntTriggerNormalize || isFirefoxHalfCharacter || isSafariHalfCharacter) {\n        idleWorkTimer.atLeast(3000); // give user time to type\n        // if this is a keydown, e.g., the keyup shouldn't trigger a normalize\n        thisKeyDoesntTriggerNormalize = true;\n      }\n\n      if (!specialHandled && !thisKeyDoesntTriggerNormalize && !inInternationalComposition &&\n          type !== 'keyup') {\n        observeChangesAroundSelection();\n      }\n\n      if (type === 'keyup') {\n        thisKeyDoesntTriggerNormalize = false;\n      }\n    });\n  };\n\n  let thisKeyDoesntTriggerNormalize = false;\n\n  const doUndoRedo = (which) => {\n    // precond: normalized DOM\n    if (undoModule.enabled) {\n      let whichMethod;\n      if (which === 'undo') whichMethod = 'performUndo';\n      if (which === 'redo') whichMethod = 'performRedo';\n      if (whichMethod) {\n        const oldEventType = currentCallStack.editEvent.eventType;\n        currentCallStack.startNewEvent(which);\n        undoModule[whichMethod]((backset, selectionInfo) => {\n          if (backset) {\n            performDocumentApplyChangeset(backset);\n          }\n          if (selectionInfo) {\n            performSelectionChange(\n                lineAndColumnFromChar(selectionInfo.selStart),\n                lineAndColumnFromChar(selectionInfo.selEnd),\n                selectionInfo.selFocusAtStart);\n          }\n          const oldEvent = currentCallStack.startNewEvent(oldEventType, true);\n          return oldEvent;\n        });\n      }\n    }\n  };\n  editorInfo.ace_doUndoRedo = doUndoRedo;\n\n  const setSelection = (selection) => {\n    const copyPoint = (pt) => ({\n      node: pt.node,\n      index: pt.index,\n      maxIndex: pt.maxIndex,\n    });\n    let isCollapsed;\n\n    const pointToRangeBound = (pt) => {\n      const p = copyPoint(pt);\n      // Make sure Firefox cursor is deep enough; fixes cursor jumping when at top level,\n      // and also problem where cut/copy of a whole line selected with fake arrow-keys\n      // copies the next line too.\n      if (isCollapsed) {\n        const diveDeep = () => {\n          while (p.node.childNodes.length > 0) {\n            if (p.index === 0) {\n              p.node = p.node.firstChild;\n              p.maxIndex = nodeMaxIndex(p.node);\n            } else if (p.index === p.maxIndex) {\n              p.node = p.node.lastChild;\n              p.maxIndex = nodeMaxIndex(p.node);\n              p.index = p.maxIndex;\n            } else { break; }\n          }\n        };\n        // now fix problem where cursor at end of text node at end of span-like element\n        // with background doesn't seem to show up...\n        if (isNodeText(p.node) && p.index === p.maxIndex) {\n          let n = p.node;\n          while (!n.nextSibling && n !== targetBody && n.parentNode !== targetBody) {\n            n = n.parentNode;\n          }\n          if (n.nextSibling &&\n              !(typeof n.nextSibling.tagName === 'string' &&\n                n.nextSibling.tagName.toLowerCase() === 'br') &&\n              n !== p.node && n !== targetBody && n.parentNode !== targetBody) {\n            // found a parent, go to next node and dive in\n            p.node = n.nextSibling;\n            p.maxIndex = nodeMaxIndex(p.node);\n            p.index = 0;\n            diveDeep();\n          }\n        }\n        // try to make sure insertion point is styled;\n        // also fixes other FF problems\n        if (!isNodeText(p.node)) {\n          diveDeep();\n        }\n      }\n      if (isNodeText(p.node)) {\n        return {\n          container: p.node,\n          offset: p.index,\n        };\n      } else {\n        // p.index in {0,1}\n        return {\n          container: p.node.parentNode,\n          offset: childIndex(p.node) + p.index,\n        };\n      }\n    };\n    const browserSelection = targetDoc.getSelection();\n    if (browserSelection) {\n      browserSelection.removeAllRanges();\n      if (selection) {\n        isCollapsed = (selection.startPoint.node === selection.endPoint.node &&\n                       selection.startPoint.index === selection.endPoint.index);\n        const start = pointToRangeBound(selection.startPoint);\n        const end = pointToRangeBound(selection.endPoint);\n\n        if (!isCollapsed && selection.focusAtStart &&\n            browserSelection.collapse && browserSelection.extend) {\n          // can handle \"backwards\"-oriented selection, shift-arrow-keys move start\n          // of selection\n          browserSelection.collapse(end.container, end.offset);\n          browserSelection.extend(start.container, start.offset);\n        } else {\n          const range = document.createRange();\n          range.setStart(start.container, start.offset);\n          range.setEnd(end.container, end.offset);\n          browserSelection.removeAllRanges();\n          browserSelection.addRange(range);\n        }\n      }\n    }\n  };\n\n  const updateBrowserSelectionFromRep = () => {\n    // requires normalized DOM!\n    const selStart = rep.selStart;\n    const selEnd = rep.selEnd;\n\n    if (!(selStart && selEnd)) {\n      setSelection(null);\n      return;\n    }\n\n    const selection = {};\n\n    const ss = [selStart[0], selStart[1]];\n    selection.startPoint = getPointForLineAndChar(ss);\n\n    const se = [selEnd[0], selEnd[1]];\n    selection.endPoint = getPointForLineAndChar(se);\n\n    selection.focusAtStart = !!rep.selFocusAtStart;\n    setSelection(selection);\n  };\n  editorInfo.ace_updateBrowserSelectionFromRep = updateBrowserSelectionFromRep;\n  editorInfo.ace_focus = focus;\n  editorInfo.ace_importText = importText;\n  editorInfo.ace_importAText = importAText;\n  editorInfo.ace_exportText = exportText;\n  editorInfo.ace_editorChangedSize = editorChangedSize;\n  editorInfo.ace_setOnKeyPress = setOnKeyPress;\n  editorInfo.ace_setOnKeyDown = setOnKeyDown;\n  editorInfo.ace_setNotifyDirty = setNotifyDirty;\n  editorInfo.ace_dispose = dispose;\n  editorInfo.ace_setEditable = setEditable;\n  editorInfo.ace_execCommand = execCommand;\n  editorInfo.ace_replaceRange = replaceRange;\n  editorInfo.ace_getAuthorInfos = getAuthorInfos;\n  editorInfo.ace_performDocumentReplaceRange = performDocumentReplaceRange;\n  editorInfo.ace_performDocumentReplaceCharRange = performDocumentReplaceCharRange;\n  editorInfo.ace_setSelection = setSelection;\n\n  const nodeMaxIndex = (nd) => {\n    if (isNodeText(nd)) return nd.nodeValue.length;\n    else return 1;\n  };\n\n  const getSelection = () => {\n    // returns null, or a structure containing startPoint and endPoint,\n    // each of which has node (a magicdom node), index, and maxIndex.  If the node\n    // is a text node, maxIndex is the length of the text; else maxIndex is 1.\n    // index is between 0 and maxIndex, inclusive.\n    const browserSelection = targetDoc.getSelection();\n    if (!browserSelection || browserSelection.type === 'None' ||\n        browserSelection.rangeCount === 0) {\n      return null;\n    }\n    const range = browserSelection.getRangeAt(0);\n\n    const isInBody = (n) => {\n      while (n && !(n.tagName && n.tagName.toLowerCase() === 'body')) {\n        n = n.parentNode;\n      }\n      return !!n;\n    };\n\n    const pointFromRangeBound = (container, offset) => {\n      if (!isInBody(container)) {\n        // command-click in Firefox selects whole document, HEAD and BODY!\n        return {\n          node: targetBody,\n          index: 0,\n          maxIndex: 1,\n        };\n      }\n      const n = container;\n      const childCount = n.childNodes.length;\n      if (isNodeText(n)) {\n        return {\n          node: n,\n          index: offset,\n          maxIndex: n.nodeValue.length,\n        };\n      } else if (childCount === 0) {\n        return {\n          node: n,\n          index: 0,\n          maxIndex: 1,\n        };\n      // treat point between two nodes as BEFORE the second (rather than after the first)\n      // if possible; this way point at end of a line block-element is treated as\n      // at beginning of next line\n      } else if (offset === childCount) {\n        const nd = n.childNodes.item(childCount - 1);\n        const max = nodeMaxIndex(nd);\n        return {\n          node: nd,\n          index: max,\n          maxIndex: max,\n        };\n      } else {\n        const nd = n.childNodes.item(offset);\n        const max = nodeMaxIndex(nd);\n        return {\n          node: nd,\n          index: 0,\n          maxIndex: max,\n        };\n      }\n    };\n    const selection = {\n      startPoint: pointFromRangeBound(range.startContainer, range.startOffset),\n      endPoint: pointFromRangeBound(range.endContainer, range.endOffset),\n      focusAtStart:\n          (range.startContainer !== range.endContainer || range.startOffset !== range.endOffset) &&\n          browserSelection.anchorNode &&\n          browserSelection.anchorNode === range.endContainer &&\n          browserSelection.anchorOffset === range.endOffset,\n    };\n\n    if (selection.startPoint.node.ownerDocument !== targetDoc) {\n      return null;\n    }\n\n    return selection;\n  };\n\n  const childIndex = (n) => {\n    let idx = 0;\n    while (n.previousSibling) {\n      idx++;\n      n = n.previousSibling;\n    }\n    return idx;\n  };\n\n  const fixView = () => {\n    // calling this method repeatedly should be fast\n    if (getInnerWidth() === 0 || getInnerHeight() === 0) {\n      return;\n    }\n\n    enforceEditability();\n\n    $(sideDiv).addClass('sidedivdelayed');\n  };\n\n  const _teardownActions = [];\n\n  const teardown = () => { for (const a of _teardownActions) a(); };\n\n  let inInternationalComposition = null;\n  editorInfo.ace_getInInternationalComposition = () => inInternationalComposition;\n\n  const bindTheEventHandlers = () => {\n    $(targetDoc).on('keydown', handleKeyEvent);\n    $(targetDoc).on('keypress', handleKeyEvent);\n    $(targetDoc).on('keyup', handleKeyEvent);\n    $(targetDoc).on('click', handleClick);\n    // dropdowns on edit bar need to be closed on clicks on both pad inner and pad outer\n    $(outerDoc).on('click', hideEditBarDropdowns);\n\n    // If non-nullish, pasting on a link should be suppressed.\n    let suppressPasteOnLink = null;\n\n    $(targetBody).on('auxclick', (e) => {\n      if (e.originalEvent.button === 1 && (e.target.a || e.target.localName === 'a')) {\n        // The user middle-clicked on a link. Usually users do this to open a link in a new tab, but\n        // in X11 (Linux) this will instead paste the contents of the primary selection at the mouse\n        // cursor. Users almost certainly do not want to paste when middle-clicking on a link, so\n        // tell the 'paste' event handler to suppress the paste. This is done by starting a\n        // short-lived timer that suppresses paste (when the target is a link) until either the\n        // paste event arrives or the timer fires.\n        //\n        // Why it is implemented this way:\n        //   * Users want to be able to paste on a link via Ctrl-V, the Edit menu, or the context\n        //     menu (https://github.com/ether/etherpad-lite/issues/2775) so we cannot simply\n        //     suppress all paste actions when the target is a link.\n        //   * Non-X11 systems do not paste when the user middle-clicks, so the paste suppression\n        //     must be self-resetting.\n        //   * On non-X11 systems, middle click should continue to open the link in a new tab.\n        //     Suppressing the middle click here in the 'auxclick' handler (via e.preventDefault())\n        //     would break that behavior.\n        suppressPasteOnLink = scheduler.setTimeout(() => { suppressPasteOnLink = null; }, 0);\n      }\n    });\n\n    $(targetBody).on('paste', (e) => {\n      if (suppressPasteOnLink != null && (e.target.a || e.target.localName === 'a')) {\n        scheduler.clearTimeout(suppressPasteOnLink);\n        suppressPasteOnLink = null;\n        e.preventDefault();\n        return;\n      }\n\n      // Call paste hook\n      hooks.callAll('acePaste', {\n        editorInfo,\n        rep,\n        documentAttributeManager,\n        e,\n      });\n    });\n\n    // We reference document here, this is because if we don't this will expose a bug\n    // in Google Chrome.  This bug will cause the last character on the last line to\n    // not fire an event when dropped into..\n    $(targetBody).on('drop', (e) => {\n      if (e.target.a || e.target.localName === 'a') {\n        e.preventDefault();\n      }\n\n      // Bug fix: when user drags some content and drop it far from its origin, we\n      // need to merge the changes into a single changeset. So mark origin with <style>,\n      // in order to make content be observed by incorporateUserChanges() (see\n      // observeSuspiciousNodes() for more info)\n      const selection = getSelection();\n      if (selection) {\n        const firstLineSelected = topLevel(selection.startPoint.node);\n        const lastLineSelected = topLevel(selection.endPoint.node);\n\n        const lineBeforeSelection = firstLineSelected.previousSibling;\n        const lineAfterSelection = lastLineSelected.nextSibling;\n\n        const neighbor = lineBeforeSelection || lineAfterSelection;\n        neighbor.appendChild(targetDoc.createElement('style'));\n      }\n\n      // Call drop hook\n      hooks.callAll('aceDrop', {\n        editorInfo,\n        rep,\n        documentAttributeManager,\n        e,\n      });\n    });\n\n    $(targetDoc.documentElement).on('compositionstart', () => {\n      if (inInternationalComposition) return;\n      inInternationalComposition = new Promise((resolve) => {\n        $(targetDoc.documentElement).one('compositionend', () => {\n          inInternationalComposition = null;\n          resolve();\n        });\n      });\n    });\n  };\n\n  const topLevel = (n) => {\n    if ((!n) || n === targetBody) return null;\n    while (n.parentNode !== targetBody) {\n      n = n.parentNode;\n    }\n    return n;\n  };\n\n  const getSelectionPointX = (point) => {\n    // doesn't work in wrap-mode\n    const node = point.node;\n    const index = point.index;\n    const leftOf = (n) => n.offsetLeft;\n    const rightOf = (n) => n.offsetLeft + n.offsetWidth;\n\n    if (!isNodeText(node)) {\n      if (index === 0) return leftOf(node);\n      else return rightOf(node);\n    } else {\n      // we can get bounds of element nodes, so look for those.\n      // allow consecutive text nodes for robustness.\n      let charsToLeft = index;\n      let charsToRight = node.nodeValue.length - index;\n      let n;\n      for (n = node.previousSibling; n && isNodeText(n); n = n.previousSibling) {\n        charsToLeft += n.nodeValue;\n      }\n      const leftEdge = (n ? rightOf(n) : leftOf(node.parentNode));\n      for (n = node.nextSibling; n && isNodeText(n); n = n.nextSibling) charsToRight += n.nodeValue;\n      const rightEdge = (n ? leftOf(n) : rightOf(node.parentNode));\n      const frac = (charsToLeft / (charsToLeft + charsToRight));\n      const pixLoc = leftEdge + frac * (rightEdge - leftEdge);\n      return Math.round(pixLoc);\n    }\n  };\n\n  const getInnerHeight = () => {\n    const h = browser.opera ? outerWin.innerHeight : outerDoc.documentElement.clientHeight;\n    if (h) return h;\n\n    // deal with case where iframe is hidden, hope that\n    // style.height of iframe container is set in px\n    return Number(editorInfo.frame.parentNode.style.height.replace(/[^0-9]/g, '') || 0);\n  };\n\n  const getInnerWidth = () => outerDoc.documentElement.clientWidth;\n\n  const scrollXHorizontallyIntoView = (pixelX) => {\n    const distInsideLeft = pixelX - outerWin.scrollX;\n    const distInsideRight = outerWin.scrollX + getInnerWidth() - pixelX;\n    if (distInsideLeft < 0) {\n      outerWin.scrollBy(distInsideLeft, 0);\n    } else if (distInsideRight < 0) {\n      outerWin.scrollBy(-distInsideRight + 1, 0);\n    }\n  };\n\n  const scrollSelectionIntoView = () => {\n    if (!rep.selStart) return;\n    fixView();\n    const innerHeight = getInnerHeight();\n    scroll.scrollNodeVerticallyIntoView(rep, innerHeight);\n    if (!doesWrap) {\n      const browserSelection = getSelection();\n      if (browserSelection) {\n        const focusPoint =\n            browserSelection.focusAtStart ? browserSelection.startPoint : browserSelection.endPoint;\n        const selectionPointX = getSelectionPointX(focusPoint);\n        scrollXHorizontallyIntoView(selectionPointX);\n        fixView();\n      }\n    }\n  };\n\n  const listAttributeName = 'list';\n\n  const getLineListType = (lineNum) => documentAttributeManager\n      .getAttributeOnLine(lineNum, listAttributeName);\n  editorInfo.ace_getLineListType = getLineListType;\n\n\n  const doInsertList = (type) => {\n    if (!(rep.selStart && rep.selEnd)) {\n      return;\n    }\n\n    const firstLine = rep.selStart[0];\n    const lastLine = Math.max(firstLine, rep.selEnd[0] - ((rep.selEnd[1] === 0) ? 1 : 0));\n\n    let allLinesAreList = true;\n    for (let n = firstLine; n <= lastLine; n++) {\n      const listType = getLineListType(n);\n      if (!listType || listType.slice(0, type.length) !== type) {\n        allLinesAreList = false;\n        break;\n      }\n    }\n\n    const mods = [];\n    for (let n = firstLine; n <= lastLine; n++) {\n      let level = 0;\n      let togglingOn = true;\n      const listType = /([a-z]+)([0-9]+)/.exec(getLineListType(n));\n\n      // Used to outdent if ol is removed\n      if (allLinesAreList) {\n        togglingOn = false;\n      }\n\n      if (listType) {\n        level = Number(listType[2]);\n      }\n      const t = getLineListType(n);\n\n      if (t === listType) togglingOn = false;\n\n      if (togglingOn) {\n        mods.push([n, allLinesAreList ? `indent${level}` : (t ? type + level : `${type}1`)]);\n      } else {\n        // scrap the entire indentation and list type\n        if (level === 1) { // if outdending but are the first item in the list then outdent\n          setLineListType(n, ''); // outdent\n        }\n        // else change to indented not bullet\n        if (level > 1) {\n          setLineListType(n, ''); // remove bullet\n          setLineListType(n, `indent${level}`); // in/outdent\n        }\n      }\n    }\n\n    for (const mod of mods) setLineListType(mod[0], mod[1]);\n  };\n\n  const doInsertUnorderedList = () => {\n    doInsertList('bullet');\n  };\n  const doInsertOrderedList = () => {\n    doInsertList('number');\n  };\n  editorInfo.ace_doInsertUnorderedList = doInsertUnorderedList;\n  editorInfo.ace_doInsertOrderedList = doInsertOrderedList;\n\n\n  // We apply the height of a line in the doc body, to the corresponding sidediv line number\n  const updateLineNumbers = () => {\n    // Refs #4228, to avoid layout trashing, we need to first calculate all the heights,\n    // and then apply at once all new height to div elements\n    const lineOffsets = [];\n\n    // To place the line number on the same Z point as the first character of the first line\n    // we need to know the line height including the margins of the firstChild within the line\n    // This is somewhat computationally expensive as it looks at the first element within\n    // the line.  Alternative, cheaper approaches are welcome.\n    // Original Issue: https://github.com/ether/etherpad-lite/issues/4527\n    const lineHeights = [];\n\n    // 24 is the default line height within Etherpad - There may be quirks here such as\n    // none text elements (images/embeds etc) where the line height will be greater\n    // but as it's non-text type the line-height/margins might not be present and it\n    // could be that this breaks a theme that has a different default line height..\n    // So instead of using an integer here we get the value from the Editor CSS.\n    const innerdocbodyStyles = getComputedStyle(targetBody);\n    const defaultLineHeight = parseInt(innerdocbodyStyles['line-height']);\n\n    for (const docLine of targetBody.children) {\n      let h;\n      const nextDocLine = docLine.nextElementSibling;\n      if (nextDocLine) {\n        if (lineOffsets.length === 0) {\n          // It's the first line. For line number alignment purposes, its\n          // height is taken to be the top offset of the next line. If we\n          // didn't do this special case, we would miss out on any top margin\n          // included on the first line. The default stylesheet doesn't add\n          // extra margins/padding, but plugins might.\n          h = nextDocLine.offsetTop - parseInt(\n              window.getComputedStyle(targetBody)\n                  .getPropertyValue('padding-top').split('px')[0]);\n        } else {\n          h = nextDocLine.offsetTop - docLine.offsetTop;\n        }\n      } else {\n        // last line\n        h = (docLine.clientHeight || docLine.offsetHeight);\n      }\n      lineOffsets.push(h);\n\n      if (docLine.clientHeight !== defaultLineHeight) {\n        // line is wrapped OR has a larger line height within so we will do additional\n        // computation to figure out the line-height of the first element and\n        // use that for displaying the side div line number inline with the first line\n        // of content -- This is used in ep_headings, ep_font_size etc. where the line\n        // height is increased.\n        const elementStyle = window.getComputedStyle(docLine.firstElementChild);\n        const lineHeight = parseInt(elementStyle.getPropertyValue('line-height'));\n        const marginBottom = parseInt(elementStyle.getPropertyValue('margin-bottom'));\n        lineHeights.push(lineHeight + marginBottom);\n      } else {\n        lineHeights.push(defaultLineHeight);\n      }\n    }\n\n    let newNumLines = rep.lines.length();\n    if (newNumLines < 1) newNumLines = 1;\n    while (sideDivInner.children.length < newNumLines) appendNewSideDivLine();\n    while (sideDivInner.children.length > newNumLines) sideDivInner.lastElementChild.remove();\n    for (const [i, sideDivLine] of Array.prototype.entries.call(sideDivInner.children)) {\n      sideDivLine.style.height = `${lineOffsets[i]}px`;\n      sideDivLine.style.lineHeight = `${lineHeights[i]}px`;\n    }\n  };\n\n\n  // Init documentAttributeManager\n  documentAttributeManager = new AttributeManager(rep, performDocumentApplyChangeset);\n\n  editorInfo.ace_performDocumentApplyAttributesToRange =\n      (...args) => documentAttributeManager.setAttributesOnRange(...args);\n\n  this.init = async () => {\n    await $.ready;\n    inCallStack('setup', () => {\n      if (browser.firefox) $(targetBody).addClass('mozilla');\n      if (browser.safari) $(targetBody).addClass('safari');\n      targetBody.classList.toggle('authorColors', true);\n      targetBody.classList.toggle('doesWrap', doesWrap);\n\n      enforceEditability();\n\n      // set up dom and rep\n      while (targetBody.firstChild) targetBody.removeChild(targetBody.firstChild);\n      const oneEntry = createDomLineEntry('');\n      doRepLineSplice(0, rep.lines.length(), [oneEntry]);\n      insertDomLines(null, [oneEntry.domInfo]);\n      rep.alines = splitAttributionLines(\n          makeAttribution('\\n'), '\\n');\n\n      bindTheEventHandlers();\n    });\n\n    hooks.callAll('aceInitialized', {\n      editorInfo,\n      rep,\n      documentAttributeManager,\n    });\n  };\n}\n\nexports.init = async (editorInfo, cssManagers) => {\n  const editor = new Ace2Inner(editorInfo, cssManagers);\n  await editor.init();\n};\n"
  },
  {
    "path": "src/static/js/attributes.ts",
    "content": "'use strict';\n\n// Low-level utilities for manipulating attribute strings. For a high-level API, see AttributeMap.\n\n/**\n * A `[key, value]` pair of strings describing a text attribute.\n *\n * @typedef {[string, string]} Attribute\n */\n\n/**\n * A concatenated sequence of zero or more attribute identifiers, each one represented by an\n * asterisk followed by a base-36 encoded attribute number.\n *\n * Examples: '', '*0', '*3*j*z*1q'\n *\n * @typedef {string} AttributeString\n */\n\nimport AttributePool from \"./AttributePool\";\nimport {Attribute} from \"./types/Attribute\";\n\n/**\n * Converts an attribute string into a sequence of attribute identifier numbers.\n *\n * WARNING: This only works on attribute strings. It does NOT work on serialized operations or\n * changesets.\n *\n * @param {AttributeString} str - Attribute string.\n * @yields {number} The attribute numbers (to look up in the associated pool), in the order they\n *     appear in `str`.\n * @returns {Generator<number>}\n */\nexport const decodeAttribString = function* (str: string): Generator<number> {\n  const re = /\\*([0-9a-z]+)|./gy;\n  let match;\n  while ((match = re.exec(str)) != null) {\n    const [m, n] = match;\n    if (n == null) throw new Error(`invalid character in attribute string: ${m}`);\n    yield Number.parseInt(n, 36);\n  }\n};\n\nconst checkAttribNum = (n: number|object) => {\n  if (typeof n !== 'number') throw new TypeError(`not a number: ${n}`);\n  if (n < 0) throw new Error(`attribute number is negative: ${n}`);\n  if (n !== Math.trunc(n)) throw new Error(`attribute number is not an integer: ${n}`);\n};\n\n/**\n * Inverse of `decodeAttribString`.\n *\n * @param {Iterable<number>} attribNums - Sequence of attribute numbers.\n * @returns {AttributeString}\n */\nexport const encodeAttribString = (attribNums: Iterable<number>): string => {\n  let str = '';\n  for (const n of attribNums) {\n    checkAttribNum(n);\n    str += `*${n.toString(36).toLowerCase()}`;\n  }\n  return str;\n};\n\n/**\n * Converts a sequence of attribute numbers into a sequence of attributes.\n *\n * @param {Iterable<number>} attribNums - Attribute numbers to look up in the pool.\n * @param {AttributePool} pool - Attribute pool.\n * @yields {Attribute} The identified attributes, in the same order as `attribNums`.\n * @returns {Generator<Attribute>}\n */\nexport const attribsFromNums = function* (attribNums: Iterable<number>, pool: AttributePool): Generator<Attribute> {\n  for (const n of attribNums) {\n    checkAttribNum(n);\n    const attrib = pool.getAttrib(n);\n    if (attrib == null) throw new Error(`attribute ${n} does not exist in pool`);\n    yield attrib;\n  }\n};\n\n/**\n * Inverse of `attribsFromNums`.\n *\n * @param {Iterable<Attribute>} attribs - Attributes. Any attributes not already in `pool` are\n *     inserted into `pool`. No checking is performed to ensure that the attributes are in the\n *     canonical order and that there are no duplicate keys. (Use an AttributeMap and/or `sort()` if\n *     required.)\n * @param {AttributePool} pool - Attribute pool.\n * @yields {number} The attribute number of each attribute in `attribs`, in order.\n * @returns {Generator<number>}\n */\nexport const attribsToNums = function* (attribs: Iterable<Attribute>, pool: AttributePool) {\n  for (const attrib of attribs) yield pool.putAttrib(attrib);\n};\n\n/**\n * Convenience function that is equivalent to `attribsFromNums(decodeAttribString(str), pool)`.\n *\n * WARNING: This only works on attribute strings. It does NOT work on serialized operations or\n * changesets.\n *\n * @param {AttributeString} str - Attribute string.\n * @param {AttributePool} pool - Attribute pool.\n * @yields {Attribute} The attributes identified in `str`, in order.\n * @returns {Generator<Attribute>}\n */\nexport const attribsFromString = function* (str: string, pool: AttributePool): Generator<Attribute> {\n  yield* attribsFromNums(decodeAttribString(str), pool);\n};\n\n/**\n * Inverse of `attribsFromString`.\n *\n * @param {Iterable<Attribute>} attribs - Attributes. The attributes to insert into the pool (if\n *     necessary) and encode. No checking is performed to ensure that the attributes are in the\n *     canonical order and that there are no duplicate keys. (Use an AttributeMap and/or `sort()` if\n *     required.)\n * @param {AttributePool} pool - Attribute pool.\n * @returns {AttributeString}\n */\nexport const attribsToString =\n  (attribs: Iterable<Attribute>, pool: AttributePool): string => encodeAttribString(attribsToNums(attribs, pool));\n\n/**\n * Sorts the attributes in canonical order. The order of entries with the same attribute name is\n * unspecified.\n *\n * @param {Attribute[]} attribs - Attributes to sort in place.\n * @returns {Attribute[]} `attribs` (for chaining).\n */\nexport const sort = (attribs: Attribute[]): Attribute[] => attribs.sort(([keyA], [keyB]) => (keyA > keyB ? 1 : 0) - (keyA < keyB ? 1 : 0));\n\nexport default {\n  decodeAttribString,\n  encodeAttribString,\n  attribsFromNums,\n  attribsToNums,\n  attribsFromString,\n  attribsToString,\n  sort,\n}\n"
  },
  {
    "path": "src/static/js/basic_error_handler.ts",
    "content": "// @ts-nocheck\n// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0\n\n/* Copyright 2021 Richard Hansen <rhansen@rhansen.org> */\n\n'use strict';\n\n// Set up an error handler to display errors that happen during page load. This handler will be\n// overridden with a nicer handler by setupGlobalExceptionHandler() in pad_utils.js.\n\n(() => {\n  const originalHandler = window.onerror;\n  window.onerror = (...args) => {\n    const [msg, url, line, col, err] = args;\n\n    // Purge the existing HTML and styles for a consistent view.\n    document.body.textContent = '';\n    for (const el of document.querySelectorAll('head style, head link[rel=\"stylesheet\"]')) {\n      el.remove();\n    }\n\n    const box = document.body;\n    box.textContent = '';\n    const summary = document.createElement('p');\n    box.appendChild(summary);\n    summary.appendChild(document.createTextNode('An error occurred while loading the page:'));\n    const msgBlock = document.createElement('blockquote');\n    box.appendChild(msgBlock);\n    msgBlock.style.fontWeight = 'bold';\n    msgBlock.appendChild(document.createTextNode(msg));\n    const loc = document.createElement('p');\n    box.appendChild(loc);\n    loc.appendChild(document.createTextNode(`in ${url}`));\n    loc.appendChild(document.createElement('br'));\n    loc.appendChild(document.createTextNode(`at line ${line}:${col}`));\n    const stackSummary = document.createElement('p');\n    box.appendChild(stackSummary);\n    stackSummary.appendChild(document.createTextNode('Stack trace:'));\n    const stackBlock = document.createElement('blockquote');\n    box.appendChild(stackBlock);\n    const stack = document.createElement('pre');\n    stackBlock.appendChild(stack);\n    stack.appendChild(document.createTextNode(err.stack || err.toString()));\n\n    if (typeof originalHandler === 'function') originalHandler(...args);\n  };\n})();\n\n// @license-end\n"
  },
  {
    "path": "src/static/js/broadcast.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst makeCSSManager = require('./cssmanager').makeCSSManager;\nconst domline = require('./domline').domline;\nimport AttribPool from './AttributePool';\nimport {compose, deserializeOps, inverse, moveOpsToNewPool, mutateAttributionLines, mutateTextLines, splitAttributionLines, splitTextLines, unpack} from './Changeset';\nconst attributes = require('./attributes');\nconst linestylefilter = require('./linestylefilter').linestylefilter;\nconst colorutils = require('./colorutils').colorutils;\nconst _ = require('./underscore');\nconst hooks = require('./pluginfw/hooks');\n\nimport html10n from './vendors/html10n';\n\n\n// These parameters were global, now they are injected. A reference to the\n// Timeslider controller would probably be more appropriate.\nconst loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, BroadcastSlider) => {\n  let goToRevisionIfEnabledCount = 0;\n  let changesetLoader = undefined;\n\n  const debugLog = (...args) => {\n    try {\n      if (window.console) console.log(...args);\n    } catch (e) {\n      if (window.console) console.log('error printing: ', e);\n    }\n  };\n\n  const padContents = {\n    currentRevision: clientVars.collab_client_vars.rev,\n    currentTime: clientVars.collab_client_vars.time,\n    currentLines:\n        splitTextLines(clientVars.collab_client_vars.initialAttributedText.text),\n    currentDivs: null,\n    // to be filled in once the dom loads\n    apool: (new AttribPool()).fromJsonable(clientVars.collab_client_vars.apool),\n    alines: splitAttributionLines(\n        clientVars.collab_client_vars.initialAttributedText.attribs,\n        clientVars.collab_client_vars.initialAttributedText.text),\n\n    // generates a jquery element containing HTML for a line\n    lineToElement(line, aline) {\n      const element = document.createElement('div');\n      const emptyLine = (line === '\\n');\n      const domInfo = domline.createDomLine(!emptyLine, true);\n      linestylefilter.populateDomLine(line, aline, this.apool, domInfo);\n      domInfo.prepareForAdd();\n      element.className = domInfo.node.className;\n      element.innerHTML = domInfo.node.innerHTML;\n      element.id = Math.random();\n      return $(element);\n    },\n\n    // splice the lines\n    splice(start, numRemoved, ...newLines) {\n      // remove spliced-out lines from DOM\n      for (let i = start; i < start + numRemoved && i < this.currentDivs.length; i++) {\n        this.currentDivs[i].remove();\n      }\n\n      // remove spliced-out line divs from currentDivs array\n      this.currentDivs.splice(start, numRemoved);\n\n      const newDivs = [];\n      for (let i = 0; i < newLines.length; i++) {\n        newDivs.push(this.lineToElement(newLines[i], this.alines[start + i]));\n      }\n\n      // grab the div just before the first one\n      let startDiv = this.currentDivs[start - 1] || null;\n\n      // insert the div elements into the correct place, in the correct order\n      for (let i = 0; i < newDivs.length; i++) {\n        if (startDiv) {\n          startDiv.after(newDivs[i]);\n        } else {\n          $('#innerdocbody').prepend(newDivs[i]);\n        }\n        startDiv = newDivs[i];\n      }\n\n      // insert new divs into currentDivs array\n      this.currentDivs.splice(start, 0, ...newDivs);\n\n      // call currentLines.splice, to keep the currentLines array up to date\n      this.currentLines.splice(start, numRemoved, ...newLines);\n    },\n    // returns the contents of the specified line I\n    get(i) {\n      return this.currentLines[i];\n    },\n    // returns the number of lines in the document\n    length() {\n      return this.currentLines.length;\n    },\n\n    getActiveAuthors() {\n      const authorIds = new Set();\n      for (const aline of this.alines) {\n        for (const op of deserializeOps(aline)) {\n          for (const [k, v] of attributes.attribsFromString(op.attribs, this.apool)) {\n            if (k !== 'author') continue;\n            if (v) authorIds.add(v);\n          }\n        }\n      }\n      return [...authorIds].sort();\n    },\n  };\n\n  const applyChangeset = (changeset, revision, preventSliderMovement, timeDelta) => {\n    // disable the next 'gotorevision' call handled by a timeslider update\n    if (!preventSliderMovement) {\n      goToRevisionIfEnabledCount++;\n      BroadcastSlider.setSliderPosition(revision);\n    }\n\n    const oldAlines = padContents.alines.slice();\n    try {\n      // must mutate attribution lines before text lines\n      mutateAttributionLines(changeset, padContents.alines, padContents.apool);\n    } catch (e) {\n      debugLog(e);\n    }\n\n    // scroll to the area that is changed before the lines are mutated\n    if ($('#options-followContents').is(':checked') ||\n        $('#options-followContents').prop('checked')) {\n      // get the index of the first line that has mutated attributes\n      // the last line in `oldAlines` should always equal to \"|1+1\", ie newline without attributes\n      // so it should be safe to assume this line has changed attributes when inserting content at\n      // the bottom of a pad\n      let lineChanged;\n      _.some(oldAlines, (line, index) => {\n        if (line !== padContents.alines[index]) {\n          lineChanged = index;\n          return true; // break\n        }\n      });\n      // some chars are replaced (no attributes change and no length change)\n      // test if there are keep ops at the start of the cs\n      if (lineChanged === undefined) {\n        const [op] = deserializeOps(unpack(changeset).ops);\n        lineChanged = op != null && op.opcode === '=' ? op.lines : 0;\n      }\n\n      const goToLineNumber = (lineNumber) => {\n        // Sets the Y scrolling of the browser to go to this line\n        const line = $('#innerdocbody').find(`div:nth-child(${lineNumber + 1})`);\n        const newY = $(line)[0].offsetTop;\n        const ecb = document.getElementById('editorcontainerbox');\n        // Chrome 55 - 59 bugfix\n        if (ecb.scrollTo) {\n          ecb.scrollTo({top: newY, behavior: 'auto'});\n        } else {\n          $('#editorcontainerbox').scrollTop(newY);\n        }\n      };\n\n      goToLineNumber(lineChanged);\n    }\n\n    mutateTextLines(changeset, padContents);\n    padContents.currentRevision = revision;\n    padContents.currentTime += timeDelta;\n\n    updateTimer();\n\n    const authors = _.map(padContents.getActiveAuthors(), (name) => authorData[name]);\n\n    BroadcastSlider.setAuthors(authors);\n  };\n\n  const loadedNewChangeset = (changesetForward, changesetBackward, revision, timeDelta) => {\n    const revisionInfo = window.revisionInfo;\n    const broadcasting = (BroadcastSlider.getSliderPosition() === revisionInfo.latest);\n    revisionInfo.addChangeset(\n        revision, revision + 1, changesetForward, changesetBackward, timeDelta);\n    BroadcastSlider.setSliderLength(revisionInfo.latest);\n    if (broadcasting) applyChangeset(changesetForward, revision + 1, false, timeDelta);\n  };\n\n  /*\n   At this point, we must be certain that the changeset really does map from\n   the current revision to the specified revision.  Any mistakes here will\n   cause the whole slider to get out of sync.\n   */\n\n  const updateTimer = () => {\n    const zpad = (str, length) => {\n      str = `${str}`;\n      while (str.length < length) str = `0${str}`;\n      return str;\n    };\n\n    const date = new Date(padContents.currentTime);\n    const dateFormat = () => {\n      const month = zpad(date.getMonth() + 1, 2);\n      const day = zpad(date.getDate(), 2);\n      const year = (date.getFullYear());\n      const hours = zpad(date.getHours(), 2);\n      const minutes = zpad(date.getMinutes(), 2);\n      const seconds = zpad(date.getSeconds(), 2);\n      return (html10n.get('timeslider.dateformat', {\n        day,\n        month,\n        year,\n        hours,\n        minutes,\n        seconds,\n      }));\n    };\n\n\n    $('#timer').html(dateFormat());\n    const revisionDate = html10n.get('timeslider.saved', {\n      day: date.getDate(),\n      month: [\n        html10n.get('timeslider.month.january'),\n        html10n.get('timeslider.month.february'),\n        html10n.get('timeslider.month.march'),\n        html10n.get('timeslider.month.april'),\n        html10n.get('timeslider.month.may'),\n        html10n.get('timeslider.month.june'),\n        html10n.get('timeslider.month.july'),\n        html10n.get('timeslider.month.august'),\n        html10n.get('timeslider.month.september'),\n        html10n.get('timeslider.month.october'),\n        html10n.get('timeslider.month.november'),\n        html10n.get('timeslider.month.december'),\n      ][date.getMonth()],\n      year: date.getFullYear(),\n    });\n    $('#revision_date').html(revisionDate);\n  };\n\n  updateTimer();\n\n  const goToRevision = (newRevision) => {\n    padContents.targetRevision = newRevision;\n    const path = window.revisionInfo.getPath(padContents.currentRevision, newRevision);\n\n    hooks.aCallAll('goToRevisionEvent', {\n      rev: newRevision,\n    });\n\n    if (path.status === 'complete') {\n      const cs = path.changesets;\n      let changeset = cs[0];\n      let timeDelta = path.times[0];\n      for (let i = 1; i < cs.length; i++) {\n        changeset = compose(changeset, cs[i], padContents.apool);\n        timeDelta += path.times[i];\n      }\n      if (changeset) applyChangeset(changeset, path.rev, true, timeDelta);\n    } else if (path.status === 'partial') {\n      // callback is called after changeset information is pulled from server\n      // this may never get called, if the changeset has already been loaded\n      const update = (start, end) => {\n        // if we've called goToRevision in the time since, don't goToRevision\n        goToRevision(padContents.targetRevision);\n      };\n\n      // do our best with what we have...\n      const cs = path.changesets;\n\n      let changeset = cs[0];\n      let timeDelta = path.times[0];\n      for (let i = 1; i < cs.length; i++) {\n        changeset = compose(changeset, cs[i], padContents.apool);\n        timeDelta += path.times[i];\n      }\n      if (changeset) applyChangeset(changeset, path.rev, true, timeDelta);\n\n      // Loading changeset history for new revision\n      loadChangesetsForRevision(newRevision, update);\n      // Loading changeset history for old revision (to make diff between old and new revision)\n      loadChangesetsForRevision(padContents.currentRevision);\n    }\n\n    const authors = _.map(padContents.getActiveAuthors(), (name) => authorData[name]);\n    BroadcastSlider.setAuthors(authors);\n  };\n\n  const loadChangesetsForRevision = (revision, callback) => {\n    if (BroadcastSlider.getSliderLength() > 10000) {\n      const start = (Math.floor((revision) / 10000) * 10000); // revision 0 to 10\n      changesetLoader.queueUp(start, 100);\n    }\n\n    if (BroadcastSlider.getSliderLength() > 1000) {\n      const start = (Math.floor((revision) / 1000) * 1000); // (start from -1, go to 19) + 1\n      changesetLoader.queueUp(start, 10);\n    }\n\n    const start = (Math.floor((revision) / 100) * 100);\n\n    changesetLoader.queueUp(start, 1, callback);\n  };\n\n  changesetLoader = {\n    running: false,\n    resolved: [],\n    requestQueue1: [],\n    requestQueue2: [],\n    requestQueue3: [],\n    reqCallbacks: [],\n    queueUp(revision, width, callback) {\n      if (revision < 0) revision = 0;\n      // if(this.requestQueue.indexOf(revision) != -1)\n      //   return; // already in the queue.\n      if (this.resolved.indexOf(`${revision}_${width}`) !== -1) {\n        // already loaded from the server\n        return;\n      }\n      this.resolved.push(`${revision}_${width}`);\n\n      const requestQueue =\n          width === 1 ? this.requestQueue3\n          : width === 10 ? this.requestQueue2\n          : this.requestQueue1;\n      requestQueue.push(\n          {\n            rev: revision,\n            res: width,\n            callback,\n          });\n      if (!this.running) {\n        this.running = true;\n        setTimeout(() => this.loadFromQueue(), 10);\n      }\n    },\n    loadFromQueue() {\n      const requestQueue =\n          this.requestQueue1.length > 0 ? this.requestQueue1\n          : this.requestQueue2.length > 0 ? this.requestQueue2\n          : this.requestQueue3.length > 0 ? this.requestQueue3\n          : null;\n\n      if (!requestQueue) {\n        this.running = false;\n        return;\n      }\n\n      const request = requestQueue.pop();\n      const granularity = request.res;\n      const callback = request.callback;\n      const start = request.rev;\n      const requestID = Math.floor(Math.random() * 100000);\n\n      sendSocketMsg('CHANGESET_REQ', {\n        start,\n        granularity,\n        requestID,\n      });\n\n      this.reqCallbacks[requestID] = callback;\n    },\n    handleSocketResponse(message) {\n      const start = message.data.start;\n      const granularity = message.data.granularity;\n      const callback = this.reqCallbacks[message.data.requestID];\n      delete this.reqCallbacks[message.data.requestID];\n\n      this.handleResponse(message.data, start, granularity, callback);\n      setTimeout(() => this.loadFromQueue(), 10);\n    },\n    handleResponse: (data, start, granularity, callback) => {\n      const pool = (new AttribPool()).fromJsonable(data.apool);\n      for (let i = 0; i < data.forwardsChangesets.length; i++) {\n        const astart = start + i * granularity - 1; // rev -1 is a blank single line\n        let aend = start + (i + 1) * granularity - 1; // totalRevs is the most recent revision\n        if (aend > data.actualEndNum - 1) aend = data.actualEndNum - 1;\n        // debugLog(\"adding changeset:\", astart, aend);\n        const forwardcs =\n            moveOpsToNewPool(data.forwardsChangesets[i], pool, padContents.apool);\n        const backwardcs =\n            moveOpsToNewPool(data.backwardsChangesets[i], pool, padContents.apool);\n        window.revisionInfo.addChangeset(astart, aend, forwardcs, backwardcs, data.timeDeltas[i]);\n      }\n      if (callback) callback(start - 1, start + data.forwardsChangesets.length * granularity - 1);\n    },\n    handleMessageFromServer(obj) {\n      if (obj.type === 'COLLABROOM') {\n        obj = obj.data;\n\n        if (obj.type === 'NEW_CHANGES') {\n          const changeset = moveOpsToNewPool(\n              obj.changeset, (new AttribPool()).fromJsonable(obj.apool), padContents.apool);\n\n          let changesetBack = inverse(\n              obj.changeset, padContents.currentLines, padContents.alines, padContents.apool);\n\n          changesetBack = moveOpsToNewPool(\n              changesetBack, (new AttribPool()).fromJsonable(obj.apool), padContents.apool);\n\n          loadedNewChangeset(changeset, changesetBack, obj.newRev - 1, obj.timeDelta);\n        } else if (obj.type === 'NEW_AUTHORDATA') {\n          const authorMap = {};\n          authorMap[obj.author] = obj.data;\n          receiveAuthorData(authorMap);\n\n          const authors = _.map(padContents.getActiveAuthors(), (name) => authorData[name]);\n\n          BroadcastSlider.setAuthors(authors);\n        } else if (obj.type === 'NEW_SAVEDREV') {\n          const savedRev = obj.savedRev;\n          BroadcastSlider.addSavedRevision(savedRev.revNum, savedRev);\n        }\n        hooks.callAll(`handleClientTimesliderMessage_${obj.type}`, {payload: obj});\n      } else if (obj.type === 'CHANGESET_REQ') {\n        this.handleSocketResponse(obj);\n      } else {\n        debugLog(`Unknown message type: ${obj.type}`);\n      }\n    },\n  };\n\n  // to start upon window load, just push a function onto this array\n  // window['onloadFuncts'].push(setUpSocket);\n  // window['onloadFuncts'].push(function ()\n  fireWhenAllScriptsAreLoaded.push(() => {\n    // set up the currentDivs and DOM\n    padContents.currentDivs = [];\n    $('#innerdocbody').html('');\n    for (let i = 0; i < padContents.currentLines.length; i++) {\n      const div = padContents.lineToElement(padContents.currentLines[i], padContents.alines[i]);\n      padContents.currentDivs.push(div);\n      $('#innerdocbody').append(div);\n    }\n  });\n\n  // this is necessary to keep infinite loops of events firing,\n  // since goToRevision changes the slider position\n  const goToRevisionIfEnabled = (...args) => {\n    if (goToRevisionIfEnabledCount > 0) {\n      goToRevisionIfEnabledCount--;\n    } else {\n      goToRevision(...args);\n    }\n  };\n\n  BroadcastSlider.onSlider(goToRevisionIfEnabled);\n\n  const dynamicCSS = makeCSSManager(document.querySelector('style[title=\"dynamicsyntax\"]').sheet);\n  const authorData = {};\n\n  const receiveAuthorData = (newAuthorData) => {\n    for (const [author, data] of Object.entries(newAuthorData)) {\n      const bgcolor = typeof data.colorId === 'number'\n        ? clientVars.colorPalette[data.colorId] : data.colorId;\n      if (bgcolor) {\n        const selector = dynamicCSS.selectorStyle(`.${linestylefilter.getAuthorClassName(author)}`);\n        selector.backgroundColor = bgcolor;\n        selector.color = (colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5)\n          ? '#ffffff' : '#000000'; // see ace2_inner.js for the other part\n      }\n      authorData[author] = data;\n    }\n  };\n\n  receiveAuthorData(clientVars.collab_client_vars.historicalAuthorData);\n\n  return changesetLoader;\n};\n\nexports.loadBroadcastJS = loadBroadcastJS;\n"
  },
  {
    "path": "src/static/js/broadcast_revisions.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n// revision info is a skip list whos entries represent a particular revision\n// of the document.  These revisions are connected together by various\n// changesets,  or deltas, between any two revisions.\n\nconst loadBroadcastRevisionsJS = () => {\n  function Revision(revNum) {\n    this.rev = revNum;\n    this.changesets = [];\n  }\n\n  Revision.prototype.addChangeset = function (destIndex, changeset, timeDelta) {\n    const changesetWrapper = {\n      deltaRev: destIndex - this.rev,\n      deltaTime: timeDelta,\n      getValue: () => changeset,\n    };\n    this.changesets.push(changesetWrapper);\n    this.changesets.sort((a, b) => (b.deltaRev - a.deltaRev));\n  };\n\n  const revisionInfo = {};\n  revisionInfo.addChangeset = function (fromIndex, toIndex, changeset, backChangeset, timeDelta) {\n    const startRevision = this[fromIndex] || this.createNew(fromIndex);\n    const endRevision = this[toIndex] || this.createNew(toIndex);\n    startRevision.addChangeset(toIndex, changeset, timeDelta);\n    endRevision.addChangeset(fromIndex, backChangeset, -1 * timeDelta);\n  };\n\n  revisionInfo.latest = clientVars.collab_client_vars.rev || -1;\n\n  revisionInfo.createNew = function (index) {\n    this[index] = new Revision(index);\n    if (index > this.latest) {\n      this.latest = index;\n    }\n\n    return this[index];\n  };\n\n  // assuming that there is a path from fromIndex to toIndex, and that the links\n  // are laid out in a skip-list format\n  revisionInfo.getPath = function (fromIndex, toIndex) {\n    const changesets = [];\n    const spans = [];\n    const times = [];\n    let elem = this[fromIndex] || this.createNew(fromIndex);\n    if (elem.changesets.length !== 0 && fromIndex !== toIndex) {\n      const reverse = !(fromIndex < toIndex);\n      while (((elem.rev < toIndex) && !reverse) || ((elem.rev > toIndex) && reverse)) {\n        let couldNotContinue = false;\n        const oldRev = elem.rev;\n\n        for (let i = reverse ? elem.changesets.length - 1 : 0;\n          reverse ? i >= 0 : i < elem.changesets.length;\n          i += reverse ? -1 : 1) {\n          if (((elem.changesets[i].deltaRev < 0) && !reverse) ||\n              ((elem.changesets[i].deltaRev > 0) && reverse)) {\n            couldNotContinue = true;\n            break;\n          }\n\n          if (((elem.rev + elem.changesets[i].deltaRev <= toIndex) && !reverse) ||\n              ((elem.rev + elem.changesets[i].deltaRev >= toIndex) && reverse)) {\n            const topush = elem.changesets[i];\n            changesets.push(topush.getValue());\n            spans.push(elem.changesets[i].deltaRev);\n            times.push(topush.deltaTime);\n            elem = this[elem.rev + elem.changesets[i].deltaRev];\n            break;\n          }\n        }\n\n        if (couldNotContinue || oldRev === elem.rev) break;\n      }\n    }\n\n    let status = 'partial';\n    if (elem.rev === toIndex) status = 'complete';\n\n    return {\n      fromRev: fromIndex,\n      rev: elem.rev,\n      status,\n      changesets,\n      spans,\n      times,\n    };\n  };\n  window.revisionInfo = revisionInfo;\n};\n\nexports.loadBroadcastRevisionsJS = loadBroadcastRevisionsJS;\n"
  },
  {
    "path": "src/static/js/broadcast_slider.ts",
    "content": "// @ts-nocheck\n'use strict';\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// These parameters were global, now they are injected. A reference to the\n// Timeslider controller would probably be more appropriate.\nconst _ = require('./underscore');\nconst padmodals = require('./pad_modals').padmodals;\nconst colorutils = require('./colorutils').colorutils;\nimport html10n from './vendors/html10n';\n\nconst loadBroadcastSliderJS = (fireWhenAllScriptsAreLoaded) => {\n  let BroadcastSlider;\n\n  // Hack to ensure timeslider i18n values are in\n  $(\"[data-key='timeslider_returnToPad'] > a > span\").html(\n      html10n.get('timeslider.toolbar.returnbutton'));\n\n  (() => { // wrap this code in its own namespace\n    let sliderLength = 1000;\n    let sliderPos = 0;\n    let sliderActive = false;\n    const slidercallbacks = [];\n    const savedRevisions = [];\n    let sliderPlaying = false;\n\n    const _callSliderCallbacks = (newval) => {\n      sliderPos = newval;\n      for (let i = 0; i < slidercallbacks.length; i++) {\n        slidercallbacks[i](newval);\n      }\n    };\n\n    const updateSliderElements = () => {\n      for (let i = 0; i < savedRevisions.length; i++) {\n        const position = parseInt(savedRevisions[i].attr('pos'));\n        savedRevisions[i].css(\n            'left', (position * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0)) - 1);\n      }\n      $('#ui-slider-handle').css(\n          'left', sliderPos * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0));\n    };\n\n    const addSavedRevision = (position, info) => {\n      const newSavedRevision = $('<div></div>');\n      newSavedRevision.addClass('star');\n\n      newSavedRevision.attr('pos', position);\n      newSavedRevision.css(\n          'left', (position * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0)) - 1);\n      $('#ui-slider-bar').append(newSavedRevision);\n      newSavedRevision.on('mouseup', (evt) => {\n        BroadcastSlider.setSliderPosition(position);\n      });\n      savedRevisions.push(newSavedRevision);\n    };\n\n    /* Begin small 'API' */\n\n    const onSlider = (callback) => {\n      slidercallbacks.push(callback);\n    };\n\n    const getSliderPosition = () => sliderPos;\n\n    const setSliderPosition = (newpos) => {\n      newpos = Number(newpos);\n      if (newpos < 0 || newpos > sliderLength) return;\n      if (!newpos) {\n        newpos = 0; // stops it from displaying NaN if newpos isn't set\n      }\n      window.location.hash = `#${newpos}`;\n      $('#ui-slider-handle').css(\n          'left', newpos * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0));\n      $('a.tlink').map(function () {\n        $(this).attr('href', $(this).attr('thref').replace('%revision%', newpos));\n      });\n\n      $('#revision_label').html(html10n.get('timeslider.version', {version: newpos}));\n\n      $('#leftstar, #leftstep').toggleClass('disabled', newpos === 0);\n      $('#rightstar, #rightstep').toggleClass('disabled', newpos === sliderLength);\n\n      sliderPos = newpos;\n      _callSliderCallbacks(newpos);\n    };\n\n    const getSliderLength = () => sliderLength;\n\n    const setSliderLength = (newlength) => {\n      sliderLength = newlength;\n      updateSliderElements();\n    };\n\n    // just take over the whole slider screen with a reconnect message\n\n    const showReconnectUI = () => {\n      padmodals.showModal('disconnected');\n    };\n\n    const setAuthors = (authors) => {\n      const authorsList = $('#authorsList');\n      authorsList.empty();\n      let numAnonymous = 0;\n      let numNamed = 0;\n      const colorsAnonymous = [];\n      _.each(authors, (author) => {\n        if (author) {\n          const authorColor = clientVars.colorPalette[author.colorId] || author.colorId;\n          if (author.name) {\n            if (numNamed !== 0) authorsList.append(', ');\n            const textColor =\n                colorutils.textColorFromBackgroundColor(authorColor, clientVars.skinName);\n            $('<span />')\n                .text(author.name || 'unnamed')\n                .css('background-color', authorColor)\n                .css('color', textColor)\n                .addClass('author')\n                .appendTo(authorsList);\n\n            numNamed++;\n          } else {\n            numAnonymous++;\n            if (authorColor) colorsAnonymous.push(authorColor);\n          }\n        }\n      });\n      if (numAnonymous > 0) {\n        const anonymousAuthorString = html10n.get('timeslider.unnamedauthors', {num: numAnonymous});\n\n        if (numNamed !== 0) {\n          authorsList.append(` + ${anonymousAuthorString}`);\n        } else {\n          authorsList.append(anonymousAuthorString);\n        }\n\n        if (colorsAnonymous.length > 0) {\n          authorsList.append(' (');\n          _.each(colorsAnonymous, (color, i) => {\n            if (i > 0) authorsList.append(' ');\n            $('<span>&nbsp;</span>')\n                .css('background-color', color)\n                .addClass('author author-anonymous')\n                .appendTo(authorsList);\n          });\n          authorsList.append(')');\n        }\n      }\n      if (authors.length === 0) {\n        authorsList.append(html10n.get('timeslider.toolbar.authorsList'));\n      }\n    };\n\n    const playButtonUpdater = () => {\n      if (sliderPlaying) {\n        if (getSliderPosition() + 1 > sliderLength) {\n          $('#playpause_button_icon').toggleClass('pause');\n          sliderPlaying = false;\n          return;\n        }\n        setSliderPosition(getSliderPosition() + 1);\n\n        setTimeout(playButtonUpdater, 100);\n      }\n    };\n\n    const playpause = () => {\n      $('#playpause_button_icon').toggleClass('pause');\n\n      if (!sliderPlaying) {\n        if (getSliderPosition() === sliderLength) setSliderPosition(0);\n        sliderPlaying = true;\n        playButtonUpdater();\n      } else {\n        sliderPlaying = false;\n      }\n    };\n\n    BroadcastSlider = {\n      onSlider,\n      getSliderPosition,\n      setSliderPosition,\n      getSliderLength,\n      setSliderLength,\n      isSliderActive: () => sliderActive,\n      playpause,\n      addSavedRevision,\n      showReconnectUI,\n      setAuthors,\n    };\n\n    // assign event handlers to html UI elements after page load\n    fireWhenAllScriptsAreLoaded.push(() => {\n      $(document).on('keyup', (e) => {\n        if (!e) e = window.event;\n        const code = e.keyCode || e.which;\n\n        if (code === 37) { // left\n          if (e.shiftKey) {\n            $('#leftstar').trigger('click');\n          } else {\n            $('#leftstep').trigger('click');\n          }\n        } else if (code === 39) { // right\n          if (e.shiftKey) {\n            $('#rightstar').trigger('click');\n          } else {\n            $('#rightstep').trigger('click');\n          }\n        } else if (code === 32) { // spacebar\n          $('#playpause_button_icon').trigger('click');\n        }\n      });\n\n      // Resize\n      $(window).on('resize', () => {\n        updateSliderElements();\n      });\n\n      // Slider click\n      $('#ui-slider-bar').on('mousedown', (evt) => {\n        $('#ui-slider-handle').css('left', (evt.clientX - $('#ui-slider-bar').offset().left));\n        $('#ui-slider-handle').trigger(evt);\n      });\n\n      // Slider dragging\n      $('#ui-slider-handle').on('mousedown', function (evt) {\n        this.startLoc = evt.clientX;\n        this.currentLoc = parseInt($(this).css('left'));\n        sliderActive = true;\n        $(document).on('mousemove', (evt2) => {\n          $(this).css('pointer', 'move');\n          let newloc = this.currentLoc + (evt2.clientX - this.startLoc);\n          if (newloc < 0) newloc = 0;\n          const maxPos = $('#ui-slider-bar').width() - 2;\n          if (newloc > maxPos) newloc = maxPos;\n          const version = Math.floor(newloc * sliderLength / maxPos);\n          $('#revision_label').html(html10n.get('timeslider.version', {version}));\n          $(this).css('left', newloc);\n          if (getSliderPosition() !== version) _callSliderCallbacks(version);\n        });\n        $(document).on('mouseup', (evt2) => {\n          $(document).off('mousemove');\n          $(document).off('mouseup');\n          sliderActive = false;\n          let newloc = this.currentLoc + (evt2.clientX - this.startLoc);\n          if (newloc < 0) newloc = 0;\n          const maxPos = $('#ui-slider-bar').width() - 2;\n          if (newloc > maxPos) newloc = maxPos;\n          $(this).css('left', newloc);\n          setSliderPosition(Math.floor(newloc * sliderLength / maxPos));\n          if (parseInt($(this).css('left')) < 2) {\n            $(this).css('left', '2px');\n          } else {\n            this.currentLoc = parseInt($(this).css('left'));\n          }\n        });\n      });\n\n      // play/pause toggling\n      $('#playpause_button_icon').on('click', (evt) => {\n        BroadcastSlider.playpause();\n      });\n\n      // next/prev saved revision and changeset\n      $('.stepper').on('click', function (evt) {\n        switch ($(this).attr('id')) {\n          case 'leftstep':\n            setSliderPosition(getSliderPosition() - 1);\n            break;\n          case 'rightstep':\n            setSliderPosition(getSliderPosition() + 1);\n            break;\n          case 'leftstar': {\n            let nextStar = 0; // default to first revision in document\n            for (let i = 0; i < savedRevisions.length; i++) {\n              const pos = parseInt(savedRevisions[i].attr('pos'));\n              if (pos < getSliderPosition() && nextStar < pos) nextStar = pos;\n            }\n            setSliderPosition(nextStar);\n            break;\n          }\n          case 'rightstar': {\n            let nextStar = sliderLength; // default to last revision in document\n            for (let i = 0; i < savedRevisions.length; i++) {\n              const pos = parseInt(savedRevisions[i].attr('pos'));\n              if (pos > getSliderPosition() && nextStar > pos) nextStar = pos;\n            }\n            setSliderPosition(nextStar);\n            break;\n          }\n        }\n      });\n\n      if (clientVars) {\n        $('#timeslider-wrapper').show();\n\n        if (window.location.hash.length > 1) {\n          const hashRev = Number(window.location.hash.substr(1));\n          if (!isNaN(hashRev)) {\n            // this is necessary because of the socket.io-event which loads the changesets\n            setTimeout(() => { setSliderPosition(hashRev); }, 1);\n          }\n        }\n\n        setSliderLength(clientVars.collab_client_vars.rev);\n        setSliderPosition(clientVars.collab_client_vars.rev);\n\n        _.each(clientVars.savedRevisions, (revision) => {\n          addSavedRevision(revision.revNum, revision);\n        });\n      }\n    });\n  })();\n\n  BroadcastSlider.onSlider((loc) => {\n    $('#viewlatest').html(\n        `${loc === BroadcastSlider.getSliderLength() ? 'Viewing' : 'View'} latest content`);\n  });\n\n  return BroadcastSlider;\n};\n\nexports.loadBroadcastSliderJS = loadBroadcastSliderJS;\n"
  },
  {
    "path": "src/static/js/caretPosition.ts",
    "content": "'use strict';\n\n// One rep.line(div) can be broken in more than one line in the browser.\n// This function is useful to get the caret position of the line as\n// is represented by the browser\nimport {Position, RepModel, RepNode} from \"./types/RepModel\";\n\nexport const getPosition = () => {\n  const range = getSelectionRange();\n  // @ts-ignore\n  if (!range || $(range.endContainer).closest('body')[0].id !== 'innerdocbody') return null;\n  // When there's a <br> or any element that has no height, we can't get the dimension of the\n  // element where the caret is. As we can't get the element height, we create a text node to get\n  // the dimensions on the position.\n  const clonedRange = createSelectionRange(range);\n  const shadowCaret = $(document.createTextNode('|'));\n  clonedRange.insertNode(shadowCaret[0]);\n  clonedRange.selectNode(shadowCaret[0]);\n  const line = getPositionOfElementOrSelection(clonedRange);\n  shadowCaret.remove();\n  return line;\n};\n\nconst createSelectionRange = (range: Range) => {\n  const clonedRange = range.cloneRange();\n\n  // we set the selection start and end to avoid error when user selects a text bigger than\n  // the viewport height and uses the arrow keys to expand the selection. In this particular\n  // case is necessary to know where the selections ends because both edges of the selection\n  // is out of the viewport but we only use the end of it to calculate if it needs to scroll\n  clonedRange.setStart(range.endContainer, range.endOffset);\n  clonedRange.setEnd(range.endContainer, range.endOffset);\n  return clonedRange;\n};\n\nconst getPositionOfRepLineAtOffset = (node: any, offset: number) => {\n  // it is not a text node, so we cannot make a selection\n  if (node.tagName === 'BR' || node.tagName === 'EMPTY') {\n    return getPositionOfElementOrSelection(node);\n  }\n\n  while (node.length === 0 && node.nextSibling) {\n    node = node.nextSibling as any;\n  }\n\n  const newRange = new Range();\n  newRange.setStart(node, offset);\n  newRange.setEnd(node, offset);\n  const linePosition = getPositionOfElementOrSelection(newRange);\n  newRange.detach(); // performance sake\n  return linePosition;\n};\n\nconst getPositionOfElementOrSelection = (element: Range):Position => {\n  const rect = element.getBoundingClientRect();\n  return {\n    bottom: rect.bottom,\n    height: rect.height,\n    top: rect.top,\n  } satisfies Position;\n};\n\n// here we have two possibilities:\n// [1] the line before the caret line has the same type, so both of them has the same margin,\n// padding height, etc. So, we can use the caret line to make calculation necessary to know\n// where is the top of the previous line\n// [2] the line before is part of another rep line. It's possible this line has different margins\n// height. So we have to get the exactly position of the line\nexport const getPositionTopOfPreviousBrowserLine = (caretLinePosition: Position, rep: RepModel) => {\n  let previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1]\n  const isCaretLineFirstBrowserLine = caretLineIsFirstBrowserLine(caretLinePosition.top, rep);\n\n  // the caret is in the beginning of a rep line, so the previous browser line\n  // is the last line browser line of the a rep line\n  if (isCaretLineFirstBrowserLine) { // [2]\n    const lineBeforeCaretLine = rep.selStart[0] - 1;\n    const firstLineVisibleBeforeCaretLine = getPreviousVisibleLine(lineBeforeCaretLine, rep);\n    const linePosition =\n      getDimensionOfLastBrowserLineOfRepLine(firstLineVisibleBeforeCaretLine, rep);\n    previousLineTop = linePosition.top;\n  }\n  return previousLineTop;\n};\n\nconst caretLineIsFirstBrowserLine = (caretLineTop: number, rep: RepModel) => {\n  const caretRepLine = rep.selStart[0];\n  const lineNode = rep.lines.atIndex(caretRepLine).lineNode;\n  const firstRootNode = getFirstRootChildNode(lineNode);\n\n  // to get the position of the node we get the position of the first char\n  const positionOfFirstRootNode = getPositionOfRepLineAtOffset(firstRootNode, 1);\n  return positionOfFirstRootNode.top === caretLineTop;\n};\n\n// find the first root node, usually it is a text node\nconst getFirstRootChildNode = (node: RepNode) => {\n  if (!node.firstChild) {\n    return node;\n  } else {\n    return getFirstRootChildNode(node.firstChild);\n  }\n};\n\nconst getDimensionOfLastBrowserLineOfRepLine = (line: number, rep: RepModel) => {\n  const lineNode = rep.lines.atIndex(line).lineNode;\n  const lastRootChildNode = getLastRootChildNode(lineNode);\n\n  // we get the position of the line in the last char of it\n  const lastRootChildNodePosition =\n    getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);\n  return lastRootChildNodePosition;\n};\n\nconst getLastRootChildNode = (node: RepNode) => {\n  if (!node.lastChild) {\n    return {\n      node,\n      length: node.length,\n    };\n  } else {\n    return getLastRootChildNode(node.lastChild);\n  }\n};\n\n// here we have two possibilities:\n// [1] The next line is part of the same rep line of the caret line, so we have the same dimensions.\n// So, we can use the caret line to calculate the bottom of the line.\n// [2] the next line is part of another rep line.\n// It's possible this line has different dimensions, so we have to get the exactly dimension of it\nexport const getBottomOfNextBrowserLine = (caretLinePosition: Position, rep: RepModel) => {\n  let nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; // [1]\n  const isCaretLineLastBrowserLine =\n    caretLineIsLastBrowserLineOfRepLine(caretLinePosition.top, rep);\n\n  // the caret is at the end of a rep line, so we can get the next browser line dimension\n  // using the position of the first char of the next rep line\n  if (isCaretLineLastBrowserLine) { // [2]\n    const nextLineAfterCaretLine = rep.selStart[0] + 1;\n    const firstNextLineVisibleAfterCaretLine = getNextVisibleLine(nextLineAfterCaretLine, rep);\n    const linePosition =\n      getDimensionOfFirstBrowserLineOfRepLine(firstNextLineVisibleAfterCaretLine, rep);\n    nextLineBottom = linePosition.bottom;\n  }\n  return nextLineBottom;\n};\n\nconst caretLineIsLastBrowserLineOfRepLine = (caretLineTop: number, rep: RepModel) => {\n  const caretRepLine = rep.selStart[0];\n  const lineNode = rep.lines.atIndex(caretRepLine).lineNode;\n  const lastRootChildNode = getLastRootChildNode(lineNode);\n\n  // we take a rep line and get the position of the last char of it\n  const lastRootChildNodePosition =\n    getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);\n  return lastRootChildNodePosition.top === caretLineTop;\n};\n\nexport const getPreviousVisibleLine = (line: number, rep: RepModel): number => {\n  const firstLineOfPad = 0;\n  if (line <= firstLineOfPad) {\n    return firstLineOfPad;\n  } else if (isLineVisible(line, rep)) {\n    return line;\n  } else {\n    return getPreviousVisibleLine(line - 1, rep);\n  }\n};\n\n\n\nexport const getNextVisibleLine = (line: number, rep: RepModel): number => {\n  const lastLineOfThePad = rep.lines.length() - 1;\n  if (line >= lastLineOfThePad) {\n    return lastLineOfThePad;\n  } else if (isLineVisible(line, rep)) {\n    return line;\n  } else {\n    return getNextVisibleLine(line + 1, rep);\n  }\n};\n\nconst isLineVisible = (line: number, rep: RepModel) => rep.lines.atIndex(line).lineNode.offsetHeight > 0;\n\nconst getDimensionOfFirstBrowserLineOfRepLine = (line: number, rep: RepModel) => {\n  const lineNode = rep.lines.atIndex(line).lineNode;\n  const firstRootChildNode = getFirstRootChildNode(lineNode);\n\n  // we can get the position of the line, getting the position of the first char of the rep line\n  const firstRootChildNodePosition = getPositionOfRepLineAtOffset(firstRootChildNode, 1);\n  return firstRootChildNodePosition;\n};\n\nconst getSelectionRange = () => {\n  if (!window.getSelection) {\n    return;\n  }\n  const selection = window.getSelection();\n  if (selection && selection.type !== 'None' && selection.rangeCount > 0) {\n    return selection.getRangeAt(0);\n  } else {\n    return null;\n  }\n};\n"
  },
  {
    "path": "src/static/js/changesettracker.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport AttributeMap from './AttributeMap';\nimport AttributePool from './AttributePool';\nimport {applyToAText, checkRep, cloneAText, compose, deserializeOps, follow, identity, isIdentity, makeAText, moveOpsToNewPool, newLen, pack, prepareForWire, unpack} from './Changeset';\nimport {MergingOpAssembler} from \"./MergingOpAssembler\";\n\nconst makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => {\n  // latest official text from server\n  let baseAText = makeAText('\\n');\n  // changes applied to baseText that have been submitted\n  let submittedChangeset = null;\n  // changes applied to submittedChangeset since it was prepared\n  let userChangeset = identity(1);\n  // is the changesetTracker enabled\n  let tracking = false;\n  // stack state flag so that when we change the rep we don't\n  // handle the notification recursively.  When setting, always\n  // unset in a \"finally\" block.  When set to true, the setter\n  // takes change of userChangeset.\n  let applyingNonUserChanges = false;\n\n  let changeCallback = null;\n\n  let changeCallbackTimeout = null;\n\n  const setChangeCallbackTimeout = () => {\n    // can call this multiple times per call-stack, because\n    // we only schedule a call to changeCallback if it exists\n    // and if there isn't a timeout already scheduled.\n    if (changeCallback && changeCallbackTimeout == null) {\n      changeCallbackTimeout = scheduler.setTimeout(() => {\n        try {\n          changeCallback();\n        } catch (pseudoError) {\n          // as empty as my soul\n        } finally {\n          changeCallbackTimeout = null;\n        }\n      }, 0);\n    }\n  };\n\n  let self;\n  return self = {\n    isTracking: () => tracking,\n    setBaseText: (text) => {\n      self.setBaseAttributedText(makeAText(text), null);\n    },\n    setBaseAttributedText: (atext, apoolJsonObj) => {\n      aceCallbacksProvider.withCallbacks('setBaseText', (callbacks) => {\n        tracking = true;\n        baseAText = cloneAText(atext);\n        if (apoolJsonObj) {\n          const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj);\n          baseAText.attribs = moveOpsToNewPool(baseAText.attribs, wireApool, apool);\n        }\n        submittedChangeset = null;\n        userChangeset = identity(atext.text.length);\n        applyingNonUserChanges = true;\n        try {\n          callbacks.setDocumentAttributedText(atext);\n        } finally {\n          applyingNonUserChanges = false;\n        }\n      });\n    },\n    composeUserChangeset: (c) => {\n      if (!tracking) return;\n      if (applyingNonUserChanges) return;\n      if (isIdentity(c)) return;\n      userChangeset = compose(userChangeset, c, apool);\n\n      setChangeCallbackTimeout();\n    },\n    applyChangesToBase: (c, optAuthor, apoolJsonObj) => {\n      if (!tracking) return;\n\n      aceCallbacksProvider.withCallbacks('applyChangesToBase', (callbacks) => {\n        if (apoolJsonObj) {\n          const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj);\n          c = moveOpsToNewPool(c, wireApool, apool);\n        }\n\n        baseAText = applyToAText(c, baseAText, apool);\n\n        let c2 = c;\n        if (submittedChangeset) {\n          const oldSubmittedChangeset = submittedChangeset;\n          submittedChangeset = follow(c, oldSubmittedChangeset, false, apool);\n          c2 = follow(oldSubmittedChangeset, c, true, apool);\n        }\n\n        const preferInsertingAfterUserChanges = true;\n        const oldUserChangeset = userChangeset;\n        userChangeset = follow(\n            c2, oldUserChangeset, preferInsertingAfterUserChanges, apool);\n        const postChange = follow(\n            oldUserChangeset, c2, !preferInsertingAfterUserChanges, apool);\n\n        const preferInsertionAfterCaret = true; // (optAuthor && optAuthor > thisAuthor);\n        applyingNonUserChanges = true;\n        try {\n          callbacks.applyChangesetToDocument(postChange, preferInsertionAfterCaret);\n        } finally {\n          applyingNonUserChanges = false;\n        }\n      });\n    },\n    prepareUserChangeset: () => {\n      // If there are user changes to submit, 'changeset' will be the\n      // changeset, else it will be null.\n      let toSubmit;\n      if (submittedChangeset) {\n        // submission must have been canceled, prepare new changeset\n        // that includes old submittedChangeset\n        toSubmit = compose(submittedChangeset, userChangeset, apool);\n      } else {\n        // Get my authorID\n        const authorId = window.pad.myUserInfo.userId;\n\n        // Sanitize authorship: Replace all author attributes with this user's author ID in case the\n        // text was copied from another author.\n        const cs = unpack(userChangeset);\n        const assem = new MergingOpAssembler();\n\n        for (const op of deserializeOps(cs.ops)) {\n          if (op.opcode === '+') {\n            const attribs = AttributeMap.fromString(op.attribs, apool);\n            const oldAuthorId = attribs.get('author');\n            if (oldAuthorId != null && oldAuthorId !== authorId) {\n              attribs.set('author', authorId);\n              op.attribs = attribs.toString();\n            }\n          }\n          assem.append(op);\n        }\n        assem.endDocument();\n        userChangeset = pack(cs.oldLen, cs.newLen, assem.toString(), cs.charBank);\n        checkRep(userChangeset);\n\n        if (isIdentity(userChangeset)) toSubmit = null;\n        else toSubmit = userChangeset;\n      }\n\n      let cs = null;\n      if (toSubmit) {\n        submittedChangeset = toSubmit;\n        userChangeset = identity(newLen(toSubmit));\n\n        cs = toSubmit;\n      }\n      let wireApool = null;\n      if (cs) {\n        const forWire = prepareForWire(cs, apool);\n        wireApool = forWire.pool.toJsonable();\n        cs = forWire.translated;\n      }\n\n      const data = {\n        changeset: cs,\n        apool: wireApool,\n      };\n      return data;\n    },\n    applyPreparedChangesetToBase: () => {\n      if (!submittedChangeset) {\n        // violation of protocol; use prepareUserChangeset first\n        throw new Error('applySubmittedChangesToBase: no submitted changes to apply');\n      }\n      // bumpDebug(\"applying committed changeset: \"+submittedChangeset.encodeToString(false));\n      baseAText = applyToAText(submittedChangeset, baseAText, apool);\n      submittedChangeset = null;\n    },\n    setUserChangeNotificationCallback: (callback) => {\n      changeCallback = callback;\n    },\n    hasUncommittedChanges: () => !!(submittedChangeset || (!isIdentity(userChangeset))),\n  };\n};\n\nexports.makeChangesetTracker = makeChangesetTracker;\n"
  },
  {
    "path": "src/static/js/chat.ts",
    "content": "// @ts-nocheck\n'use strict';\n/**\n * Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport ChatMessage from './ChatMessage';\nimport padutils from './pad_utils'\nconst padcookie = require('./pad_cookie').padcookie;\nconst Tinycon = require('tinycon/tinycon');\nconst hooks = require('./pluginfw/hooks');\nconst padeditor = require('./pad_editor').padeditor;\nimport html10n from './vendors/html10n';\n\n// Removes diacritics and lower-cases letters. https://stackoverflow.com/a/37511463\nconst normalize = (s) => s.normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase();\n\n\nexports.chat = (() => {\n  let isStuck = false;\n  let userAndChat = false;\n  let chatMentions = 0;\n  return {\n    show() {\n      $('#chaticon').removeClass('visible');\n      $('#chatbox').addClass('visible');\n      this.scrollDown(true);\n      chatMentions = 0;\n      Tinycon.setBubble(0);\n      $('.chat-gritter-msg').each(function () {\n        $.gritter.remove(this.id);\n      });\n    },\n    focus: () => {\n      setTimeout(() => {\n        $('#chatinput').trigger('focus');\n      }, 100);\n    },\n    // Make chat stick to right hand side of screen\n    stickToScreen(fromInitialCall) {\n      if ($('#options-stickychat').prop('checked')) {\n        $('#options-stickychat').prop('checked', false);\n      }\n      if (pad.settings.hideChat) {\n        return;\n      }\n      this.show();\n      isStuck = (!isStuck || fromInitialCall);\n      $('#chatbox').hide();\n      // Add timeout to disable the chatbox animations\n      setTimeout(() => {\n        $('#chatbox, .sticky-container').toggleClass('stickyChat', isStuck);\n        $('#chatbox').css('display', 'flex');\n      }, 0);\n\n      padcookie.setPref('chatAlwaysVisible', isStuck);\n      $('#options-stickychat').prop('checked', isStuck);\n    },\n    chatAndUsers(fromInitialCall) {\n      const toEnable = $('#options-chatandusers').is(':checked');\n      if (toEnable || !userAndChat || fromInitialCall) {\n        this.stickToScreen(true);\n        $('#options-stickychat').prop('checked', true);\n        $('#options-chatandusers').prop('checked', true);\n        $('#options-stickychat').prop('disabled', true);\n        userAndChat = true;\n      } else {\n        $('#options-stickychat').prop('disabled', false);\n        userAndChat = false;\n      }\n      padcookie.setPref('chatAndUsers', userAndChat);\n      $('#users, .sticky-container')\n          .toggleClass('chatAndUsers popup-show stickyUsers', userAndChat);\n      $('#chatbox').toggleClass('chatAndUsersChat', userAndChat);\n    },\n    hide() {\n      // decide on hide logic based on chat window being maximized or not\n      if ($('#options-stickychat').prop('checked')) {\n        this.stickToScreen();\n        $('#options-stickychat').prop('checked', false);\n      } else {\n        $('#chatcounter').text('0');\n        $('#chaticon').addClass('visible');\n        $('#chatbox').removeClass('visible');\n      }\n    },\n    scrollDown(force) {\n      if ($('#chatbox').hasClass('visible')) {\n        if (force || !this.lastMessage || !this.lastMessage.position() ||\n            this.lastMessage.position().top < ($('#chattext').outerHeight() + 20)) {\n          // if we use a slow animate here we can have a race condition\n          // when a users focus can not be moved away from the last message recieved.\n          $('#chattext').animate(\n              {scrollTop: $('#chattext')[0].scrollHeight},\n              {duration: 400, queue: false});\n          this.lastMessage = $('#chattext > p').eq(-1);\n        }\n      }\n    },\n    async send() {\n      const text = $('#chatinput').val();\n      if (text.replace(/\\s+/, '').length === 0) return;\n      const message = new ChatMessage(text);\n      await hooks.aCallAll('chatSendMessage', Object.freeze({message}));\n      this._pad.collabClient.sendMessage({type: 'CHAT_MESSAGE', message});\n      $('#chatinput').val('');\n    },\n    async addMessage(msg, increment, isHistoryAdd) {\n      msg = ChatMessage.fromObject(msg);\n      // correct the time\n      msg.time += this._pad.clientTimeOffset;\n\n      if (!msg.authorId) {\n        /*\n         * If, for a bug or a database corruption, the message coming from the\n         * server does not contain the authorId field (see for example #3731),\n         * let's be defensive and replace it with \"unknown\".\n         */\n        msg.authorId = 'unknown';\n        console.warn(\n            'The \"authorId\" field of a chat message coming from the server was not present. ' +\n            'Replacing with \"unknown\". This may be a bug or a database corruption.');\n      }\n\n      const authorClass = (authorId) => `author-${authorId.replace(/[^a-y0-9]/g, (c) => {\n        if (c === '.') return '-';\n        return `z${c.charCodeAt(0)}z`;\n      })}`;\n\n      // the hook args\n      const ctx = {\n        authorName: msg.displayName != null ? msg.displayName : html10n.get('pad.userlist.unnamed'),\n        author: msg.authorId,\n        text: padutils.escapeHtmlWithClickableLinks(msg.text, '_blank'),\n        message: msg,\n        rendered: null,\n        sticky: false,\n        timestamp: msg.time,\n        timeStr: (() => {\n          let minutes = `${new Date(msg.time).getMinutes()}`;\n          let hours = `${new Date(msg.time).getHours()}`;\n          if (minutes.length === 1) minutes = `0${minutes}`;\n          if (hours.length === 1) hours = `0${hours}`;\n          return `${hours}:${minutes}`;\n        })(),\n        duration: 4000,\n      };\n\n      // is the users focus already in the chatbox?\n      const alreadyFocused = $('#chatinput').is(':focus');\n\n      // does the user already have the chatbox open?\n      const chatOpen = $('#chatbox').hasClass('visible');\n\n      // does this message contain this user's name? (is the current user mentioned?)\n      const wasMentioned =\n          msg.authorId !== window.clientVars.userId &&\n          ctx.authorName !== html10n.get('pad.userlist.unnamed') &&\n          normalize(ctx.text).includes(normalize(ctx.authorName));\n\n      // If the user was mentioned, make the message sticky\n      if (wasMentioned && !alreadyFocused && !isHistoryAdd && !chatOpen) {\n        chatMentions++;\n        Tinycon.setBubble(chatMentions);\n        ctx.sticky = true;\n      }\n\n      await hooks.aCallAll('chatNewMessage', ctx);\n      const cls = authorClass(ctx.author);\n      const chatMsg = ctx.rendered != null ? $(ctx.rendered) : $('<p>')\n          .attr('data-authorId', ctx.author)\n          .addClass(cls)\n          .append($('<b>').text(`${ctx.authorName}:`))\n          .append($('<span>')\n              .addClass('time')\n              .addClass(cls)\n              // Hook functions are trusted to not introduce an XSS vulnerability by adding\n              // unescaped user input to ctx.timeStr.\n              .html(ctx.timeStr))\n          .append(' ')\n          // ctx.text was HTML-escaped before calling the hook. Hook functions are trusted to not\n          // introduce an XSS vulnerability by adding unescaped user input.\n          .append($('<div>').html(ctx.text).contents());\n      if (isHistoryAdd) chatMsg.insertAfter('#chatloadmessagesbutton');\n      else $('#chattext').append(chatMsg);\n      chatMsg.each((i, e) => html10n.translateElement(html10n.translations, e));\n\n      // should we increment the counter??\n      if (increment && !isHistoryAdd) {\n        // Update the counter of unread messages\n        let count = Number($('#chatcounter').text());\n        count++;\n        $('#chatcounter').text(count);\n\n        if (!chatOpen && ctx.duration > 0) {\n          const text = $('<p>')\n              .append($('<span>').addClass('author-name').text(ctx.authorName))\n              // ctx.text was HTML-escaped before calling the hook. Hook functions are trusted\n              // to not introduce an XSS vulnerability by adding unescaped user input.\n              .append($('<div>').html(ctx.text).contents());\n          text.each((i, e) => html10n.translateElement(html10n.translations, e));\n          $.gritter.add({\n            text,\n            sticky: ctx.sticky,\n            time: ctx.duration,\n            position: 'bottom',\n            class_name: 'chat-gritter-msg',\n          });\n        }\n      }\n      if (!isHistoryAdd) this.scrollDown();\n    },\n    init(pad) {\n      this._pad = pad;\n      $('#chatinput').on('keydown', (evt) => {\n        // If the event is Alt C or Escape & we're already in the chat menu\n        // Send the users focus back to the pad\n        if ((evt.altKey === true && evt.which === 67) || evt.which === 27) {\n          // If we're in chat already..\n          $(':focus').trigger('blur'); // required to do not try to remove!\n          padeditor.ace.focus(); // Sends focus back to pad\n          evt.preventDefault();\n          return false;\n        }\n      });\n      // Clear the chat mentions when the user clicks on the chat input box\n      $('#chatinput').on('click', () => {\n        chatMentions = 0;\n        Tinycon.setBubble(0);\n      });\n\n      const self = this;\n      $('body:not(#chatinput)').on('keypress', function (evt) {\n        if (evt.altKey && evt.which === 67) {\n          // Alt c focuses on the Chat window\n          $(this).trigger('blur');\n          self.show();\n          $('#chatinput').trigger('focus');\n          evt.preventDefault();\n        }\n      });\n\n      $('#chatinput').on('keypress', (evt) => {\n        // if the user typed enter, fire the send\n        if (evt.key === 'Enter' && !evt.shiftKey) {\n          evt.preventDefault();\n          this.send();\n        }\n      });\n\n      // initial messages are loaded in pad.js' _afterHandshake\n\n      $('#chatcounter').text(0);\n      $('#chatloadmessagesbutton').on('click', () => {\n        const start = Math.max(this.historyPointer - 20, 0);\n        const end = this.historyPointer;\n\n        if (start === end) return; // nothing to load\n\n        $('#chatloadmessagesbutton').css('display', 'none');\n        $('#chatloadmessagesball').css('display', 'block');\n\n        pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end});\n        this.historyPointer = start;\n      });\n    },\n  };\n})();\n"
  },
  {
    "path": "src/static/js/collab_client.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst chat = require('./chat').chat;\nconst hooks = require('./pluginfw/hooks');\nconst browser = require('./vendors/browser');\n\n// Dependency fill on init. This exists for `pad.socket` only.\n// TODO: bind directly to the socket.\nlet pad = undefined;\nconst getSocket = () => pad && pad.socket;\n\n/** Call this when the document is ready, and a new Ace2Editor() has been created and inited.\n    ACE's ready callback does not need to have fired yet.\n    \"serverVars\" are from calling doc.getCollabClientVars() on the server. */\nconst getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad) => {\n  const editor = ace2editor;\n  pad = _pad; // Inject pad to avoid a circular dependency.\n\n  let rev = serverVars.rev;\n  let committing = false;\n  let stateMessage;\n  let channelState = 'CONNECTING';\n  let lastCommitTime = 0;\n  let initialStartConnectTime = 0;\n  let commitDelay = 500;\n\n  const userId = initialUserInfo.userId;\n  // var socket;\n  const userSet = {}; // userId -> userInfo\n  userSet[userId] = initialUserInfo;\n\n  let isPendingRevision = false;\n\n  const callbacks = {\n    onUserJoin: () => {},\n    onUserLeave: () => {},\n    onUpdateUserInfo: () => {},\n    onChannelStateChange: () => {},\n    onClientMessage: () => {},\n    onInternalAction: () => {},\n    onConnectionTrouble: () => {},\n    onServerMessage: () => {},\n  };\n  if (browser.firefox) {\n    // Prevent \"escape\" from taking effect and canceling a comet connection;\n    // doesn't work if focus is on an iframe.\n    $(window).on('keydown', (evt) => {\n      if (evt.which === 27) {\n        evt.preventDefault();\n      }\n    });\n  }\n\n  const handleUserChanges = () => {\n    if (editor.getInInternationalComposition()) {\n      // handleUserChanges() will be called again once composition ends so there's no need to set up\n      // a future call before returning.\n      return;\n    }\n    const now = Date.now();\n    if ((!getSocket()) || channelState === 'CONNECTING') {\n      if (channelState === 'CONNECTING' && (now - initialStartConnectTime) > 20000) {\n        setChannelState('DISCONNECTED', 'initsocketfail');\n      } else {\n        // check again in a bit\n        setTimeout(handleUserChanges, 1000);\n      }\n      return;\n    }\n\n    if (committing) {\n      if (now - lastCommitTime > 20000) {\n        // a commit is taking too long\n        setChannelState('DISCONNECTED', 'slowcommit');\n      } else if (now - lastCommitTime > 5000) {\n        callbacks.onConnectionTrouble('SLOW');\n      } else {\n        // run again in a few seconds, to detect a disconnect\n        setTimeout(handleUserChanges, 3000);\n      }\n      return;\n    }\n\n    const earliestCommit = lastCommitTime + commitDelay;\n    if (now < earliestCommit) {\n      setTimeout(handleUserChanges, earliestCommit - now);\n      return;\n    }\n\n    let sentMessage = false;\n    // Check if there are any pending revisions to be received from server.\n    // Allow only if there are no pending revisions to be received from server\n    if (!isPendingRevision) {\n      const userChangesData = editor.prepareUserChangeset();\n      if (userChangesData.changeset) {\n        lastCommitTime = now;\n        committing = true;\n        stateMessage = {\n          type: 'USER_CHANGES',\n          baseRev: rev,\n          changeset: userChangesData.changeset,\n          apool: userChangesData.apool,\n        };\n        sendMessage(stateMessage);\n        sentMessage = true;\n        callbacks.onInternalAction('commitPerformed');\n      }\n    } else {\n      // run again in a few seconds, to check if there was a reconnection attempt\n      setTimeout(handleUserChanges, 3000);\n    }\n\n    if (sentMessage) {\n      // run again in a few seconds, to detect a disconnect\n      setTimeout(handleUserChanges, 3000);\n    }\n  };\n\n  const acceptCommit = () => {\n    editor.applyPreparedChangesetToBase();\n    setStateIdle();\n    try {\n      callbacks.onInternalAction('commitAcceptedByServer');\n      callbacks.onConnectionTrouble('OK');\n    } catch (err) { /* intentionally ignored */ }\n    handleUserChanges();\n  };\n\n  const setUpSocket = () => {\n    setChannelState('CONNECTED');\n    doDeferredActions();\n\n    initialStartConnectTime = Date.now();\n  };\n\n  const sendMessage = (msg) => {\n    getSocket().emit('message',\n        {\n          type: 'COLLABROOM',\n          component: 'pad',\n          data: msg,\n        });\n  };\n\n  const serverMessageTaskQueue = new class {\n    constructor() {\n      this._promiseChain = Promise.resolve();\n    }\n\n    async enqueue(fn) {\n      const taskPromise = this._promiseChain.then(fn);\n      // Use .catch() to prevent rejections from halting the queue.\n      this._promiseChain = taskPromise.catch(() => {});\n      // Do NOT do `return await this._promiseChain;` because the caller would not see an error if\n      // fn() throws/rejects (due to the .catch() added above).\n      return await taskPromise;\n    }\n  }();\n\n  const handleMessageFromServer = (evt) => {\n    if (!getSocket()) return;\n    if (!evt.data) return;\n    const wrapper = evt;\n    if (wrapper.type !== 'COLLABROOM' && wrapper.type !== 'CUSTOM') return;\n    const msg = wrapper.data;\n\n    if (msg.type === 'NEW_CHANGES') {\n      serverMessageTaskQueue.enqueue(async () => {\n        // Avoid updating the DOM while the user is composing a character. Notes about this `await`:\n        //   * `await null;` is equivalent to `await Promise.resolve(null);`, so if the user is not\n        //     currently composing a character then execution will continue without error.\n        //   * We assume that it is not possible for a new 'compositionstart' event to fire after\n        //     the `await` but before the next line of code after the `await` (or, if it is\n        //     possible, that the chances are so small or the consequences so minor that it's not\n        //     worth addressing).\n        await editor.getInInternationalComposition();\n        const {newRev, changeset, author = '', apool} = msg;\n        if (newRev !== (rev + 1)) {\n          window.console.warn(`bad message revision on NEW_CHANGES: ${newRev} not ${rev + 1}`);\n          // setChannelState(\"DISCONNECTED\", \"badmessage_newchanges\");\n          return;\n        }\n        rev = newRev;\n        editor.applyChangesToBase(changeset, author, apool);\n      });\n    } else if (msg.type === 'ACCEPT_COMMIT') {\n      serverMessageTaskQueue.enqueue(() => {\n        const {newRev} = msg;\n        // newRev will equal rev if the changeset has no net effect (identity changeset, removing\n        // and re-adding the same characters with the same attributes, or retransmission of an\n        // already applied changeset).\n        if (![rev, rev + 1].includes(newRev)) {\n          window.console.warn(`bad message revision on ACCEPT_COMMIT: ${newRev} not ${rev + 1}`);\n          // setChannelState(\"DISCONNECTED\", \"badmessage_acceptcommit\");\n          return;\n        }\n        rev = newRev;\n        acceptCommit();\n      });\n    } else if (msg.type === 'CLIENT_RECONNECT') {\n      // Server sends a CLIENT_RECONNECT message when there is a client reconnect.\n      // Server also returns all pending revisions along with this CLIENT_RECONNECT message\n      serverMessageTaskQueue.enqueue(() => {\n        if (msg.noChanges) {\n          // If no revisions are pending, just make everything normal\n          setIsPendingRevision(false);\n          return;\n        }\n        const {headRev, newRev, changeset, author = '', apool} = msg;\n        if (newRev !== (rev + 1)) {\n          window.console.warn(`bad message revision on CLIENT_RECONNECT: ${newRev} not ${rev + 1}`);\n          // setChannelState(\"DISCONNECTED\", \"badmessage_acceptcommit\");\n          return;\n        }\n        rev = newRev;\n        if (author === pad.getUserId()) {\n          acceptCommit();\n        } else {\n          editor.applyChangesToBase(changeset, author, apool);\n        }\n        if (newRev === headRev) {\n          // Once we have applied all pending revisions, make everything normal\n          setIsPendingRevision(false);\n        }\n      });\n    } else if (msg.type === 'USER_NEWINFO') {\n      const userInfo = msg.userInfo;\n      const id = userInfo.userId;\n      if (userSet[id]) {\n        userSet[id] = userInfo;\n        callbacks.onUpdateUserInfo(userInfo);\n      } else {\n        userSet[id] = userInfo;\n        callbacks.onUserJoin(userInfo);\n      }\n      tellAceActiveAuthorInfo(userInfo);\n    } else if (msg.type === 'USER_LEAVE') {\n      const userInfo = msg.userInfo;\n      const id = userInfo.userId;\n      if (userSet[id]) {\n        delete userSet[userInfo.userId];\n        fadeAceAuthorInfo(userInfo);\n        callbacks.onUserLeave(userInfo);\n      }\n    } else if (msg.type === 'CLIENT_MESSAGE') {\n      callbacks.onClientMessage(msg.payload);\n    } else if (msg.type === 'CHAT_MESSAGE') {\n      chat.addMessage(msg.message, true, false);\n    } else if (msg.type === 'CHAT_MESSAGES') {\n      for (let i = msg.messages.length - 1; i >= 0; i--) {\n        chat.addMessage(msg.messages[i], true, true);\n      }\n      if (!chat.gotInitalMessages) {\n        chat.scrollDown();\n        chat.gotInitalMessages = true;\n        chat.historyPointer = clientVars.chatHead - msg.messages.length;\n      }\n\n      // messages are loaded, so hide the loading-ball\n      $('#chatloadmessagesball').css('display', 'none');\n\n      // there are less than 100 messages or we reached the top\n      if (chat.historyPointer <= 0) {\n        $('#chatloadmessagesbutton').css('display', 'none');\n      } else {\n        // there are still more messages, re-show the load-button\n        $('#chatloadmessagesbutton').css('display', 'block');\n      }\n    }\n\n    // HACKISH: User messages do not have \"payload\" but \"userInfo\", so that all\n    // \"handleClientMessage_USER_\" hooks would work, populate payload\n    // FIXME: USER_* messages to have \"payload\" property instead of \"userInfo\",\n    // seems like a quite a big work\n    if (msg.type.indexOf('USER_') > -1) {\n      msg.payload = msg.userInfo;\n    }\n    // Similar for NEW_CHANGES\n    if (msg.type === 'NEW_CHANGES') msg.payload = msg;\n\n    hooks.callAll(`handleClientMessage_${msg.type}`, {payload: msg.payload});\n  };\n\n  const updateUserInfo = (userInfo) => {\n    userInfo.userId = userId;\n    userSet[userId] = userInfo;\n    tellAceActiveAuthorInfo(userInfo);\n    if (!getSocket()) return;\n    sendMessage(\n        {\n          type: 'USERINFO_UPDATE',\n          userInfo,\n        });\n  };\n\n  const tellAceActiveAuthorInfo = (userInfo) => {\n    tellAceAuthorInfo(userInfo.userId, userInfo.colorId);\n  };\n\n  const tellAceAuthorInfo = (userId, colorId, inactive) => {\n    if (typeof colorId === 'number') {\n      colorId = clientVars.colorPalette[colorId];\n    }\n\n    const cssColor = colorId;\n    if (inactive) {\n      editor.setAuthorInfo(userId, {\n        bgcolor: cssColor,\n        fade: 0.5,\n      });\n    } else {\n      editor.setAuthorInfo(userId, {\n        bgcolor: cssColor,\n      });\n    }\n  };\n\n  const fadeAceAuthorInfo = (userInfo) => {\n    tellAceAuthorInfo(userInfo.userId, userInfo.colorId, true);\n  };\n\n  const getConnectedUsers = () => valuesArray(userSet);\n\n  const tellAceAboutHistoricalAuthors = (hadata) => {\n    for (const [author, data] of Object.entries(hadata)) {\n      if (!userSet[author]) {\n        tellAceAuthorInfo(author, data.colorId, true);\n      }\n    }\n  };\n\n  const setChannelState = (newChannelState, moreInfo) => {\n    if (newChannelState !== channelState) {\n      channelState = newChannelState;\n      callbacks.onChannelStateChange(channelState, moreInfo);\n    }\n  };\n\n  const valuesArray = (obj) => {\n    const array = [];\n    $.each(obj, (k, v) => {\n      array.push(v);\n    });\n    return array;\n  };\n\n  // We need to present a working interface even before the socket\n  // is connected for the first time.\n  let deferredActions = [];\n\n  const defer = (func, tag) => function (...args) {\n    const action = () => {\n      func.call(this, ...args);\n    };\n    action.tag = tag;\n    if (channelState === 'CONNECTING') {\n      deferredActions.push(action);\n    } else {\n      action();\n    }\n  };\n\n  const doDeferredActions = (tag) => {\n    const newArray = [];\n    for (let i = 0; i < deferredActions.length; i++) {\n      const a = deferredActions[i];\n      if ((!tag) || (tag === a.tag)) {\n        a();\n      } else {\n        newArray.push(a);\n      }\n    }\n    deferredActions = newArray;\n  };\n\n  const sendClientMessage = (msg) => {\n    sendMessage(\n        {\n          type: 'CLIENT_MESSAGE',\n          payload: msg,\n        });\n  };\n\n  const getCurrentRevisionNumber = () => rev;\n\n  const getMissedChanges = () => {\n    const obj = {};\n    obj.userInfo = userSet[userId];\n    obj.baseRev = rev;\n    if (committing && stateMessage) {\n      obj.committedChangeset = stateMessage.changeset;\n      obj.committedChangesetAPool = stateMessage.apool;\n      editor.applyPreparedChangesetToBase();\n    }\n    const userChangesData = editor.prepareUserChangeset();\n    if (userChangesData.changeset) {\n      obj.furtherChangeset = userChangesData.changeset;\n      obj.furtherChangesetAPool = userChangesData.apool;\n    }\n    return obj;\n  };\n\n  const setStateIdle = () => {\n    committing = false;\n    callbacks.onInternalAction('newlyIdle');\n    schedulePerhapsCallIdleFuncs();\n  };\n\n  const setIsPendingRevision = (value) => {\n    isPendingRevision = value;\n  };\n\n  const idleFuncs = [];\n\n  const callWhenNotCommitting = (func) => {\n    idleFuncs.push(func);\n    schedulePerhapsCallIdleFuncs();\n  };\n\n  const schedulePerhapsCallIdleFuncs = () => {\n    setTimeout(() => {\n      if (!committing) {\n        while (idleFuncs.length > 0) {\n          const f = idleFuncs.shift();\n          f();\n        }\n      }\n    }, 0);\n  };\n\n  const self = {\n    setOnUserJoin: (cb) => {\n      callbacks.onUserJoin = cb;\n    },\n    setOnUserLeave: (cb) => {\n      callbacks.onUserLeave = cb;\n    },\n    setOnUpdateUserInfo: (cb) => {\n      callbacks.onUpdateUserInfo = cb;\n    },\n    setOnChannelStateChange: (cb) => {\n      callbacks.onChannelStateChange = cb;\n    },\n    setOnClientMessage: (cb) => {\n      callbacks.onClientMessage = cb;\n    },\n    setOnInternalAction: (cb) => {\n      callbacks.onInternalAction = cb;\n    },\n    setOnConnectionTrouble: (cb) => {\n      callbacks.onConnectionTrouble = cb;\n    },\n    updateUserInfo: defer(updateUserInfo),\n    handleMessageFromServer,\n    getConnectedUsers,\n    sendClientMessage,\n    sendMessage,\n    getCurrentRevisionNumber,\n    getMissedChanges,\n    callWhenNotCommitting,\n    addHistoricalAuthors: tellAceAboutHistoricalAuthors,\n    setChannelState,\n    setStateIdle,\n    setIsPendingRevision,\n    set commitDelay(ms) { commitDelay = ms; },\n    get commitDelay() { return commitDelay; },\n  };\n\n  tellAceAboutHistoricalAuthors(serverVars.historicalAuthorData);\n  tellAceActiveAuthorInfo(initialUserInfo);\n\n  editor.setProperty('userAuthor', userId);\n  editor.setBaseAttributedText(serverVars.initialAttributedText, serverVars.apool);\n  editor.setUserChangeNotificationCallback(handleUserChanges);\n\n  setUpSocket();\n  return self;\n};\n\nexports.getCollabClient = getCollabClient;\n"
  },
  {
    "path": "src/static/js/colorutils.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/colorutils.js\n// THIS FILE IS ALSO SERVED AS CLIENT-SIDE JS\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst colorutils = {};\n\n// Check that a given value is a css hex color value, e.g.\n// \"#ffffff\" or \"#fff\"\ncolorutils.isCssHex = (cssColor) => /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(cssColor);\n\n// \"#ffffff\" or \"#fff\" or \"ffffff\" or \"fff\" to [1.0, 1.0, 1.0]\ncolorutils.css2triple = (cssColor) => {\n  const sixHex = colorutils.css2sixhex(cssColor);\n\n  const hexToFloat = (hh) => Number(`0x${hh}`) / 255;\n  return [\n    hexToFloat(sixHex.substr(0, 2)),\n    hexToFloat(sixHex.substr(2, 2)),\n    hexToFloat(sixHex.substr(4, 2)),\n  ];\n};\n\n// \"#ffffff\" or \"#fff\" or \"ffffff\" or \"fff\" to \"ffffff\"\ncolorutils.css2sixhex = (cssColor) => {\n  let h = /[0-9a-fA-F]+/.exec(cssColor)[0];\n  if (h.length !== 6) {\n    const a = h.charAt(0);\n    const b = h.charAt(1);\n    const c = h.charAt(2);\n    h = a + a + b + b + c + c;\n  }\n  return h;\n};\n\n// [1.0, 1.0, 1.0] -> \"#ffffff\"\ncolorutils.triple2css = (triple) => {\n  const floatToHex = (n) => {\n    const n2 = colorutils.clamp(Math.round(n * 255), 0, 255);\n    return (`0${n2.toString(16)}`).slice(-2);\n  };\n  return `#${floatToHex(triple[0])}${floatToHex(triple[1])}${floatToHex(triple[2])}`;\n};\n\n\ncolorutils.clamp = (v, bot, top) => v < bot ? bot : (v > top ? top : v);\ncolorutils.min3 = (a, b, c) => (a < b) ? (a < c ? a : c) : (b < c ? b : c);\ncolorutils.max3 = (a, b, c) => (a > b) ? (a > c ? a : c) : (b > c ? b : c);\ncolorutils.colorMin = (c) => colorutils.min3(c[0], c[1], c[2]);\ncolorutils.colorMax = (c) => colorutils.max3(c[0], c[1], c[2]);\ncolorutils.scale = (v, bot, top) => colorutils.clamp(bot + v * (top - bot), 0, 1);\ncolorutils.unscale = (v, bot, top) => colorutils.clamp((v - bot) / (top - bot), 0, 1);\n\ncolorutils.scaleColor = (c, bot, top) => [\n  colorutils.scale(c[0], bot, top),\n  colorutils.scale(c[1], bot, top),\n  colorutils.scale(c[2], bot, top),\n];\n\ncolorutils.unscaleColor = (c, bot, top) => [\n  colorutils.unscale(c[0], bot, top),\n  colorutils.unscale(c[1], bot, top),\n  colorutils.unscale(c[2], bot, top),\n];\n\n// rule of thumb for RGB brightness; 1.0 is white\ncolorutils.luminosity = (c) => c[0] * 0.30 + c[1] * 0.59 + c[2] * 0.11;\n\ncolorutils.saturate = (c) => {\n  const min = colorutils.colorMin(c);\n  const max = colorutils.colorMax(c);\n  if (max - min <= 0) return [1.0, 1.0, 1.0];\n  return colorutils.unscaleColor(c, min, max);\n};\n\ncolorutils.blend = (c1, c2, t) => [\n  colorutils.scale(t, c1[0], c2[0]),\n  colorutils.scale(t, c1[1], c2[1]),\n  colorutils.scale(t, c1[2], c2[2]),\n];\n\ncolorutils.invert = (c) => [1 - c[0], 1 - c[1], 1 - c[2]];\n\ncolorutils.complementary = (c) => {\n  const inv = colorutils.invert(c);\n  return [\n    (inv[0] >= c[0]) ? Math.min(inv[0] * 1.30, 1) : (c[0] * 0.30),\n    (inv[1] >= c[1]) ? Math.min(inv[1] * 1.59, 1) : (c[1] * 0.59),\n    (inv[2] >= c[2]) ? Math.min(inv[2] * 1.11, 1) : (c[2] * 0.11),\n  ];\n};\n\ncolorutils.textColorFromBackgroundColor = (bgcolor, skinName) => {\n  const white = skinName === 'colibris' ? 'var(--super-light-color)' : '#fff';\n  const black = skinName === 'colibris' ? 'var(--super-dark-color)' : '#222';\n\n  return colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5 ? white : black;\n};\n\nexports.colorutils = colorutils;\n"
  },
  {
    "path": "src/static/js/contentcollector.ts",
    "content": "// @ts-nocheck\n\n'use strict';\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n// THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.contentcollector\n// %APPJET%: import(\"etherpad.collab.ace.easysync2.Changeset\");\n// %APPJET%: import(\"etherpad.admin.plugins\");\nimport Op from \"./Op\";\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst _MAX_LIST_LEVEL = 16;\n\nimport AttributeMap from './AttributeMap';\nimport UNorm from 'unorm';\nimport {subattribution} from './Changeset';\nimport {SmartOpAssembler} from \"./SmartOpAssembler\";\nconst hooks = require('./pluginfw/hooks');\n\nconst sanitizeUnicode = (s) => UNorm.nfc(s);\nconst tagName = (n) => n.tagName && n.tagName.toLowerCase();\n// supportedElems are Supported natively within Etherpad and don't require a plugin\nconst supportedElems = new Set([\n  'author',\n  'b',\n  'bold',\n  'br',\n  'div',\n  'font',\n  'i',\n  'insertorder',\n  'italic',\n  'li',\n  'lmkr',\n  'ol',\n  'p',\n  'pre',\n  'strong',\n  's',\n  'span',\n  'u',\n  'ul',\n]);\n\nconst makeContentCollector = (collectStyles, abrowser, apool, className2Author) => {\n  const _blockElems = {\n    div: 1,\n    p: 1,\n    pre: 1,\n    li: 1,\n  };\n\n  hooks.callAll('ccRegisterBlockElements').forEach((element) => {\n    _blockElems[element] = 1;\n    supportedElems.add(element);\n  });\n\n  const isBlockElement = (n) => !!_blockElems[tagName(n) || ''];\n\n  const textify = (str) => sanitizeUnicode(\n      str.replace(/(\\n | \\n)/g, ' ')\n          .replace(/[\\n\\r ]/g, ' ')\n          .replace(/\\xa0/g, ' ')\n          .replace(/\\t/g, '        '));\n\n  const getAssoc = (node, name) => node[`_magicdom_${name}`];\n\n  const lines = (() => {\n    const textArray = [];\n    const attribsArray = [];\n    let attribsBuilder = null;\n    const op = new Op('+');\n    const self = {\n      length: () => textArray.length,\n      atColumnZero: () => textArray[textArray.length - 1] === '',\n      startNew: () => {\n        textArray.push('');\n        self.flush(true);\n        attribsBuilder = new SmartOpAssembler();\n      },\n      textOfLine: (i) => textArray[i],\n      appendText: (txt, attrString = '') => {\n        textArray[textArray.length - 1] += txt;\n        op.attribs = attrString;\n        op.chars = txt.length;\n        attribsBuilder.append(op);\n      },\n      textLines: () => textArray.slice(),\n      attribLines: () => attribsArray,\n      // call flush only when you're done\n      flush: (withNewline) => {\n        if (attribsBuilder) {\n          attribsArray.push(attribsBuilder.toString());\n          attribsBuilder = null;\n        }\n      },\n    };\n    self.startNew();\n    return self;\n  })();\n  const cc = {};\n\n  const _ensureColumnZero = (state) => {\n    if (!lines.atColumnZero()) {\n      cc.startNewLine(state);\n    }\n  };\n  let selection, startPoint, endPoint;\n  let selStart = [-1, -1];\n  let selEnd = [-1, -1];\n  const _isEmpty = (node, state) => {\n    // consider clean blank lines pasted in IE to be empty\n    if (node.childNodes.length === 0) return true;\n    if (node.childNodes.length === 1 &&\n        getAssoc(node, 'shouldBeEmpty') &&\n        node.innerHTML === '&nbsp;' &&\n        !getAssoc(node, 'unpasted')) {\n      if (state) {\n        const child = node.childNodes[0];\n        _reachPoint(child, 0, state);\n        _reachPoint(child, 1, state);\n      }\n      return true;\n    }\n    return false;\n  };\n\n  const _pointHere = (charsAfter, state) => {\n    const ln = lines.length() - 1;\n    let chr = lines.textOfLine(ln).length;\n    if (chr === 0 && Object.keys(state.lineAttributes).length !== 0) {\n      chr += 1; // listMarker\n    }\n    chr += charsAfter;\n    return [ln, chr];\n  };\n\n  const _reachBlockPoint = (nd, idx, state) => {\n    if (nd.nodeType !== nd.TEXT_NODE) _reachPoint(nd, idx, state);\n  };\n\n  const _reachPoint = (nd, idx, state) => {\n    if (startPoint && nd === startPoint.node && startPoint.index === idx) {\n      selStart = _pointHere(0, state);\n    }\n    if (endPoint && nd === endPoint.node && endPoint.index === idx) {\n      selEnd = _pointHere(0, state);\n    }\n  };\n  cc.incrementFlag = (state, flagName) => {\n    state.flags[flagName] = (state.flags[flagName] || 0) + 1;\n  };\n  cc.decrementFlag = (state, flagName) => {\n    state.flags[flagName]--;\n  };\n  cc.incrementAttrib = (state, attribName) => {\n    if (!state.attribs[attribName]) {\n      state.attribs[attribName] = 1;\n    } else {\n      state.attribs[attribName]++;\n    }\n    _recalcAttribString(state);\n  };\n  cc.decrementAttrib = (state, attribName) => {\n    state.attribs[attribName]--;\n    _recalcAttribString(state);\n  };\n\n  const _enterList = (state, listType) => {\n    if (!listType) return;\n    const oldListType = state.lineAttributes.list;\n    if (listType !== 'none') {\n      state.listNesting = (state.listNesting || 0) + 1;\n      // reminder that listType can be \"number2\", \"number3\" etc.\n      if (listType.indexOf('number') !== -1) {\n        state.start = (state.start || 0) + 1;\n      }\n    }\n\n    if (listType === 'none') {\n      delete state.lineAttributes.list;\n    } else {\n      state.lineAttributes.list = listType;\n    }\n    _recalcAttribString(state);\n    return oldListType;\n  };\n\n  const _exitList = (state, oldListType) => {\n    if (state.lineAttributes.list) {\n      state.listNesting--;\n    }\n    if (oldListType && oldListType !== 'none') {\n      state.lineAttributes.list = oldListType;\n    } else {\n      delete state.lineAttributes.list;\n      delete state.lineAttributes.start;\n    }\n    _recalcAttribString(state);\n  };\n\n  const _enterAuthor = (state, author) => {\n    const oldAuthor = state.author;\n    state.authorLevel = (state.authorLevel || 0) + 1;\n    state.author = author;\n    _recalcAttribString(state);\n    return oldAuthor;\n  };\n\n  const _exitAuthor = (state, oldAuthor) => {\n    state.authorLevel--;\n    state.author = oldAuthor;\n    _recalcAttribString(state);\n  };\n\n  const _recalcAttribString = (state) => {\n    const attribs = new AttributeMap(apool);\n    for (const [a, count] of Object.entries(state.attribs)) {\n      if (!count) continue;\n      // The following splitting of the attribute name is a workaround\n      // to enable the content collector to store key-value attributes\n      // see https://github.com/ether/etherpad-lite/issues/2567 for more information\n      // in long term the contentcollector should be refactored to get rid of this workaround\n      //\n      // TODO: This approach doesn't support changing existing values: if both 'foo::bar' and\n      // 'foo::baz' are in state.attribs then the last one encountered while iterating will win.\n      const ATTRIBUTE_SPLIT_STRING = '::';\n\n      // see if attributeString is splittable\n      const attributeSplits = a.split(ATTRIBUTE_SPLIT_STRING);\n      if (attributeSplits.length > 1) {\n        // the attribute name follows the convention key::value\n        // so save it as a key value attribute\n        const [k, v] = attributeSplits;\n        if (v) attribs.set(k, v);\n      } else {\n        // the \"normal\" case, the attribute is just a switch\n        // so set it true\n        attribs.set(a, 'true');\n      }\n    }\n    if (state.authorLevel > 0) {\n      if (apool.putAttrib(['author', state.author], true) >= 0) {\n        // require that author already be in pool\n        // (don't add authors from other documents, etc.)\n        if (state.author) attribs.set('author', state.author);\n      }\n    }\n    state.attribString = attribs.toString();\n  };\n\n  const _produceLineAttributesMarker = (state) => {\n    // TODO: This has to go to AttributeManager.\n    const attribs = new AttributeMap(apool)\n        .set('lmkr', '1')\n        .set('insertorder', 'first')\n        // TODO: Converting all falsy values in state.lineAttributes into removals is awkward.\n        // Better would be to never add 0, false, null, or undefined to state.lineAttributes in the\n        // first place (I'm looking at you, state.lineAttributes.start).\n        .update(Object.entries(state.lineAttributes).map(([k, v]) => [k, v || '']), true);\n    lines.appendText('*', attribs.toString());\n  };\n  cc.startNewLine = (state) => {\n    if (state) {\n      const atBeginningOfLine = lines.textOfLine(lines.length() - 1).length === 0;\n      if (atBeginningOfLine && Object.keys(state.lineAttributes).length !== 0) {\n        _produceLineAttributesMarker(state);\n      }\n    }\n    lines.startNew();\n  };\n  cc.notifySelection = (sel) => {\n    if (sel) {\n      selection = sel;\n      startPoint = selection.startPoint;\n      endPoint = selection.endPoint;\n    }\n  };\n  cc.doAttrib = (state, na) => {\n    state.localAttribs = (state.localAttribs || []);\n    state.localAttribs.push(na);\n    cc.incrementAttrib(state, na);\n  };\n  cc.collectContent = function (node, state) {\n    let unsupportedElements = null;\n    if (!state) {\n      state = {\n        flags: { /* name -> nesting counter*/\n        },\n        localAttribs: null,\n        attribs: { /* name -> nesting counter*/\n        },\n        attribString: '',\n        // lineAttributes maintain a map from attributes to attribute values set on a line\n        lineAttributes: {\n          /*\n          example:\n          'list': 'bullet1',\n          */\n        },\n        unsupportedElements: new Set(),\n      };\n      unsupportedElements = state.unsupportedElements;\n    }\n    const localAttribs = state.localAttribs;\n    state.localAttribs = null;\n    const isBlock = isBlockElement(node);\n    if (!isBlock && node.name && (node.name !== 'body')) {\n      if (!supportedElems.has(node.name)) state.unsupportedElements.add(node.name);\n    }\n    const isEmpty = _isEmpty(node, state);\n    if (isBlock) _ensureColumnZero(state);\n    const startLine = lines.length() - 1;\n    _reachBlockPoint(node, 0, state);\n\n    if (node.nodeType === node.TEXT_NODE) {\n      const tname = node.parentNode.getAttribute('name');\n      const context = {cc: this, state, tname, node, text: node.nodeValue};\n      // Hook functions may either return a string (deprecated) or modify context.text. If any hook\n      // function modifies context.text then all returned strings are ignored. If no hook functions\n      // modify context.text, the first hook function to return a string wins.\n      const [hookTxt] =\n          hooks.callAll('collectContentLineText', context).filter((s) => typeof s === 'string');\n      let txt = context.text === node.nodeValue && hookTxt != null ? hookTxt : context.text;\n\n      let rest = '';\n      let x = 0; // offset into original text\n      if (txt.length === 0) {\n        if (startPoint && node === startPoint.node) {\n          selStart = _pointHere(0, state);\n        }\n        if (endPoint && node === endPoint.node) {\n          selEnd = _pointHere(0, state);\n        }\n      }\n      while (txt.length > 0) {\n        let consumed = 0;\n        if (state.flags.preMode) {\n          const firstLine = txt.split('\\n', 1)[0];\n          consumed = firstLine.length + 1;\n          rest = txt.substring(consumed);\n          txt = firstLine;\n        } else { /* will only run this loop body once */\n        }\n        if (startPoint && node === startPoint.node && startPoint.index - x <= txt.length) {\n          selStart = _pointHere(startPoint.index - x, state);\n        }\n        if (endPoint && node === endPoint.node && endPoint.index - x <= txt.length) {\n          selEnd = _pointHere(endPoint.index - x, state);\n        }\n        let txt2 = txt;\n        if ((!state.flags.preMode) && /^[\\r\\n]*$/.exec(txt)) {\n          // prevents textnodes containing just \"\\n\" from being significant\n          // in safari when pasting text, now that we convert them to\n          // spaces instead of removing them, because in other cases\n          // removing \"\\n\" from pasted HTML will collapse words together.\n          txt2 = '';\n        }\n        const atBeginningOfLine = lines.textOfLine(lines.length() - 1).length === 0;\n        if (atBeginningOfLine) {\n          // newlines in the source mustn't become spaces at beginning of line box\n          txt2 = txt2.replace(/^\\n*/, '');\n        }\n        if (atBeginningOfLine && Object.keys(state.lineAttributes).length !== 0) {\n          _produceLineAttributesMarker(state);\n        }\n        lines.appendText(textify(txt2), state.attribString);\n        x += consumed;\n        txt = rest;\n        if (txt.length > 0) {\n          cc.startNewLine(state);\n        }\n      }\n    } else if (node.nodeType === node.ELEMENT_NODE) {\n      const tname = tagName(node) || '';\n\n      if (tname === 'img') {\n        hooks.callAll('collectContentImage', {\n          cc,\n          state,\n          tname,\n          styl: null,\n          cls: null,\n          node,\n        });\n      } else {\n        // THIS SEEMS VERY HACKY! -- Please submit a better fix!\n        delete state.lineAttributes.img;\n      }\n\n      if (tname === 'br') {\n        this.breakLine = true;\n        const tvalue = node.getAttribute('value');\n        const [startNewLine = true] = hooks.callAll('collectContentLineBreak', {\n          cc: this,\n          state,\n          tname,\n          tvalue,\n          styl: null,\n          cls: null,\n        });\n        if (startNewLine) {\n          cc.startNewLine(state);\n        }\n      } else if (tname === 'script' || tname === 'style') {\n        // ignore\n      } else if (!isEmpty) {\n        let styl = node.getAttribute('style');\n        let cls = node.getAttribute('class');\n        let isPre = (tname === 'pre');\n        if ((!isPre) && abrowser && abrowser.safari) {\n          isPre = (styl && /\\bwhite-space:\\s*pre\\b/i.exec(styl));\n        }\n        if (isPre) cc.incrementFlag(state, 'preMode');\n        let oldListTypeOrNull = null;\n        let oldAuthorOrNull = null;\n\n        // LibreOffice Writer puts in weird items during import or copy/paste, we should drop them.\n        if (cls === 'Numbering_20_Symbols' || cls === 'Bullet_20_Symbols') {\n          styl = null;\n          cls = null;\n\n          // We have to return here but this could break things in the future,\n          // for now it shows how to fix the problem\n          return;\n        }\n        if (collectStyles) {\n          hooks.callAll('collectContentPre', {\n            cc,\n            state,\n            tname,\n            styl,\n            cls,\n          });\n          if (tname === 'b' ||\n              (styl && /\\bfont-weight:\\s*bold\\b/i.exec(styl)) ||\n              tname === 'strong') {\n            cc.doAttrib(state, 'bold');\n          }\n          if (tname === 'i' ||\n              (styl && /\\bfont-style:\\s*italic\\b/i.exec(styl)) ||\n              tname === 'em') {\n            cc.doAttrib(state, 'italic');\n          }\n          if (tname === 'u' ||\n              (styl && /\\btext-decoration:\\s*underline\\b/i.exec(styl)) ||\n              tname === 'ins') {\n            cc.doAttrib(state, 'underline');\n          }\n          if (tname === 's' ||\n              (styl && /\\btext-decoration:\\s*line-through\\b/i.exec(styl)) ||\n              tname === 'del') {\n            cc.doAttrib(state, 'strikethrough');\n          }\n          if (tname === 'ul' || tname === 'ol') {\n            let type = node.getAttribute('class');\n            const rr = cls && /(?:^| )list-([a-z]+[0-9]+)\\b/.exec(cls);\n            // lists do not need to have a type, so before we make a wrong guess\n            // check if we find a better hint within the node's children\n            if (!rr && !type) {\n              for (const child of node.childNodes) {\n                if (tagName(child) !== 'ul') continue;\n                type = child.getAttribute('class');\n                if (type) break;\n              }\n            }\n            if (rr && rr[1]) {\n              type = rr[1];\n            } else {\n              if (tname === 'ul') {\n                const cls = node.getAttribute('class');\n                if ((type && type.match('indent')) || (cls && cls.match('indent'))) {\n                  type = 'indent';\n                } else {\n                  type = 'bullet';\n                }\n              } else {\n                type = 'number';\n              }\n              type += String(Math.min(_MAX_LIST_LEVEL, (state.listNesting || 0) + 1));\n            }\n            oldListTypeOrNull = (_enterList(state, type) || 'none');\n          } else if ((tname === 'div' || tname === 'p') && cls && cls.match(/(?:^| )ace-line\\b/)) {\n            // This has undesirable behavior in Chrome but is right in other browsers.\n            // See https://github.com/ether/etherpad-lite/issues/2412 for reasoning\n            if (!abrowser.chrome) oldListTypeOrNull = (_enterList(state, undefined) || 'none');\n          } else if (tname === 'li') {\n            state.lineAttributes.start = state.start || 0;\n            _recalcAttribString(state);\n            if (state.lineAttributes.list.indexOf('number') !== -1) {\n              /*\n               Nested OLs are not --> <ol><li>1</li><ol>nested</ol></ol>\n               They are           --> <ol><li>1</li><li><ol><li>nested</li></ol></li></ol>\n               Note how the <ol> item has to be inside a <li>\n               Because of this we don't increment the start number\n              */\n              if (node.parentNode && tagName(node.parentNode) !== 'ol') {\n                /*\n                TODO: start number has to increment based on indentLevel(numberX)\n                This means we have to build an object IE\n                {\n                 1: 4\n                 2: 3\n                 3: 5\n                }\n                But the browser seems to handle it fine using CSS..  Why can't we do the same\n                with exports?  We can..  But let's leave this comment in because it might be useful\n                in the future..\n                */\n                state.start++; // not if it's parent is an OL or UL.\n              }\n            }\n            // UL list items never modify the start value.\n            if (node.parentNode && tagName(node.parentNode) === 'ul') {\n              state.start++;\n              // TODO, this is hacky.\n              // Because if the first item is an UL it will increment a list no?\n              // A much more graceful way would be to say, ul increases if it's within an OL\n              // But I don't know a way to do that because we're only aware of the previous Line\n              // As the concept of parent's doesn't exist when processing each domline...\n            }\n          } else {\n            // Below needs more testin if it's necessary as _exitList should take care of this.\n            // delete state.start;\n            // delete state.listNesting;\n            // _recalcAttribString(state);\n          }\n          if (className2Author && cls) {\n            const classes = cls.match(/\\S+/g);\n            if (classes && classes.length > 0) {\n              for (let i = 0; i < classes.length; i++) {\n                const c = classes[i];\n                const a = className2Author(c);\n                if (a) {\n                  oldAuthorOrNull = (_enterAuthor(state, a) || 'none');\n                  break;\n                }\n              }\n            }\n          }\n        }\n\n        for (const c of node.childNodes) {\n          cc.collectContent(c, state);\n        }\n\n        if (collectStyles) {\n          hooks.callAll('collectContentPost', {\n            cc,\n            state,\n            tname,\n            styl,\n            cls,\n          });\n        }\n\n        if (isPre) cc.decrementFlag(state, 'preMode');\n        if (state.localAttribs) {\n          for (let i = 0; i < state.localAttribs.length; i++) {\n            cc.decrementAttrib(state, state.localAttribs[i]);\n          }\n        }\n        if (oldListTypeOrNull) {\n          _exitList(state, oldListTypeOrNull);\n        }\n        if (oldAuthorOrNull) {\n          _exitAuthor(state, oldAuthorOrNull);\n        }\n      }\n    }\n    _reachBlockPoint(node, 1, state);\n    if (isBlock) {\n      if (lines.length() - 1 === startLine) {\n        // added additional check to resolve https://github.com/JohnMcLear/ep_copy_paste_images/issues/20\n        // this does mean that images etc can't be pasted on lists but imho that's fine\n\n        // If we're doing an export event we need to start a new lines\n        // Export events don't have window available.\n        // commented out to solve #2412 - https://github.com/ether/etherpad-lite/issues/2412\n        if ((state.lineAttributes && !state.lineAttributes.list) || typeof window === 'undefined') {\n          cc.startNewLine(state);\n        }\n      } else {\n        _ensureColumnZero(state);\n      }\n    }\n    state.localAttribs = localAttribs;\n    if (unsupportedElements && unsupportedElements.size) {\n      console.warn('Ignoring unsupported elements (you might want to install a plugin): ' +\n                   `${[...unsupportedElements].join(', ')}`);\n    }\n  };\n  // can pass a falsy value for end of doc\n  cc.notifyNextNode = (node) => {\n    // an \"empty block\" won't end a line; this addresses an issue in IE with\n    // typing into a blank line at the end of the document.  typed text\n    // goes into the body, and the empty line div still looks clean.\n    // it is incorporated as dirty by the rule that a dirty region has\n    // to end a line.\n    if ((!node) || (isBlockElement(node) && !_isEmpty(node))) {\n      _ensureColumnZero(null);\n    }\n  };\n  // each returns [line, char] or [-1,-1]\n  const getSelectionStart = () => selStart;\n  const getSelectionEnd = () => selEnd;\n\n  // returns array of strings for lines found, last entry will be \"\" if\n  // last line is complete (i.e. if a following span should be on a new line).\n  // can be called at any point\n  cc.getLines = () => lines.textLines();\n\n  cc.finish = () => {\n    lines.flush();\n    const lineAttribs = lines.attribLines();\n    const lineStrings = cc.getLines();\n\n    lineStrings.length--;\n    lineAttribs.length--;\n\n    const ss = getSelectionStart();\n    const se = getSelectionEnd();\n\n    const fixLongLines = () => {\n      // design mode does not deal with with really long lines!\n      const lineLimit = 2000; // chars\n      const buffer = 10; // chars allowed over before wrapping\n      let linesWrapped = 0;\n      let numLinesAfter = 0;\n      for (let i = lineStrings.length - 1; i >= 0; i--) {\n        let oldString = lineStrings[i];\n        let oldAttribString = lineAttribs[i];\n        if (oldString.length > lineLimit + buffer) {\n          const newStrings = [];\n          const newAttribStrings = [];\n          while (oldString.length > lineLimit) {\n            // var semiloc = oldString.lastIndexOf(';', lineLimit-1);\n            // var lengthToTake = (semiloc >= 0 ? (semiloc+1) : lineLimit);\n            const lengthToTake = lineLimit;\n            newStrings.push(oldString.substring(0, lengthToTake));\n            oldString = oldString.substring(lengthToTake);\n            newAttribStrings.push(subattribution(oldAttribString, 0, lengthToTake));\n            oldAttribString = subattribution(oldAttribString, lengthToTake);\n          }\n          if (oldString.length > 0) {\n            newStrings.push(oldString);\n            newAttribStrings.push(oldAttribString);\n          }\n\n          const fixLineNumber = (lineChar) => {\n            if (lineChar[0] < 0) return;\n            let n = lineChar[0];\n            let c = lineChar[1];\n            if (n > i) {\n              n += (newStrings.length - 1);\n            } else if (n === i) {\n              let a = 0;\n              while (c > newStrings[a].length) {\n                c -= newStrings[a].length;\n                a++;\n              }\n              n += a;\n            }\n            lineChar[0] = n;\n            lineChar[1] = c;\n          };\n          fixLineNumber(ss);\n          fixLineNumber(se);\n          linesWrapped++;\n          numLinesAfter += newStrings.length;\n          lineStrings.splice(i, 1, ...newStrings);\n          lineAttribs.splice(i, 1, ...newAttribStrings);\n        }\n      }\n      return {\n        linesWrapped,\n        numLinesAfter,\n      };\n    };\n    const wrapData = fixLongLines();\n\n    return {\n      selStart: ss,\n      selEnd: se,\n      linesWrapped: wrapData.linesWrapped,\n      numLinesAfter: wrapData.numLinesAfter,\n      lines: lineStrings,\n      lineAttribs,\n    };\n  };\n\n  return cc;\n};\n\nexports.sanitizeUnicode = sanitizeUnicode;\nexports.makeContentCollector = makeContentCollector;\nexports.supportedElems = supportedElems;\n"
  },
  {
    "path": "src/static/js/cssmanager.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexports.makeCSSManager = (browserSheet) => {\n  const browserRules = () => (browserSheet.cssRules || browserSheet.rules);\n\n  const browserDeleteRule = (i) => {\n    if (browserSheet.deleteRule) browserSheet.deleteRule(i);\n    else browserSheet.removeRule(i);\n  };\n\n  const browserInsertRule = (i, selector) => {\n    if (browserSheet.insertRule) browserSheet.insertRule(`${selector} {}`, i);\n    else browserSheet.addRule(selector, null, i);\n  };\n  const selectorList = [];\n\n  const indexOfSelector = (selector) => {\n    for (let i = 0; i < selectorList.length; i++) {\n      if (selectorList[i] === selector) {\n        return i;\n      }\n    }\n    return -1;\n  };\n\n  const selectorStyle = (selector) => {\n    let i = indexOfSelector(selector);\n    if (i < 0) {\n      // add selector\n      browserInsertRule(0, selector);\n      selectorList.splice(0, 0, selector);\n      i = 0;\n    }\n    return browserRules().item(i).style;\n  };\n\n  const removeSelectorStyle = (selector) => {\n    const i = indexOfSelector(selector);\n    if (i >= 0) {\n      browserDeleteRule(i);\n      selectorList.splice(i, 1);\n    }\n  };\n\n  return {\n    selectorStyle,\n    removeSelectorStyle,\n    info: () => `${selectorList.length}:${browserRules().length}`,\n  };\n};\n"
  },
  {
    "path": "src/static/js/domline.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n// THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.domline\n// %APPJET%: import(\"etherpad.admin.plugins\");\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// requires: top\n// requires: plugins\n// requires: undefined\n\nconst Security = require('./security');\nconst hooks = require('./pluginfw/hooks');\nconst _ = require('./underscore');\nconst lineAttributeMarker = require('./linestylefilter').lineAttributeMarker;\nconst noop = () => {};\n\n\nconst domline = {};\n\ndomline.addToLineClass = (lineClass, cls) => {\n  // an \"empty span\" at any point can be used to add classes to\n  // the line, using line:className.  otherwise, we ignore\n  // the span.\n  cls.replace(/\\S+/g, (c) => {\n    if (c.indexOf('line:') === 0) {\n      // add class to line\n      lineClass = (lineClass ? `${lineClass} ` : '') + c.substring(5);\n    }\n  });\n  return lineClass;\n};\n\n// if \"document\" is falsy we don't create a DOM node, just\n// an object with innerHTML and className\ndomline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => {\n  const result = {\n    node: null,\n    appendSpan: noop,\n    prepareForAdd: noop,\n    notifyAdded: noop,\n    clearSpans: noop,\n    finishUpdate: noop,\n    lineMarker: 0,\n  };\n\n  const document = optDocument;\n\n  if (document) {\n    result.node = document.createElement('div');\n    // JAWS and NVDA screen reader compatibility. Only needed if in a real browser.\n    result.node.setAttribute('aria-live', 'assertive');\n  } else {\n    result.node = {\n      innerHTML: '',\n      className: '',\n    };\n  }\n\n  let html = [];\n  let preHtml = '';\n  let postHtml = '';\n  let curHTML = null;\n\n  const processSpaces = (s) => domline.processSpaces(s, doesWrap);\n  const perTextNodeProcess = (doesWrap ? _.identity : processSpaces);\n  const perHtmlLineProcess = (doesWrap ? processSpaces : _.identity);\n  let lineClass = 'ace-line';\n\n  result.appendSpan = (txt, cls) => {\n    let processedMarker = false;\n    // Handle lineAttributeMarker, if present\n    if (cls.indexOf(lineAttributeMarker) >= 0) {\n      let listType = /(?:^| )list:(\\S+)/.exec(cls);\n      const start = /(?:^| )start:(\\S+)/.exec(cls);\n\n      _.map(hooks.callAll('aceDomLinePreProcessLineAttributes', {\n        domline,\n        cls,\n      }), (modifier) => {\n        preHtml += modifier.preHtml;\n        postHtml += modifier.postHtml;\n        processedMarker |= modifier.processedMarker;\n      });\n      if (listType) {\n        listType = listType[1];\n        if (listType) {\n          if (listType.indexOf('number') < 0) {\n            preHtml += `<ul class=\"list-${Security.escapeHTMLAttribute(listType)}\"><li>`;\n            postHtml = `</li></ul>${postHtml}`;\n          } else {\n            if (start) { // is it a start of a list with more than one item in?\n              if (Number.parseInt(start[1]) === 1) { // if its the first one at this level?\n                // Add start class to DIV node\n                lineClass = `${lineClass} ` + `list-start-${listType}`;\n              }\n              preHtml +=\n                `<ol start=${start[1]} class=\"list-${Security.escapeHTMLAttribute(listType)}\"><li>`;\n            } else {\n              // Handles pasted contents into existing lists\n              preHtml += `<ol class=\"list-${Security.escapeHTMLAttribute(listType)}\"><li>`;\n            }\n            postHtml += '</li></ol>';\n          }\n        }\n        processedMarker = true;\n      }\n      _.map(hooks.callAll('aceDomLineProcessLineAttributes', {\n        domline,\n        cls,\n      }), (modifier) => {\n        preHtml += modifier.preHtml;\n        postHtml += modifier.postHtml;\n        processedMarker |= modifier.processedMarker;\n      });\n      if (processedMarker) {\n        result.lineMarker += txt.length;\n        return; // don't append any text\n      }\n    }\n    let href = null;\n    let simpleTags = null;\n    if (cls.indexOf('url') >= 0) {\n      cls = cls.replace(/(^| )url:(\\S+)/g, (x0, space, url) => {\n        href = url;\n        return `${space}url`;\n      });\n    }\n    if (cls.indexOf('tag') >= 0) {\n      cls = cls.replace(/(^| )tag:(\\S+)/g, (x0, space, tag) => {\n        if (!simpleTags) simpleTags = [];\n        simpleTags.push(tag.toLowerCase());\n        return space + tag;\n      });\n    }\n\n    let extraOpenTags = '';\n    let extraCloseTags = '';\n\n    _.map(hooks.callAll('aceCreateDomLine', {\n      domline,\n      cls,\n    }), (modifier) => {\n      cls = modifier.cls;\n      extraOpenTags += modifier.extraOpenTags;\n      extraCloseTags = modifier.extraCloseTags + extraCloseTags;\n    });\n\n    if ((!txt) && cls) {\n      lineClass = domline.addToLineClass(lineClass, cls);\n    } else if (txt) {\n      if (href) {\n        const urn_schemes = new RegExp('^(about|geo|mailto|tel):');\n        // if the url doesn't include a protocol prefix, assume http\n        if (!~href.indexOf('://') && !urn_schemes.test(href)) {\n          href = `http://${href}`;\n        }\n        // Using rel=\"noreferrer\" stops leaking the URL/location of the pad when\n        // clicking links in the document.\n        // Not all browsers understand this attribute, but it's part of the HTML5 standard.\n        // https://html.spec.whatwg.org/multipage/links.html#link-type-noreferrer\n        // Additionally, we do rel=\"noopener\" to ensure a higher level of referrer security.\n        // https://html.spec.whatwg.org/multipage/links.html#link-type-noopener\n        // https://mathiasbynens.github.io/rel-noopener/\n        // https://github.com/ether/etherpad-lite/pull/3636\n        const escapedHref = Security.escapeHTMLAttribute(href);\n        extraOpenTags = `${extraOpenTags}<a href=\"${escapedHref}\" rel=\"noreferrer noopener\">`;\n        extraCloseTags = `</a>${extraCloseTags}`;\n      }\n      if (simpleTags) {\n        simpleTags.sort();\n        extraOpenTags = `${extraOpenTags}<${simpleTags.join('><')}>`;\n        simpleTags.reverse();\n        extraCloseTags = `</${simpleTags.join('></')}>${extraCloseTags}`;\n      }\n      html.push(\n          '<span class=\"', Security.escapeHTMLAttribute(cls || ''),\n          '\">',\n          extraOpenTags,\n          perTextNodeProcess(Security.escapeHTML(txt)),\n          extraCloseTags,\n          '</span>');\n    }\n  };\n  result.clearSpans = () => {\n    html = [];\n    lineClass = 'ace-line';\n    result.lineMarker = 0;\n  };\n\n  const writeHTML = () => {\n    let newHTML = perHtmlLineProcess(html.join(''));\n    if (!newHTML) {\n      if ((!document) || (!optBrowser)) {\n        newHTML += '&nbsp;';\n      } else {\n        newHTML += '<br/>';\n      }\n    }\n    if (nonEmpty) {\n      newHTML = (preHtml || '') + newHTML + (postHtml || '');\n    }\n    html = preHtml = postHtml = ''; // free memory\n    if (newHTML !== curHTML) {\n      curHTML = newHTML;\n      result.node.innerHTML = curHTML;\n    }\n    if (lineClass != null) result.node.className = lineClass;\n\n    hooks.callAll('acePostWriteDomLineHTML', {\n      node: result.node,\n    });\n  };\n  result.prepareForAdd = writeHTML;\n  result.finishUpdate = writeHTML;\n  return result;\n};\n\ndomline.processSpaces = (s, doesWrap) => {\n  if (s.indexOf('<') < 0 && !doesWrap) {\n    // short-cut\n    return s.replace(/ /g, '&nbsp;');\n  }\n  const parts = [];\n  s.replace(/<[^>]*>?| |[^ <]+/g, (m) => {\n    parts.push(m);\n  });\n  if (doesWrap) {\n    let endOfLine = true;\n    let beforeSpace = false;\n    // last space in a run is normal, others are nbsp,\n    // end of line is nbsp\n    for (let i = parts.length - 1; i >= 0; i--) {\n      const p = parts[i];\n      if (p === ' ') {\n        if (endOfLine || beforeSpace) parts[i] = '&nbsp;';\n        endOfLine = false;\n        beforeSpace = true;\n      } else if (p.charAt(0) !== '<') {\n        endOfLine = false;\n        beforeSpace = false;\n      }\n    }\n    // beginning of line is nbsp\n    for (let i = 0; i < parts.length; i++) {\n      const p = parts[i];\n      if (p === ' ') {\n        parts[i] = '&nbsp;';\n        break;\n      } else if (p.charAt(0) !== '<') {\n        break;\n      }\n    }\n  } else {\n    for (let i = 0; i < parts.length; i++) {\n      const p = parts[i];\n      if (p === ' ') {\n        parts[i] = '&nbsp;';\n      }\n    }\n  }\n  return parts.join('');\n};\n\nexports.domline = domline;\n"
  },
  {
    "path": "src/static/js/index.ts",
    "content": "'use strict';\n\n/* eslint-disable-next-line max-len */\n// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0\n/**\n * Copyright 2011 Peter Martischka, Primary Technology.\n * Copyright 2020 Richard Hansen\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\nconst randomPadName = () => {\n  // the number of distinct chars (64) is chosen to ensure that the selection will be uniform when\n  // using the PRNG below\n  const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_';\n  // the length of the pad name is chosen to get 120-bit security: log2(64^20) = 120\n  const stringLength = 20;\n  // make room for 8-bit integer values that span from 0 to 255.\n  const randomarray = new Uint8Array(stringLength);\n  // use browser's PRNG to generate a \"unique\" sequence\n  crypto.getRandomValues(randomarray);\n  let randomstring = '';\n  for (let i = 0; i < stringLength; i++) {\n    // instead of writing \"Math.floor(randomarray[i]/256*64)\"\n    // we can save some cycles.\n    const rnum = Math.floor(randomarray[i] / 4);\n    randomstring += chars.substring(rnum, rnum + 1);\n  }\n  return randomstring;\n};\n\n$(() => {\n  $('#go2Name').on('submit', () => {\n    const padname = $('#padname').val() as string;\n    if (padname.length > 0) {\n      window.location.href = `p/${encodeURIComponent(padname.trim())}`;\n    } else {\n      alert('Please enter a name');\n    }\n    return false;\n  });\n\n  $('#button').on('click', () => {\n    window.location.href = `p/${randomPadName()}`;\n  });\n\n  // start the custom js\n  // @ts-ignore\n  if (typeof window.customStart === 'function') window.customStart();\n});\n\n// @license-end\n"
  },
  {
    "path": "src/static/js/l10n.ts",
    "content": "import html10n from '../js/vendors/html10n';\n\n\n// Set language for l10n\nlet regexpLang: string | undefined;\nlet language = document.cookie.match(/language=((\\w{2,3})(-\\w+)?)/);\nif (language) regexpLang = language[1];\n\nhtml10n.mt.bind('indexed', () => {\n  html10n.localize([regexpLang, navigator.language, 'en']);\n});\n\nhtml10n.mt.bind('localized', () => {\n  document.documentElement.lang = html10n.getLanguage()!;\n  document.documentElement.dir = html10n.getDirection()!;\n});\n"
  },
  {
    "path": "src/static/js/linestylefilter.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n// THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.linestylefilter\n// %APPJET%: import(\"etherpad.collab.ace.easysync2.Changeset\");\n// %APPJET%: import(\"etherpad.admin.plugins\");\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// requires: easysync2.Changeset\n// requires: top\n// requires: plugins\n// requires: undefined\n\nimport {deserializeOps} from './Changeset';\nimport attributes from './attributes';\nconst hooks = require('./pluginfw/hooks');\nconst linestylefilter = {};\nconst AttributeManager = require('./AttributeManager');\nimport padutils from './pad_utils'\nimport Op from \"./Op\";\n\nlinestylefilter.ATTRIB_CLASSES = {\n  bold: 'tag:b',\n  italic: 'tag:i',\n  underline: 'tag:u',\n  strikethrough: 'tag:s',\n};\n\nconst lineAttributeMarker = 'lineAttribMarker';\nexports.lineAttributeMarker = lineAttributeMarker;\n\nlinestylefilter.getAuthorClassName = (author) => `author-${author.replace(/[^a-y0-9]/g, (c) => {\n  if (c === '.') return '-';\n  return `z${c.charCodeAt(0)}z`;\n})}`;\n\n// lineLength is without newline; aline includes newline,\n// but may be falsy if lineLength == 0\nlinestylefilter.getLineStyleFilter = (lineLength, aline, textAndClassFunc, apool) => {\n  // Plugin Hook to add more Attrib Classes\n  for (const attribClasses of hooks.callAll('aceAttribClasses', linestylefilter.ATTRIB_CLASSES)) {\n    Object.assign(linestylefilter.ATTRIB_CLASSES, attribClasses);\n  }\n\n  if (lineLength === 0) return textAndClassFunc;\n\n  const nextAfterAuthorColors = textAndClassFunc;\n\n  const authorColorFunc = (() => {\n    const lineEnd = lineLength;\n    let curIndex = 0;\n    let extraClasses;\n    let leftInAuthor;\n\n    const attribsToClasses = (attribs) => {\n      let classes = '';\n      let isLineAttribMarker = false;\n\n      for (const [key, value] of attributes.attribsFromString(attribs, apool)) {\n        if (!key || !value) continue;\n        if (!isLineAttribMarker && AttributeManager.lineAttributes.indexOf(key) >= 0) {\n          isLineAttribMarker = true;\n        }\n        if (key === 'author') {\n          classes += ` ${linestylefilter.getAuthorClassName(value)}`;\n        } else if (key === 'list') {\n          classes += ` list:${value}`;\n        } else if (key === 'start') {\n          // Needed to introduce the correct Ordered list item start number on import\n          classes += ` start:${value}`;\n        } else if (linestylefilter.ATTRIB_CLASSES[key]) {\n          classes += ` ${linestylefilter.ATTRIB_CLASSES[key]}`;\n        } else {\n          const results = hooks.callAll('aceAttribsToClasses', {linestylefilter, key, value});\n          classes += ` ${results.join(' ')}`;\n        }\n      }\n\n      if (isLineAttribMarker) classes += ` ${lineAttributeMarker}`;\n      return classes.substring(1);\n    };\n\n    const attrOps = deserializeOps(aline);\n    let attrOpsNext = attrOps.next();\n    let nextOp, nextOpClasses;\n\n    const goNextOp = () => {\n      nextOp = attrOpsNext.done ? new Op() : attrOpsNext.value;\n      if (!attrOpsNext.done) attrOpsNext = attrOps.next();\n      nextOpClasses = (nextOp.opcode && attribsToClasses(nextOp.attribs));\n    };\n    goNextOp();\n\n    const nextClasses = () => {\n      if (curIndex < lineEnd) {\n        extraClasses = nextOpClasses;\n        leftInAuthor = nextOp.chars;\n        goNextOp();\n        while (nextOp.opcode && nextOpClasses === extraClasses) {\n          leftInAuthor += nextOp.chars;\n          goNextOp();\n        }\n      }\n    };\n    nextClasses();\n\n    return (txt, cls) => {\n      const disableAuthColorForThisLine = hooks.callAll('disableAuthorColorsForThisLine', {\n        linestylefilter,\n        text: txt,\n        class: cls,\n      });\n      const disableAuthors = (disableAuthColorForThisLine == null ||\n        disableAuthColorForThisLine.length === 0) ? false : disableAuthColorForThisLine[0];\n      while (txt.length > 0) {\n        if (leftInAuthor <= 0 || disableAuthors) {\n          // prevent infinite loop if something funny's going on\n          return nextAfterAuthorColors(txt, cls);\n        }\n        let spanSize = txt.length;\n        if (spanSize > leftInAuthor) {\n          spanSize = leftInAuthor;\n        }\n        const curTxt = txt.substring(0, spanSize);\n        txt = txt.substring(spanSize);\n        nextAfterAuthorColors(curTxt, (cls && `${cls} `) + extraClasses);\n        curIndex += spanSize;\n        leftInAuthor -= spanSize;\n        if (leftInAuthor === 0) {\n          nextClasses();\n        }\n      }\n    };\n  })();\n  return authorColorFunc;\n};\n\nlinestylefilter.getAtSignSplitterFilter = (lineText, textAndClassFunc) => {\n  const at = /@/g;\n  at.lastIndex = 0;\n  let splitPoints = null;\n  let execResult;\n  while ((execResult = at.exec(lineText))) {\n    if (!splitPoints) {\n      splitPoints = [];\n    }\n    splitPoints.push(execResult.index);\n  }\n\n  if (!splitPoints) return textAndClassFunc;\n\n  return linestylefilter.textAndClassFuncSplitter(textAndClassFunc, splitPoints);\n};\n\nlinestylefilter.getRegexpFilter = (regExp, tag) => (lineText, textAndClassFunc) => {\n  regExp.lastIndex = 0;\n  let regExpMatchs = null;\n  let splitPoints = null;\n  let execResult;\n  while ((execResult = regExp.exec(lineText))) {\n    if (!regExpMatchs) {\n      regExpMatchs = [];\n      splitPoints = [];\n    }\n    const startIndex = execResult.index;\n    const regExpMatch = execResult[0];\n    regExpMatchs.push([startIndex, regExpMatch]);\n    splitPoints.push(startIndex, startIndex + regExpMatch.length);\n  }\n\n  if (!regExpMatchs) return textAndClassFunc;\n\n  const regExpMatchForIndex = (idx) => {\n    for (let k = 0; k < regExpMatchs.length; k++) {\n      const u = regExpMatchs[k];\n      if (idx >= u[0] && idx < u[0] + u[1].length) {\n        return u[1];\n      }\n    }\n    return false;\n  };\n\n  const handleRegExpMatchsAfterSplit = (() => {\n    let curIndex = 0;\n    return (txt, cls) => {\n      const txtlen = txt.length;\n      let newCls = cls;\n      const regExpMatch = regExpMatchForIndex(curIndex);\n      if (regExpMatch) {\n        newCls += ` ${tag}:${regExpMatch}`;\n      }\n      textAndClassFunc(txt, newCls);\n      curIndex += txtlen;\n    };\n  })();\n\n  return linestylefilter.textAndClassFuncSplitter(handleRegExpMatchsAfterSplit, splitPoints);\n};\n\n\nlinestylefilter.getURLFilter = linestylefilter.getRegexpFilter(padutils.urlRegex, 'url');\n\nlinestylefilter.textAndClassFuncSplitter = (func, splitPointsOpt) => {\n  let nextPointIndex = 0;\n  let idx = 0;\n\n  // don't split at 0\n  while (splitPointsOpt &&\n      nextPointIndex < splitPointsOpt.length &&\n      splitPointsOpt[nextPointIndex] === 0) {\n    nextPointIndex++;\n  }\n\n  const spanHandler = (txt, cls) => {\n    if ((!splitPointsOpt) || nextPointIndex >= splitPointsOpt.length) {\n      func(txt, cls);\n      idx += txt.length;\n    } else {\n      const splitPoints = splitPointsOpt;\n      const pointLocInSpan = splitPoints[nextPointIndex] - idx;\n      const txtlen = txt.length;\n      if (pointLocInSpan >= txtlen) {\n        func(txt, cls);\n        idx += txt.length;\n        if (pointLocInSpan === txtlen) {\n          nextPointIndex++;\n        }\n      } else {\n        if (pointLocInSpan > 0) {\n          func(txt.substring(0, pointLocInSpan), cls);\n          idx += pointLocInSpan;\n        }\n        nextPointIndex++;\n        // recurse\n        spanHandler(txt.substring(pointLocInSpan), cls);\n      }\n    }\n  };\n  return spanHandler;\n};\n\nlinestylefilter.getFilterStack = (lineText, textAndClassFunc, abrowser) => {\n  let func = linestylefilter.getURLFilter(lineText, textAndClassFunc);\n\n  const hookFilters = hooks.callAll('aceGetFilterStack', {\n    linestylefilter,\n    browser: abrowser,\n  });\n  hookFilters.map((hookFilter) => {\n    func = hookFilter(lineText, func);\n  });\n\n  return func;\n};\n\n// domLineObj is like that returned by domline.createDomLine\nlinestylefilter.populateDomLine = (textLine, aline, apool, domLineObj) => {\n  // remove final newline from text if any\n  let text = textLine;\n  if (text.slice(-1) === '\\n') {\n    text = text.substring(0, text.length - 1);\n  }\n\n  const textAndClassFunc = (tokenText, tokenClass) => {\n    domLineObj.appendSpan(tokenText, tokenClass);\n  };\n\n  let func = linestylefilter.getFilterStack(text, textAndClassFunc);\n  func = linestylefilter.getLineStyleFilter(text.length, aline, func, apool);\n  func(text, '');\n};\n\nexports.linestylefilter = linestylefilter;\n"
  },
  {
    "path": "src/static/js/pad.ts",
    "content": "// @ts-nocheck\n'use strict';\nconst skinVariants = require('./skin_variants');\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n/**\n * Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nlet socket;\n\n\n// These jQuery things should create local references, but for now `require()`\n// assigns to the global `$` and augments it with plugins.\nrequire('./vendors/jquery');\nrequire('./vendors/farbtastic');\nrequire('./vendors/gritter');\n\nimport html10n from './vendors/html10n'\n\nimport {Cookies} from \"./pad_utils\";\n\nconst chat = require('./chat').chat;\nconst getCollabClient = require('./collab_client').getCollabClient;\nconst padconnectionstatus = require('./pad_connectionstatus').padconnectionstatus;\nconst padcookie = require('./pad_cookie').padcookie;\nconst padeditbar = require('./pad_editbar').padeditbar;\nconst padeditor = require('./pad_editor').padeditor;\nconst padimpexp = require('./pad_impexp').padimpexp;\nconst padmodals = require('./pad_modals').padmodals;\nconst padsavedrevs = require('./pad_savedrevs');\nconst paduserlist = require('./pad_userlist').paduserlist;\nimport padutils from './pad_utils'\nconst colorutils = require('./colorutils').colorutils;\nimport {randomString} from \"./pad_utils\";\nconst socketio = require('./socketio');\n\nconst hooks = require('./pluginfw/hooks');\n\n// This array represents all GET-parameters which can be used to change a setting.\n//   name:     the parameter-name, eg  `?noColors=true`  =>  `noColors`\n//   checkVal: the callback is only executed when\n//                * the parameter was supplied and matches checkVal\n//                * the parameter was supplied and checkVal is null\n//   callback: the function to call when all above succeeds, `val` is the value supplied by the user\nconst getParameters = [\n  {\n    name: 'noColors',\n    checkVal: 'true',\n    callback: (val) => {\n      settings.noColors = true;\n      $('#clearAuthorship').hide();\n    },\n  },\n  {\n    name: 'showControls',\n    checkVal: 'true',\n    callback: (val) => {\n      $('#editbar').css('display', 'flex');\n    },\n  },\n  {\n    name: 'showChat',\n    checkVal: null,\n    callback: (val) => {\n      if (val === 'false') {\n        settings.hideChat = true;\n        chat.hide();\n        $('#chaticon').hide();\n      }\n    },\n  },\n  {\n    name: 'showLineNumbers',\n    checkVal: 'false',\n    callback: (val) => {\n      settings.LineNumbersDisabled = true;\n    },\n  },\n  {\n    name: 'useMonospaceFont',\n    checkVal: 'true',\n    callback: (val) => {\n      settings.useMonospaceFontGlobal = true;\n    },\n  },\n  {\n    name: 'userName',\n    checkVal: null,\n    callback: (val) => {\n      settings.globalUserName = val;\n      clientVars.userName = val;\n    },\n  },\n  {\n    name: 'userColor',\n    checkVal: null,\n    callback: (val) => {\n      settings.globalUserColor = val;\n      clientVars.userColor = val;\n    },\n  },\n  {\n    name: 'rtl',\n    checkVal: 'true',\n    callback: (val) => {\n      settings.rtlIsTrue = true;\n    },\n  },\n  {\n    name: 'alwaysShowChat',\n    checkVal: 'true',\n    callback: (val) => {\n      if (!settings.hideChat) chat.stickToScreen();\n    },\n  },\n  {\n    name: 'chatAndUsers',\n    checkVal: 'true',\n    callback: (val) => {\n      chat.chatAndUsers();\n    },\n  },\n  {\n    name: 'lang',\n    checkVal: null,\n    callback: (val) => {\n      console.log('Val is', val)\n      html10n.localize([val, 'en']);\n      Cookies.set('language', val);\n    },\n  },\n];\n\nconst getParams = () => {\n  // Tries server enforced options first..\n  for (const setting of getParameters) {\n    let value = clientVars.padOptions[setting.name];\n    if (value == null) continue;\n    value = value.toString();\n    if (value === setting.checkVal || setting.checkVal == null) {\n      setting.callback(value);\n    }\n  }\n\n  // Then URL applied stuff\n  const params = getUrlVars();\n  for (const setting of getParameters) {\n    const value = params.get(setting.name);\n    if (value && (value === setting.checkVal || setting.checkVal == null)) {\n      setting.callback(value);\n    }\n  }\n};\n\nconst getUrlVars = () => new URL(window.location.href).searchParams;\n\nconst sendClientReady = (isReconnect) => {\n  let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1);\n  // unescape necessary due to Safari and Opera interpretation of spaces\n  padId = decodeURIComponent(padId);\n\n  if (!isReconnect) {\n    const titleArray = document.title.split('|');\n    const title = titleArray[titleArray.length - 1];\n    document.title = `${padId.replace(/_+/g, ' ')} | ${title}`;\n  }\n\n  let token = Cookies.get('token');\n  if (token == null || !padutils.isValidAuthorToken(token)) {\n    token = padutils.generateAuthorToken();\n    Cookies.set('token', token, {expires: 60});\n  }\n\n  // If known, propagate the display name and color to the server in the CLIENT_READY message. This\n  // allows the server to include the values in its reply CLIENT_VARS message (which avoids\n  // initialization race conditions) and in the USER_NEWINFO messages sent to the other users on the\n  // pad (which enables them to display a user join notification with the correct name).\n  const params = getUrlVars();\n  const userInfo = {\n    colorId: params.get('userColor'),\n    name: params.get('userName'),\n  };\n\n  const msg = {\n    component: 'pad',\n    type: 'CLIENT_READY',\n    padId,\n    sessionID: Cookies.get('sessionID'),\n    token,\n    userInfo,\n  };\n\n  // this is a reconnect, lets tell the server our revisionnumber\n  if (isReconnect) {\n    msg.client_rev = pad.collabClient.getCurrentRevisionNumber();\n    msg.reconnect = true;\n  }\n\n  socket.emit(\"message\", msg);\n};\n\nconst handshake = async () => {\n  let receivedClientVars = false;\n  let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1);\n  // unescape necessary due to Safari and Opera interpretation of spaces\n  padId = decodeURIComponent(padId);\n\n  // padId is used here for sharding / scaling.  We prefix the padId with padId: so it's clear\n  // to the proxy/gateway/whatever that this is a pad connection and should be treated as such\n  socket = pad.socket = socketio.connect(exports.baseURL, '/', {\n    query: {padId},\n    reconnectionAttempts: 5,\n    reconnection: true,\n    reconnectionDelay: 1000,\n    reconnectionDelayMax: 5000,\n  });\n\n  socket.once('connect', () => {\n    sendClientReady(false);\n  });\n\n  socket.io.on('reconnect', () => {\n    // pad.collabClient might be null if the hanshake failed (or it never got that far).\n    if (pad.collabClient != null) {\n      pad.collabClient.setChannelState('CONNECTED');\n    }\n    sendClientReady(receivedClientVars);\n  });\n\n  const socketReconnecting = () => {\n    // pad.collabClient might be null if the hanshake failed (or it never got that far).\n    if (pad.collabClient != null) {\n      pad.collabClient.setStateIdle();\n      pad.collabClient.setIsPendingRevision(true);\n      pad.collabClient.setChannelState('RECONNECTING');\n    }\n  };\n\n  socket.on('disconnect', (reason) => {\n    // The socket.io client will automatically try to reconnect for all reasons other than \"io\n    // server disconnect\".\n    console.log(`Socket disconnected: ${reason}`)\n    //if (reason !== 'io server disconnect' || reason !== 'ping timeout') return;\n    socketReconnecting();\n  });\n\n\n  socket.on('shout', (obj) => {\n    if(obj.type === \"COLLABROOM\") {\n      let date = new Date(obj.data.payload.timestamp);\n      $.gritter.add({\n        // (string | mandatory) the heading of the notification\n        title: 'Admin message',\n        // (string | mandatory) the text inside the notification\n        text: '[' + date.toLocaleTimeString() + ']: ' + obj.data.payload.message.message,\n        // (bool | optional) if you want it to fade out on its own or just sit there\n        sticky: obj.data.payload.message.sticky\n      });\n    }\n  })\n\n  socket.io.on('reconnect_attempt', socketReconnecting);\n\n  socket.io.on('reconnect_failed', (error) => {\n    // pad.collabClient might be null if the hanshake failed (or it never got that far).\n    if (pad.collabClient != null) {\n      pad.collabClient.setChannelState('DISCONNECTED', 'reconnect_timeout');\n    } else {\n      throw new Error('Reconnect timed out');\n    }\n  });\n\n\n  socket.on('error', (error) => {\n    // pad.collabClient might be null if the error occurred before the hanshake completed.\n    if (pad.collabClient != null) {\n      pad.collabClient.setStateIdle();\n      pad.collabClient.setIsPendingRevision(true);\n    }\n    // Don't throw an exception. Error events do not indicate problems that are not already\n    // addressed by reconnection logic, so throwing an exception each time there's a socket.io error\n    // just annoys users and fills logs.\n  });\n\n  socket.on('message', (obj) => {\n    // the access was not granted, give the user a message\n    if (obj.accessStatus) {\n      if (obj.accessStatus === 'deny') {\n        $('#loading').hide();\n        $('#permissionDenied').show();\n\n        if (receivedClientVars) {\n          // got kicked\n          $('#editorcontainer').hide();\n          $('#editorloadingbox').show();\n        }\n      }\n    } else if (!receivedClientVars && obj.type === 'CLIENT_VARS') {\n      receivedClientVars = true;\n      window.clientVars = obj.data;\n      if (window.clientVars.sessionRefreshInterval) {\n        const ping =\n            () => $.ajax('../_extendExpressSessionLifetime', {method: 'PUT'}).catch(() => {});\n        setInterval(ping, window.clientVars.sessionRefreshInterval);\n      }\n      if(window.clientVars.mode === \"development\") {\n        console.warn('Enabling development mode with live update')\n        socket.on('liveupdate', ()=>{\n\n          console.log('Live reload update received')\n          location.reload()\n        })\n      }\n\n    } else if (obj.disconnect) {\n      padconnectionstatus.disconnected(obj.disconnect);\n      socket.disconnect();\n\n      // block user from making any change to the pad\n      padeditor.disable();\n      padeditbar.disable();\n      padimpexp.disable();\n\n      return;\n    } else {\n      pad._messageQ.enqueue(obj);\n    }\n  });\n\n  await Promise.all([\n    new Promise((resolve) => {\n      const h = (obj) => {\n        if (obj.accessStatus || obj.type !== 'CLIENT_VARS') return;\n        socket.off('message', h);\n        resolve();\n      };\n      socket.on('message', h);\n    }),\n    // This hook is only intended to be used by test code. If a plugin would like to use this hook,\n    // the hook must first be promoted to officially supported by deleting the leading underscore\n    // from the name, adding documentation to `doc/api/hooks_client-side.md`, and deleting this\n    // comment.\n    hooks.aCallAll('_socketCreated', {socket}),\n  ]);\n};\n\n/** Defers message handling until setCollabClient() is called with a non-null value. */\nclass MessageQueue {\n  constructor() {\n    this._q = [];\n    this._cc = null;\n  }\n\n  setCollabClient(cc) {\n    this._cc = cc;\n    this.enqueue(); // Flush.\n  }\n\n  enqueue(...msgs) {\n    if (this._cc == null) {\n      this._q.push(...msgs);\n    } else {\n      while (this._q.length > 0) this._cc.handleMessageFromServer(this._q.shift());\n      for (const msg of msgs) this._cc.handleMessageFromServer(msg);\n    }\n  }\n}\n\nconst pad = {\n  // don't access these directly from outside this file, except\n  // for debugging\n  collabClient: null,\n  myUserInfo: null,\n  diagnosticInfo: {},\n  initTime: 0,\n  clientTimeOffset: null,\n  padOptions: {},\n  _messageQ: new MessageQueue(),\n\n  // these don't require init; clientVars should all go through here\n  getPadId: () => clientVars.padId,\n  getClientIp: () => clientVars.clientIp,\n  getColorPalette: () => clientVars.colorPalette,\n  getPrivilege: (name) => clientVars.accountPrivs[name],\n  getUserId: () => pad.myUserInfo.userId,\n  getUserName: () => pad.myUserInfo.name,\n  userList: () => paduserlist.users(),\n  sendClientMessage: (msg) => {\n    pad.collabClient.sendClientMessage(msg);\n  },\n\n  init() {\n    padutils.setupGlobalExceptionHandler();\n\n    // $(handler), $().ready(handler), $.wait($.ready).then(handler), etc. don't work if handler is\n    // an async function for some bizarre reason, so the async function is wrapped in a non-async\n    // function.\n    $(() => (async () => {\n      if (window.customStart != null) window.customStart();\n      $('#colorpicker').farbtastic({callback: '#mycolorpickerpreview', width: 220});\n      $('#readonlyinput').on('click', () => { padeditbar.setEmbedLinks(); });\n      padcookie.init();\n      await handshake();\n      this._afterHandshake();\n    })());\n  },\n  _afterHandshake() {\n    pad.clientTimeOffset = Date.now() - clientVars.serverTimestamp;\n    // initialize the chat\n    chat.init(this);\n    getParams();\n\n    padcookie.init(); // initialize the cookies\n    pad.initTime = +(new Date());\n    pad.padOptions = clientVars.initialOptions;\n\n    pad.myUserInfo = {\n      userId: clientVars.userId,\n      name: clientVars.userName,\n      ip: pad.getClientIp(),\n      colorId: clientVars.userColor,\n    };\n\n    const postAceInit = () => {\n      padeditbar.init();\n      setTimeout(() => {\n        padeditor.ace.focus();\n      }, 0);\n      const optionsStickyChat = $('#options-stickychat');\n      optionsStickyChat.on('click', () => { chat.stickToScreen(); });\n      // if we have a cookie for always showing chat then show it\n      if (padcookie.getPref('chatAlwaysVisible')) {\n        chat.stickToScreen(true); // stick it to the screen\n        optionsStickyChat.prop('checked', true); // set the checkbox to on\n      }\n      // if we have a cookie for always showing chat then show it\n      if (padcookie.getPref('chatAndUsers')) {\n        chat.chatAndUsers(true); // stick it to the screen\n        $('#options-chatandusers').prop('checked', true); // set the checkbox to on\n      }\n      if (padcookie.getPref('showAuthorshipColors') === false) {\n        pad.changeViewOption('showAuthorColors', false);\n      }\n      if (padcookie.getPref('showLineNumbers') === false) {\n        pad.changeViewOption('showLineNumbers', false);\n      }\n      if (padcookie.getPref('rtlIsTrue') === true) {\n        pad.changeViewOption('rtlIsTrue', true);\n      }\n      pad.changeViewOption('padFontFamily', padcookie.getPref('padFontFamily'));\n      $('#viewfontmenu').val(padcookie.getPref('padFontFamily')).niceSelect('update');\n\n      // Prevent sticky chat or chat and users to be checked for mobiles\n      const checkChatAndUsersVisibility = (x) => {\n        if (x.matches) { // If media query matches\n          $('#options-chatandusers:checked').trigger('click');\n          $('#options-stickychat:checked').trigger('click');\n        }\n      };\n      const mobileMatch = window.matchMedia('(max-width: 800px)');\n      mobileMatch.addListener(checkChatAndUsersVisibility); // check if window resized\n      setTimeout(() => { checkChatAndUsersVisibility(mobileMatch); }, 0); // check now after load\n\n      $('#editorcontainer').addClass('initialized');\n\n      if (window.clientVars.enableDarkMode) {\n        $('#theme-switcher').attr('style', 'display: flex;');\n      }\n\n      if (window.location.hash.toLowerCase() !== '#skinvariantsbuilder' && window.clientVars.enableDarkMode && (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) && !skinVariants.isWhiteModeEnabledInLocalStorage()) {\n        skinVariants.updateSkinVariantsClasses(['super-dark-editor', 'dark-background', 'super-dark-toolbar']);\n      }\n\n      hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad});\n    };\n\n    // order of inits is important here:\n    padimpexp.init(this);\n    padsavedrevs.init(this);\n    padeditor.init(pad.padOptions.view || {}, this).then(postAceInit);\n    paduserlist.init(pad.myUserInfo, this);\n    padconnectionstatus.init();\n    padmodals.init(this);\n\n    pad.collabClient = getCollabClient(\n        padeditor.ace, clientVars.collab_client_vars, pad.myUserInfo,\n        {colorPalette: pad.getColorPalette()}, pad);\n    this._messageQ.setCollabClient(this.collabClient);\n    pad.collabClient.setOnUserJoin(pad.handleUserJoin);\n    pad.collabClient.setOnUpdateUserInfo(pad.handleUserUpdate);\n    pad.collabClient.setOnUserLeave(pad.handleUserLeave);\n    pad.collabClient.setOnClientMessage(pad.handleClientMessage);\n    pad.collabClient.setOnChannelStateChange(pad.handleChannelStateChange);\n    pad.collabClient.setOnInternalAction(pad.handleCollabAction);\n\n    // load initial chat-messages\n    if (clientVars.chatHead !== -1) {\n      const chatHead = clientVars.chatHead;\n      const start = Math.max(chatHead - 100, 0);\n      pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end: chatHead});\n    } else {\n      // there are no messages\n      $('#chatloadmessagesbutton').css('display', 'none');\n    }\n\n    if (window.clientVars.readonly) {\n      chat.hide();\n      $('#myusernameedit').attr('disabled', true);\n      $('#chatinput').attr('disabled', true);\n      $('#chaticon').hide();\n      $('#options-chatandusers').parent().hide();\n      $('#options-stickychat').parent().hide();\n    } else if (!settings.hideChat) { $('#chaticon').show(); }\n\n    $('body').addClass(window.clientVars.readonly ? 'readonly' : 'readwrite');\n\n    padeditor.ace.callWithAce((ace) => {\n      ace.ace_setEditable(!window.clientVars.readonly);\n    });\n\n    // If the LineNumbersDisabled value is set to true then we need to hide the Line Numbers\n    if (settings.LineNumbersDisabled === true) {\n      this.changeViewOption('showLineNumbers', false);\n    }\n\n    // If the noColors value is set to true then we need to\n    // hide the background colors on the ace spans\n    if (settings.noColors === true) {\n      this.changeViewOption('noColors', true);\n    }\n\n    if (settings.rtlIsTrue === true) {\n      this.changeViewOption('rtlIsTrue', true);\n    }\n\n    // If the Monospacefont value is set to true then change it to monospace.\n    if (settings.useMonospaceFontGlobal === true) {\n      this.changeViewOption('padFontFamily', 'RobotoMono');\n    }\n    // if the globalUserName value is set we need to tell the server and\n    // the client about the new authorname\n    if (settings.globalUserName !== false) {\n      this.notifyChangeName(settings.globalUserName); // Notifies the server\n      this.myUserInfo.name = settings.globalUserName;\n      $('#myusernameedit').val(settings.globalUserName); // Updates the current users UI\n    }\n    if (settings.globalUserColor !== false && colorutils.isCssHex(settings.globalUserColor)) {\n      // Add a 'globalUserColor' property to myUserInfo,\n      // so collabClient knows we have a query parameter.\n      this.myUserInfo.globalUserColor = settings.globalUserColor;\n      this.notifyChangeColor(settings.globalUserColor); // Updates this.myUserInfo.colorId\n      paduserlist.setMyUserInfo(this.myUserInfo);\n    }\n  },\n\n  dispose: () => {\n    padeditor.dispose();\n  },\n  notifyChangeName: (newName) => {\n    pad.myUserInfo.name = newName;\n    pad.collabClient.updateUserInfo(pad.myUserInfo);\n  },\n  notifyChangeColor: (newColorId) => {\n    pad.myUserInfo.colorId = newColorId;\n    pad.collabClient.updateUserInfo(pad.myUserInfo);\n  },\n  changePadOption: (key, value) => {\n    const options = {};\n    options[key] = value;\n    pad.handleOptionsChange(options);\n    pad.collabClient.sendClientMessage(\n        {\n          type: 'padoptions',\n          options,\n          changedBy: pad.myUserInfo.name || 'unnamed',\n        });\n  },\n  changeViewOption: (key, value) => {\n    const options = {\n      view: {},\n    };\n    options.view[key] = value;\n    pad.handleOptionsChange(options);\n  },\n  handleOptionsChange: (opts) => {\n    // opts object is a full set of options or just\n    // some options to change\n    if (opts.view) {\n      if (!pad.padOptions.view) {\n        pad.padOptions.view = {};\n      }\n      for (const [k, v] of Object.entries(opts.view)) {\n        pad.padOptions.view[k] = v;\n        padcookie.setPref(k, v);\n      }\n      padeditor.setViewOptions(pad.padOptions.view);\n    }\n  },\n  // caller shouldn't mutate the object\n  getPadOptions: () => pad.padOptions,\n  suggestUserName: (userId, name) => {\n    pad.collabClient.sendClientMessage(\n        {\n          type: 'suggestUserName',\n          unnamedId: userId,\n          newName: name,\n        });\n  },\n  handleUserJoin: (userInfo) => {\n    paduserlist.userJoinOrUpdate(userInfo);\n  },\n  handleUserUpdate: (userInfo) => {\n    paduserlist.userJoinOrUpdate(userInfo);\n  },\n  handleUserLeave: (userInfo) => {\n    paduserlist.userLeave(userInfo);\n  },\n  handleClientMessage: (msg) => {\n    if (msg.type === 'suggestUserName') {\n      if (msg.unnamedId === pad.myUserInfo.userId && msg.newName && !pad.myUserInfo.name) {\n        pad.notifyChangeName(msg.newName);\n        paduserlist.setMyUserInfo(pad.myUserInfo);\n      }\n    } else if (msg.type === 'newRevisionList') {\n      padsavedrevs.newRevisionList(msg.revisionList);\n    } else if (msg.type === 'revisionLabel') {\n      padsavedrevs.newRevisionList(msg.revisionList);\n    } else if (msg.type === 'padoptions') {\n      const opts = msg.options;\n      pad.handleOptionsChange(opts);\n    }\n  },\n  handleChannelStateChange: (newState, message) => {\n    const oldFullyConnected = !!padconnectionstatus.isFullyConnected();\n    const wasConnecting = (padconnectionstatus.getStatus().what === 'connecting');\n    if (newState === 'CONNECTED') {\n      padeditor.enable();\n      padeditbar.enable();\n      padimpexp.enable();\n      padconnectionstatus.connected();\n    } else if (newState === 'RECONNECTING') {\n      padeditor.disable();\n      padeditbar.disable();\n      padimpexp.disable();\n      padconnectionstatus.reconnecting();\n    } else if (newState === 'DISCONNECTED') {\n      pad.diagnosticInfo.disconnectedMessage = message;\n      pad.diagnosticInfo.padId = pad.getPadId();\n      pad.diagnosticInfo.socket = {};\n\n      // we filter non objects from the socket object and put them in the diagnosticInfo\n      // this ensures we have no cyclic data - this allows us to stringify the data\n      for (const [i, value] of Object.entries(socket.socket || {})) {\n        const type = typeof value;\n\n        if (type === 'string' || type === 'number') {\n          pad.diagnosticInfo.socket[i] = value;\n        }\n      }\n\n      pad.asyncSendDiagnosticInfo();\n      if (typeof window.ajlog === 'string') {\n        window.ajlog += (`Disconnected: ${message}\\n`);\n      }\n      padeditor.disable();\n      padeditbar.disable();\n      padimpexp.disable();\n\n      padconnectionstatus.disconnected(message);\n    }\n    const newFullyConnected = !!padconnectionstatus.isFullyConnected();\n    if (newFullyConnected !== oldFullyConnected) {\n      pad.handleIsFullyConnected(newFullyConnected, wasConnecting);\n    }\n  },\n  handleIsFullyConnected: (isConnected, isInitialConnect) => {\n    pad.determineChatVisibility(isConnected && !isInitialConnect);\n    pad.determineChatAndUsersVisibility(isConnected && !isInitialConnect);\n    pad.determineAuthorshipColorsVisibility();\n    setTimeout(() => {\n      padeditbar.toggleDropDown('none');\n    }, 1000);\n  },\n  determineChatVisibility: (asNowConnectedFeedback) => {\n    const chatVisCookie = padcookie.getPref('chatAlwaysVisible');\n    if (chatVisCookie) { // if the cookie is set for chat always visible\n      chat.stickToScreen(true); // stick it to the screen\n      $('#options-stickychat').prop('checked', true); // set the checkbox to on\n    } else {\n      $('#options-stickychat').prop('checked', false); // set the checkbox for off\n    }\n  },\n  determineChatAndUsersVisibility: (asNowConnectedFeedback) => {\n    const chatAUVisCookie = padcookie.getPref('chatAndUsersVisible');\n    if (chatAUVisCookie) { // if the cookie is set for chat always visible\n      chat.chatAndUsers(true); // stick it to the screen\n      $('#options-chatandusers').prop('checked', true); // set the checkbox to on\n    } else {\n      $('#options-chatandusers').prop('checked', false); // set the checkbox for off\n    }\n  },\n  determineAuthorshipColorsVisibility: () => {\n    const authColCookie = padcookie.getPref('showAuthorshipColors');\n    if (authColCookie) {\n      pad.changeViewOption('showAuthorColors', true);\n      $('#options-colorscheck').prop('checked', true);\n    } else {\n      $('#options-colorscheck').prop('checked', false);\n    }\n  },\n  handleCollabAction: (action) => {\n    if (action === 'commitPerformed') {\n      padeditbar.setSyncStatus('syncing');\n    } else if (action === 'newlyIdle') {\n      padeditbar.setSyncStatus('done');\n    }\n  },\n  asyncSendDiagnosticInfo: () => {\n    const currentUrl = window.location.href;\n    fetch('../ep/pad/connection-diagnostic-info', {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n      },\n      body: JSON.stringify({\n        diagnosticInfo: pad.diagnosticInfo,\n      }),\n    }).catch((error) => {\n      console.error('Error sending diagnostic info:', error);\n    })\n  },\n  forceReconnect: () => {\n    $('form#reconnectform input.padId').val(pad.getPadId());\n    pad.diagnosticInfo.collabDiagnosticInfo = pad.collabClient.getDiagnosticInfo();\n    $('form#reconnectform input.diagnosticInfo').val(JSON.stringify(pad.diagnosticInfo));\n    $('form#reconnectform input.missedChanges')\n        .val(JSON.stringify(pad.collabClient.getMissedChanges()));\n    $('form#reconnectform').trigger('submit');\n  },\n  callWhenNotCommitting: (f) => {\n    pad.collabClient.callWhenNotCommitting(f);\n  },\n  getCollabRevisionNumber: () => pad.collabClient.getCurrentRevisionNumber(),\n  isFullyConnected: () => padconnectionstatus.isFullyConnected(),\n  addHistoricalAuthors: (data) => {\n    if (!pad.collabClient) {\n      window.setTimeout(() => {\n        pad.addHistoricalAuthors(data);\n      }, 1000);\n    } else {\n      pad.collabClient.addHistoricalAuthors(data);\n    }\n  },\n};\n\nconst init = () => pad.init();\n\nconst settings = {\n  LineNumbersDisabled: false,\n  noColors: false,\n  useMonospaceFontGlobal: false,\n  globalUserName: false,\n  globalUserColor: false,\n  rtlIsTrue: false,\n};\n\npad.settings = settings;\n\nexports.baseURL = '';\nexports.settings = settings;\nexports.randomString = randomString;\nexports.getParams = getParams;\nexports.pad = pad;\nexports.init = init;\n"
  },
  {
    "path": "src/static/js/pad_automatic_reconnect.ts",
    "content": "// @ts-nocheck\n'use strict';\nimport html10n from './vendors/html10n';\n\nexports.showCountDownTimerToReconnectOnModal = ($modal, pad) => {\n  if (clientVars.automaticReconnectionTimeout && $modal.is('.with_reconnect_timer')) {\n    createCountDownElementsIfNecessary($modal);\n\n    const timer = createTimerForModal($modal, pad);\n\n    $modal.find('#cancelreconnect').one('click', () => {\n      timer.cancel();\n      disableAutomaticReconnection($modal);\n    });\n\n    enableAutomaticReconnection($modal);\n  }\n};\n\nconst createCountDownElementsIfNecessary = ($modal) => {\n  const elementsDoNotExist = $modal.find('#cancelreconnect').length === 0;\n  if (elementsDoNotExist) {\n    const $defaultMessage = $modal.find('#defaulttext');\n    const $reconnectButton = $modal.find('#forcereconnect');\n\n    // create extra DOM elements, if they don't exist\n    const $reconnectTimerMessage =\n        $('<p>')\n            .addClass('reconnecttimer')\n            .append(\n                $('<span>')\n                    .attr('data-l10n-id', 'pad.modals.reconnecttimer')\n                    .text('Trying to reconnect in'))\n            .append(' ')\n            .append(\n                $('<span>')\n                    .addClass('timetoexpire'));\n    const $cancelReconnect =\n        $('<button>')\n            .attr('id', 'cancelreconnect')\n            .attr('data-l10n-id', 'pad.modals.cancel')\n            .text('Cancel');\n\n    localize($reconnectTimerMessage);\n    localize($cancelReconnect);\n\n    $reconnectTimerMessage.insertAfter($defaultMessage);\n    $cancelReconnect.insertAfter($reconnectButton);\n  }\n};\n\nconst localize = ($element) => {\n  html10n.translateElement(html10n.translations, $element.get(0));\n};\n\nconst createTimerForModal = ($modal, pad) => {\n  const timeUntilReconnection =\n      clientVars.automaticReconnectionTimeout * reconnectionTries.nextTry();\n  const timer = new CountDownTimer(timeUntilReconnection);\n\n  timer.onTick((minutes, seconds) => {\n    updateCountDownTimerMessage($modal, minutes, seconds);\n  }).onExpire(() => {\n    const wasANetworkError = $modal.is('.disconnected');\n    if (wasANetworkError) {\n      // cannot simply reconnect, client is having issues to establish connection to server\n      waitUntilClientCanConnectToServerAndThen(() => { forceReconnection($modal); }, pad);\n    } else {\n      forceReconnection($modal);\n    }\n  }).start();\n\n  return timer;\n};\n\nconst disableAutomaticReconnection = ($modal) => {\n  toggleAutomaticReconnectionOption($modal, true);\n};\nconst enableAutomaticReconnection = ($modal) => {\n  toggleAutomaticReconnectionOption($modal, false);\n};\nconst toggleAutomaticReconnectionOption = ($modal, disableAutomaticReconnect) => {\n  $modal.find('#cancelreconnect, .reconnecttimer').toggleClass('hidden', disableAutomaticReconnect);\n  $modal.find('#defaulttext').toggleClass('hidden', !disableAutomaticReconnect);\n};\n\nconst waitUntilClientCanConnectToServerAndThen = (callback, pad) => {\n  whenConnectionIsRestablishedWithServer(callback, pad);\n  pad.socket.connect();\n};\n\nconst whenConnectionIsRestablishedWithServer = (callback, pad) => {\n  // only add listener for the first try, don't need to add another listener\n  // on every unsuccessful try\n  if (reconnectionTries.counter === 1) {\n    pad.socket.once('connect', callback);\n  }\n};\n\nconst forceReconnection = ($modal) => {\n  $modal.find('#forcereconnect').trigger('click');\n};\n\nconst updateCountDownTimerMessage = ($modal, minutes, seconds) => {\n  minutes = minutes < 10 ? `0${minutes}` : minutes;\n  seconds = seconds < 10 ? `0${seconds}` : seconds;\n\n  $modal.find('.timetoexpire').text(`${minutes}:${seconds}`);\n};\n\n// store number of tries to reconnect to server, in order to increase time to wait\n// until next try\nconst reconnectionTries = {\n  counter: 0,\n\n  nextTry() {\n    // double the time to try to reconnect on every time reconnection fails\n    const nextCounterFactor = 2 ** this.counter;\n    this.counter++;\n\n    return nextCounterFactor;\n  },\n};\n\n// Timer based on http://stackoverflow.com/a/20618517.\n// duration: how many **seconds** until the timer ends\n// granularity (optional): how many **milliseconds**\n// between each 'tick' of timer. Default: 1000ms (1s)\nconst CountDownTimer = function (duration, granularity) {\n  this.duration = duration;\n  this.granularity = granularity || 1000;\n  this.running = false;\n\n  this.onTickCallbacks = [];\n  this.onExpireCallbacks = [];\n};\n\nCountDownTimer.prototype.start = function () {\n  if (this.running) {\n    return;\n  }\n  this.running = true;\n  const start = Date.now();\n  const that = this;\n  let diff;\n  const timer = () => {\n    diff = that.duration - Math.floor((Date.now() - start) / 1000);\n\n    if (diff > 0) {\n      that.timeoutId = setTimeout(timer, that.granularity);\n      that.tick(diff);\n    } else {\n      that.running = false;\n      that.tick(0);\n      that.expire();\n    }\n  };\n  timer();\n};\n\nCountDownTimer.prototype.tick = function (diff) {\n  const obj = CountDownTimer.parse(diff);\n  this.onTickCallbacks.forEach(function (callback) {\n    callback.call(this, obj.minutes, obj.seconds);\n  }, this);\n};\nCountDownTimer.prototype.expire = function () {\n  this.onExpireCallbacks.forEach(function (callback) {\n    callback.call(this);\n  }, this);\n};\n\nCountDownTimer.prototype.onTick = function (callback) {\n  if (typeof callback === 'function') {\n    this.onTickCallbacks.push(callback);\n  }\n  return this;\n};\n\nCountDownTimer.prototype.onExpire = function (callback) {\n  if (typeof callback === 'function') {\n    this.onExpireCallbacks.push(callback);\n  }\n  return this;\n};\n\nCountDownTimer.prototype.cancel = function () {\n  this.running = false;\n  clearTimeout(this.timeoutId);\n  return this;\n};\n\nCountDownTimer.parse = (seconds) => ({\n  minutes: (seconds / 60) | 0,\n  seconds: (seconds % 60) | 0,\n});\n"
  },
  {
    "path": "src/static/js/pad_connectionstatus.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst padmodals = require('./pad_modals').padmodals;\n\nconst padconnectionstatus = (() => {\n  let status = {\n    what: 'connecting',\n  };\n\n  const self = {\n    init: () => {\n      $('button#forcereconnect').on('click', () => {\n        window.location.reload();\n      });\n    },\n    connected: () => {\n      status = {\n        what: 'connected',\n      };\n      padmodals.showModal('connected');\n      padmodals.hideOverlay();\n    },\n    reconnecting: () => {\n      status = {\n        what: 'reconnecting',\n      };\n\n      padmodals.showModal('reconnecting');\n      padmodals.showOverlay();\n    },\n    disconnected: (msg) => {\n      if (status.what === 'disconnected') return;\n\n      status = {\n        what: 'disconnected',\n        why: msg,\n      };\n\n      // These message IDs correspond to localized strings that are presented to the user. If a new\n      // message ID is added here then a new div must be added to src/templates/pad.html and the\n      // corresponding l10n IDs must be added to the language files in src/locales.\n      const knownReasons = [\n        'badChangeset',\n        'corruptPad',\n        'deleted',\n        'disconnected',\n        'initsocketfail',\n        'looping',\n        'rateLimited',\n        'rejected',\n        'slowcommit',\n        'unauth',\n        'userdup',\n      ];\n      let k = String(msg);\n      if (knownReasons.indexOf(k) === -1) {\n        // Fall back to a generic message.\n        k = 'disconnected';\n      }\n\n      padmodals.showModal(k);\n      padmodals.showOverlay();\n    },\n    isFullyConnected: () => status.what === 'connected',\n    getStatus: () => status,\n  };\n  return self;\n})();\n\nexports.padconnectionstatus = padconnectionstatus;\n"
  },
  {
    "path": "src/static/js/pad_cookie.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {Cookies} from \"./pad_utils\";\n\nexports.padcookie = new class {\n  constructor() {\n    this.cookieName_ = window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp';\n  }\n\n  init() {\n    const prefs = this.readPrefs_() || {};\n    delete prefs.userId;\n    delete prefs.name;\n    delete prefs.colorId;\n    this.writePrefs_(prefs);\n    // Re-read the saved cookie to test if cookies are enabled.\n    if (this.readPrefs_() == null) {\n      $.gritter.add({\n        title: 'Error',\n        text: html10n.get('pad.noCookie'),\n        sticky: true,\n        class_name: 'error',\n      });\n    }\n  }\n\n  readPrefs_() {\n    try {\n      const json = Cookies.get(this.cookieName_);\n      if (json == null) return null;\n      return JSON.parse(json);\n    } catch (e) {\n      return null;\n    }\n  }\n\n  writePrefs_(prefs) {\n    Cookies.set(this.cookieName_, JSON.stringify(prefs), {expires: 365 * 100});\n  }\n\n  getPref(prefName) {\n    return this.readPrefs_()[prefName];\n  }\n\n  setPref(prefName, value) {\n    const prefs = this.readPrefs_();\n    prefs[prefName] = value;\n    this.writePrefs_(prefs);\n  }\n\n  clear() {\n    this.writePrefs_({});\n  }\n}();\n"
  },
  {
    "path": "src/static/js/pad_editbar.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst browser = require('./vendors/browser');\nconst hooks = require('./pluginfw/hooks');\nimport padutils from \"./pad_utils\";\nconst padeditor = require('./pad_editor').padeditor;\nconst padsavedrevs = require('./pad_savedrevs');\nconst _ = require('underscore');\nrequire('./vendors/nice-select');\n\nclass ToolbarItem {\n  constructor(element) {\n    this.$el = element;\n  }\n\n  getCommand() {\n    return this.$el.attr('data-key');\n  }\n\n  getValue() {\n    if (this.isSelect()) {\n      return this.$el.find('select').val();\n    }\n  }\n\n  setValue(val) {\n    if (this.isSelect()) {\n      return this.$el.find('select').val(val);\n    }\n  }\n\n  getType() {\n    return this.$el.attr('data-type');\n  }\n\n  isSelect() {\n    return this.getType() === 'select';\n  }\n\n  isButton() {\n    return this.getType() === 'button';\n  }\n\n  bind(callback) {\n    if (this.isButton()) {\n      this.$el.on('click', (event) => {\n        $(':focus').trigger('blur');\n        callback(this.getCommand(), this);\n        event.preventDefault();\n      });\n    } else if (this.isSelect()) {\n      this.$el.find('select').on('change', () => {\n        callback(this.getCommand(), this);\n      });\n    }\n  }\n}\n\nconst syncAnimation = (() => {\n  const SYNCING = -100;\n  const DONE = 100;\n  let state = DONE;\n  const fps = 25;\n  const step = 1 / fps;\n  const T_START = -0.5;\n  const T_FADE = 1.0;\n  const T_GONE = 1.5;\n  const animator = padutils.makeAnimationScheduler(() => {\n    if (state === SYNCING || state === DONE) {\n      return false;\n    } else if (state >= T_GONE) {\n      state = DONE;\n      $('#syncstatussyncing').css('display', 'none');\n      $('#syncstatusdone').css('display', 'none');\n      return false;\n    } else if (state < 0) {\n      state += step;\n      if (state >= 0) {\n        $('#syncstatussyncing').css('display', 'none');\n        $('#syncstatusdone').css('display', 'block').css('opacity', 1);\n      }\n      return true;\n    } else {\n      state += step;\n      if (state >= T_FADE) {\n        $('#syncstatusdone').css('opacity', (T_GONE - state) / (T_GONE - T_FADE));\n      }\n      return true;\n    }\n  }, step * 1000);\n  return {\n    syncing: () => {\n      state = SYNCING;\n      $('#syncstatussyncing').css('display', 'block');\n      $('#syncstatusdone').css('display', 'none');\n    },\n    done: () => {\n      state = T_START;\n      animator.scheduleAnimation();\n    },\n  };\n})();\n\nexports.padeditbar = new class {\n  constructor() {\n    this._editbarPosition = 0;\n    this.commands = {};\n    this.dropdowns = [];\n  }\n\n  init() {\n    $('#editbar .editbarbutton').attr('unselectable', 'on'); // for IE\n    this.enable();\n    $('#editbar [data-key]').each((i, elt) => {\n      $(elt).off('click');\n      new ToolbarItem($(elt)).bind((command, item) => {\n        this.triggerCommand(command, item);\n      });\n    });\n\n    $('body:not(#editorcontainerbox)').on('keydown', (evt) => {\n      this._bodyKeyEvent(evt);\n    });\n\n    $('.show-more-icon-btn').on('click', () => {\n      $('.toolbar').toggleClass('full-icons');\n    });\n    this.checkAllIconsAreDisplayedInToolbar();\n    $(window).on('resize', _.debounce(() => this.checkAllIconsAreDisplayedInToolbar(), 100));\n\n    this._registerDefaultCommands();\n\n    hooks.callAll('postToolbarInit', {\n      toolbar: this,\n      ace: padeditor.ace,\n    });\n\n    /*\n     * On safari, the dropdown in the toolbar gets hidden because of toolbar\n     * overflow:hidden property. This is a bug from Safari: any children with\n     * position:fixed (like the dropdown) should be displayed no matter\n     * overflow:hidden on parent\n     */\n    if (!browser.safari) {\n      $('select').niceSelect();\n    }\n\n    // When editor is scrolled, we add a class to style the editbar differently\n    $('iframe[name=\"ace_outer\"]').contents().on('scroll', (ev) => {\n      $('#editbar').toggleClass('editor-scrolled', $(ev.currentTarget).scrollTop() > 2);\n    });\n  }\n  isEnabled() { return true; }\n  disable() {\n    $('#editbar').addClass('disabledtoolbar').removeClass('enabledtoolbar');\n  }\n  enable() {\n    $('#editbar').addClass('enabledtoolbar').removeClass('disabledtoolbar');\n  }\n  registerCommand(cmd, callback) {\n    this.commands[cmd] = callback;\n    return this;\n  }\n  registerDropdownCommand(cmd, dropdown) {\n    dropdown = dropdown || cmd;\n    this.dropdowns.push(dropdown);\n    this.registerCommand(cmd, () => {\n      this.toggleDropDown(dropdown);\n    });\n  }\n  registerAceCommand(cmd, callback) {\n    this.registerCommand(cmd, (cmd, ace, item) => {\n      ace.callWithAce((ace) => {\n        callback(cmd, ace, item);\n      }, cmd, true);\n    });\n  }\n  triggerCommand(cmd, item) {\n    if (this.isEnabled() && this.commands[cmd]) {\n      this.commands[cmd](cmd, padeditor.ace, item);\n    }\n    if (padeditor.ace) padeditor.ace.focus();\n  }\n\n  // cb is deprecated (this function is synchronous so a callback is unnecessary).\n  toggleDropDown(moduleName, cb = null) {\n    let cbErr = null;\n    try {\n      // do nothing if users are sticked\n      if (moduleName === 'users' && $('#users').hasClass('stickyUsers')) {\n        return;\n      }\n\n      $('.nice-select').removeClass('open');\n      $('.toolbar-popup').removeClass('popup-show');\n\n      // hide all modules and remove highlighting of all buttons\n      if (moduleName === 'none') {\n        for (const thisModuleName of this.dropdowns) {\n          // skip the userlist\n          if (thisModuleName === 'users') continue;\n\n          const module = $(`#${thisModuleName}`);\n\n          // skip any \"force reconnect\" message\n          const isAForceReconnectMessage = module.find('button#forcereconnect:visible').length > 0;\n          if (isAForceReconnectMessage) continue;\n          if (module.hasClass('popup-show')) {\n            $(`li[data-key=${thisModuleName}] > a`).removeClass('selected');\n            module.removeClass('popup-show');\n          }\n        }\n      } else {\n        // hide all modules that are not selected and remove highlighting\n        // respectively add highlighting to the corresponding button\n        for (const thisModuleName of this.dropdowns) {\n          const module = $(`#${thisModuleName}`);\n\n          if (module.hasClass('popup-show')) {\n            $(`li[data-key=${thisModuleName}] > a`).removeClass('selected');\n            module.removeClass('popup-show');\n          } else if (thisModuleName === moduleName) {\n            $(`li[data-key=${thisModuleName}] > a`).addClass('selected');\n            module.addClass('popup-show');\n          }\n        }\n      }\n    } catch (err) {\n      cbErr = err || new Error(err);\n    } finally {\n      if (cb) Promise.resolve().then(() => cb(cbErr));\n    }\n  }\n  setSyncStatus(status) {\n    if (status === 'syncing') {\n      syncAnimation.syncing();\n    } else if (status === 'done') {\n      syncAnimation.done();\n    }\n  }\n  setEmbedLinks() {\n    const padUrl = window.location.href.split('?')[0];\n    const params = '?showControls=true&showChat=true&showLineNumbers=true&useMonospaceFont=false';\n    const props = 'width=\"100%\" height=\"600\" frameborder=\"0\"';\n\n    if ($('#readonlyinput').is(':checked')) {\n      const urlParts = padUrl.split('/');\n      urlParts.pop();\n      const readonlyLink = `${urlParts.join('/')}/${clientVars.readOnlyId}`;\n      $('#embedinput')\n          .val(`<iframe name=\"embed_readonly\" src=\"${readonlyLink}${params}\" ${props}></iframe>`);\n      $('#linkinput').val(readonlyLink);\n    } else {\n      $('#embedinput')\n          .val(`<iframe name=\"embed_readwrite\" src=\"${padUrl}${params}\" ${props}></iframe>`);\n      $('#linkinput').val(padUrl);\n    }\n  }\n  checkAllIconsAreDisplayedInToolbar() {\n    // reset style\n    $('.toolbar').removeClass('cropped');\n    $('body').removeClass('mobile-layout');\n    const menuLeft = $('.toolbar .menu_left')[0];\n\n    // this is approximate, we cannot measure it because on mobile\n    // Layout it takes the full width on the bottom of the page\n    const menuRightWidth = 280;\n    if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width() - menuRightWidth ||\n        $('.toolbar').width() < 1000) {\n      $('body').addClass('mobile-layout');\n    }\n    if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width()) {\n      $('.toolbar').addClass('cropped');\n    }\n  }\n\n  _bodyKeyEvent(evt) {\n    // If the event is Alt F9 or Escape & we're already in the editbar menu\n    // Send the users focus back to the pad\n    if ((evt.keyCode === 120 && evt.altKey) || evt.keyCode === 27) {\n      if ($(':focus').parents('.toolbar').length === 1) {\n        // If we're in the editbar already..\n        // Close any dropdowns we have open..\n        this.toggleDropDown('none');\n        // Shift focus away from any drop downs\n        $(':focus').trigger('blur'); // required to do not try to remove!\n        // Check we're on a pad and not on the timeslider\n        // Or some other window I haven't thought about!\n        if (typeof pad === 'undefined') {\n          // Timeslider probably..\n          $('#editorcontainerbox').trigger('focus'); // Focus back onto the pad\n        } else {\n          padeditor.ace.focus(); // Sends focus back to pad\n          // The above focus doesn't always work in FF, you have to hit enter afterwards\n          evt.preventDefault();\n        }\n      } else {\n        // Focus on the editbar :)\n        const firstEditbarElement = $('#editbar button').first();\n\n        $(evt.currentTarget).trigger('blur');\n        firstEditbarElement.trigger('focus');\n        evt.preventDefault();\n      }\n    }\n    // Are we in the toolbar??\n    if ($(':focus').parents('.toolbar').length === 1) {\n      // On arrow keys go to next/previous button item in editbar\n      if (evt.keyCode !== 39 && evt.keyCode !== 37) return;\n\n      // Get all the focusable items in the editbar\n      const focusItems = $('#editbar').find('button, select');\n\n      // On left arrow move to next button in editbar\n      if (evt.keyCode === 37) {\n        // If a dropdown is visible or we're in an input don't move to the next button\n        if ($('.popup').is(':visible') || evt.target.localName === 'input') return;\n\n        this._editbarPosition--;\n        // Allow focus to shift back to end of row and start of row\n        if (this._editbarPosition === -1) this._editbarPosition = focusItems.length - 1;\n        $(focusItems[this._editbarPosition]).trigger('focus');\n      }\n\n      // On right arrow move to next button in editbar\n      if (evt.keyCode === 39) {\n        // If a dropdown is visible or we're in an input don't move to the next button\n        if ($('.popup').is(':visible') || evt.target.localName === 'input') return;\n\n        this._editbarPosition++;\n        // Allow focus to shift back to end of row and start of row\n        if (this._editbarPosition >= focusItems.length) this._editbarPosition = 0;\n        $(focusItems[this._editbarPosition]).trigger('focus');\n      }\n    }\n  }\n\n  _registerDefaultCommands() {\n    this.registerDropdownCommand('showusers', 'users');\n    this.registerDropdownCommand('settings');\n    this.registerDropdownCommand('connectivity');\n    this.registerDropdownCommand('import_export');\n    this.registerDropdownCommand('embed');\n    this.registerCommand('home', ()=>{\n      window.location.href = window.location.href + \"/../..\"\n    })\n\n    this.registerCommand('settings', () => {\n      this.toggleDropDown('settings');\n      $('#options-stickychat').trigger('focus');\n    });\n\n    this.registerCommand('import_export', () => {\n      this.toggleDropDown('import_export');\n      // If Import file input exists then focus on it..\n      if ($('#importfileinput').length !== 0) {\n        setTimeout(() => {\n          $('#importfileinput').trigger('focus');\n        }, 100);\n      } else {\n        $('.exportlink').first().trigger('focus');\n      }\n    });\n\n    this.registerCommand('showusers', () => {\n      this.toggleDropDown('users');\n      $('#myusernameedit').trigger('focus');\n    });\n\n    this.registerCommand('embed', () => {\n      this.setEmbedLinks();\n      this.toggleDropDown('embed');\n      $('#linkinput').trigger('focus').trigger('select');\n    });\n\n    this.registerCommand('savedRevision', () => {\n      padsavedrevs.saveNow();\n    });\n\n    this.registerCommand('showTimeSlider', () => {\n      document.location = `${document.location.pathname}/timeslider`;\n    });\n\n    const aceAttributeCommand = (cmd, ace) => {\n      ace.ace_toggleAttributeOnSelection(cmd);\n    };\n    this.registerAceCommand('bold', aceAttributeCommand);\n    this.registerAceCommand('italic', aceAttributeCommand);\n    this.registerAceCommand('underline', aceAttributeCommand);\n    this.registerAceCommand('strikethrough', aceAttributeCommand);\n\n    this.registerAceCommand('undo', (cmd, ace) => {\n      ace.ace_doUndoRedo(cmd);\n    });\n\n    this.registerAceCommand('redo', (cmd, ace) => {\n      ace.ace_doUndoRedo(cmd);\n    });\n\n    this.registerAceCommand('insertunorderedlist', (cmd, ace) => {\n      ace.ace_doInsertUnorderedList();\n    });\n\n    this.registerAceCommand('insertorderedlist', (cmd, ace) => {\n      ace.ace_doInsertOrderedList();\n    });\n\n    this.registerAceCommand('indent', (cmd, ace) => {\n      if (!ace.ace_doIndentOutdent(false)) {\n        ace.ace_doInsertUnorderedList();\n      }\n    });\n\n    this.registerAceCommand('outdent', (cmd, ace) => {\n      ace.ace_doIndentOutdent(true);\n    });\n\n    this.registerAceCommand('clearauthorship', (cmd, ace) => {\n      // If we have the whole document selected IE control A has been hit\n      const rep = ace.ace_getRep();\n      let doPrompt = false;\n      const lastChar = rep.lines.atIndex(rep.lines.length() - 1).width - 1;\n      const lastLineIndex = rep.lines.length() - 1;\n      if (rep.selStart[0] === 0 && rep.selStart[1] === 0) {\n        // nesting intentionally here to make things readable\n        if (rep.selEnd[0] === lastLineIndex && rep.selEnd[1] === lastChar) {\n          doPrompt = true;\n        }\n      }\n      /*\n       * NOTICE: This command isn't fired on Control Shift C.\n       * I intentionally didn't create duplicate code because if you are hitting\n       * Control Shift C we make the assumption you are a \"power user\"\n       * and as such we assume you don't need the prompt to bug you each time!\n       * This does make wonder if it's worth having a checkbox to avoid being\n       * prompted again but that's probably overkill for this contribution.\n       */\n\n      // if we don't have any text selected, we have a caret or we have already said to prompt\n      if ((!(rep.selStart && rep.selEnd)) || ace.ace_isCaret() || doPrompt) {\n        if (window.confirm(html10n.get('pad.editbar.clearcolors'))) {\n          ace.ace_performDocumentApplyAttributesToCharRange(0, ace.ace_getRep().alltext.length, [\n            ['author', ''],\n          ]);\n        }\n      } else {\n        ace.ace_setAttributeOnSelection('author', '');\n      }\n    });\n\n    this.registerCommand('timeslider_returnToPad', (cmd) => {\n      if (document.referrer.length > 0 &&\n          document.referrer.substring(document.referrer.lastIndexOf('/') - 1,\n              document.referrer.lastIndexOf('/')) === 'p') {\n        document.location = document.referrer;\n      } else {\n        document.location = document.location.href\n            .substring(0, document.location.href.lastIndexOf('/'));\n      }\n    });\n  }\n}();\n"
  },
  {
    "path": "src/static/js/pad_editor.ts",
    "content": "// @ts-nocheck\n'use strict';\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport padutils,{Cookies} from \"./pad_utils\";\nconst padcookie = require('./pad_cookie').padcookie;\nconst Ace2Editor = require('./ace').Ace2Editor;\nimport html10n from '../js/vendors/html10n'\nconst skinVariants = require('./skin_variants');\n\nconst padeditor = (() => {\n  let pad = undefined;\n  let settings = undefined;\n\n  const self = {\n    ace: null,\n    // this is accessed directly from other files\n    viewZoom: 100,\n    init: async (initialViewOptions, _pad) => {\n      pad = _pad;\n      settings = pad.settings;\n      self.ace = new Ace2Editor();\n      await self.ace.init('editorcontainer', '');\n      $('#editorloadingbox').hide();\n      // Listen for clicks on sidediv items\n      const $outerdoc = $('iframe[name=\"ace_outer\"]').contents().find('#outerdocbody');\n      $outerdoc.find('#sidedivinner').on('click', 'div', function () {\n        const targetLineNumber = $(this).index() + 1;\n        window.location.hash = `L${targetLineNumber}`;\n      });\n      exports.focusOnLine(self.ace);\n      self.ace.setProperty('wraps', true);\n      self.initViewOptions();\n      self.setViewOptions(initialViewOptions);\n      // view bar\n      $('#viewbarcontents').show();\n    },\n    initViewOptions: () => {\n      // Line numbers\n      padutils.bindCheckboxChange($('#options-linenoscheck'), () => {\n        pad.changeViewOption('showLineNumbers', padutils.getCheckbox($('#options-linenoscheck')));\n      });\n\n      // Author colors\n      padutils.bindCheckboxChange($('#options-colorscheck'), () => {\n        padcookie.setPref('showAuthorshipColors', padutils.getCheckbox('#options-colorscheck'));\n        pad.changeViewOption('showAuthorColors', padutils.getCheckbox('#options-colorscheck'));\n      });\n\n      // Right to left\n      padutils.bindCheckboxChange($('#options-rtlcheck'), () => {\n        pad.changeViewOption('rtlIsTrue', padutils.getCheckbox($('#options-rtlcheck')));\n      });\n      html10n.bind('localized', () => {\n        pad.changeViewOption('rtlIsTrue', ('rtl' === html10n.getDirection()));\n        padutils.setCheckbox($('#options-rtlcheck'), ('rtl' === html10n.getDirection()));\n      });\n\n\n\n      // font family change\n      $('#viewfontmenu').on('change', () => {\n        pad.changeViewOption('padFontFamily', $('#viewfontmenu').val());\n      });\n\n      // delete pad\n      $('#delete-pad').on('click', () => {\n        if (window.confirm(html10n.get('pad.delete.confirm'))) {\n          pad.collabClient.sendMessage({type: 'PAD_DELETE', data:{padId: pad.getPadId()}});\n          // redirect to home page after deletion\n          window.location.href = '/';\n        }\n      })\n\n      // theme switch\n      $('#theme-switcher').on('click',()=>{\n          if (skinVariants.isDarkMode()) {\n            skinVariants.setDarkModeInLocalStorage(false);\n            skinVariants.updateSkinVariantsClasses(['super-light-toolbar super-light-editor light-background']);\n          } else {\n            skinVariants.setDarkModeInLocalStorage(true);\n            skinVariants.updateSkinVariantsClasses(['super-dark-editor', 'dark-background', 'super-dark-toolbar']);\n          }\n      })\n\n      // Language\n      html10n.bind('localized', () => {\n        $('#languagemenu').val(html10n.getLanguage());\n        // translate the value of 'unnamed' and 'Enter your name' textboxes in the userlist\n\n        // this does not interfere with html10n's normal value-setting because\n        // html10n just ingores <input>s\n        // also, a value which has been set by the user will be not overwritten\n        // since a user-edited <input> does *not* have the editempty-class\n        $('input[data-l10n-id]').each((key, input) => {\n          input = $(input);\n          if (input.hasClass('editempty')) {\n            input.val(html10n.get(input.attr('data-l10n-id')));\n          }\n        });\n      });\n      $('#languagemenu').val(html10n.getLanguage());\n      $('#languagemenu').on('change', () => {\n        Cookies.set('language', $('#languagemenu').val());\n        html10n.localize([$('#languagemenu').val(), 'en']);\n        if ($('select').niceSelect) {\n          $('select').niceSelect('update');\n        }\n      });\n    },\n    setViewOptions: (newOptions) => {\n      const getOption = (key, defaultValue) => {\n        const value = String(newOptions[key]);\n        if (value === 'true') return true;\n        if (value === 'false') return false;\n        return defaultValue;\n      };\n\n      let v;\n\n      v = getOption('rtlIsTrue', ('rtl' === html10n.getDirection()));\n      self.ace.setProperty('rtlIsTrue', v);\n      padutils.setCheckbox($('#options-rtlcheck'), v);\n\n      v = getOption('showLineNumbers', true);\n      self.ace.setProperty('showslinenumbers', v);\n      padutils.setCheckbox($('#options-linenoscheck'), v);\n\n      v = getOption('showAuthorColors', true);\n      self.ace.setProperty('showsauthorcolors', v);\n      $('#chattext').toggleClass('authorColors', v);\n      $('iframe[name=\"ace_outer\"]').contents().find('#sidedivinner').toggleClass('authorColors', v);\n      padutils.setCheckbox($('#options-colorscheck'), v);\n\n      // Override from parameters if true\n      if (settings.noColors !== false) {\n        self.ace.setProperty('showsauthorcolors', !settings.noColors);\n      }\n\n      self.ace.setProperty('textface', newOptions.padFontFamily || '');\n    },\n    dispose: () => {\n      if (self.ace) {\n        self.ace.destroy();\n        self.ace = null;\n      }\n    },\n    enable: () => {\n      if (self.ace) {\n        self.ace.setEditable(true);\n      }\n    },\n    disable: () => {\n      if (self.ace) {\n        self.ace.setEditable(false);\n      }\n    },\n    restoreRevisionText: (dataFromServer) => {\n      pad.addHistoricalAuthors(dataFromServer.historicalAuthorData);\n      self.ace.importAText(dataFromServer.atext, dataFromServer.apool, true);\n    },\n  };\n  return self;\n})();\n\nexports.padeditor = padeditor;\n\nexports.focusOnLine = (ace) => {\n  // If a number is in the URI IE #L124 go to that line number\n  const lineNumber = window.location.hash.substr(1);\n  if (lineNumber) {\n    if (lineNumber[0] === 'L') {\n      const $outerdoc = $('iframe[name=\"ace_outer\"]').contents().find('#outerdocbody');\n      const lineNumberInt = parseInt(lineNumber.substr(1));\n      if (lineNumberInt) {\n        const $inner = $('iframe[name=\"ace_outer\"]').contents().find('iframe')\n            .contents().find('#innerdocbody');\n        const line = $inner.find(`div:nth-child(${lineNumberInt})`);\n        if (line.length !== 0) {\n          let offsetTop = line.offset().top;\n          offsetTop += parseInt($outerdoc.css('padding-top').replace('px', ''));\n          const hasMobileLayout = $('body').hasClass('mobile-layout');\n          if (!hasMobileLayout) {\n            offsetTop += parseInt($inner.css('padding-top').replace('px', ''));\n          }\n          const $outerdocHTML = $('iframe[name=\"ace_outer\"]').contents()\n              .find('#outerdocbody').parent();\n          $outerdoc.css({top: `${offsetTop}px`}); // Chrome\n          $outerdocHTML.animate({scrollTop: offsetTop}); // needed for FF\n          const node = line[0];\n          ace.callWithAce((ace) => {\n            const selection = {\n              startPoint: {\n                index: 0,\n                focusAtStart: true,\n                maxIndex: 1,\n                node,\n              },\n              endPoint: {\n                index: 0,\n                focusAtStart: true,\n                maxIndex: 1,\n                node,\n              },\n            };\n            ace.ace_setSelection(selection);\n          });\n        }\n      }\n    }\n  }\n  // End of setSelection / set Y position of editor\n};\n"
  },
  {
    "path": "src/static/js/pad_impexp.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport html10n from './vendors/html10n';\n\n\nconst padimpexp = (() => {\n  let pad;\n\n  // /// import\n  const addImportFrames = () => {\n    $('#import .importframe').remove();\n    const iframe = $('<iframe>')\n        .css('display', 'none')\n        .attr('name', 'importiframe')\n        .addClass('importframe');\n    $('#import').append(iframe);\n  };\n\n  const fileInputUpdated = () => {\n    $('#importsubmitinput').addClass('throbbold');\n    $('#importformfilediv').addClass('importformenabled');\n    $('#importsubmitinput').prop('disabled', false);\n    $('#importmessagefail').fadeOut('fast');\n  };\n\n  const fileInputSubmit = function (e) {\n    e.preventDefault();\n    $('#importmessagefail').fadeOut('fast');\n    if (!window.confirm(html10n.get('pad.impexp.confirmimport'))) return;\n    $('#importsubmitinput').attr({disabled: true}).val(html10n.get('pad.impexp.importing'));\n    window.setTimeout(() => $('#importfileinput').attr({disabled: true}), 0);\n    $('#importarrow').stop(true, true).hide();\n    $('#importstatusball').show();\n    (async () => {\n      const {code, message, data: {directDatabaseAccess} = {}} = await $.ajax({\n        url: `${window.location.href.split('?')[0].split('#')[0]}/import`,\n        method: 'POST',\n        data: new FormData(this),\n        processData: false,\n        contentType: false,\n        dataType: 'json',\n        timeout: 25000,\n      }).catch((err) => {\n        if (err.responseJSON) return err.responseJSON;\n        return {code: 2, message: 'Unknown import error'};\n      });\n      if (code !== 0) {\n        importErrorMessage(message);\n      } else {\n        $('#import_export').removeClass('popup-show');\n        if (directDatabaseAccess) window.location.reload();\n      }\n      $('#importsubmitinput').prop('disabled', false).val(html10n.get('pad.impexp.importbutton'));\n      window.setTimeout(() => $('#importfileinput').prop('disabled', false), 0);\n      $('#importstatusball').hide();\n      addImportFrames();\n    })();\n  };\n\n  const importErrorMessage = (status) => {\n    const known = [\n      'convertFailed',\n      'uploadFailed',\n      'padHasData',\n      'maxFileSize',\n      'permission',\n    ];\n    const msg = html10n.get(`pad.impexp.${known.indexOf(status) !== -1 ? status : 'copypaste'}`);\n\n    const showError = (fade) => {\n      const popup = $('#importmessagefail').empty()\n          .append($('<strong>')\n              .css('color', 'red')\n              .text(`${html10n.get('pad.impexp.importfailed')}: `))\n          .append(document.createTextNode(msg));\n      popup[(fade ? 'fadeIn' : 'show')]();\n    };\n\n    if ($('#importexport .importmessage').is(':visible')) {\n      $('#importmessagesuccess').fadeOut('fast');\n      $('#importmessagefail').fadeOut('fast', () => showError(true));\n    } else {\n      showError();\n    }\n  };\n\n  // /// export\n\n  function cantExport() {\n    let type = $(this);\n    if (type.hasClass('exporthrefpdf')) {\n      type = 'PDF';\n    } else if (type.hasClass('exporthrefdoc')) {\n      type = 'Microsoft Word';\n    } else if (type.hasClass('exporthrefodt')) {\n      type = 'OpenDocument';\n    } else {\n      type = 'this file';\n    }\n    alert(html10n.get('pad.impexp.exportdisabled', {type}));\n    return false;\n  }\n\n  // ///\n  const self = {\n    init: (_pad) => {\n      pad = _pad;\n\n      // get /p/padname\n      // if /p/ isn't available due to a rewrite we use the clientVars padId\n      const padRootPath = /.*\\/p\\/[^/]+/.exec(document.location.pathname) || clientVars.padId;\n\n      // i10l buttom import\n      $('#importsubmitinput').val(html10n.get('pad.impexp.importbutton'));\n      html10n.bind('localized', () => {\n        $('#importsubmitinput').val(html10n.get('pad.impexp.importbutton'));\n      });\n\n      // build the export links\n      $('#exporthtmla').attr('href', `${padRootPath}/export/html`);\n      $('#exportetherpada').attr('href', `${padRootPath}/export/etherpad`);\n      $('#exportplaina').attr('href', `${padRootPath}/export/txt`);\n\n      // hide stuff thats not avaible if abiword/soffice is disabled\n      if (clientVars.exportAvailable === 'no') {\n        $('#exportworda').remove();\n        $('#exportpdfa').remove();\n        $('#exportopena').remove();\n\n        $('#importmessageabiword').show();\n      } else if (clientVars.exportAvailable === 'withoutPDF') {\n        $('#exportpdfa').remove();\n\n        $('#exportworda').attr('href', `${padRootPath}/export/doc`);\n        $('#exportopena').attr('href', `${padRootPath}/export/odt`);\n\n        $('#importexport').css({height: '142px'});\n        $('#importexportline').css({height: '142px'});\n      } else {\n        $('#exportworda').attr('href', `${padRootPath}/export/doc`);\n        $('#exportpdfa').attr('href', `${padRootPath}/export/pdf`);\n        $('#exportopena').attr('href', `${padRootPath}/export/odt`);\n      }\n\n      addImportFrames();\n      $('#importfileinput').on('change', fileInputUpdated);\n      $('#importform').off('submit').on('submit', fileInputSubmit);\n      $('.disabledexport').on('click', cantExport);\n    },\n    disable: () => {\n      $('#impexp-disabled-clickcatcher').show();\n      $('#import').css('opacity', 0.5);\n      $('#impexp-export').css('opacity', 0.5);\n    },\n    enable: () => {\n      $('#impexp-disabled-clickcatcher').hide();\n      $('#import').css('opacity', 1);\n      $('#impexp-export').css('opacity', 1);\n    },\n  };\n  return self;\n})();\n\nexports.padimpexp = padimpexp;\n"
  },
  {
    "path": "src/static/js/pad_modals.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst padeditbar = require('./pad_editbar').padeditbar;\nconst automaticReconnect = require('./pad_automatic_reconnect');\n\nconst padmodals = (() => {\n  let pad = undefined;\n  const self = {\n    init: (_pad) => {\n      pad = _pad;\n    },\n    showModal: (messageId) => {\n      padeditbar.toggleDropDown('none');\n      $('#connectivity .visible').removeClass('visible');\n      $(`#connectivity .${messageId}`).addClass('visible');\n\n      const $modal = $(`#connectivity .${messageId}`);\n      automaticReconnect.showCountDownTimerToReconnectOnModal($modal, pad);\n\n      padeditbar.toggleDropDown('connectivity');\n    },\n    showOverlay: () => {\n      // Prevent the user to interact with the toolbar. Useful when user is disconnected for example\n      $('#toolbar-overlay').show();\n    },\n    hideOverlay: () => {\n      $('#toolbar-overlay').hide();\n    },\n  };\n  return self;\n})();\n\nexports.padmodals = padmodals;\n"
  },
  {
    "path": "src/static/js/pad_savedrevs.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * Copyright 2012 Peter 'Pita' Martischka\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nlet pad;\n\nexports.saveNow = () => {\n  pad.collabClient.sendMessage({type: 'SAVE_REVISION'});\n  window.$.gritter.add({\n    // (string | mandatory) the heading of the notification\n    title: html10n.get('pad.savedrevs.marked'),\n    // (string | mandatory) the text inside the notification\n    text: html10n.get('pad.savedrevs.timeslider') ||\n        'You can view saved revisions in the timeslider',\n    // (bool | optional) if you want it to fade out on its own or just sit there\n    sticky: false,\n    time: 3000,\n    class_name: 'saved-revision',\n  });\n};\n\nexports.init = (_pad) => {\n  pad = _pad;\n};\n"
  },
  {
    "path": "src/static/js/pad_userlist.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport padutils from './pad_utils'\nconst hooks = require('./pluginfw/hooks');\nimport html10n from './vendors/html10n';\nlet myUserInfo = {};\n\nlet colorPickerOpen = false;\nlet colorPickerSetup = false;\n\nconst paduserlist = (() => {\n  const rowManager = (() => {\n    // The row manager handles rendering rows of the user list and animating\n    // their insertion, removal, and reordering.  It manipulates TD height\n    // and TD opacity.\n\n    const nextRowId = () => `usertr${nextRowId.counter++}`;\n    nextRowId.counter = 1;\n    // objects are shared; fields are \"domId\",\"data\",\"animationStep\"\n    const rowsFadingOut = []; // unordered set\n    const rowsFadingIn = []; // unordered set\n    const rowsPresent = []; // in order\n    const ANIMATION_START = -12; // just starting to fade in\n    const ANIMATION_END = 12; // just finishing fading out\n\n    const animateStep = () => {\n      // animation must be symmetrical\n      for (let i = rowsFadingIn.length - 1; i >= 0; i--) { // backwards to allow removal\n        const row = rowsFadingIn[i];\n        const step = ++row.animationStep;\n        const animHeight = getAnimationHeight(step, row.animationPower);\n        const node = rowNode(row);\n        const baseOpacity = (row.opacity === undefined ? 1 : row.opacity);\n        if (step <= -OPACITY_STEPS) {\n          node.find('td').height(animHeight);\n        } else if (step === -OPACITY_STEPS + 1) {\n          node.empty().append(createUserRowTds(animHeight, row.data))\n              .find('td').css('opacity', baseOpacity * 1 / OPACITY_STEPS);\n        } else if (step < 0) {\n          node.find('td').css('opacity', baseOpacity * (OPACITY_STEPS - (-step)) / OPACITY_STEPS)\n              .height(animHeight);\n        } else if (step === 0) {\n          // set HTML in case modified during animation\n          node.empty().append(createUserRowTds(animHeight, row.data))\n              .find('td').css('opacity', baseOpacity * 1).height(animHeight);\n          rowsFadingIn.splice(i, 1); // remove from set\n        }\n      }\n      for (let i = rowsFadingOut.length - 1; i >= 0; i--) { // backwards to allow removal\n        const row = rowsFadingOut[i];\n        const step = ++row.animationStep;\n        const node = rowNode(row);\n        const animHeight = getAnimationHeight(step, row.animationPower);\n        const baseOpacity = (row.opacity === undefined ? 1 : row.opacity);\n        if (step < OPACITY_STEPS) {\n          node.find('td').css('opacity', baseOpacity * (OPACITY_STEPS - step) / OPACITY_STEPS)\n              .height(animHeight);\n        } else if (step === OPACITY_STEPS) {\n          node.empty().append(createEmptyRowTds(animHeight));\n        } else if (step <= ANIMATION_END) {\n          node.find('td').height(animHeight);\n        } else {\n          rowsFadingOut.splice(i, 1); // remove from set\n          node.remove();\n        }\n      }\n\n      handleOtherUserInputs();\n\n      return (rowsFadingIn.length > 0) || (rowsFadingOut.length > 0); // is more to do\n    };\n\n    const getAnimationHeight = (step, power) => {\n      let a = Math.abs(step / 12);\n      if (power === 2) a **= 2;\n      else if (power === 3) a **= 3;\n      else if (power === 4) a **= 4;\n      else if (power >= 5) a **= 5;\n      return Math.round(26 * (1 - a));\n    };\n    const OPACITY_STEPS = 6;\n\n    const ANIMATION_STEP_TIME = 20;\n    const LOWER_FRAMERATE_FACTOR = 2;\n    const {scheduleAnimation} =\n        padutils.makeAnimationScheduler(animateStep, ANIMATION_STEP_TIME, LOWER_FRAMERATE_FACTOR);\n\n    const NUMCOLS = 4;\n\n    // we do lots of manipulation of table rows and stuff that JQuery makes ok, despite\n    // IE's poor handling when manipulating the DOM directly.\n\n    const createEmptyRowTds = (height) => $('<td>')\n        .attr('colspan', NUMCOLS)\n        .css('border', 0)\n        .css('height', `${height}px`);\n\n    const isNameEditable = (data) => (!data.name) && (data.status !== 'Disconnected');\n\n    const replaceUserRowContents = (tr, height, data) => {\n      const tds = createUserRowTds(height, data);\n      if (isNameEditable(data) && tr.find('td.usertdname input:enabled').length > 0) {\n        // preserve input field node\n        tds.each((i, td) => {\n          const oldTd = $(tr.find('td').get(i));\n          if (!oldTd.hasClass('usertdname')) {\n            oldTd.replaceWith(td);\n          } else {\n            // Prevent leak. I'm not 100% confident that this is necessary, but it shouldn't hurt.\n            $(td).remove();\n          }\n        });\n      } else {\n        tr.empty().append(tds);\n      }\n      return tr;\n    };\n\n    const createUserRowTds = (height, data) => {\n      let name;\n      if (data.name) {\n        name = document.createTextNode(data.name);\n      } else {\n        name = $('<input>')\n            .attr('data-l10n-id', 'pad.userlist.unnamed')\n            .attr('type', 'text')\n            .addClass('editempty')\n            .addClass('newinput')\n            .attr('value', html10n.get('pad.userlist.unnamed'));\n        if (isNameEditable(data)) name.attr('disabled', 'disabled');\n      }\n      return $()\n          .add($('<td>')\n              .css('height', `${height}px`)\n              .addClass('usertdswatch')\n              .append($('<div>')\n                  .addClass('swatch')\n                  .css('background', padutils.escapeHtml(data.color))\n                  .html('&nbsp;')))\n          .add($('<td>')\n              .css('height', `${height}px`)\n              .addClass('usertdname')\n              .append(name))\n          .add($('<td>')\n              .css('height', `${height}px`)\n              .addClass('activity')\n              .text(data.activity));\n    };\n\n    const createRow = (id, contents, authorId) => $('<tr>')\n        .attr('data-authorId', authorId)\n        .attr('id', id)\n        .append(contents);\n\n    const rowNode = (row) => $(`#${row.domId}`);\n\n    const handleRowData = (row) => {\n      if (row.data && row.data.status === 'Disconnected') {\n        row.opacity = 0.5;\n      } else {\n        delete row.opacity;\n      }\n    };\n\n    const handleOtherUserInputs = () => {\n      // handle 'INPUT' elements for naming other unnamed users\n      $('#otheruserstable input.newinput').each(function () {\n        const input = $(this);\n        const tr = input.closest('tr');\n        if (tr.length > 0) {\n          const index = tr.parent().children().index(tr);\n          if (index >= 0 && rowsPresent.length > index) {\n            const userId = rowsPresent[index].data.id;\n            rowManagerMakeNameEditor($(this), userId);\n          }\n        }\n      }).removeClass('newinput');\n    };\n\n    // animationPower is 0 to skip animation, 1 for linear, 2 for quadratic, etc.\n\n\n    const insertRow = (position, data, animationPower) => {\n      position = Math.max(0, Math.min(rowsPresent.length, position));\n      animationPower = (animationPower === undefined ? 4 : animationPower);\n\n      const domId = nextRowId();\n      const row = {\n        data,\n        animationStep: ANIMATION_START,\n        domId,\n        animationPower,\n      };\n      const authorId = data.id;\n\n      handleRowData(row);\n      rowsPresent.splice(position, 0, row);\n      let tr;\n      if (animationPower === 0) {\n        tr = createRow(domId, createUserRowTds(getAnimationHeight(0), data), authorId);\n        row.animationStep = 0;\n      } else {\n        rowsFadingIn.push(row);\n        tr = createRow(domId, createEmptyRowTds(getAnimationHeight(ANIMATION_START)), authorId);\n      }\n      $('table#otheruserstable').show();\n      if (position === 0) {\n        $('table#otheruserstable').prepend(tr);\n      } else {\n        rowNode(rowsPresent[position - 1]).after(tr);\n      }\n\n      if (animationPower !== 0) {\n        scheduleAnimation();\n      }\n\n      handleOtherUserInputs();\n\n      return row;\n    };\n\n    const updateRow = (position, data) => {\n      const row = rowsPresent[position];\n      if (row) {\n        row.data = data;\n        handleRowData(row);\n        if (row.animationStep === 0) {\n          // not currently animating\n          const tr = rowNode(row);\n          replaceUserRowContents(tr, getAnimationHeight(0), row.data)\n              .find('td')\n              .css('opacity', (row.opacity === undefined ? 1 : row.opacity));\n          handleOtherUserInputs();\n        }\n      }\n    };\n\n    const removeRow = (position, animationPower) => {\n      animationPower = (animationPower === undefined ? 4 : animationPower);\n      const row = rowsPresent[position];\n      if (row) {\n        rowsPresent.splice(position, 1); // remove\n        if (animationPower === 0) {\n          rowNode(row).remove();\n        } else {\n          row.animationStep = -row.animationStep; // use symmetry\n          row.animationPower = animationPower;\n          rowsFadingOut.push(row);\n          scheduleAnimation();\n        }\n      }\n      if (rowsPresent.length === 0) {\n        $('table#otheruserstable').hide();\n      }\n    };\n\n    // newPosition is position after the row has been removed\n\n\n    const moveRow = (oldPosition, newPosition, animationPower) => {\n      animationPower = (animationPower === undefined ? 1 : animationPower); // linear is best\n      const row = rowsPresent[oldPosition];\n      if (row && oldPosition !== newPosition) {\n        const rowData = row.data;\n        removeRow(oldPosition, animationPower);\n        insertRow(newPosition, rowData, animationPower);\n      }\n    };\n\n    const self = {\n      insertRow,\n      removeRow,\n      moveRow,\n      updateRow,\n    };\n    return self;\n  })(); // //////// rowManager\n  const otherUsersInfo = [];\n  const otherUsersData = [];\n\n  const rowManagerMakeNameEditor = (jnode, userId) => {\n    setUpEditable(jnode, () => {\n      const existingIndex = findExistingIndex(userId);\n      if (existingIndex >= 0) {\n        return otherUsersInfo[existingIndex].name || '';\n      } else {\n        return '';\n      }\n    }, (newName) => {\n      if (!newName) {\n        jnode.addClass('editempty');\n        jnode.val(html10n.get('pad.userlist.unnamed'));\n      } else {\n        jnode.attr('disabled', 'disabled');\n        pad.suggestUserName(userId, newName);\n      }\n    });\n  };\n\n  const findExistingIndex = (userId) => {\n    let existingIndex = -1;\n    for (let i = 0; i < otherUsersInfo.length; i++) {\n      if (otherUsersInfo[i].userId === userId) {\n        existingIndex = i;\n        break;\n      }\n    }\n    return existingIndex;\n  };\n\n  const setUpEditable = (jqueryNode, valueGetter, valueSetter) => {\n    jqueryNode.on('focus', (evt) => {\n      const oldValue = valueGetter();\n      if (jqueryNode.val() !== oldValue) {\n        jqueryNode.val(oldValue);\n      }\n      jqueryNode.addClass('editactive').removeClass('editempty');\n    });\n    jqueryNode.on('blur', (evt) => {\n      const newValue = jqueryNode.removeClass('editactive').val();\n      valueSetter(newValue);\n    });\n    padutils.bindEnterAndEscape(jqueryNode, () => {\n      jqueryNode.trigger('blur');\n    }, () => {\n      jqueryNode.val(valueGetter()).trigger('blur');\n    });\n    jqueryNode.prop('disabled', false).addClass('editable');\n  };\n\n  let pad = undefined;\n  const self = {\n    init: (myInitialUserInfo, _pad) => {\n      pad = _pad;\n\n      self.setMyUserInfo(myInitialUserInfo);\n\n      if ($('#online_count').length === 0) {\n        $('#editbar [data-key=showusers] > a').append('<span id=\"online_count\">1</span>');\n      }\n\n      $('#otheruserstable tr').remove();\n\n      $('#myusernameedit').addClass('myusernameedithoverable');\n      setUpEditable($('#myusernameedit'), () => myUserInfo.name || '', (newValue) => {\n        myUserInfo.name = newValue;\n        pad.notifyChangeName(newValue);\n        // wrap with setTimeout to do later because we get\n        // a double \"blur\" fire in IE...\n        window.setTimeout(() => {\n          self.renderMyUserInfo();\n        }, 0);\n      });\n\n      // color picker\n      $('#myswatchbox').on('click', showColorPicker);\n      $('#mycolorpicker .pickerswatchouter').on('click', function () {\n        $('#mycolorpicker .pickerswatchouter').removeClass('picked');\n        $(this).addClass('picked');\n      });\n      $('#mycolorpickersave').on('click', () => {\n        closeColorPicker(true);\n      });\n      $('#mycolorpickercancel').on('click', () => {\n        closeColorPicker(false);\n      });\n      //\n    },\n    usersOnline: () => {\n      // Returns an object of users who are currently online on this pad\n      // Make a copy of the otherUsersInfo, otherwise every call to users\n      // modifies the referenced array\n      const userList = [].concat(otherUsersInfo);\n      // Now we need to add ourselves..\n      userList.push(myUserInfo);\n      return userList;\n    },\n    users: () => {\n      // Returns an object of users who have been on this pad\n      const userList = self.usersOnline();\n\n      // Now we add historical authors\n      const historical = clientVars.collab_client_vars.historicalAuthorData;\n      for (const [key, {userId}] of Object.entries(historical)) {\n        // Check we don't already have this author in our array\n        let exists = false;\n\n        userList.forEach((user) => {\n          if (user.userId === userId) exists = true;\n        });\n\n        if (exists === false) {\n          userList.push(historical[key]);\n        }\n      }\n      return userList;\n    },\n    setMyUserInfo: (info) => {\n      // translate the colorId\n      if (typeof info.colorId === 'number') {\n        info.colorId = clientVars.colorPalette[info.colorId];\n      }\n\n      myUserInfo = $.extend(\n          {}, info);\n\n      self.renderMyUserInfo();\n    },\n    userJoinOrUpdate: (info) => {\n      if ((!info.userId) || (info.userId === myUserInfo.userId)) {\n        // not sure how this would happen\n        return;\n      }\n\n      hooks.callAll('userJoinOrUpdate', {\n        userInfo: info,\n      });\n\n      const userData = {};\n      userData.color = typeof info.colorId === 'number'\n        ? clientVars.colorPalette[info.colorId] : info.colorId;\n      userData.name = info.name;\n      userData.status = '';\n      userData.activity = '';\n      userData.id = info.userId;\n\n      const existingIndex = findExistingIndex(info.userId);\n\n      let numUsersBesides = otherUsersInfo.length;\n      if (existingIndex >= 0) {\n        numUsersBesides--;\n      }\n      const newIndex = padutils.binarySearch(numUsersBesides, (n) => {\n        if (existingIndex >= 0 && n >= existingIndex) {\n          // pretend existingIndex isn't there\n          n++;\n        }\n        const infoN = otherUsersInfo[n];\n        const nameN = (infoN.name || '').toLowerCase();\n        const nameThis = (info.name || '').toLowerCase();\n        const idN = infoN.userId;\n        const idThis = info.userId;\n        return (nameN > nameThis) || (nameN === nameThis && idN > idThis);\n      });\n\n      if (existingIndex >= 0) {\n        // update\n        if (existingIndex === newIndex) {\n          otherUsersInfo[existingIndex] = info;\n          otherUsersData[existingIndex] = userData;\n          rowManager.updateRow(existingIndex, userData);\n        } else {\n          otherUsersInfo.splice(existingIndex, 1);\n          otherUsersData.splice(existingIndex, 1);\n          otherUsersInfo.splice(newIndex, 0, info);\n          otherUsersData.splice(newIndex, 0, userData);\n          rowManager.updateRow(existingIndex, userData);\n          rowManager.moveRow(existingIndex, newIndex);\n        }\n      } else {\n        otherUsersInfo.splice(newIndex, 0, info);\n        otherUsersData.splice(newIndex, 0, userData);\n        rowManager.insertRow(newIndex, userData);\n      }\n\n      self.updateNumberOfOnlineUsers();\n    },\n    updateNumberOfOnlineUsers: () => {\n      let online = 1; // you are always online!\n      for (let i = 0; i < otherUsersData.length; i++) {\n        if (otherUsersData[i].status === '') {\n          online++;\n        }\n      }\n\n      if (localStorage.getItem('recentPads') != null) {\n        const recentPadsList = JSON.parse(localStorage.getItem('recentPads'));\n        const pathSegments = window.location.pathname.split('/');\n        const padName = pathSegments[pathSegments.length - 1];\n        const existingPad = recentPadsList.find((pad) => pad.name === padName);\n        if (existingPad) {\n          existingPad.members = online;\n        }\n        localStorage.setItem('recentPads', JSON.stringify(recentPadsList));\n      }\n\n      $('#online_count').text(online);\n\n      return online;\n    },\n    userLeave: (info) => {\n      const existingIndex = findExistingIndex(info.userId);\n      if (existingIndex >= 0) {\n        const userData = otherUsersData[existingIndex];\n        userData.status = 'Disconnected';\n        rowManager.updateRow(existingIndex, userData);\n        if (userData.leaveTimer) {\n          window.clearTimeout(userData.leaveTimer);\n        }\n        // set up a timer that will only fire if no leaves,\n        // joins, or updates happen for this user in the\n        // next N seconds, to remove the user from the list.\n        const thisUserId = info.userId;\n        const thisLeaveTimer = window.setTimeout(() => {\n          const newExistingIndex = findExistingIndex(thisUserId);\n          if (newExistingIndex >= 0) {\n            const newUserData = otherUsersData[newExistingIndex];\n            if (newUserData.status === 'Disconnected' &&\n                newUserData.leaveTimer === thisLeaveTimer) {\n              otherUsersInfo.splice(newExistingIndex, 1);\n              otherUsersData.splice(newExistingIndex, 1);\n              rowManager.removeRow(newExistingIndex);\n              hooks.callAll('userLeave', {\n                userInfo: info,\n              });\n            }\n          }\n        }, 8000); // how long to wait\n        userData.leaveTimer = thisLeaveTimer;\n      }\n\n      self.updateNumberOfOnlineUsers();\n    },\n    renderMyUserInfo: () => {\n      if (myUserInfo.name) {\n        $('#myusernameedit').removeClass('editempty').val(myUserInfo.name);\n      } else {\n        $('#myusernameedit').attr('placeholder', html10n.get('pad.userlist.entername'));\n      }\n      if (colorPickerOpen) {\n        $('#myswatchbox').addClass('myswatchboxunhoverable').removeClass('myswatchboxhoverable');\n      } else {\n        $('#myswatchbox').addClass('myswatchboxhoverable').removeClass('myswatchboxunhoverable');\n      }\n\n      $('#myswatch').css({'background-color': myUserInfo.colorId});\n      $('li[data-key=showusers] > a').css({'box-shadow': `inset 0 0 30px ${myUserInfo.colorId}`});\n    },\n  };\n  return self;\n})();\n\nconst getColorPickerSwatchIndex = (jnode) => $('#colorpickerswatches li').index(jnode);\n\nconst closeColorPicker = (accept) => {\n  if (accept) {\n    let newColor = $('#mycolorpickerpreview').css('background-color');\n    const parts = newColor.match(/^rgb\\((\\d+),\\s*(\\d+),\\s*(\\d+)\\)$/);\n    // parts now should be [\"rgb(0, 70, 255\", \"0\", \"70\", \"255\"]\n    if (parts) {\n      delete (parts[0]);\n      for (let i = 1; i <= 3; ++i) {\n        parts[i] = parseInt(parts[i]).toString(16);\n        if (parts[i].length === 1) parts[i] = `0${parts[i]}`;\n      }\n      newColor = `#${parts.join('')}`; // \"0070ff\"\n    }\n    myUserInfo.colorId = newColor;\n    pad.notifyChangeColor(newColor);\n    paduserlist.renderMyUserInfo();\n  } else {\n    // pad.notifyChangeColor(previousColorId);\n    // paduserlist.renderMyUserInfo();\n  }\n\n  colorPickerOpen = false;\n  $('#mycolorpicker').removeClass('popup-show');\n};\n\nconst showColorPicker = () => {\n  $.farbtastic('#colorpicker').setColor(myUserInfo.colorId);\n\n  if (!colorPickerOpen) {\n    const palette = pad.getColorPalette();\n\n    if (!colorPickerSetup) {\n      const colorsList = $('#colorpickerswatches');\n      for (let i = 0; i < palette.length; i++) {\n        const li = $('<li>', {\n          style: `background: ${palette[i]};`,\n        });\n\n        li.appendTo(colorsList);\n\n        li.on('click', (event) => {\n          $('#colorpickerswatches li').removeClass('picked');\n          $(event.target).addClass('picked');\n\n          const newColorId = getColorPickerSwatchIndex($('#colorpickerswatches .picked'));\n          pad.notifyChangeColor(newColorId);\n        });\n      }\n\n      colorPickerSetup = true;\n    }\n\n    $('#mycolorpicker').addClass('popup-show');\n    colorPickerOpen = true;\n\n    $('#colorpickerswatches li').removeClass('picked');\n    $($('#colorpickerswatches li')[myUserInfo.colorId]).addClass('picked'); // seems weird\n  }\n};\n\nexports.paduserlist = paduserlist;\n"
  },
  {
    "path": "src/static/js/pad_utils.ts",
    "content": "'use strict';\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\nimport {binarySearch} from \"./ace2_common\";\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst Security = require('security');\nimport jsCookie, {CookiesStatic} from 'js-cookie'\n\n/**\n * Generates a random String with the given length. Is needed to generate the Author, Group,\n * readonly, session Ids\n */\nexport const randomString = (len?: number) => {\n  const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';\n  let randomstring = '';\n  len = len || 20;\n  for (let i = 0; i < len; i++) {\n    const rnum = Math.floor(Math.random() * chars.length);\n    randomstring += chars.substring(rnum, rnum + 1);\n  }\n  return randomstring;\n};\n\n// Set of \"letter or digit\" chars is based on section 20.5.16 of the original Java Language Spec.\nconst wordCharRegex = new RegExp(`[${[\n  '\\u0030-\\u0039',\n  '\\u0041-\\u005A',\n  '\\u0061-\\u007A',\n  '\\u00C0-\\u00D6',\n  '\\u00D8-\\u00F6',\n  '\\u00F8-\\u00FF',\n  '\\u0100-\\u1FFF',\n  '\\u3040-\\u9FFF',\n  '\\uF900-\\uFDFF',\n  '\\uFE70-\\uFEFE',\n  '\\uFF10-\\uFF19',\n  '\\uFF21-\\uFF3A',\n  '\\uFF41-\\uFF5A',\n  '\\uFF66-\\uFFDC',\n].join('')}]`);\n\nconst urlRegex = (() => {\n  // TODO: wordCharRegex matches many characters that are not permitted in URIs. Are they included\n  // here as an attempt to support IRIs? (See https://tools.ietf.org/html/rfc3987.)\n  const urlChar = `[-:@_.,~%+/?=&#!;()\\\\[\\\\]$'*${wordCharRegex.source.slice(1, -1)}]`;\n  // Matches a single character that should not be considered part of the URL if it is the last\n  // character that matches urlChar.\n  const postUrlPunct = '[:.,;?!)\\\\]\\'*]';\n  // Schemes that must be followed by ://\n  const withAuth = `(?:${[\n    '(?:x-)?man',\n    'afp',\n    'file',\n    'ftps?',\n    'gopher',\n    'https?',\n    'nfs',\n    'sftp',\n    'smb',\n    'txmt',\n  ].join('|')})://`;\n  // Schemes that do not need to be followed by ://\n  const withoutAuth = `(?:${[\n    'about',\n    'geo',\n    'mailto',\n    'tel',\n  ].join('|')}):`;\n  return new RegExp(\n    `(?:${withAuth}|${withoutAuth}|www\\\\.)${urlChar}*(?!${postUrlPunct})${urlChar}`, 'g');\n})();\n\n// https://stackoverflow.com/a/68957976\nconst base64url = /^(?=(?:.{4})*$)[A-Za-z0-9_-]*(?:[AQgw]==|[AEIMQUYcgkosw048]=)?$/;\n\ntype PadEvent = {\n  which: number\n}\n\ntype JQueryNode = JQuery<HTMLElement>\n\nclass PadUtils {\n  public urlRegex: RegExp\n  public wordCharRegex: RegExp\n  public warnDeprecatedFlags: {\n    disabledForTestingOnly: boolean,\n    _rl?: {\n      prevs: Map<string, number>,\n      now: () => number,\n      period: number\n    }\n    logger?: any\n  }\n  public globalExceptionHandler: null | any = null;\n\n\n  constructor() {\n    this.warnDeprecatedFlags = {\n      disabledForTestingOnly: false\n    }\n    this.wordCharRegex = wordCharRegex\n    this.urlRegex = urlRegex\n  }\n\n  /**\n   * Prints a warning message followed by a stack trace (to make it easier to figure out what code\n   * is using the deprecated function).\n   *\n   * Identical deprecation warnings (as determined by the stack trace, if available) are rate\n   * limited to avoid log spam.\n   *\n   * Most browsers include UI widget to examine the stack at the time of the warning, but this\n   * includes the stack in the log message for a couple of reasons:\n   *   - This makes it possible to see the stack if the code runs in Node.js.\n   *   - Users are more likely to paste the stack in bug reports they might file.\n   *\n   * @param {...*} args - Passed to `padutils.warnDeprecated.logger.warn` (or `console.warn` if no\n   *     logger is set), with a stack trace appended if available.\n   */\n  warnDeprecated = (...args: any[]) => {\n    if (this.warnDeprecatedFlags.disabledForTestingOnly) return;\n    const err = new Error();\n    if (Error.captureStackTrace) Error.captureStackTrace(err, this.warnDeprecated);\n    err.name = '';\n    // Rate limit identical deprecation warnings (as determined by the stack) to avoid log spam.\n    if (typeof err.stack === 'string') {\n      if (this.warnDeprecatedFlags._rl == null) {\n        this.warnDeprecatedFlags._rl =\n          {prevs: new Map(), now: () => Date.now(), period: 10 * 60 * 1000};\n      }\n      const rl = this.warnDeprecatedFlags._rl;\n      const now = rl.now();\n      const prev = rl.prevs.get(err.stack);\n      if (prev != null && now - prev < rl.period) return;\n      rl.prevs.set(err.stack, now);\n    }\n    if (err.stack) args.push(err.stack);\n    (this.warnDeprecatedFlags.logger || console).warn(...args);\n  }\n  escapeHtml = (x: string) => Security.escapeHTML(String(x))\n  uniqueId = () => {\n    const pad = require('./pad').pad; // Sidestep circular dependency\n    // returns string that is exactly 'width' chars, padding with zeros and taking rightmost digits\n    const encodeNum =\n      (n: number, width: number) => (Array(width + 1).join('0') + Number(n).toString(35)).slice(-width);\n    return [\n      pad.getClientIp(),\n      encodeNum(+new Date(), 7),\n      encodeNum(Math.floor(Math.random() * 1e9), 4),\n    ].join('.');\n  }\n\n  // e.g. \"Thu Jun 18 2009 13:09\"\n  simpleDateTime = (date: string) => {\n    const d = new Date(+date); // accept either number or date\n    const dayOfWeek = (['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'])[d.getDay()];\n    const month = ([\n      'Jan',\n      'Feb',\n      'Mar',\n      'Apr',\n      'May',\n      'Jun',\n      'Jul',\n      'Aug',\n      'Sep',\n      'Oct',\n      'Nov',\n      'Dec',\n    ])[d.getMonth()];\n    const dayOfMonth = d.getDate();\n    const year = d.getFullYear();\n    const hourmin = `${d.getHours()}:${(`0${d.getMinutes()}`).slice(-2)}`;\n    return `${dayOfWeek} ${month} ${dayOfMonth} ${year} ${hourmin}`;\n  }\n  // returns null if no URLs, or [[startIndex1, url1], [startIndex2, url2], ...]\n  findURLs = (text: string) => {\n    // Copy padutils.urlRegex so that the use of .exec() below (which mutates the RegExp object)\n    // does not break other concurrent uses of padutils.urlRegex.\n    const urlRegex = new RegExp(this.urlRegex, 'g');\n    urlRegex.lastIndex = 0;\n    let urls: [number, string][] | null = null;\n    let execResult;\n    // TODO: Switch to String.prototype.matchAll() after support for Node.js < 12.0.0 is dropped.\n    while ((execResult = urlRegex.exec(text))) {\n      urls = (urls || []);\n      const startIndex = execResult.index;\n      const url = execResult[0];\n      urls.push([startIndex, url]);\n    }\n    return urls;\n  }\n  escapeHtmlWithClickableLinks = (text: string, target: string) => {\n    let idx = 0;\n    const pieces = [];\n    const urls = this.findURLs(text);\n\n    const advanceTo = (i: number) => {\n        if (i > idx) {\n          pieces.push(Security.escapeHTML(text.substring(idx, i)));\n          idx = i;\n        }\n      }\n    ;\n    if (urls) {\n      for (let j = 0; j < urls.length; j++) {\n        const startIndex = urls[j][0];\n        const href = urls[j][1];\n        advanceTo(startIndex);\n        // Using rel=\"noreferrer\" stops leaking the URL/location of the pad when clicking links in\n        // the document. Not all browsers understand this attribute, but it's part of the HTML5\n        // standard. https://html.spec.whatwg.org/multipage/links.html#link-type-noreferrer\n        // Additionally, we do rel=\"noopener\" to ensure a higher level of referrer security.\n        // https://html.spec.whatwg.org/multipage/links.html#link-type-noopener\n        // https://mathiasbynens.github.io/rel-noopener/\n        // https://github.com/ether/etherpad-lite/pull/3636\n        pieces.push(\n          '<a ',\n          (target ? `target=\"${Security.escapeHTMLAttribute(target)}\" ` : ''),\n          'href=\"',\n          Security.escapeHTMLAttribute(href),\n          '\" rel=\"noreferrer noopener\">');\n        advanceTo(startIndex + href.length);\n        pieces.push('</a>');\n      }\n    }\n    advanceTo(text.length);\n    return pieces.join('');\n  }\n  bindEnterAndEscape = (node: JQueryNode, onEnter: Function, onEscape: Function) => {\n    // Use keypress instead of keyup in bindEnterAndEscape. Keyup event is fired on enter in IME\n    // (Input Method Editor), But keypress is not. So, I changed to use keypress instead of keyup.\n    // It is work on Windows (IE8, Chrome 6.0.472), CentOs (Firefox 3.0) and Mac OSX (Firefox\n    // 3.6.10, Chrome 6.0.472, Safari 5.0).\n    if (onEnter) {\n      node.on('keypress', (evt: { which: number; }) => {\n        if (evt.which === 13) {\n          onEnter(evt);\n        }\n      });\n    }\n\n    if (onEscape) {\n      node.on('keydown', (evt) => {\n        if (evt.which === 27) {\n          onEscape(evt);\n        }\n      });\n    }\n  }\n\n  timediff = (d: number) => {\n    const pad = require('./pad').pad; // Sidestep circular dependency\n    const format = (n: number, word: string) => {\n        n = Math.round(n);\n        return (`${n} ${word}${n !== 1 ? 's' : ''} ago`);\n      }\n    ;\n    d = Math.max(0, (+(new Date()) - (+d) - pad.clientTimeOffset) / 1000);\n    if (d < 60) {\n      return format(d, 'second');\n    }\n    d /= 60;\n    if (d < 60) {\n      return format(d, 'minute');\n    }\n    d /= 60;\n    if (d < 24) {\n      return format(d, 'hour');\n    }\n    d /= 24;\n    return format(d, 'day');\n  }\n  makeAnimationScheduler =\n    (funcToAnimateOneStep: any, stepTime: number, stepsAtOnce?: number) => {\n      if (stepsAtOnce === undefined) {\n        stepsAtOnce = 1;\n      }\n\n      let animationTimer: any = null;\n\n      const scheduleAnimation = () => {\n        if (!animationTimer) {\n          animationTimer = window.setTimeout(() => {\n            animationTimer = null;\n            let n = stepsAtOnce;\n            let moreToDo = true;\n            while (moreToDo && n > 0) {\n              moreToDo = funcToAnimateOneStep();\n              n--;\n            }\n            if (moreToDo) {\n              // more to do\n              scheduleAnimation();\n            }\n          }, stepTime * stepsAtOnce);\n        }\n      };\n      return {scheduleAnimation};\n    }\n\n  makeFieldLabeledWhenEmpty\n    =\n    (field: JQueryNode, labelText: string) => {\n      field = $(field);\n\n      const clear = () => {\n          field.addClass('editempty');\n          field.val(labelText);\n        }\n      ;\n      field.focus(() => {\n        if (field.hasClass('editempty')) {\n          field.val('');\n        }\n        field.removeClass('editempty');\n      });\n      field.on('blur', () => {\n        if (!field.val()) {\n          clear();\n        }\n      });\n      return {\n        clear,\n      };\n    }\n  getCheckbox = (node: string) => $(node).is(':checked')\n  setCheckbox =\n    (node: JQueryNode, value: boolean) => {\n      if (value) {\n        $(node).attr('checked', 'checked');\n      } else {\n        $(node).prop('checked', false);\n      }\n    }\n  bindCheckboxChange =\n    (node: JQueryNode, func: Function) => {\n      // @ts-ignore\n      $(node).on(\"change\", func);\n    }\n  encodeUserId =\n    (userId: string) => userId.replace(/[^a-y0-9]/g, (c) => {\n      if (c === '.') return '-';\n      return `z${c.charCodeAt(0)}z`;\n    })\n  decodeUserId =\n    (encodedUserId: string) => encodedUserId.replace(/[a-y0-9]+|-|z.+?z/g, (cc) => {\n      if (cc === '-') {\n        return '.';\n      } else if (cc.charAt(0) === 'z') {\n        return String.fromCharCode(Number(cc.slice(1, -1)));\n      } else {\n        return cc;\n      }\n    })\n  /**\n   * Returns whether a string has the expected format to be used as a secret token identifying an\n   * author. The format is defined as: 't.' followed by a non-empty base64url string (RFC 4648\n   * section 5 with padding).\n   *\n   * Being strict about what constitutes a valid token enables unambiguous extensibility (e.g.,\n   * conditional transformation of a token to a database key in a way that does not allow a\n   * malicious user to impersonate another user).\n   */\n  isValidAuthorToken = (t: string | object) => {\n    if (typeof t !== 'string' || !t.startsWith('t.')) return false;\n    const v = t.slice(2);\n    return v.length > 0 && base64url.test(v);\n  }\n\n\n  /**\n   * Returns a string that can be used in the `token` cookie as a secret that authenticates a\n   * particular author.\n   */\n  generateAuthorToken = () => `t.${randomString()}`\n  setupGlobalExceptionHandler = () => {\n    if (this.globalExceptionHandler == null) {\n      this.globalExceptionHandler = (e: any) => {\n        let type;\n        let err;\n        let msg, url, linenumber;\n        if (e instanceof ErrorEvent) {\n          type = 'Uncaught exception';\n          err = e.error || {};\n          ({message: msg, filename: url, lineno: linenumber} = e);\n        } else if (e instanceof PromiseRejectionEvent) {\n          type = 'Unhandled Promise rejection';\n          err = e.reason || {};\n          ({message: msg = 'unknown', fileName: url = 'unknown', lineNumber: linenumber = -1} = err);\n        } else {\n          throw new Error(`unknown event: ${e.toString()}`);\n        }\n        if (err.name != null && msg !== err.name && !msg.startsWith(`${err.name}: `)) {\n          msg = `${err.name}: ${msg}`;\n        }\n        const errorId = randomString(20);\n\n        let msgAlreadyVisible = false;\n        $('.gritter-item .error-msg').each(function () {\n          if ($(this).text() === msg) {\n            msgAlreadyVisible = true;\n          }\n        });\n\n        if (!msgAlreadyVisible) {\n          const txt = document.createTextNode.bind(document); // Convenience shorthand.\n          const errorMsg = [\n            $('<p>')\n              .append($('<b>').text('Please press and hold Ctrl and press F5 to reload this page')),\n            $('<p>')\n              .text('If the problem persists, please send this error message to your webmaster:'),\n            $('<div>').css('text-align', 'left').css('font-size', '.8em').css('margin-top', '1em')\n              .append($('<b>').addClass('error-msg').text(msg)).append($('<br>'))\n              .append(txt(`at ${url} at line ${linenumber}`)).append($('<br>'))\n              .append(txt(`ErrorId: ${errorId}`)).append($('<br>'))\n              .append(txt(type)).append($('<br>'))\n              .append(txt(`URL: ${window.location.href}`)).append($('<br>'))\n              .append(txt(`UserAgent: ${navigator.userAgent}`)).append($('<br>')),\n          ];\n\n          // @ts-ignore\n          $.gritter.add({\n            title: 'An error occurred',\n            text: errorMsg,\n            class_name: 'error',\n            position: 'bottom',\n            sticky: true,\n          });\n        }\n\n        // send javascript errors to the server\n        $.post('../jserror', {\n          errorInfo: JSON.stringify({\n            errorId,\n            type,\n            msg,\n            url: window.location.href,\n            source: url,\n            linenumber,\n            userAgent: navigator.userAgent,\n            stack: err.stack,\n          }),\n        });\n      };\n      window.onerror = null; // Clear any pre-existing global error handler.\n      window.addEventListener('error', this.globalExceptionHandler);\n      window.addEventListener('unhandledrejection', this.globalExceptionHandler);\n    }\n  }\n  binarySearch = binarySearch\n}\n\n// https://stackoverflow.com/a/42660748\nconst inThirdPartyIframe = () => {\n  try {\n    return (!window.top!.location.hostname);\n  } catch (e) {\n    return true;\n  }\n};\n\nexport let Cookies: CookiesStatic<string>\n// This file is included from Node so that it can reuse randomString, but Node doesn't have a global\n// window object.\nif (typeof window !== 'undefined') {\n  Cookies = jsCookie.withAttributes({\n    // Use `SameSite=Lax`, unless Etherpad is embedded in an iframe from another site in which case\n    // use `SameSite=None`. For iframes from another site, only `None` has a chance of working\n    // because the cookies are third-party (not same-site). Many browsers/users block third-party\n    // cookies, but maybe blocked is better than definitely blocked (which would happen with `Lax`\n    // or `Strict`). Note: `None` will not work unless secure is true.\n    //\n    // `Strict` is not used because it has few security benefits but significant usability drawbacks\n    // vs. `Lax`. See https://stackoverflow.com/q/41841880 for discussion.\n    sameSite: inThirdPartyIframe() ? 'None' : 'Lax',\n    secure: window.location.protocol === 'https:',\n  });\n}\n\nexport default new PadUtils()\n"
  },
  {
    "path": "src/static/js/pluginfw/LinkInstaller.ts",
    "content": "import {IPluginInfo, PluginManager} from \"live-plugin-manager\";\nimport path from \"path\";\nimport {node_modules, pluginInstallPath} from \"./installer\";\nimport {accessSync, constants, rmSync, symlinkSync, unlinkSync} from \"node:fs\";\nimport {dependencies, name} from '../../../package.json'\nimport {pathToFileURL} from 'node:url';\nimport settings from '../../../node/utils/Settings';\nimport {readFileSync} from \"fs\";\n\nexport class LinkInstaller {\n    private livePluginManager: PluginManager;\n    private loadedPlugins: IPluginInfo[] = [];\n    /*\n    * A map of dependencies to their dependents\n    *\n     */\n    private readonly dependenciesMap: Map<string, Set<string>>;\n\n    constructor() {\n        this.livePluginManager = new PluginManager({\n            pluginsPath: pluginInstallPath,\n            hostRequire: undefined,\n            cwd: path.join(settings.root, 'src')\n        });\n        this.dependenciesMap = new Map();\n\n    }\n\n\n    public async init() {\n        // Insert Etherpad lite dependencies\n        for (let [dependency] of Object.entries(dependencies)) {\n            if (this.dependenciesMap.has(dependency)) {\n                this.dependenciesMap.get(dependency)?.add(name)\n            } else {\n                this.dependenciesMap.set(dependency, new Set([name]))\n            }\n        }\n    }\n\n    public async installFromPath(path: string) {\n        const installedPlugin = await this.livePluginManager.installFromPath(path)\n        this.linkDependency(installedPlugin.name)\n        await this.checkLinkedDependencies(installedPlugin)\n    }\n\n    public async installFromGitHub(repository: string) {\n        const installedPlugin = await this.livePluginManager.installFromGithub(repository)\n        this.linkDependency(installedPlugin.name)\n        await this.checkLinkedDependencies(installedPlugin)\n    }\n\n    public async installPlugin(pluginName: string, version?: string) {\n        if (version) {\n            const installedPlugin = await this.livePluginManager.install(pluginName, version);\n            this.linkDependency(pluginName)\n            await this.checkLinkedDependencies(installedPlugin)\n        } else {\n            const installedPlugin = await this.livePluginManager.install(pluginName);\n            this.linkDependency(pluginName)\n            await this.checkLinkedDependencies(installedPlugin)\n        }\n    }\n\n    public async listPlugins() {\n        const plugins = this.livePluginManager.list()\n        if (plugins && plugins.length > 0 && this.loadedPlugins.length == 0) {\n            this.loadedPlugins = plugins\n            // Check already installed plugins\n            for (let plugin of plugins) {\n                await this.checkLinkedDependencies(plugin)\n            }\n        }\n        return plugins\n    }\n\n    public async uninstallPlugin(pluginName: string) {\n        const installedPlugin = this.livePluginManager.getInfo(pluginName)\n        if (installedPlugin) {\n            console.debug(`Uninstalling plugin ${pluginName}`)\n            await this.removeSymlink(installedPlugin)\n            await this.livePluginManager.uninstall(pluginName)\n            await this.removeSubDependencies(installedPlugin)\n        }\n    }\n\n    private async removeSubDependencies(plugin: IPluginInfo) {\n        const pluginDependencies = Object.keys(plugin.dependencies)\n        console.debug(\"Removing sub dependencies\",pluginDependencies)\n        for (let dependency of pluginDependencies) {\n            await this.removeSubDependency(plugin.name, dependency)\n        }\n    }\n\n    private async removeSubDependency(_name: string, dependency:string) {\n        if (this.dependenciesMap.has(dependency)) {\n            console.debug(`Dependency ${dependency} is still being used by other plugins`)\n            return\n        }\n        // Read sub dependencies\n        try {\n            const json:IPluginInfo = JSON.parse(\n                readFileSync(pathToFileURL(path.join(pluginInstallPath, dependency, 'package.json'))) as unknown as string);\n            if(json.dependencies){\n                for (let [subDependency] of Object.entries(json.dependencies)) {\n                    await this.removeSubDependency(dependency, subDependency)\n                }\n            }\n        } catch (e){}\n        this.uninstallDependency(dependency)\n    }\n\n    private uninstallDependency(dependency: string) {\n        try {\n            console.debug(`Uninstalling dependency ${dependency}`)\n            // Check if the dependency is already installed\n            accessSync(path.join(pluginInstallPath, dependency), constants.F_OK)\n            rmSync(path.join(pluginInstallPath, dependency), {\n                force: true,\n                recursive: true\n            })\n        } catch (err) {\n            // Symlink does not exist\n            // So nothing to do\n        }\n    }\n\n    private async removeSymlink(plugin: IPluginInfo) {\n        try {\n            accessSync(path.join(node_modules, plugin.name), constants.F_OK)\n            await this.unlinkSubDependencies(plugin)\n            // Remove the plugin itself\n            this.unlinkDependency(plugin.name)\n        } catch (err) {\n            console.error(`Symlink for ${plugin.name} does not exist`)\n            // Symlink does not exist\n            // So nothing to do\n        }\n    }\n\n    private async unlinkSubDependencies(plugin: IPluginInfo) {\n        const pluginDependencies = Object.keys(plugin.dependencies)\n        for (let dependency of pluginDependencies) {\n            this.dependenciesMap.get(dependency)?.delete(plugin.name)\n            await this.unlinkSubDependency(plugin.name, dependency)\n        }\n    }\n\n    private async unlinkSubDependency(plugin: string, dependency: string) {\n        if (this.dependenciesMap.has(dependency)) {\n            this.dependenciesMap.get(dependency)?.delete(plugin)\n            if (this.dependenciesMap.get(dependency)!.size > 0) {\n                // We have other dependants so do not uninstall\n                return\n            }\n        }\n        this.unlinkDependency(dependency)\n        // Read sub dependencies\n        try {\n            const json:IPluginInfo = JSON.parse(\n                readFileSync(pathToFileURL(path.join(pluginInstallPath, dependency, 'package.json'))) as unknown as string);\n            if(json.dependencies){\n                for (let [subDependency] of Object.entries(json.dependencies)) {\n                    await this.unlinkSubDependency(dependency, subDependency)\n                }\n            }\n        } catch (e){}\n\n        console.debug(\"Unlinking sub dependency\",dependency)\n        this.dependenciesMap.delete(dependency)\n    }\n\n\n    private async addSubDependencies(plugin: IPluginInfo) {\n        const pluginDependencies = Object.keys(plugin.dependencies)\n        for (let dependency of pluginDependencies) {\n            await this.addSubDependency(plugin.name, dependency)\n        }\n    }\n\n    private async addSubDependency(plugin: string, dependency: string) {\n        if (this.dependenciesMap.has(dependency)) {\n            // We already added the sub dependency\n            this.dependenciesMap.get(dependency)?.add(plugin)\n        } else {\n\n            try {\n                this.linkDependency(dependency)\n                // Read sub dependencies\n                const json:IPluginInfo = JSON.parse(\n                    readFileSync(pathToFileURL(path.join(pluginInstallPath, dependency, 'package.json'))) as unknown as string);\n                if(json.dependencies){\n                    Object.keys(json.dependencies).forEach((subDependency: string) => {\n                        this.addSubDependency(dependency, subDependency)\n                    })\n                }\n            } catch (err) {\n                console.error(`Error reading package.json ${err} for ${pathToFileURL(path.join(pluginInstallPath, dependency, 'package.json')).toString()}`)\n            }\n            this.dependenciesMap.set(dependency, new Set([plugin]))\n        }\n    }\n\n    private linkDependency(dependency: string) {\n        try {\n            // Check if the dependency is already installed\n            accessSync(path.join(node_modules, dependency), constants.F_OK)\n        } catch (err) {\n            try {\n                if(dependency.startsWith(\"@\")){\n                    const newDependency = dependency.split(\"@\")[0]\n                    symlinkSync(path.join(pluginInstallPath, dependency), path.join(node_modules, newDependency), 'dir')\n                } else {\n                    symlinkSync(path.join(pluginInstallPath, dependency), path.join(node_modules, dependency), 'dir')\n                }\n            } catch (e) {\n                // Nothing to do. We're all set\n            }\n        }\n    }\n\n    private unlinkDependency(dependency: string) {\n        try {\n            // Check if the dependency is already installed\n            accessSync(path.join(node_modules, dependency), constants.F_OK)\n            unlinkSync(path.join(node_modules, dependency))\n        } catch (err) {\n            // Symlink does not exist\n            // So nothing to do\n        }\n    }\n\n\n    private async checkLinkedDependencies(plugin: IPluginInfo) {\n        // Check if the plugin really exists at source\n        try {\n            accessSync(path.join(pluginInstallPath, plugin.name), constants.F_OK)\n            // Skip if the plugin is already linked\n        } catch (err) {\n            // The plugin is not installed\n            console.debug(`Plugin ${plugin.name} is not installed`)\n        }\n        await this.addSubDependencies(plugin)\n        this.dependenciesMap.set(plugin.name, new Set())\n    }\n}\n"
  },
  {
    "path": "src/static/js/pluginfw/client_plugins.ts",
    "content": "// @ts-nocheck\n'use strict';\n\nconst pluginUtils = require('./shared');\nconst defs = require('./plugin_defs');\n\nexports.baseURL = '';\n\nexports.ensure = (cb) => !defs.loaded ? exports.update(cb) : cb();\n\nexports.update = async (modules) => {\n  const data = await jQuery.getJSON(\n    `${exports.baseURL}pluginfw/plugin-definitions.json?v=${clientVars.randomVersionString}`);\n  defs.plugins = data.plugins;\n  defs.parts = data.parts;\n  defs.hooks = pluginUtils.extractHooks(defs.parts, 'client_hooks', null, modules);\n  defs.loaded = true;\n};\n\nconst adoptPluginsFromAncestorsOf = (frame) => {\n  // Bind plugins with parent;\n  let parentRequire = null;\n  try {\n    while ((frame = frame.parent)) {\n      if (typeof (frame.require) !== 'undefined') {\n        parentRequire = frame.require;\n        break;\n      }\n    }\n  } catch (error) {\n    // Silence (this can only be a XDomain issue).\n    console.error(error);\n  }\n\n  if (!parentRequire) throw new Error('Parent plugins could not be found.');\n\n  const ancestorPluginDefs = parentRequire('ep_etherpad-lite/static/js/pluginfw/plugin_defs');\n  defs.hooks = ancestorPluginDefs.hooks;\n  defs.loaded = ancestorPluginDefs.loaded;\n  defs.parts = ancestorPluginDefs.parts;\n  defs.plugins = ancestorPluginDefs.plugins;\n  const ancestorPlugins = parentRequire('ep_etherpad-lite/static/js/pluginfw/client_plugins');\n  exports.baseURL = ancestorPlugins.baseURL;\n  exports.ensure = ancestorPlugins.ensure;\n  exports.update = ancestorPlugins.update;\n};\n\nexports.adoptPluginsFromAncestorsOf = adoptPluginsFromAncestorsOf;\n"
  },
  {
    "path": "src/static/js/pluginfw/hooks.ts",
    "content": "// @ts-nocheck\n'use strict';\n\nconst pluginDefs = require('./plugin_defs');\n\n// Maps the name of a server-side hook to a string explaining the deprecation\n// (e.g., 'use the foo hook instead').\n//\n// If you want to deprecate the fooBar hook, do the following:\n//\n//     const hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks');\n//     hooks.deprecationNotices.fooBar = 'use the newSpiffy hook instead';\n//\nexports.deprecationNotices = {};\n\nconst deprecationWarned = {};\n\nconst checkDeprecation = (hook) => {\n  const notice = exports.deprecationNotices[hook.hook_name];\n  if (notice == null) return;\n  if (deprecationWarned[hook.hook_fn_name]) return;\n  console.warn(`${hook.hook_name} hook used by the ${hook.part.plugin} plugin ` +\n               `(${hook.hook_fn_name}) is deprecated: ${notice}`);\n  deprecationWarned[hook.hook_fn_name] = true;\n};\n\n// Calls the node-style callback when the Promise settles. Unlike util.callbackify, this takes a\n// Promise (rather than a function that returns a Promise), and it returns a Promise (rather than a\n// function that returns undefined).\nconst attachCallback = (p, cb) => p.then(\n    (val) => cb(null, val),\n    // Callbacks often only check the truthiness, not the nullness, of the first parameter. To avoid\n    // problems, always pass a truthy value as the first argument if the Promise is rejected.\n    (err) => cb(err || new Error(err)));\n\n// Normalizes the value provided by hook functions so that it is always an array. `undefined` (but\n// not `null`!) becomes an empty array, array values are returned unmodified, and non-array values\n// are wrapped in an array (so `null` becomes `[null]`).\nconst normalizeValue = (val) => {\n  // `undefined` is treated the same as `[]`. IMPORTANT: `null` is *not* treated the same as `[]`\n  // because some hooks use `null` as a special value.\n  if (val === undefined) return [];\n  if (Array.isArray(val)) return val;\n  return [val];\n};\n\n// Flattens the array one level.\nconst flatten1 = (array) => array.reduce((a, b) => a.concat(b), []);\n\n// Calls the hook function synchronously and returns the value provided by the hook function (via\n// callback or return value).\n//\n// A synchronous hook function can provide a value in these ways:\n//\n//   * Call the callback, passing the desired value (which may be `undefined`) directly as the first\n//     argument, then return `undefined`.\n//   * For hook functions with three (or more) parameters: Directly return the desired value, which\n//     must not be `undefined`. Note: If a three-parameter hook function directly returns\n//     `undefined` and it has not already called the callback then it is indicating that it is not\n//     yet done and will eventually call the callback. This behavior is not supported by synchronous\n//     hooks.\n//   * For hook functions with two (or fewer) parameters: Directly return the desired value (which\n//     may be `undefined`).\n//\n// The callback passed to a hook function is guaranteed to return `undefined`, so it is safe for\n// hook functions to do `return cb(value);`.\n//\n// A hook function can signal an error by throwing.\n//\n// A hook function settles when it provides a value (via callback or return) or throws. If a hook\n// function attempts to settle again (e.g., call the callback again, or call the callback and also\n// return a value) then the second attempt has no effect except either an error message is logged or\n// there will be an unhandled promise rejection depending on whether the subsequent attempt is a\n// duplicate (same value or error) or different, respectively.\n//\n// See the tests in src/tests/backend/specs/hooks.js for examples of supported and prohibited\n// behaviors.\n//\nconst callHookFnSync = (hook, context) => {\n  checkDeprecation(hook);\n\n  // This var is used to keep track of whether the hook function already settled.\n  let outcome;\n\n  // This is used to prevent recursion.\n  let doubleSettleErr;\n\n  const settle = (err, val, how) => {\n    doubleSettleErr = null;\n    const state = err == null ? 'resolved' : 'rejected';\n    if (outcome != null) {\n      // It was already settled, which indicates a bug.\n      const action = err == null ? 'resolve' : 'reject';\n      const msg = (`DOUBLE SETTLE BUG IN HOOK FUNCTION (plugin: ${hook.part.plugin}, ` +\n                   `function name: ${hook.hook_fn_name}, hook: ${hook.hook_name}): ` +\n                   `Attempt to ${action} via ${how} but it already ${outcome.state} ` +\n                   `via ${outcome.how}. Ignoring this attempt to ${action}.`);\n      console.error(msg);\n      if (state !== outcome.state || (err == null ? val !== outcome.val : err !== outcome.err)) {\n        // The second settle attempt differs from the first, which might indicate a serious bug.\n        doubleSettleErr = new Error(msg);\n        throw doubleSettleErr;\n      }\n      return;\n    }\n    outcome = {state, err, val, how};\n    if (val && typeof val.then === 'function') {\n      console.error(`PROHIBITED PROMISE BUG IN HOOK FUNCTION (plugin: ${hook.part.plugin}, ` +\n                    `function name: ${hook.hook_fn_name}, hook: ${hook.hook_name}): ` +\n                    'The hook function provided a \"thenable\" (e.g., a Promise) which is ' +\n                    'prohibited because the hook expects to get the value synchronously.');\n    }\n  };\n\n  // IMPORTANT: This callback must return `undefined` so that a hook function can safely do\n  // `return callback(value);` for backwards compatibility.\n  const callback = (ret) => {\n    settle(null, ret, 'callback');\n  };\n\n  let val;\n  try {\n    val = hook.hook_fn(hook.hook_name, context, callback);\n  } catch (err) {\n    if (err === doubleSettleErr) throw err; // Avoid recursion.\n    try {\n      settle(err, null, 'thrown exception');\n    } catch (doubleSettleErr) {\n      // Schedule the throw of the double settle error on the event loop via\n      // Promise.resolve().then() (which will result in an unhandled Promise rejection) so that the\n      // original error is the error that is seen by the caller. Fixing the original error will\n      // likely fix the double settle bug, so the original error should get priority.\n      Promise.resolve().then(() => { throw doubleSettleErr; });\n    }\n    throw err;\n  }\n\n  // IMPORTANT: This MUST check for undefined -- not nullish -- because some hooks intentionally use\n  // null as a special value.\n  if (val === undefined) {\n    if (outcome != null) return outcome.val; // Already settled via callback.\n    if (hook.hook_fn.length >= 3) {\n      console.error(`UNSETTLED FUNCTION BUG IN HOOK FUNCTION (plugin: ${hook.part.plugin}, ` +\n                    `function name: ${hook.hook_fn_name}, hook: ${hook.hook_name}): ` +\n                    'The hook function neither called the callback nor returned a non-undefined ' +\n                    'value. This is prohibited because it will result in freezes when a future ' +\n                    'version of Etherpad updates the hook to support asynchronous behavior.');\n    } else {\n      // The hook function is assumed to not have a callback parameter, so fall through and accept\n      // `undefined` as the resolved value.\n      //\n      // IMPORTANT: \"Rest\" parameters and default parameters are not included in `Function.length`,\n      // so the assumption does not hold for wrappers such as:\n      //\n      //     const wrapper = (...args) => real(...args);\n      //\n      // ECMAScript does not provide a way to determine whether a function has default or rest\n      // parameters, so there is no way to be certain that a hook function with `length` < 3 will\n      // not call the callback. Synchronous hook functions that call the callback even though\n      // `length` < 3 will still work properly without any logged warnings or errors, but:\n      //\n      //   * Once the hook is upgraded to support asynchronous hook functions, calling the callback\n      //     asynchronously will cause a double settle error, and the hook function will prematurely\n      //     resolve to `undefined` instead of the desired value.\n      //\n      //   * The above \"unsettled function\" warning is not logged if the function fails to call the\n      //     callback like it is supposed to.\n      //\n      // Wrapper functions can avoid problems by setting the wrapper's `length` property to match\n      // the real function's `length` property:\n      //\n      //     Object.defineProperty(wrapper, 'length', {value: real.length});\n    }\n  }\n\n  settle(null, val, 'returned value');\n  return outcome.val;\n};\n\n// DEPRECATED: Use `callAllSerial()` or `aCallAll()` instead.\n//\n// Invokes all registered hook functions synchronously.\n//\n// Arguments:\n//   * hookName: Name of the hook to invoke.\n//   * context: Passed unmodified to the hook functions, except nullish becomes {}.\n//\n// Return value:\n//   A flattened array of hook results. Specifically, it is equivalent to doing the following:\n//     1. Collect all values returned by the hook functions into an array.\n//     2. Convert each `undefined` entry into `[]`.\n//     3. Flatten one level.\nexports.callAll = (hookName, context) => {\n  if (context == null) context = {};\n  const hooks = pluginDefs.hooks[hookName] || [];\n  return flatten1(hooks.map((hook) => normalizeValue(callHookFnSync(hook, context))));\n};\n\n// Calls the hook function asynchronously and returns a Promise that either resolves to the hook\n// function's provided value or rejects with an error generated by the hook function.\n//\n// An asynchronous hook function can provide a value in these ways:\n//\n//   * Call the callback, passing a Promise (or thenable) that resolves to the desired value (which\n//     may be `undefined`) as the first argument.\n//   * Call the callback, passing the desired value (which may be `undefined`) directly as the first\n//     argument.\n//   * Return a Promise (or thenable) that resolves to the desired value (which may be `undefined`).\n//   * For hook functions with three (or more) parameters: Directly return the desired value, which\n//     must not be `undefined`. Note: If a hook function directly returns `undefined` and it has not\n//     already called the callback then it is indicating that it is not yet done and will eventually\n//     call the callback.\n//   * For hook functions with two (or fewer) parameters: Directly return the desired value (which\n//     may be `undefined`).\n//\n// The callback passed to a hook function is guaranteed to return `undefined`, so it is safe for\n// hook functions to do `return cb(valueOrPromise);`.\n//\n// A hook function can signal an error in these ways:\n//\n//   * Throw.\n//   * Return a Promise that rejects.\n//   * Pass a Promise that rejects as the first argument to the provided callback.\n//\n// A hook function settles when it directly provides a value, when it throws, or when the Promise it\n// provides settles (resolves or rejects). If a hook function attempts to settle again (e.g., call\n// the callback again, or return a value and also call the callback) then the second attempt has no\n// effect except either an error message is logged or an Error object is thrown depending on whether\n// the subsequent attempt is a duplicate (same value or error) or different, respectively.\n//\n// See the tests in src/tests/backend/specs/hooks.js for examples of supported and prohibited\n// behaviors.\n//\nconst callHookFnAsync = async (hook, context) => {\n  checkDeprecation(hook);\n  return await new Promise((resolve, reject) => {\n    // This var is used to keep track of whether the hook function already settled.\n    let outcome;\n\n    const settle = (err, val, how) => {\n      const state = err == null ? 'resolved' : 'rejected';\n      if (outcome != null) {\n        // It was already settled, which indicates a bug.\n        const action = err == null ? 'resolve' : 'reject';\n        const msg = (`DOUBLE SETTLE BUG IN HOOK FUNCTION (plugin: ${hook.part.plugin}, ` +\n                     `function name: ${hook.hook_fn_name}, hook: ${hook.hook_name}): ` +\n                     `Attempt to ${action} via ${how} but it already ${outcome.state} ` +\n                     `via ${outcome.how}. Ignoring this attempt to ${action}.`);\n        console.error(msg);\n        if (state !== outcome.state || (err == null ? val !== outcome.val : err !== outcome.err)) {\n          // The second settle attempt differs from the first, which might indicate a serious bug.\n          throw new Error(msg);\n        }\n        return;\n      }\n      outcome = {state, err, val, how};\n      if (err == null) { resolve(val); } else { reject(err); }\n    };\n\n    // IMPORTANT: This callback must return `undefined` so that a hook function can safely do\n    // `return callback(value);` for backwards compatibility.\n    const callback = (ret) => {\n      // Wrap ret in a Promise so that a hook function can do `callback(asyncFunction());`. Note: If\n      // ret is a Promise (or other thenable), Promise.resolve() will flatten it into this new\n      // Promise.\n      Promise.resolve(ret).then(\n          (val) => settle(null, val, 'callback'),\n          (err) => settle(err, null, 'rejected Promise passed to callback'));\n    };\n\n    let ret;\n    try {\n      ret = hook.hook_fn(hook.hook_name, context, callback);\n    } catch (err) {\n      try {\n        settle(err, null, 'thrown exception');\n      } catch (doubleSettleErr) {\n        // Schedule the throw of the double settle error on the event loop via\n        // Promise.resolve().then() (which will result in an unhandled Promise rejection) so that\n        // the original error is the error that is seen by the caller. Fixing the original error\n        // will likely fix the double settle bug, so the original error should get priority.\n        Promise.resolve().then(() => { throw doubleSettleErr; });\n      }\n      throw err;\n    }\n\n    // IMPORTANT: This MUST check for undefined -- not nullish -- because some hooks intentionally\n    // use null as a special value.\n    if (ret === undefined) {\n      if (hook.hook_fn.length >= 3) {\n        // The hook function has a callback parameter and it returned undefined, which means the\n        // hook function will settle (or has already settled) via the provided callback.\n        return;\n      } else {\n        // The hook function is assumed to not have a callback parameter, so fall through and accept\n        // `undefined` as the resolved value.\n        //\n        // IMPORTANT: \"Rest\" parameters and default parameters are not included in\n        // `Function.length`, so the assumption does not hold for wrappers such as:\n        //\n        //     const wrapper = (...args) => real(...args);\n        //\n        // ECMAScript does not provide a way to determine whether a function has default or rest\n        // parameters, so there is no way to be certain that a hook function with `length` < 3 will\n        // not call the callback. Hook functions with `length` < 3 that call the callback\n        // asynchronously will cause a double settle error, and the hook function will prematurely\n        // resolve to `undefined` instead of the desired value.\n        //\n        // Wrapper functions can avoid problems by setting the wrapper's `length` property to match\n        // the real function's `length` property:\n        //\n        //     Object.defineProperty(wrapper, 'length', {value: real.length});\n      }\n    }\n\n    // Wrap ret in a Promise so that hook functions can be async (or otherwise return a Promise).\n    // Note: If ret is a Promise (or other thenable), Promise.resolve() will flatten it into this\n    // new Promise.\n    Promise.resolve(ret).then(\n        (val) => settle(null, val, 'returned value'),\n        (err) => settle(err, null, 'Promise rejection'));\n  });\n};\n\n// Invokes all registered hook functions asynchronously and concurrently. This is NOT the async\n// equivalent of `callAll()`: `callAll()` calls the hook functions serially (one at a time) but this\n// function calls them concurrently. Use `callAllSerial()` if the hook functions must be called one\n// at a time.\n//\n// Arguments:\n//   * hookName: Name of the hook to invoke.\n//   * context: Passed unmodified to the hook functions, except nullish becomes {}.\n//   * cb: Deprecated. Optional node-style callback. The following:\n//         const p1 = hooks.aCallAll('myHook', context, cb);\n//     is equivalent to:\n//         const p2 = hooks.aCallAll('myHook', context).then(\n//             (val) => cb(null, val), (err) => cb(err || new Error(err)));\n//\n// Return value:\n//   If cb is nullish, this function resolves to a flattened array of hook results. Specifically, it\n//   is equivalent to doing the following:\n//     1. Collect all values returned by the hook functions into an array.\n//     2. Convert each `undefined` entry into `[]`.\n//     3. Flatten one level.\n//   If cb is non-null, this function resolves to the value returned by cb.\nexports.aCallAll = async (hookName, context, cb = null) => {\n  if (cb != null) return await attachCallback(exports.aCallAll(hookName, context), cb);\n  if (context == null) context = {};\n  const hooks = pluginDefs.hooks[hookName] || [];\n  const results = await Promise.all(\n      hooks.map(async (hook) => normalizeValue(await callHookFnAsync(hook, context))));\n  return flatten1(results);\n};\n\n// Like `aCallAll()` except the hook functions are called one at a time instead of concurrently.\n// Only use this function if the hook functions must be called one at a time, otherwise use\n// `aCallAll()`.\nexports.callAllSerial = async (hookName, context) => {\n  if (context == null) context = {};\n  const hooks = pluginDefs.hooks[hookName] || [];\n  const results = [];\n  for (const hook of hooks) {\n    results.push(normalizeValue(await callHookFnAsync(hook, context)));\n  }\n  return flatten1(results);\n};\n\n// DEPRECATED: Use `aCallFirst()` instead.\n//\n// Like `aCallFirst()`, but synchronous. Hook functions must provide their values synchronously.\nexports.callFirst = (hookName, context) => {\n  if (context == null) context = {};\n  const predicate = (val) => val.length;\n  const hooks = pluginDefs.hooks[hookName] || [];\n  for (const hook of hooks) {\n    const val = normalizeValue(callHookFnSync(hook, context));\n    if (predicate(val)) return val;\n  }\n  return [];\n};\n\n// Invokes the registered hook functions one at a time until one provides a value that meets a\n// customizable condition.\n//\n// Arguments:\n//   * hookName: Name of the hook to invoke.\n//   * context: Passed unmodified to the hook functions, except nullish becomes {}.\n//   * cb: Deprecated callback. The following:\n//         const p1 = hooks.aCallFirst('myHook', context, cb);\n//     is equivalent to:\n//         const p2 = hooks.aCallFirst('myHook', context).then(\n//             (val) => cb(null, val), (err) => cb(err || new Error(err)));\n//   * predicate: Optional predicate function that returns true if the hook function provided a\n//     value that satisfies a desired condition. If nullish, the predicate defaults to a non-empty\n//     array check. The predicate is invoked each time a hook function returns. It takes one\n//     argument: the normalized value provided by the hook function. If the predicate returns\n//     truthy, iteration over the hook functions stops (no more hook functions will be called).\n//\n// Return value:\n//   If cb is nullish, resolves to an array that is either the normalized value that satisfied the\n//   predicate or empty if the predicate was never satisfied. If cb is non-nullish, resolves to the\n//   value returned from cb().\nexports.aCallFirst = async (hookName, context, cb = null, predicate = null) => {\n  if (cb != null) {\n    return await attachCallback(exports.aCallFirst(hookName, context, null, predicate), cb);\n  }\n  if (context == null) context = {};\n  if (predicate == null) predicate = (val) => val.length;\n  const hooks = pluginDefs.hooks[hookName] || [];\n  for (const hook of hooks) {\n    const val = normalizeValue(await callHookFnAsync(hook, context));\n    if (predicate(val)) return val;\n  }\n  return [];\n};\n\nexports.exportedForTestingOnly = {\n  callHookFnAsync,\n  callHookFnSync,\n  deprecationWarned,\n};\n"
  },
  {
    "path": "src/static/js/pluginfw/installer.ts",
    "content": "'use strict';\n\nimport log4js from \"log4js\";\n\nimport axios, {AxiosResponse} from \"axios\";\nimport {PackageData, PackageInfo} from \"../../../node/types/PackageInfo\";\nimport {MapArrayType} from \"../../../node/types/MapType\";\n\nimport path from \"path\";\n\nimport {promises as fs} from \"fs\";\n\nconst plugins = require('./plugins');\nconst hooks = require('./hooks');\nconst runCmd = require('../../../node/utils/run_cmd');\nimport  settings, {\n  getEpVersion,\n  reloadSettings\n} from '../../../node/utils/Settings';\nimport {LinkInstaller} from \"./LinkInstaller\";\n\nimport {findEtherpadRoot} from '../../../node/utils/AbsolutePaths';\nconst logger = log4js.getLogger('plugins');\n\nexport const pluginInstallPath = path.join(settings.root, 'src','plugin_packages');\nexport const node_modules = path.join(findEtherpadRoot(),'src', 'node_modules');\n\nexport const installedPluginsPath = path.join(settings.root, 'var/installed_plugins.json');\n\nconst onAllTasksFinished = async () => {\n  await plugins.update();\n  await persistInstalledPlugins();\n  reloadSettings();\n  await hooks.aCallAll('loadSettings', {settings});\n  await hooks.aCallAll('restartServer');\n};\n\nconst headers = {\n  'User-Agent': `Etherpad/${getEpVersion()}`,\n};\n\nlet tasks = 0;\n\nexport const linkInstaller = new LinkInstaller();\n\nconst wrapTaskCb = (cb:Function|null) => {\n  tasks++;\n\n  return (...args: any) => {\n    cb && cb(...args);\n    tasks--;\n    if (tasks === 0) onAllTasksFinished();\n  };\n};\n\nconst migratePluginsFromNodeModules = async () => {\n  logger.info('start migration of plugins in node_modules');\n  // Notes:\n  //   * Do not pass `--prod` otherwise `npm ls` will fail if there is no `package.json`.\n  //   * The `--no-production` flag is required (or the `NODE_ENV` environment variable must be\n  //     unset or set to `development`) because otherwise `npm ls` will not mention any packages\n  //     that are not included in `package.json` (which is expected to not exist).\n  const cmd = ['pnpm', 'ls', '--long', '--json', '--depth=0', '--no-production'];\n  const [{dependencies = {}}] = JSON.parse(await runCmd(cmd,\n      {stdio: [null, 'string']}));\n\n  await Promise.all(Object.entries(dependencies)\n      .filter(([pkg, info]) => pkg.startsWith(plugins.prefix) && pkg !== 'ep_etherpad-lite')\n      .map(async ([pkg, info]) => {\n          const _info = info as PackageInfo\n          if (!_info.resolved) {\n          // Install from node_modules directory\n          await linkInstaller.installFromPath(`${findEtherpadRoot()}/node_modules/${pkg}`);\n        } else {\n          await linkInstaller.installPlugin(pkg);\n        }\n      }));\n  await persistInstalledPlugins();\n};\n\nexport const checkForMigration = async () => {\n  logger.info('check installed plugins for migration');\n  // Initialize linkInstaller\n  await linkInstaller.init()\n\n  try {\n    await fs.access(installedPluginsPath, fs.constants.F_OK);\n  } catch (err) {\n    await migratePluginsFromNodeModules();\n  }\n\n  /*\n  * Check if the plugin is already installed in node_modules\n  * If not, create a symlink to node_modules\n  * This is necessary as\n  * 1. Live Plugin Manager does not support loading plugins from the directory so that node can access them normally\n  * 2. Plugins can't be directly installed to node_modules otherwise upgrading Etherpad will remove them\n */\n\n\n  fs.stat(pluginInstallPath).then(async (err) => {\n    const files = await fs.readdir(pluginInstallPath);\n\n    for (let file of files){\n      const moduleName = path.basename(file);\n      if (moduleName === '.versions') {\n        // Skip the directory using live-plugin-manager\n        continue;\n      }\n      try {\n        await fs.access(path.join(node_modules, moduleName), fs.constants.F_OK);\n        logger.debug(`plugin ${moduleName} already exists in node_modules`);\n      } catch (err) {\n        // Create symlink to node_modules\n        logger.debug(`create symlink for ${file} to ${path.join(node_modules,moduleName)}`)\n        await fs.symlink(path.join(pluginInstallPath,file), path.join(node_modules,moduleName), 'dir')\n      }\n    }\n  }).catch(()=>{\n    logger.debug('plugin directory does not exist');\n  })\n  const fileContent = await fs.readFile(installedPluginsPath);\n  const installedPlugins = JSON.parse(fileContent.toString());\n\n  for (const plugin of installedPlugins.plugins) {\n    if (plugin.name.startsWith(plugins.prefix) && plugin.name !== 'ep_etherpad-lite') {\n      try {\n        await linkInstaller.installPlugin(plugin.name, plugin.version);\n      } catch (e) {\n        logger.error(`Error installing plugin ${plugin.name} with version ${plugin.version}: ${e}`);\n      }\n    }\n  }\n};\n\nconst persistInstalledPlugins = async () => {\n  const installedPlugins:{\n    plugins: PackageData[]\n  } = {plugins: []};\n  for (const pkg of Object.values(await plugins.getPackages()) as PackageData[]) {\n    installedPlugins.plugins.push({\n      name: pkg.name,\n      version: pkg.version,\n    });\n  }\n  installedPlugins.plugins = [...new Set(installedPlugins.plugins)];\n  await fs.writeFile(installedPluginsPath, JSON.stringify(installedPlugins));\n};\n\nexport const uninstall = async (pluginName: string, cb:Function|null = null) => {\n  cb = wrapTaskCb(cb);\n  logger.info(`Uninstalling plugin ${pluginName}...`);\n\n  await linkInstaller.uninstallPlugin(pluginName);\n  logger.info(`Successfully uninstalled plugin ${pluginName}`);\n  await hooks.aCallAll('pluginUninstall', {pluginName});\n  cb(null);\n};\n\nexport const install = async (pluginName: string, cb:Function|null = null) => {\n  cb = wrapTaskCb(cb);\n  logger.info(`Installing plugin ${pluginName}...`);\n  await linkInstaller.installPlugin(pluginName);\n  logger.info(`Successfully installed plugin ${pluginName}`);\n  await hooks.aCallAll('pluginInstall', {pluginName});\n  cb(null);\n};\n\nexport let availablePlugins:MapArrayType<PackageInfo>|null = null;\nlet cacheTimestamp = 0;\n\nexport const getAvailablePlugins = async (maxCacheAge: number | false) => {\n  const nowTimestamp = Math.round(Date.now() / 1000);\n\n  // check cache age before making any request\n  if (availablePlugins && maxCacheAge && (nowTimestamp - cacheTimestamp) <= maxCacheAge) {\n    return availablePlugins;\n  }\n\n  const pluginsLoaded: AxiosResponse<MapArrayType<PackageInfo>> = await axios.get(`${settings.updateServer}/plugins.json`, {headers})\n  availablePlugins = pluginsLoaded.data;\n  cacheTimestamp = nowTimestamp;\n  return availablePlugins;\n};\n\n\nexport const search = (searchTerm: string, maxCacheAge: number) => getAvailablePlugins(maxCacheAge).then(\n    (results: MapArrayType<PackageInfo>) => {\n      const res:MapArrayType<PackageData> = {};\n\n      if (searchTerm) {\n        searchTerm = searchTerm.toLowerCase();\n      }\n\n      for (const pluginName in results) {\n        // for every available plugin\n        // TODO: Also search in keywords here!\n        if (pluginName.indexOf(plugins.prefix) !== 0) continue;\n\n        if (searchTerm && !~results[pluginName].name.toLowerCase().indexOf(searchTerm) &&\n            (typeof results[pluginName].description !== 'undefined' &&\n                !~results[pluginName].description.toLowerCase().indexOf(searchTerm))\n        ) {\n          if (typeof results[pluginName].description === 'undefined') {\n            logger.debug(`plugin without Description: ${results[pluginName].name}`);\n          }\n\n          continue;\n        }\n\n        res[pluginName] = results[pluginName];\n      }\n\n      return res;\n    }\n).catch((err)=>{\n  logger.error(`Error searching plugins: ${err}`);\n  return {} as MapArrayType<PackageInfo>;\n});\n"
  },
  {
    "path": "src/static/js/pluginfw/plugin_defs.ts",
    "content": "'use strict';\n\n// This module contains processed plugin definitions. The data structures in this file are set by\n// plugins.js (server) or client_plugins.js (client).\n\n// Maps a hook name to a list of hook objects. Each hook object has the following properties:\n//   * hook_name: Name of the hook.\n//   * hook_fn: Plugin-supplied hook function.\n//   * hook_fn_name: Name of the hook function, with the form <filename>:<functionName>.\n//   * part: The ep.json part object that declared the hook. See exports.plugins.\nexports.hooks = {};\n\n// Whether the plugins have been loaded.\nexports.loaded = false;\n\n// Topologically sorted list of parts from exports.plugins.\nexports.parts = [];\n\n// Maps the name of a plugin to the plugin's definition provided in ep.json. The ep.json object is\n// augmented with additional metadata:\n//   * parts: Each part from the ep.json object is augmented with the following properties:\n//       - plugin: The name of the plugin.\n//       - full_name: Equal to <plugin>/<name>.\n//   * package (server-side only): Object containing details about the plugin package:\n//       - version\n//       - path\n//       - realPath\nexports.plugins = {};\n"
  },
  {
    "path": "src/static/js/pluginfw/plugins.ts",
    "content": "// @ts-nocheck\n'use strict';\n\nconst fs = require('fs').promises;\nconst hooks = require('./hooks');\nconst log4js = require('log4js');\nconst path = require('path');\nconst runCmd = require('../../../node/utils/run_cmd');\nconst tsort = require('./tsort');\nconst pluginUtils = require('./shared');\nconst defs = require('./plugin_defs');\nimport settings, {\n  getEpVersion,\n} from '../../../node/utils/Settings';\n\nconst logger = log4js.getLogger('plugins');\n\n// Log the version of npm at startup.\n(async () => {\n  try {\n    const version = await runCmd(['pnpm', '--version'], {stdio: [null, 'string']});\n    logger.info(`pnpm --version: ${version}`);\n  } catch (err) {\n    logger.error(`Failed to get pnpm version: ${err.stack || err}`);\n    // This isn't a fatal error so don't re-throw.\n  }\n})();\n\nexports.prefix = 'ep_';\n\nexports.formatPlugins = () => Object.keys(defs.plugins).join(', ');\n\nexports.getPlugins = () => Object.keys(defs.plugins);\n\nexports.formatParts = () => defs.parts.map((part) => part.full_name).join('\\n');\n\nexports.getParts = () => defs.parts.map((part) => part.full_name);\n\nconst sortHooks = (hookSetName, hooks) => {\n  for (const [pluginName, def] of Object.entries(defs.plugins)) {\n    for (const part of def.parts) {\n      for (const [hookName, hookFnName] of Object.entries(part[hookSetName] || {})) {\n        let hookEntry = hooks.get(hookName);\n        if (!hookEntry) {\n          hookEntry = new Map();\n          hooks.set(hookName, hookEntry);\n        }\n        let pluginEntry = hookEntry.get(pluginName);\n        if (!pluginEntry) {\n          pluginEntry = new Map();\n          hookEntry.set(pluginName, pluginEntry);\n        }\n        pluginEntry.set(part.name, hookFnName);\n      }\n    }\n  }\n};\n\n\nexports.getHooks = (hookSetName) => {\n  const hooks = new Map();\n  sortHooks(hookSetName, hooks);\n  return hooks;\n};\n\nexports.formatHooks = (hookSetName, html) => {\n  let hooks = new Map();\n  sortHooks(hookSetName, hooks);\n  const lines = [];\n  const sortStringKeys = (a, b) => String(a[0]).localeCompare(b[0]);\n  if (html) lines.push('<dl>');\n  hooks = new Map([...hooks].sort(sortStringKeys));\n  for (const [hookName, hookEntry] of hooks) {\n    lines.push(html ? `  <dt>${hookName}:</dt><dd><dl>` : `  ${hookName}:`);\n    const sortedHookEntry = new Map([...hookEntry].sort(sortStringKeys));\n    hooks.set(hookName, sortedHookEntry);\n    for (const [pluginName, pluginEntry] of sortedHookEntry) {\n      lines.push(html ? `    <dt>${pluginName}:</dt><dd><dl>` : `    ${pluginName}:`);\n      const sortedPluginEntry = new Map([...pluginEntry].sort(sortStringKeys));\n      sortedHookEntry.set(pluginName, sortedPluginEntry);\n      for (const [partName, hookFnName] of sortedPluginEntry) {\n        lines.push(html\n          ? `      <dt>${partName}:</dt><dd>${hookFnName}</dd>`\n          : `      ${partName}: ${hookFnName}`);\n      }\n      if (html) lines.push('    </dl></dd>');\n    }\n    if (html) lines.push('  </dl></dd>');\n  }\n  if (html) lines.push('</dl>');\n  return lines.join('\\n');\n};\n\nexports.pathNormalization = (part, hookFnName, hookName) => {\n  const tmp = hookFnName.split(':'); // hookFnName might be something like 'C:\\\\foo.js:myFunc'.\n  // If there is a single colon assume it's 'filename:funcname' not 'C:\\\\filename'.\n  const functionName = (tmp.length > 1 ? tmp.pop() : null) || hookName;\n  const moduleName = tmp.join(':') || part.plugin;\n  const packageDir = path.dirname(defs.plugins[part.plugin].package.path);\n  const fileName = path.join(packageDir, moduleName);\n  return `${fileName}:${functionName}`;\n};\n\nexports.update = async () => {\n  const packages = await exports.getPackages();\n  const parts = {}; // Key is full name. sortParts converts this into a topologically sorted array.\n  const plugins = {};\n\n  // Load plugin metadata ep.json\n  await Promise.all(Object.keys(packages).map(async (pluginName) => {\n    logger.info(`Loading plugin ${pluginName}...`);\n    await loadPlugin(packages, pluginName, plugins, parts);\n  }));\n  logger.info(`Loaded ${Object.keys(packages).length} plugins`);\n\n  defs.plugins = plugins;\n  defs.parts = sortParts(parts);\n  defs.hooks = pluginUtils.extractHooks(defs.parts, 'hooks', exports.pathNormalization);\n  defs.loaded = true;\n  await Promise.all(Object.keys(defs.plugins).map(async (p) => {\n    const logger = log4js.getLogger(`plugin:${p}`);\n    await hooks.aCallAll(`init_${p}`, {logger});\n  }));\n};\n\nexports.getPackages = async () => {\n  const {linkInstaller} = require(\"./installer\");\n  const plugins = await linkInstaller.listPlugins();\n  const newDependencies = {};\n\n  for (const plugin of plugins) {\n    if (!plugin.name.startsWith(exports.prefix)) {\n      continue;\n    }\n    plugin.path = plugin.realPath = plugin.location;\n    newDependencies[plugin.name] = plugin;\n  }\n\n  newDependencies['ep_etherpad-lite'] = {\n    name: 'ep_etherpad-lite',\n    version: getEpVersion(),\n    path: path.join(settings.root, 'node_modules/ep_etherpad-lite'),\n    realPath: path.join(settings.root, 'src'),\n  };\n\n  return newDependencies;\n};\n\nconst loadPlugin = async (packages, pluginName, plugins, parts) => {\n  const pluginPath = path.resolve(packages[pluginName].path, 'ep.json');\n  try {\n    const data = await fs.readFile(pluginPath);\n    try {\n      const plugin = JSON.parse(data);\n      plugin.package = packages[pluginName];\n      plugins[pluginName] = plugin;\n      for (const part of plugin.parts) {\n        part.plugin = pluginName;\n        part.full_name = `${pluginName}/${part.name}`;\n        parts[part.full_name] = part;\n      }\n    } catch (err) {\n      logger.error(`Unable to parse plugin definition file ${pluginPath}: ${err.stack || err}`);\n    }\n  } catch (err) {\n    logger.error(`Unable to load plugin definition file ${pluginPath}: ${err.stack || err}`);\n  }\n};\n\nconst partsToParentChildList = (parts) => {\n  const res = [];\n  for (const name of Object.keys(parts)) {\n    for (const childName of parts[name].post || []) {\n      res.push([name, childName]);\n    }\n    for (const parentName of parts[name].pre || []) {\n      res.push([parentName, name]);\n    }\n    if (!parts[name].pre && !parts[name].post) {\n      res.push([name, `:${name}`]); // Include apps with no dependency info\n    }\n  }\n  return res;\n};\n\n// Used only in Node, so no need for _\nconst sortParts = (parts) => tsort(partsToParentChildList(parts))\n    .filter((name) => parts[name] !== undefined)\n    .map((name) => parts[name]);\n"
  },
  {
    "path": "src/static/js/pluginfw/shared.ts",
    "content": "// @ts-nocheck\n'use strict';\n\nconst defs = require('./plugin_defs');\n\nconst disabledHookReasons = {\n  hooks: {\n    indexCustomInlineScripts: 'The hook makes it impossible to use a Content Security Policy ' +\n        'that prohibits inline code. Permitting inline code makes XSS vulnerabilities more likely',\n  },\n};\n\nconst loadFn = (path, hookName, modules) => {\n  let functionName;\n  const parts = path.split(':');\n\n  // on windows: C:\\foo\\bar:xyz\n  if (parts[0].length === 1) {\n    if (parts.length === 3) {\n      functionName = parts.pop();\n    }\n    path = parts.join(':');\n  } else {\n    path = parts[0];\n    functionName = parts[1];\n  }\n\n  let fn\n  if (modules === undefined || !(\"get\" in modules)) {\n    fn = require(/* webpackIgnore: true */ path);\n  } else {\n    fn = modules.get(path);\n  }\n\n  functionName = functionName ? functionName : hookName;\n\n  for (const name of functionName.split('.')) {\n    fn = fn[name];\n  }\n  return fn;\n};\n\nconst extractHooks = (parts, hookSetName, normalizer, modules) => {\n  const hooks = {};\n  for (const part of parts) {\n    for (const [hookName, regHookFnName] of Object.entries(part[hookSetName] || {})) {\n      /* On the server side, you can't just\n       * require(\"pluginname/whatever\") if the plugin is installed as\n       * a dependency of another plugin! Bah, pesky little details of\n       * npm... */\n      const hookFnName = normalizer ? normalizer(part, regHookFnName, hookName) : regHookFnName;\n\n      const disabledReason = (disabledHookReasons[hookSetName] || {})[hookName];\n      if (disabledReason) {\n        console.error(`Hook ${hookSetName}/${hookName} is disabled. Reason: ${disabledReason}`);\n        console.error(`The hook function ${hookFnName} from plugin ${part.plugin} ` +\n                      'will never be called, which may cause the plugin to fail');\n        console.error(`Please update the ${part.plugin} plugin to not use the ${hookName} hook`);\n        return;\n      }\n      let hookFn;\n      try {\n        hookFn = loadFn(hookFnName, hookName, modules);\n        if (!hookFn) throw new Error('Not a function');\n      } catch (err) {\n        console.error(`Failed to load hook function \"${hookFnName}\" for plugin \"${part.plugin}\" ` +\n                      `part \"${part.name}\" hook set \"${hookSetName}\" hook \"${hookName}\": ` +\n                      `${err.stack || err}`);\n      }\n      if (hookFn) {\n        if (hooks[hookName] == null) hooks[hookName] = [];\n        hooks[hookName].push({\n          hook_name: hookName,\n          hook_fn: hookFn,\n          hook_fn_name: hookFnName,\n          part,\n        });\n      }\n    }\n  }\n  return hooks;\n};\n\nexports.extractHooks = extractHooks;\n\n/*\n * Returns an array containing the names of the installed client-side plugins\n *\n * If no client-side plugins are installed, returns an empty array.\n * Duplicate names are always discarded.\n *\n * A client-side plugin is a plugin that implements at least one client_hook\n *\n * EXAMPLE:\n *   No plugins:   []\n *   Some plugins: [ 'ep_adminpads', 'ep_add_buttons', 'ep_activepads' ]\n */\nexports.clientPluginNames = () => {\n  const clientPluginNames = defs.parts\n      .filter((part) => Object.prototype.hasOwnProperty.call(part, 'client_hooks'))\n      .map((part) => `plugin-${part.plugin}`);\n  return [...new Set(clientPluginNames)];\n};\n"
  },
  {
    "path": "src/static/js/pluginfw/tsort.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * general topological sort\n * from https://gist.github.com/1232505\n * @author SHIN Suzuki (shinout310@gmail.com)\n * @param Array<Array> edges : list of edges. each edge forms Array<ID,ID> e.g. [12 , 3]\n *\n * @returns Array : topological sorted list of IDs\n **/\n\nconst tsort = (edges) => {\n  const nodes = {}; // hash: stringified id of the node => { id: id, afters: lisf of ids }\n  const sorted = []; // sorted list of IDs ( returned value )\n  const visited = {}; // hash: id of already visited node => true\n\n  const Node = function (id) {\n    this.id = id;\n    this.afters = [];\n  };\n\n  // 1. build data structures\n  edges.forEach((v) => {\n    const from = v[0]; const\n      to = v[1];\n    if (!nodes[from]) nodes[from] = new Node(from);\n    if (!nodes[to]) nodes[to] = new Node(to);\n    nodes[from].afters.push(to);\n  });\n\n  const visit = (idstr, ancestors) => {\n    const node = nodes[idstr];\n    const id = node.id;\n\n    // if already exists, do nothing\n    if (visited[idstr]) return;\n\n    if (!Array.isArray(ancestors)) ancestors = [];\n\n    ancestors.push(id);\n\n    visited[idstr] = true;\n\n    node.afters.forEach((afterID) => {\n      // if already in ancestors, a closed chain exists.\n      if (ancestors.indexOf(afterID) >= 0) throw new Error(`closed chain : ${afterID} is in ${id}`);\n\n      visit(afterID.toString(), ancestors.map((v) => v)); // recursive call\n    });\n\n    sorted.unshift(id);\n  };\n\n  // 2. topological sort\n  Object.keys(nodes).forEach(visit);\n\n  return sorted;\n};\n\n/**\n * TEST\n **/\nconst tsortTest = () => {\n  // example 1: success\n  let edges = [\n    [1, 2],\n    [1, 3],\n    [2, 4],\n    [3, 4],\n  ];\n\n  let sorted = tsort(edges);\n\n  // example 2: failure ( A > B > C > A )\n  edges = [\n    ['A', 'B'],\n    ['B', 'C'],\n    ['C', 'A'],\n  ];\n\n  try {\n    sorted = tsort(edges);\n    console.log('succeeded', sorted);\n  } catch (e) {\n    console.log(e.message);\n  }\n\n  // example 3: generate random edges\n  const max = 100;\n  const iteration = 30;\n  const randomInt = (max) => Math.floor(Math.random() * max) + 1;\n\n  edges = (() => {\n    const ret = [];\n    let i = 0;\n    while (i++ < iteration) ret.push([randomInt(max), randomInt(max)]);\n    return ret;\n  })();\n\n  try {\n    sorted = tsort(edges);\n    console.log('succeeded', sorted);\n  } catch (e) {\n    console.log('failed', e.message);\n  }\n};\n\n// for node.js\nif (typeof exports === 'object' && exports === this) {\n  module.exports = tsort;\n  if (process.argv[1] === __filename) tsortTest();\n}\n"
  },
  {
    "path": "src/static/js/rjquery.ts",
    "content": "// @ts-nocheck\n'use strict';\n// Provides a require'able version of jQuery without leaking $ and jQuery;\nwindow.$ = require('./vendors/jquery');\nconst jq = window.$.noConflict(true);\nexports.jQuery = exports.$ = jq;\n"
  },
  {
    "path": "src/static/js/scroll.ts",
    "content": "import {getBottomOfNextBrowserLine, getNextVisibleLine, getPosition, getPositionTopOfPreviousBrowserLine, getPreviousVisibleLine} from './caretPosition';\nimport {Position, RepModel, RepNode, WindowElementWithScrolling} from \"./types/RepModel\";\n\n\nclass Scroll {\n  private readonly outerWin: HTMLIFrameElement;\n  private readonly doc: Document;\n  private rootDocument: Document;\n  private scrollSettings: any;\n\n  constructor(outerWin: HTMLIFrameElement) {\n    // @ts-ignore\n    this.scrollSettings = window.clientVars.scrollWhenFocusLineIsOutOfViewport;\n\n    // DOM reference\n    this.outerWin = outerWin;\n    this.doc = this.outerWin.contentDocument!;\n    this.rootDocument = document;\n  }\n\n  scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary(rep: RepModel, isScrollableEvent: boolean, innerHeight: number) {\n    // are we placing the caret on the line at the bottom of viewport?\n    // And if so, do we need to scroll the editor, as defined on the settings.json?\n    const shouldScrollWhenCaretIsAtBottomOfViewport =\n      this.scrollSettings.scrollWhenCaretIsInTheLastLineOfViewport;\n    if (shouldScrollWhenCaretIsAtBottomOfViewport) {\n      // avoid scrolling when selection includes multiple lines --\n      // user can potentially be selecting more lines\n      // than it fits on viewport\n      const multipleLinesSelected = rep.selStart[0] !== rep.selEnd[0];\n\n      // avoid scrolling when pad loads\n      if (isScrollableEvent && !multipleLinesSelected && this._isCaretAtTheBottomOfViewport(rep)) {\n        // when scrollWhenFocusLineIsOutOfViewport.percentage is 0, pixelsToScroll is 0\n        const pixelsToScroll = this._getPixelsRelativeToPercentageOfViewport(innerHeight);\n        this._scrollYPage(pixelsToScroll);\n      }\n    }\n  }\n\n  scrollWhenPressArrowKeys(arrowUp: boolean, rep: RepModel, innerHeight: number) {\n    // if percentageScrollArrowUp is 0, let the scroll to be handled as default, put the previous\n    // rep line on the top of the viewport\n    if (this._arrowUpWasPressedInTheFirstLineOfTheViewport(arrowUp, rep)) {\n      const pixelsToScroll = this._getPixelsToScrollWhenUserPressesArrowUp(innerHeight);\n\n      // by default, the browser scrolls to the middle of the viewport. To avoid the twist made\n      // when we apply a second scroll, we made it immediately (without animation)\n      this._scrollYPageWithoutAnimation(-pixelsToScroll);\n    } else {\n      this.scrollNodeVerticallyIntoView(rep, innerHeight);\n    }\n  }\n\n  _isCaretAtTheBottomOfViewport(rep: RepModel) {\n    // computing a line position using getBoundingClientRect() is expensive.\n    // (obs: getBoundingClientRect() is called on caretPosition.getPosition())\n    // To avoid that, we only call this function when it is possible that the\n    // caret is in the bottom of viewport\n    const caretLine = rep.selStart[0];\n    const lineAfterCaretLine = caretLine + 1;\n    const firstLineVisibleAfterCaretLine = getNextVisibleLine(lineAfterCaretLine, rep);\n    const caretLineIsPartiallyVisibleOnViewport =\n      this._isLinePartiallyVisibleOnViewport(caretLine, rep);\n    const lineAfterCaretLineIsPartiallyVisibleOnViewport =\n      this._isLinePartiallyVisibleOnViewport(firstLineVisibleAfterCaretLine, rep);\n    if (caretLineIsPartiallyVisibleOnViewport || lineAfterCaretLineIsPartiallyVisibleOnViewport) {\n      // check if the caret is in the bottom of the viewport\n      const caretLinePosition = getPosition()!;\n      const viewportBottom = this._getViewPortTopBottom().bottom;\n      const nextLineBottom = getBottomOfNextBrowserLine(caretLinePosition, rep);\n      return nextLineBottom > viewportBottom;\n    }\n    return false;\n  };\n\n  _isLinePartiallyVisibleOnViewport(lineNumber: number, rep: RepModel){\n    const lineNode = rep.lines.atIndex(lineNumber);\n    const linePosition = this._getLineEntryTopBottom(lineNode);\n    const lineTop = linePosition.top;\n    const lineBottom = linePosition.bottom;\n    const viewport = this._getViewPortTopBottom();\n    const viewportBottom = viewport.bottom;\n    const viewportTop = viewport.top;\n\n    const topOfLineIsAboveOfViewportBottom = lineTop < viewportBottom;\n    const bottomOfLineIsOnOrBelowOfViewportBottom = lineBottom >= viewportBottom;\n    const topOfLineIsBelowViewportTop = lineTop >= viewportTop;\n    const topOfLineIsAboveViewportBottom = lineTop <= viewportBottom;\n    const bottomOfLineIsAboveViewportBottom = lineBottom <= viewportBottom;\n    const bottomOfLineIsBelowViewportTop = lineBottom >= viewportTop;\n\n    return (topOfLineIsAboveOfViewportBottom && bottomOfLineIsOnOrBelowOfViewportBottom) ||\n      (topOfLineIsBelowViewportTop && topOfLineIsAboveViewportBottom) ||\n      (bottomOfLineIsAboveViewportBottom && bottomOfLineIsBelowViewportTop);\n  };\n\n  _getViewPortTopBottom() {\n    const theTop = this.getScrollY();\n    const doc = this.doc;\n    const height = doc.documentElement.clientHeight; // includes padding\n\n    // we have to get the exactly height of the viewport.\n    // So it has to subtract all the values which changes\n    // the viewport height (E.g. padding, position top)\n    const viewportExtraSpacesAndPosition =\n      this._getEditorPositionTop() + this._getPaddingTopAddedWhenPageViewIsEnable();\n    return {\n      top: theTop,\n      bottom: (theTop + height - viewportExtraSpacesAndPosition),\n    };\n  };\n\n  _getEditorPositionTop() {\n    const editor = document.getElementsByTagName('iframe');\n    const editorPositionTop = editor[0].offsetTop;\n    return editorPositionTop;\n  };\n\n  _getPaddingTopAddedWhenPageViewIsEnable() {\n    const aceOuter = this.rootDocument.getElementsByName('ace_outer');\n    const aceOuterPaddingTop = parseInt($(aceOuter).css('padding-top'));\n    return aceOuterPaddingTop;\n  };\n\n  _getScrollXY() {\n    const win = this.outerWin as WindowElementWithScrolling;\n    const odoc = this.doc;\n    if (typeof (win.pageYOffset) === 'number') {\n      return {\n        x: win.pageXOffset,\n        y: win.pageYOffset,\n      };\n    }\n    const docel = odoc.documentElement;\n    if (docel && typeof (docel.scrollTop) === 'number') {\n      return {\n        x: docel.scrollLeft,\n        y: docel.scrollTop,\n      };\n    }\n  };\n\n  getScrollX() {\n    return this._getScrollXY()!.x;\n  };\n\n getScrollY () {\n    return this._getScrollXY()!.y;\n  };\n\n  setScrollX(x: number) {\n    this.outerWin.scrollTo(x, this.getScrollY());\n  };\n\n  setScrollY(y: number) {\n    this.outerWin.scrollTo(this.getScrollX(), y);\n  };\n\n  setScrollXY(x: number, y: number) {\n    this.outerWin.scrollTo(x, y);\n  };\n\n  _isCaretAtTheTopOfViewport(rep: RepModel) {\n    const caretLine = rep.selStart[0];\n    const linePrevCaretLine = caretLine - 1;\n    const firstLineVisibleBeforeCaretLine =\n      getPreviousVisibleLine(linePrevCaretLine, rep);\n    const caretLineIsPartiallyVisibleOnViewport =\n      this._isLinePartiallyVisibleOnViewport(caretLine, rep);\n    const lineBeforeCaretLineIsPartiallyVisibleOnViewport =\n      this._isLinePartiallyVisibleOnViewport(firstLineVisibleBeforeCaretLine, rep);\n    if (caretLineIsPartiallyVisibleOnViewport || lineBeforeCaretLineIsPartiallyVisibleOnViewport) {\n      const caretLinePosition = getPosition(); // get the position of the browser line\n      const viewportPosition = this._getViewPortTopBottom();\n      const viewportTop = viewportPosition.top;\n      const viewportBottom = viewportPosition.bottom;\n      const caretLineIsBelowViewportTop = caretLinePosition!.bottom >= viewportTop;\n      const caretLineIsAboveViewportBottom = caretLinePosition!.top < viewportBottom;\n      const caretLineIsInsideOfViewport =\n        caretLineIsBelowViewportTop && caretLineIsAboveViewportBottom;\n      if (caretLineIsInsideOfViewport) {\n        const prevLineTop = getPositionTopOfPreviousBrowserLine(caretLinePosition!, rep);\n        const previousLineIsAboveViewportTop = prevLineTop < viewportTop;\n        return previousLineIsAboveViewportTop;\n      }\n    }\n    return false;\n  };\n\n  // By default, when user makes an edition in a line out of viewport, this line goes\n// to the edge of viewport. This function gets the extra pixels necessary to get the\n// caret line in a position X relative to Y% viewport.\n  _getPixelsRelativeToPercentageOfViewport(innerHeight: number, aboveOfViewport?: boolean) {\n      let pixels = 0;\n      const scrollPercentageRelativeToViewport = this._getPercentageToScroll(aboveOfViewport);\n      if (scrollPercentageRelativeToViewport > 0 && scrollPercentageRelativeToViewport <= 1) {\n        pixels = parseInt(String(innerHeight * scrollPercentageRelativeToViewport));\n      }\n      return pixels;\n    };\n\n  // we use different percentages when change selection. It depends on if it is\n// either above the top or below the bottom of the page\n  _getPercentageToScroll(aboveOfViewport: boolean|undefined) {\n    let percentageToScroll = this.scrollSettings.percentage.editionBelowViewport;\n    if (aboveOfViewport) {\n      percentageToScroll = this.scrollSettings.percentage.editionAboveViewport;\n    }\n    return percentageToScroll;\n  };\n\n  _getPixelsToScrollWhenUserPressesArrowUp(innerHeight: number) {\n    let pixels = 0;\n    const percentageToScrollUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp;\n    if (percentageToScrollUp > 0 && percentageToScrollUp <= 1) {\n      pixels = parseInt(String(innerHeight * percentageToScrollUp));\n    }\n    return pixels;\n  };\n\n  _scrollYPage(pixelsToScroll: number) {\n    const durationOfAnimationToShowFocusline = this.scrollSettings.duration;\n    if (durationOfAnimationToShowFocusline) {\n      this._scrollYPageWithAnimation(pixelsToScroll, durationOfAnimationToShowFocusline);\n    } else {\n      this._scrollYPageWithoutAnimation(pixelsToScroll);\n    }\n  };\n\n  _scrollYPageWithoutAnimation(pixelsToScroll: number) {\n    this.outerWin.scrollBy(0, pixelsToScroll);\n  };\n\n  _scrollYPageWithAnimation(pixelsToScroll: number, durationOfAnimationToShowFocusline: number) {\n      const outerDocBody = this.doc.getElementById('outerdocbody');\n\n      // it works on later versions of Chrome\n      const $outerDocBody = $(outerDocBody!);\n      this._triggerScrollWithAnimation(\n        $outerDocBody, pixelsToScroll, durationOfAnimationToShowFocusline);\n\n      // it works on Firefox and earlier versions of Chrome\n      const $outerDocBodyParent = $outerDocBody.parent();\n      this._triggerScrollWithAnimation(\n        $outerDocBodyParent, pixelsToScroll, durationOfAnimationToShowFocusline);\n    };\n\n  _triggerScrollWithAnimation($elem:any, pixelsToScroll: number, durationOfAnimationToShowFocusline: number) {\n      // clear the queue of animation\n      $elem.stop('scrollanimation');\n      $elem.animate({\n        scrollTop: `+=${pixelsToScroll}`,\n      }, {\n        duration: durationOfAnimationToShowFocusline,\n        queue: 'scrollanimation',\n      }).dequeue('scrollanimation');\n    };\n\n\n\n  scrollNodeVerticallyIntoView(rep: RepModel, innerHeight: number) {\n    const viewport = this._getViewPortTopBottom();\n\n    // when the selection changes outside of the viewport the browser automatically scrolls the line\n    // to inside of the viewport. Tested on IE, Firefox, Chrome in releases from 2015 until now\n    // So, when the line scrolled gets outside of the viewport we let the browser handle it.\n    const linePosition = getPosition();\n    if (linePosition) {\n      const distanceOfTopOfViewport = linePosition.top - viewport.top;\n      const distanceOfBottomOfViewport = viewport.bottom - linePosition.bottom - linePosition.height;\n      const caretIsAboveOfViewport = distanceOfTopOfViewport < 0;\n      const caretIsBelowOfViewport = distanceOfBottomOfViewport < 0;\n      if (caretIsAboveOfViewport) {\n        const pixelsToScroll =\n          distanceOfTopOfViewport - this._getPixelsRelativeToPercentageOfViewport(innerHeight, true);\n        this._scrollYPage(pixelsToScroll);\n      } else if (caretIsBelowOfViewport) {\n        // setTimeout is required here as line might not be fully rendered onto the pad\n        setTimeout(() => {\n          const outer = window.parent;\n          // scroll to the very end of the pad outer\n          outer.scrollTo(0, outer[0].innerHeight);\n        }, 150);\n        // if the above setTimeout and functionality is removed then hitting an enter\n        // key while on the last line wont be an optimal user experience\n        // Details at: https://github.com/ether/etherpad-lite/pull/4639/files\n      }\n    }\n  };\n\n  _partOfRepLineIsOutOfViewport(viewportPosition: Position, rep: RepModel) {\n    const focusLine = (rep.selFocusAtStart ? rep.selStart[0] : rep.selEnd[0]);\n    const line = rep.lines.atIndex(focusLine);\n    const linePosition = this._getLineEntryTopBottom(line);\n    const lineIsAboveOfViewport = linePosition.top < viewportPosition.top;\n    const lineIsBelowOfViewport = linePosition.bottom > viewportPosition.bottom;\n\n    return lineIsBelowOfViewport || lineIsAboveOfViewport;\n  };\n\n  _getLineEntryTopBottom(entry: RepNode, destObj?: Position) {\n    const dom = entry.lineNode;\n    const top = dom.offsetTop;\n    const height = dom.offsetHeight;\n    const obj = (destObj || {}) as Position;\n    obj.top = top;\n    obj.bottom = (top + height);\n    return obj;\n  };\n\n  _arrowUpWasPressedInTheFirstLineOfTheViewport(arrowUp: boolean, rep: RepModel) {\n    const percentageScrollArrowUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp;\n    return percentageScrollArrowUp && arrowUp && this._isCaretAtTheTopOfViewport(rep);\n  };\n\n  getVisibleLineRange(rep: RepModel) {\n    const viewport = this._getViewPortTopBottom();\n    // console.log(\"viewport top/bottom: %o\", viewport);\n    const obj = {} as Position;\n    const self = this;\n    const start = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).bottom > viewport.top);\n    // return the first line that the top position is greater or equal than\n    // the viewport. That is the first line that is below the viewport bottom.\n    // So the line that is in the bottom of the viewport is the very previous one.\n    let end = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).top >= viewport.bottom);\n    if (end < start) end = start; // unlikely\n    // top.console.log(start+\",\"+(end -1));\n    return [start, end - 1];\n  };\n\n  getVisibleCharRange(rep: RepModel) {\n    const lineRange = this.getVisibleLineRange(rep);\n    return [rep.lines.offsetOfIndex(lineRange[0]), rep.lines.offsetOfIndex(lineRange[1])];\n  };\n}\n\nexport default Scroll\n"
  },
  {
    "path": "src/static/js/security.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nmodule.exports = require('security');\n"
  },
  {
    "path": "src/static/js/skin_variants.ts",
    "content": "// @ts-nocheck\n'use strict';\n\nconst containers = ['editor', 'background', 'toolbar'];\nconst colors = ['super-light', 'light', 'dark', 'super-dark'];\n\n// add corresponding classes when config change\nconst updateSkinVariantsClasses = (newClasses) => {\n  const domsToUpdate = [\n    $('html'),\n    $('iframe[name=ace_outer]').contents().find('html'),\n    $('iframe[name=ace_outer]').contents().find('iframe[name=ace_inner]').contents().find('html'),\n  ];\n\n  colors.forEach((color) => {\n    containers.forEach((container) => {\n      domsToUpdate.forEach((el) => { el.removeClass(`${color}-${container}`); });\n    });\n  });\n\n  domsToUpdate.forEach((el) => { el.removeClass('full-width-editor'); });\n\n  domsToUpdate.forEach((el) => { el.addClass(newClasses.join(' ')); });\n};\n\n\nconst isDarkMode = ()=>{\n  return $('html').hasClass('super-dark-editor')\n}\n\n\nconst setDarkModeInLocalStorage = (isDark)=>{\n  localStorage.setItem('ep_darkMode', isDark?'true':'false');\n}\n\nconst isDarkModeEnabledInLocalStorage = ()=>{\n  return localStorage.getItem('ep_darkMode')==='true';\n}\n\nconst isWhiteModeEnabledInLocalStorage = ()=>{\n  return localStorage.getItem('ep_darkMode')==='false';\n}\n\n// Specific hash to display the skin variants builder popup\nif (window.location.hash.toLowerCase() === '#skinvariantsbuilder') {\n  $('#skin-variants').addClass('popup-show');\n\n  const getNewClasses = () => {\n    const newClasses = [];\n    $('select.skin-variant-color').each(function () {\n      newClasses.push(`${$(this).val()}-${$(this).data('container')}`);\n    });\n    if ($('#skin-variant-full-width').is(':checked')) newClasses.push('full-width-editor');\n\n    $('#skin-variants-result').val(`\"skinVariants\": \"${newClasses.join(' ')}\",`);\n\n    return newClasses;\n  }\n\n  // run on init\n  const updateCheckboxFromSkinClasses = () => {\n    $('html').attr('class').split(' ').forEach((classItem) => {\n      const container = classItem.substring(classItem.lastIndexOf('-') + 1, classItem.length);\n      if (containers.indexOf(container) > -1) {\n        const color = classItem.substring(0, classItem.lastIndexOf('-'));\n        $(`.skin-variant-color[data-container=\"${container}\"`).val(color);\n      }\n    });\n\n    $('#skin-variant-full-width').prop('checked', $('html').hasClass('full-width-editor'));\n  };\n\n  $('.skin-variant').on('change', () => {\n    updateSkinVariantsClasses(getNewClasses());\n  });\n\n  updateCheckboxFromSkinClasses();\n  updateSkinVariantsClasses(getNewClasses());\n}\n\nexports.isDarkMode = isDarkMode;\nexports.setDarkModeInLocalStorage = setDarkModeInLocalStorage\nexports.isWhiteModeEnabledInLocalStorage = isWhiteModeEnabledInLocalStorage\nexports.isDarkModeEnabledInLocalStorage = isDarkModeEnabledInLocalStorage\nexports.updateSkinVariantsClasses = updateSkinVariantsClasses;\n"
  },
  {
    "path": "src/static/js/skiplist.ts",
    "content": "'use strict';\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nconst _entryWidth = (e: Entry) => (e && e.width) || 0;\n\ntype Entry = {\n  key: string,\n  value?: string\n  width?: number\n}\n\nclass Node {\n  public key: string|null\n  readonly entry: Entry|null\n  levels: number\n  upPtrs: Node[]\n  downPtrs: Node[]\n  downSkips: number[]\n  readonly downSkipWidths: number[]\n\n  constructor(entry: Entry|null, levels = 0, downSkips: number|null = 1, downSkipWidths:number|null  = 0) {\n    this.key = entry != null ? entry.key : null;\n    this.entry = entry;\n    this.levels = levels;\n    this.upPtrs = Array(levels).fill(null);\n    this.downPtrs = Array(levels).fill(null);\n    this.downSkips = Array(levels).fill(downSkips);\n    this.downSkipWidths = Array(levels).fill(downSkipWidths);\n  }\n\n  propagateWidthChange() {\n    const oldWidth = this.downSkipWidths[0];\n    const newWidth = _entryWidth(this.entry!);\n    const widthChange = newWidth - oldWidth;\n    let n: Node = this;\n    let lvl = 0;\n    while (lvl < n.levels) {\n      n.downSkipWidths[lvl] += widthChange;\n      lvl++;\n      while (lvl >= n.levels && n.upPtrs[lvl - 1]) {\n        n = n.upPtrs[lvl - 1];\n      }\n    }\n    return widthChange;\n  }\n}\n\n// A \"point\" object at index x allows modifications immediately after the first x elements of the\n// skiplist, such as multiple inserts or deletes. After an insert or delete using point P, the point\n// is still valid and points to the same index in the skiplist. Other operations with other points\n// invalidate this point.\nclass Point {\n  private skipList: SkipList\n  private readonly loc: number\n  private readonly idxs: number[]\n  private readonly nodes: Node[]\n  private widthSkips: number[]\n\n  constructor(skipList: SkipList, loc: number) {\n    this.skipList = skipList;\n    this.loc = loc;\n    const numLevels = this.skipList.start.levels;\n    let lvl = numLevels - 1;\n    let i = -1;\n    let ws = 0;\n    const nodes: Node[] = new Array(numLevels);\n    const idxs: number[] = new Array(numLevels);\n    const widthSkips: number[] = new Array(numLevels);\n    nodes[lvl] = this.skipList.start;\n    idxs[lvl] = -1;\n    widthSkips[lvl] = 0;\n    while (lvl >= 0) {\n      let n = nodes[lvl];\n      while (n.downPtrs[lvl] && (i + n.downSkips[lvl] < this.loc)) {\n        i += n.downSkips[lvl];\n        ws += n.downSkipWidths[lvl];\n        n = n.downPtrs[lvl];\n      }\n      nodes[lvl] = n;\n      idxs[lvl] = i;\n      widthSkips[lvl] = ws;\n      lvl--;\n      if (lvl >= 0) {\n        nodes[lvl] = n;\n      }\n    }\n    this.idxs = idxs;\n    this.nodes = nodes;\n    this.widthSkips = widthSkips;\n  }\n\n  toString() {\n    return `Point(${this.loc})`;\n  }\n\n  insert(entry: Entry) {\n    if (entry.key == null) throw new Error('entry.key must not be null');\n    if (this.skipList.containsKey(entry.key)) {\n      throw new Error(`an entry with key ${entry.key} already exists`);\n    }\n\n    const newNode = new Node(entry);\n    const pNodes = this.nodes;\n    const pIdxs = this.idxs;\n    const pLoc = this.loc;\n    const widthLoc = this.widthSkips[0] + this.nodes[0].downSkipWidths[0];\n    const newWidth = _entryWidth(entry);\n\n    // The new node will have at least level 1\n    // With a proability of 0.01^(n-1) the nodes level will be >= n\n    while (newNode.levels === 0 || Math.random() < 0.01) {\n      const lvl = newNode.levels;\n      newNode.levels++;\n      if (lvl === pNodes.length) {\n        // assume we have just passed the end of this.nodes, and reached one level greater\n        // than the skiplist currently supports\n        pNodes[lvl] = this.skipList.start;\n        pIdxs[lvl] = -1;\n        this.skipList.start.levels++;\n        this.skipList.end.levels++;\n        this.skipList.start.downPtrs[lvl] = this.skipList.end;\n        this.skipList.end.upPtrs[lvl] = this.skipList.start;\n        this.skipList.start.downSkips[lvl] = this.skipList.keyToNodeMap.size + 1;\n        this.skipList.start.downSkipWidths[lvl] = this.skipList._totalWidth;\n        this.widthSkips[lvl] = 0;\n      }\n      const me = newNode;\n      const up = pNodes[lvl];\n      const down = up.downPtrs[lvl];\n      const skip1 = pLoc - pIdxs[lvl];\n      const skip2 = up.downSkips[lvl] + 1 - skip1;\n      up.downSkips[lvl] = skip1;\n      up.downPtrs[lvl] = me;\n      me.downSkips[lvl] = skip2;\n      me.upPtrs[lvl] = up;\n      me.downPtrs[lvl] = down;\n      down.upPtrs[lvl] = me;\n      const widthSkip1 = widthLoc - this.widthSkips[lvl];\n      const widthSkip2 = up.downSkipWidths[lvl] + newWidth - widthSkip1;\n      up.downSkipWidths[lvl] = widthSkip1;\n      me.downSkipWidths[lvl] = widthSkip2;\n    }\n    for (let lvl = newNode.levels; lvl < pNodes.length; lvl++) {\n      const up = pNodes[lvl];\n      up.downSkips[lvl]++;\n      up.downSkipWidths[lvl] += newWidth;\n    }\n    this.skipList.keyToNodeMap.set(newNode.key as string, newNode);\n    this.skipList._totalWidth += newWidth;\n  }\n\n  delete() {\n    const elem = this.nodes[0].downPtrs[0];\n    const elemWidth = _entryWidth(elem.entry!);\n    for (let i = 0; i < this.nodes.length; i++) {\n      if (i < elem.levels) {\n        const up = elem.upPtrs[i];\n        const down = elem.downPtrs[i];\n        const totalSkip = up.downSkips[i] + elem.downSkips[i] - 1;\n        up.downPtrs[i] = down;\n        down.upPtrs[i] = up;\n        up.downSkips[i] = totalSkip;\n        const totalWidthSkip = up.downSkipWidths[i] + elem.downSkipWidths[i] - elemWidth;\n        up.downSkipWidths[i] = totalWidthSkip;\n      } else {\n        const up = this.nodes[i];\n        up.downSkips[i]--;\n        up.downSkipWidths[i] -= elemWidth;\n      }\n    }\n    this.skipList.keyToNodeMap.delete(elem.key as string);\n    this.skipList._totalWidth -= elemWidth;\n  }\n\n  getNode() {\n    return this.nodes[0].downPtrs[0];\n  }\n}\n\n/**\n * The skip-list contains \"entries\", JavaScript objects that each must have a unique \"key\"\n * property that is a string.\n */\nclass SkipList {\n  start: Node\n  end: Node\n  _totalWidth: number\n  keyToNodeMap: Map<string, Node>\n\n\n  constructor() {\n    // if there are N elements in the skiplist, \"start\" is element -1 and \"end\" is element N\n    this.start = new Node(null, 1);\n    this.end = new Node(null, 1, null, null);\n    this._totalWidth = 0;\n    this.keyToNodeMap = new Map();\n    this.start.downPtrs[0] = this.end;\n    this.end.upPtrs[0] = this.start;\n  }\n\n  _getNodeAtOffset(targetOffset: number) {\n    let i = 0;\n    let n = this.start;\n    let lvl = this.start.levels - 1;\n    while (lvl >= 0 && n.downPtrs[lvl]) {\n      while (n.downPtrs[lvl] && (i + n.downSkipWidths[lvl] <= targetOffset)) {\n        i += n.downSkipWidths[lvl];\n        n = n.downPtrs[lvl];\n      }\n      lvl--;\n    }\n    if (n === this.start) return (this.start.downPtrs[0] || null);\n    if (n === this.end) {\n      return targetOffset === this._totalWidth ? (this.end.upPtrs[0] || null) : null;\n    }\n    return n;\n  }\n\n  _getNodeIndex(node: Node, byWidth?: boolean) {\n    let dist = (byWidth ? 0 : -1);\n    let n = node;\n    while (n !== this.start) {\n      const lvl = n.levels - 1;\n      n = n.upPtrs[lvl];\n      if (byWidth) dist += n.downSkipWidths[lvl];\n      else dist += n.downSkips[lvl];\n    }\n    return dist;\n  }\n\n  totalWidth() { return this._totalWidth; }\n\n  // Returns index of first entry such that entryFunc(entry) is truthy,\n  // or length() if no such entry.  Assumes all falsy entries come before\n  // all truthy entries.\n  search(entryFunc: Function) {\n    let low = this.start;\n    let lvl = this.start.levels - 1;\n    let lowIndex = -1;\n\n    const f = (node: Node) => {\n      if (node === this.start) return false;\n      else if (node === this.end) return true;\n      else return entryFunc(node.entry);\n    };\n\n    while (lvl >= 0) {\n      let nextLow = low.downPtrs[lvl];\n      while (!f(nextLow)) {\n        lowIndex += low.downSkips[lvl];\n        low = nextLow;\n        nextLow = low.downPtrs[lvl];\n      }\n      lvl--;\n    }\n    return lowIndex + 1;\n  }\n\n  length() { return this.keyToNodeMap.size; }\n\n  atIndex(i: number) {\n    if (i < 0) console.warn(`atIndex(${i})`);\n    if (i >= this.keyToNodeMap.size) console.warn(`atIndex(${i}>=${this.keyToNodeMap.size})`);\n    return (new Point(this, i)).getNode().entry;\n  }\n\n  // differs from Array.splice() in that new elements are in an array, not varargs\n  splice(start: number, deleteCount: number, newEntryArray: Entry[]) {\n    if (start < 0) console.warn(`splice(${start}, ...)`);\n    if (start + deleteCount > this.keyToNodeMap.size) {\n      console.warn(`splice(${start}, ${deleteCount}, ...), N=${this.keyToNodeMap.size}`);\n      console.warn('%s %s %s', typeof start, typeof deleteCount, typeof this.keyToNodeMap.size);\n      console.trace();\n    }\n\n    if (!newEntryArray) newEntryArray = [];\n    const pt = new Point(this, start);\n    for (let i = 0; i < deleteCount; i++) pt.delete();\n    for (let i = (newEntryArray.length - 1); i >= 0; i--) {\n      const entry = newEntryArray[i];\n      pt.insert(entry);\n    }\n  }\n\n  next(entry: Entry) { return this.keyToNodeMap.get(entry.key)!.downPtrs[0].entry || null; }\n  prev(entry: Entry) { return this.keyToNodeMap.get(entry.key)!.upPtrs[0].entry || null; }\n  push(entry: Entry) { this.splice(this.keyToNodeMap.size, 0, [entry]); }\n\n  slice(start: number, end: number) {\n    // act like Array.slice()\n    if (start === undefined) start = 0;\n    else if (start < 0) start += this.keyToNodeMap.size;\n    if (end === undefined) end = this.keyToNodeMap.size;\n    else if (end < 0) end += this.keyToNodeMap.size;\n\n    if (start < 0) start = 0;\n    if (start > this.keyToNodeMap.size) start = this.keyToNodeMap.size;\n    if (end < 0) end = 0;\n    if (end > this.keyToNodeMap.size) end = this.keyToNodeMap.size;\n\n    if (end <= start) return [];\n    let n = this.atIndex(start);\n    const array = [n];\n    for (let i = 1; i < (end - start); i++) {\n      n = this.next(n!);\n      array.push(n);\n    }\n    return array;\n  }\n\n  atKey(key: string) { return this.keyToNodeMap.get(key)!.entry; }\n  indexOfKey(key: string) { return this._getNodeIndex(this.keyToNodeMap.get(key)!); }\n  indexOfEntry(entry: Entry) { return this.indexOfKey(entry.key); }\n  containsKey(key: string) { return this.keyToNodeMap.has(key); }\n  // gets the last entry starting at or before the offset\n  atOffset(offset: number) { return this._getNodeAtOffset(offset)!.entry; }\n  keyAtOffset(offset: number) { return this.atOffset(offset)!.key; }\n  offsetOfKey(key: string) { return this._getNodeIndex(this.keyToNodeMap.get(key)!, true); }\n  offsetOfEntry(entry: Entry) { return this.offsetOfKey(entry.key); }\n  setEntryWidth(entry: Entry, width: number) {\n    entry.width = width;\n    this._totalWidth += this.keyToNodeMap.get(entry.key)!.propagateWidthChange();\n  }\n  offsetOfIndex(i: number) {\n    if (i < 0) return 0;\n    if (i >= this.keyToNodeMap.size) return this._totalWidth;\n    return this.offsetOfEntry(this.atIndex(i)!);\n  }\n  indexOfOffset(offset: number) {\n    if (offset <= 0) return 0;\n    if (offset >= this._totalWidth) return this.keyToNodeMap.size;\n    return this.indexOfEntry(this.atOffset(offset)!);\n  }\n}\n\nexport default SkipList\n"
  },
  {
    "path": "src/static/js/socketio.ts",
    "content": "// @ts-nocheck\nimport io from 'socket.io-client';\n\n/**\n * Creates a socket.io connection.\n * @param etherpadBaseUrl - Etherpad URL. If relative, it is assumed to be relative to\n *     window.location.\n * @param namespace - socket.io namespace.\n * @param options - socket.io client options. See\n *     https://socket.io/docs/v2/client-api/#new-Manager-url-options\n * @return socket.io Socket object\n */\nconst connect = (etherpadBaseUrl, namespace = '/', options = {}) => {\n  // The API for socket.io's io() function is awkward. The documentation says that the first\n  // argument is a URL, but it is not the URL of the socket.io endpoint. The URL's path part is used\n  // as the name of the socket.io namespace to join, and the rest of the URL (including query\n  // parameters, if present) is combined with the `path` option (which defaults to '/socket.io', but\n  // is overridden here to allow users to host Etherpad at something like '/etherpad') to get the\n  // URL of the socket.io endpoint.\n  const baseUrl = new URL(etherpadBaseUrl, window.location);\n  const socketioUrl = new URL('socket.io', baseUrl);\n  const namespaceUrl = new URL(namespace, new URL('/', baseUrl));\n\n  let socketOptions = {\n    path: socketioUrl.pathname,\n    upgrade: true,\n    transports: ['polling', 'websocket'],\n  };\n  socketOptions = Object.assign(options, socketOptions);\n\n  const socket = io(namespaceUrl.href, socketOptions);\n\n  socket.on('connect_error', (error) => {\n    console.log('Error connecting to pad', error);\n    /*if (socket.io.engine.transports.indexOf('polling') === -1) {\n      console.warn('WebSocket connection failed. Falling back to long-polling.');\n      socket.io.opts.transports = ['websocket','polling'];\n      socket.io.engine.upgrade = false;\n    }*/\n  });\n\n  return socket;\n};\n\nif (typeof exports === 'object') {\n  exports.connect = connect;\n} else {\n  window.socketio = {connect};\n}\n"
  },
  {
    "path": "src/static/js/timeslider.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// These jQuery things should create local references, but for now `require()`\n// assigns to the global `$` and augments it with plugins.\nrequire('./vendors/jquery');\n\nimport {randomString, Cookies} from \"./pad_utils\";\nconst hooks = require('./pluginfw/hooks');\nimport padutils from './pad_utils'\nconst socketio = require('./socketio');\nimport html10n from '../js/vendors/html10n'\nlet token, padId, exportLinks, socket, changesetLoader, BroadcastSlider;\n\nconst init = () => {\n  padutils.setupGlobalExceptionHandler();\n  $(document).ready(() => {\n    // start the custom js\n    if (typeof customStart === 'function') customStart(); // eslint-disable-line no-undef\n\n    // get the padId out of the url\n    const urlParts = document.location.pathname.split('/');\n    padId = decodeURIComponent(urlParts[urlParts.length - 2]);\n\n    // set the title\n    document.title = `${padId.replace(/_+/g, ' ')} | ${document.title}`;\n\n    // ensure we have a token\n    token = Cookies.get('token');\n    if (token == null) {\n      token = `t.${randomString()}`;\n      Cookies.set('token', token, {expires: 60});\n    }\n\n    socket = socketio.connect(exports.baseURL, '/', {query: {padId}});\n\n    // send the ready message once we're connected\n    socket.on('connect', () => {\n      sendSocketMsg('CLIENT_READY', {});\n    });\n\n    socket.on('disconnect', (reason) => {\n      BroadcastSlider.showReconnectUI();\n      // The socket.io client will automatically try to reconnect for all reasons other than \"io\n      // server disconnect\".\n      if (reason === 'io server disconnect') socket.connect();\n    });\n\n    // route the incoming messages\n    socket.on('message', (message) => {\n      if (message.type === 'CLIENT_VARS') {\n        handleClientVars(message);\n      } else if (message.accessStatus) {\n        $('body').html('<h2>You have no permission to access this pad</h2>');\n      } else if (message.type === 'CHANGESET_REQ' || message.type === 'COLLABROOM') {\n        changesetLoader.handleMessageFromServer(message);\n      }\n    });\n\n    // get all the export links\n    exportLinks = $('#export > .exportlink');\n\n    $('button#forcereconnect').on('click', () => {\n      window.location.reload();\n    });\n\n    exports.socket = socket; // make the socket available\n    exports.BroadcastSlider = BroadcastSlider; // Make the slider available\n\n    hooks.aCallAll('postTimesliderInit');\n  });\n};\n\n// sends a message over the socket\nconst sendSocketMsg = (type, data) => {\n  socket.emit(\"message\", {\n    component: 'pad', // FIXME: Remove this stupidity!\n    type,\n    data,\n    padId,\n    token,\n    sessionID: Cookies.get('sessionID'),\n  });\n};\n\nconst fireWhenAllScriptsAreLoaded = [];\n\nconst handleClientVars = (message) => {\n  // save the client Vars\n  window.clientVars = message.data;\n\n  if (window.clientVars.sessionRefreshInterval) {\n    const ping =\n        () => $.ajax('../../_extendExpressSessionLifetime', {method: 'PUT'}).catch(() => {});\n    setInterval(ping, window.clientVars.sessionRefreshInterval);\n  }\n\n  if(window.clientVars.mode === \"development\") {\n    console.warn('Enabling development mode with live update')\n    socket.on('liveupdate', ()=>{\n      console.log('Doing live reload')\n      location.reload()\n    })\n  }\n\n  // load all script that doesn't work without the clientVars\n  BroadcastSlider = require('./broadcast_slider')\n      .loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded);\n\n  require('./broadcast_revisions').loadBroadcastRevisionsJS();\n  changesetLoader = require('./broadcast')\n      .loadBroadcastJS(socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, BroadcastSlider);\n\n  // initialize export ui\n  require('./pad_impexp').padimpexp.init();\n\n  // Create a base URI used for timeslider exports\n  const baseURI = document.location.pathname;\n\n  // change export urls when the slider moves\n  BroadcastSlider.onSlider((revno) => {\n    // exportLinks is a jQuery Array, so .each is allowed.\n    exportLinks.each(function () {\n      // Modified from regular expression to fix:\n      // https://github.com/ether/etherpad-lite/issues/4071\n      // Where a padId that was numeric would create the wrong export link\n      if (this.href) {\n        const type = this.href.split('export/')[1];\n        let href = baseURI.split('timeslider')[0];\n        href += `${revno}/export/${type}`;\n        this.setAttribute('href', href);\n      }\n    });\n  });\n\n  // fire all start functions of these scripts, formerly fired with window.load\n  for (let i = 0; i < fireWhenAllScriptsAreLoaded.length; i++) {\n    fireWhenAllScriptsAreLoaded[i]();\n  }\n  $('#ui-slider-handle').css('left', $('#ui-slider-bar').width() - 2);\n\n  // Translate some strings where we only want to set the title not the actual values\n  $('#playpause_button_icon').attr('title', html10n.get('timeslider.playPause'));\n  $('#leftstep').attr('title', html10n.get('timeslider.backRevision'));\n  $('#rightstep').attr('title', html10n.get('timeslider.forwardRevision'));\n\n  // font family change\n  $('#viewfontmenu').on('change', function () {\n    $('#innerdocbody').css('font-family', $(this).val() || '');\n  });\n};\n\nexports.baseURL = '';\nexports.init = init;\n"
  },
  {
    "path": "src/static/js/types/AText.ts",
    "content": "export type AText = {\n  text: string,\n  attribs: string,\n}\n"
  },
  {
    "path": "src/static/js/types/Attribute.ts",
    "content": "export type Attribute = [string, string]\n"
  },
  {
    "path": "src/static/js/types/ChangeSet.ts",
    "content": "export type ChangeSet = {\n  oldLen: number,\n  newLen: number,\n  ops: string\n  charBank: string\n}\n"
  },
  {
    "path": "src/static/js/types/ChangeSetBuilder.ts",
    "content": "import {Attribute} from \"./Attribute\";\nimport AttributePool from \"../AttributePool\";\n\nexport type ChangeSetBuilder = {\n  remove: (start: number, end?: number)=>void,\n  keep: (start: number, end?: number, attribs?: Attribute[], pool?: AttributePool)=>void\n}\n"
  },
  {
    "path": "src/static/js/types/PadRevision.ts",
    "content": "export type PadRevision = {\n  revNum: number;\n  savedById: string;\n  label: string;\n  timestamp: number;\n  id: string;\n}\n"
  },
  {
    "path": "src/static/js/types/RepModel.ts",
    "content": "export type RepModel = {\n  lines: {\n    atIndex: (num: number)=>RepNode,\n    offsetOfIndex: (range: number)=>number,\n    search: (filter: (e: RepNode)=>boolean)=>number,\n    length: ()=>number\n  }\n  selStart: number[],\n  selEnd: number[],\n  selFocusAtStart: boolean\n}\n\nexport type Position = {\n  bottom: number,\n  height: number,\n  top: number\n}\n\nexport type RepNode = {\n  firstChild: RepNode,\n  lineNode: RepNode\n  length: number,\n  lastChild: RepNode,\n  offsetHeight: number,\n  offsetTop: number\n}\n\nexport type WindowElementWithScrolling = HTMLIFrameElement & {\n  pageYOffset: number|string,\n  pageXOffset: number\n}\n"
  },
  {
    "path": "src/static/js/types/SocketIOMessage.ts",
    "content": "import {MapArrayType} from \"../../../node/types/MapType\";\nimport {AText} from \"./AText\";\nimport AttributePool from \"../AttributePool\";\nimport attributePool from \"../AttributePool\";\nimport ChatMessage from \"../ChatMessage\";\nimport {PadRevision} from \"./PadRevision\";\n\nexport type Part = {\n  name: string,\n  client_hooks: MapArrayType<string>,\n  hooks: MapArrayType<string>\n  pre?: string[]\n  post?: string[]\n  plugin?: string\n}\n\n\nexport type MappedPlugin = Part& {\n  plugin: string\n  full_name: string\n}\n\nexport type SocketIOMessage = {\n  type: string\n  accessStatus: string\n}\n\nexport type HistoricalAuthorData = MapArrayType<{\n  name: string;\n  colorId: number;\n  userId?: string\n}>\n\nexport type ServerVar = {\n  rev: number\n  clientIp: string\n  padId: string\n  historicalAuthorData?: HistoricalAuthorData,\n  initialAttributedText: {\n    attribs: string\n    text: string\n  },\n  apool: AttributePoolWire\n  time: number\n}\n\nexport type AttributePoolWire = {numToAttrib: {[p: number]: [string, string]}, nextNum: number}\n\n\nexport type UserInfo = {\n  userId: string\n  colorId: string,\n  name: string|null\n}\n\nexport type ClientVarPayload = {\n  readOnlyId: string\n  automaticReconnectionTimeout: number\n  sessionRefreshInterval: number,\n  atext?: AText,\n  apool?: AttributePool,\n  userName?: string,\n  userColor: number,\n  hideChat?: boolean,\n  padOptions: PadOption,\n  padId: string,\n  clientIp: string,\n  colorPalette: string[],\n  accountPrivs: {\n    maxRevisions: number,\n  },\n  collab_client_vars: ServerVar,\n  chatHead: number,\n  readonly: boolean,\n  serverTimestamp: number,\n  initialOptions: MapArrayType<string>,\n  userId: string,\n  mode: string,\n  randomVersionString: string,\n  skinName: string\n  skinVariants: string,\n  exportAvailable: string\n  savedRevisions: PadRevision[],\n  initialRevisionList: number[],\n  padShortcutEnabled: MapArrayType<boolean>,\n  initialTitle: string,\n  opts: {}\n  numConnectedUsers: number\n  abiwordAvailable: string\n  sofficeAvailable: string\n  plugins: {\n    plugins:  MapArrayType<any>\n    parts:  MappedPlugin[]\n  }\n  indentationOnNewLine: boolean\n  scrollWhenFocusLineIsOutOfViewport : {\n    percentage: {\n      editionAboveViewport: number,\n      editionBelowViewport: number\n    }\n    duration: number\n    scrollWhenCaretIsInTheLastLineOfViewport: boolean\n    percentageToScrollWhenUserPressesArrowUp: number\n  }\n  initialChangesets: []\n}\n\nexport type ClientVarData = {\n  type: \"CLIENT_VARS\"\n  data: ClientVarPayload\n}\n\nexport type ClientNewChanges = {\n  type : 'NEW_CHANGES'\n  apool: AttributePool,\n  author: string,\n  changeset: string,\n  newRev: number,\n  payload?: ClientNewChanges\n}\n\nexport type ClientAcceptCommitMessage = {\n  type: 'ACCEPT_COMMIT'\n  newRev: number\n}\n\nexport type ClientConnectMessage = {\n  type: 'CLIENT_RECONNECT',\n  noChanges: boolean,\n  headRev: number,\n  newRev: number,\n  changeset: string,\n  author: string\n  apool: AttributePool\n}\n\n\nexport type UserNewInfoMessage = {\n  type: 'USER_NEWINFO',\n  data: {\n    userInfo: UserInfo\n  }\n}\n\nexport type UserLeaveMessage = {\n  type: 'USER_LEAVE'\n  userInfo: UserInfo\n}\n\n\n\nexport type ClientMessageMessage = {\n  type: 'CLIENT_MESSAGE',\n  payload: ClientSendMessages\n}\n\nexport type ChatMessageMessage = {\n  type: 'CHAT_MESSAGE'\n  data: {\n    message: ChatMessage\n  }\n}\n\nexport type ChatMessageMessages = {\n  type: 'CHAT_MESSAGES'\n  messages: string\n}\n\nexport type ClientUserChangesMessage = {\n  type: 'USER_CHANGES',\n  baseRev: number,\n  changeset: string,\n  apool: attributePool\n}\n\n\n\nexport type ClientSendMessages =  ClientUserChangesMessage |ClientReadyMessage| ClientSendUserInfoUpdate|ChatMessageMessage| ClientMessageMessage | GetChatMessageMessage |ClientSuggestUserName | NewRevisionListMessage | RevisionLabel | PadOptionsMessage| ClientSaveRevisionMessage\n\nexport type ClientReadyMessage = {\n  type: 'CLIENT_READY',\n  component: string,\n  padId: string,\n  sessionID: string,\n  token: string,\n  userInfo: UserInfo,\n  reconnect?: boolean\n  client_rev?: number\n}\n\nexport type ClientSaveRevisionMessage = {\n  type: 'SAVE_REVISION'\n}\n\n\nexport type PadDeleteMessage = {\n  type: 'PAD_DELETE'\n  data: {\n    padId: string\n  }\n}\n\nexport type GetChatMessageMessage = {\n  type: 'GET_CHAT_MESSAGES',\n  start: number,\n  end: number\n}\n\nexport type ClientSendUserInfoUpdate = {\n  type: 'USERINFO_UPDATE',\n  userInfo: UserInfo\n}\n\nexport type ClientSuggestUserName = {\n  type: 'suggestUserName',\n  data: {\n    payload: {\n      unnamedId: string,\n      newName: string\n    }\n  }\n}\n\nexport type NewRevisionListMessage = {\n  type: 'newRevisionList',\n  revisionList: number[]\n}\n\nexport type RevisionLabel = {\n  type:  'revisionLabel'\n  revisionList: number[]\n}\n\nexport type PadOptionsMessage = {\n  type: 'padoptions'\n  options: PadOption\n  changedBy: string\n}\n\nexport type PadOption = {\n  \"noColors\"?:         boolean,\n  \"showControls\"?:     boolean,\n  \"showChat\"?:         boolean,\n  \"showLineNumbers\"?:  boolean,\n  \"useMonospaceFont\"?: boolean,\n  \"userName\"?:         null|string,\n  \"userColor\"?:        null|string,\n  \"rtl\"?:              boolean,\n  \"alwaysShowChat\"?:   boolean,\n  \"chatAndUsers\"?:     boolean,\n  \"lang\"?:             null|string,\n  view? : MapArrayType<boolean>\n}\n\n\ntype SharedMessageType = {\n  payload:{\n    timestamp: number\n  }\n}\n\nexport type x = {\n  disconnect: boolean\n}\n\nexport type ClientDisconnectedMessage = {\n  type: \"disconnected\"\n  disconnected: boolean\n}\n\nexport type UserChanges = {\n  data: ClientUserChangesMessage\n}\n\nexport type UserSuggestUserName = {\n  data: {\n    payload: ClientSuggestUserName\n  }\n}\n\nexport type ChangesetRequestMessage = {\n  type: 'CHANGESET_REQ'\n  data: {\n    granularity: number\n    start: number\n    requestID: string\n  }\n}\n\n\n\nexport type CollabroomMessage = {\n  type: 'COLLABROOM'\n  data: ClientSendUserInfoUpdate | ClientUserChangesMessage | ChatMessageMessage | GetChatMessageMessage | ClientSaveRevisionMessage | ClientMessageMessage | PadDeleteMessage\n}\n\nexport type ClientVarMessage =  | ClientVarData | ClientDisconnectedMessage | ClientReadyMessage| ChangesetRequestMessage | CollabroomMessage | CustomMessage\n\n\nexport type CustomMessage = {\n  type:  'CUSTOM'\n  data: any\n}\n\nexport type ClientCustomMessage = {\n  type: 'CUSTOM',\n  action: string,\n  payload: any\n\n}\n\nexport type SocketClientReadyMessage = {\n  type: string\n  component: string\n  padId: string\n  sessionID: string\n  token: string\n  userInfo: {\n    colorId: string|null\n    name: string|null\n  },\n  reconnect?: boolean\n  client_rev?: number\n}\n\n"
  },
  {
    "path": "src/static/js/underscore.ts",
    "content": "// @ts-nocheck\n'use strict';\n\nmodule.exports = require('underscore');\n"
  },
  {
    "path": "src/static/js/undomodule.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * This code is mostly from the old Etherpad. Please help us to comment this code.\n * This helps other people to understand this code better and helps them to improve it.\n * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED\n */\n\n/**\n * Copyright 2009 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS-IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {characterRangeFollow, compose, follow, isIdentity, unpack} from './Changeset';\nconst _ = require('./underscore');\n\nconst undoModule = (() => {\n  const stack = (() => {\n    const stackElements = [];\n    // two types of stackElements:\n    // 1) { elementType: UNDOABLE_EVENT, eventType: \"anything\", [backset: <changeset>,]\n    //      [selStart: <char number>, selEnd: <char number>, selFocusAtStart: <boolean>] }\n    // 2) { elementType: EXTERNAL_CHANGE, changeset: <changeset> }\n    // invariant: no two consecutive EXTERNAL_CHANGEs\n    let numUndoableEvents = 0;\n\n    const UNDOABLE_EVENT = 'undoableEvent';\n    const EXTERNAL_CHANGE = 'externalChange';\n\n    const clearStack = () => {\n      stackElements.length = 0;\n      stackElements.push(\n          {\n            elementType: UNDOABLE_EVENT,\n            eventType: 'bottom',\n          });\n      numUndoableEvents = 1;\n    };\n    clearStack();\n\n    const pushEvent = (event) => {\n      const e = _.extend(\n          {}, event);\n      e.elementType = UNDOABLE_EVENT;\n      stackElements.push(e);\n      numUndoableEvents++;\n    };\n\n    const pushExternalChange = (cs) => {\n      const idx = stackElements.length - 1;\n      if (stackElements[idx].elementType === EXTERNAL_CHANGE) {\n        stackElements[idx].changeset =\n            compose(stackElements[idx].changeset, cs, getAPool());\n      } else {\n        stackElements.push(\n            {\n              elementType: EXTERNAL_CHANGE,\n              changeset: cs,\n            });\n      }\n    };\n\n    const _exposeEvent = (nthFromTop) => {\n      // precond: 0 <= nthFromTop < numUndoableEvents\n      const targetIndex = stackElements.length - 1 - nthFromTop;\n      let idx = stackElements.length - 1;\n      while (idx > targetIndex || stackElements[idx].elementType === EXTERNAL_CHANGE) {\n        if (stackElements[idx].elementType === EXTERNAL_CHANGE) {\n          const ex = stackElements[idx];\n          const un = stackElements[idx - 1];\n          if (un.backset) {\n            const excs = ex.changeset;\n            const unbs = un.backset;\n            un.backset = follow(excs, un.backset, false, getAPool());\n            ex.changeset = follow(unbs, ex.changeset, true, getAPool());\n            if ((typeof un.selStart) === 'number') {\n              const newSel = characterRangeFollow(excs, un.selStart, un.selEnd);\n              un.selStart = newSel[0];\n              un.selEnd = newSel[1];\n              if (un.selStart === un.selEnd) {\n                un.selFocusAtStart = false;\n              }\n            }\n          }\n          stackElements[idx - 1] = ex;\n          stackElements[idx] = un;\n          if (idx >= 2 && stackElements[idx - 2].elementType === EXTERNAL_CHANGE) {\n            ex.changeset =\n                compose(stackElements[idx - 2].changeset, ex.changeset, getAPool());\n            stackElements.splice(idx - 2, 1);\n            idx--;\n          }\n        } else {\n          idx--;\n        }\n      }\n    };\n\n    const getNthFromTop = (n) => {\n      // precond: 0 <= n < numEvents()\n      _exposeEvent(n);\n      return stackElements[stackElements.length - 1 - n];\n    };\n\n    const numEvents = () => numUndoableEvents;\n\n    const popEvent = () => {\n      // precond: numEvents() > 0\n      _exposeEvent(0);\n      numUndoableEvents--;\n      return stackElements.pop();\n    };\n\n    return {\n      numEvents,\n      popEvent,\n      pushEvent,\n      pushExternalChange,\n      clearStack,\n      getNthFromTop,\n    };\n  })();\n\n  // invariant: stack always has at least one undoable event\n  let undoPtr = 0; // zero-index from top of stack, 0 == top\n\n  const clearHistory = () => {\n    stack.clearStack();\n    undoPtr = 0;\n  };\n\n  const _charOccurrences = (str, c) => {\n    let i = 0;\n    let count = 0;\n    while (i >= 0 && i < str.length) {\n      i = str.indexOf(c, i);\n      if (i >= 0) {\n        count++;\n        i++;\n      }\n    }\n    return count;\n  };\n\n  const _opcodeOccurrences = (cs, opcode) => _charOccurrences(unpack(cs).ops, opcode);\n\n  const _mergeChangesets = (cs1, cs2) => {\n    if (!cs1) return cs2;\n    if (!cs2) return cs1;\n\n    // Rough heuristic for whether changesets should be considered one action:\n    // each does exactly one insertion, no dels, and the composition does also; or\n    // each does exactly one deletion, no ins, and the composition does also.\n    // A little weird in that it won't merge \"make bold\" with \"insert char\"\n    // but will merge \"make bold and insert char\" with \"insert char\",\n    // though that isn't expected to come up.\n    const plusCount1 = _opcodeOccurrences(cs1, '+');\n    const plusCount2 = _opcodeOccurrences(cs2, '+');\n    const minusCount1 = _opcodeOccurrences(cs1, '-');\n    const minusCount2 = _opcodeOccurrences(cs2, '-');\n    if (plusCount1 === 1 && plusCount2 === 1 && minusCount1 === 0 && minusCount2 === 0) {\n      const merge = compose(cs1, cs2, getAPool()!);\n      const plusCount3 = _opcodeOccurrences(merge, '+');\n      const minusCount3 = _opcodeOccurrences(merge, '-');\n      if (plusCount3 === 1 && minusCount3 === 0) {\n        return merge;\n      }\n    } else if (plusCount1 === 0 && plusCount2 === 0 && minusCount1 === 1 && minusCount2 === 1) {\n      const merge = compose(cs1, cs2, getAPool()!);\n      const plusCount3 = _opcodeOccurrences(merge, '+');\n      const minusCount3 = _opcodeOccurrences(merge, '-');\n      if (plusCount3 === 0 && minusCount3 === 1) {\n        return merge;\n      }\n    }\n    return null;\n  };\n\n  const reportEvent = (event) => {\n    const topEvent = stack.getNthFromTop(0);\n\n    const applySelectionToTop = () => {\n      if ((typeof event.selStart) === 'number') {\n        topEvent.selStart = event.selStart;\n        topEvent.selEnd = event.selEnd;\n        topEvent.selFocusAtStart = event.selFocusAtStart;\n      }\n    };\n\n    if ((!event.backset) || isIdentity(event.backset)) {\n      applySelectionToTop();\n    } else {\n      let merged = false;\n      if (topEvent.eventType === event.eventType) {\n        const merge = _mergeChangesets(event.backset, topEvent.backset);\n        if (merge) {\n          topEvent.backset = merge;\n          applySelectionToTop();\n          merged = true;\n        }\n      }\n      if (!merged) {\n        /*\n         * Push the event on the undo stack only if it exists, and if it's\n         * not a \"clearauthorship\". This disallows undoing the removal of the\n         * authorship colors, but is a necessary stopgap measure against\n         * https://github.com/ether/etherpad-lite/issues/2802\n         */\n        if (event && (event.eventType !== 'clearauthorship')) {\n          stack.pushEvent(event);\n        }\n      }\n      undoPtr = 0;\n    }\n  };\n\n  const reportExternalChange = (changeset) => {\n    if (changeset && !isIdentity(changeset)) {\n      stack.pushExternalChange(changeset);\n    }\n  };\n\n  const _getSelectionInfo = (event) => {\n    if ((typeof event.selStart) !== 'number') {\n      return null;\n    } else {\n      return {\n        selStart: event.selStart,\n        selEnd: event.selEnd,\n        selFocusAtStart: event.selFocusAtStart,\n      };\n    }\n  };\n\n  // For \"undo\" and \"redo\", the change event must be returned\n  // by eventFunc and NOT reported through the normal mechanism.\n  // \"eventFunc\" should take a changeset and an optional selection info object,\n  // or can be called with no arguments to mean that no undo is possible.\n  // \"eventFunc\" will be called exactly once.\n\n  const performUndo = (eventFunc) => {\n    if (undoPtr < stack.numEvents() - 1) {\n      const backsetEvent = stack.getNthFromTop(undoPtr);\n      const selectionEvent = stack.getNthFromTop(undoPtr + 1);\n      const undoEvent = eventFunc(backsetEvent.backset, _getSelectionInfo(selectionEvent));\n      stack.pushEvent(undoEvent);\n      undoPtr += 2;\n    } else { eventFunc(); }\n  };\n\n  const performRedo = (eventFunc) => {\n    if (undoPtr >= 2) {\n      const backsetEvent = stack.getNthFromTop(0);\n      const selectionEvent = stack.getNthFromTop(1);\n      eventFunc(backsetEvent.backset, _getSelectionInfo(selectionEvent));\n      stack.popEvent();\n      undoPtr -= 2;\n    } else { eventFunc(); }\n  };\n\n  const getAPool = () => undoModule.apool;\n\n  return {\n    clearHistory,\n    reportEvent,\n    reportExternalChange,\n    performUndo,\n    performRedo,\n    enabled: true,\n    apool: null,\n  }; // apool is filled in by caller\n})();\n\nexports.undoModule = undoModule;\n"
  },
  {
    "path": "src/static/js/vendors/browser.ts",
    "content": "// @ts-nocheck\n// WARNING: This file may have been modified from original.\n// TODO: Check requirement of this file, this afaik was to cover weird edge cases\n// that have probably been fixed in browsers.\n\n/*!\n  * Bowser - a browser detector\n  * https://github.com/ded/bowser\n  * MIT License | (c) Dustin Diaz 2015\n  */\n\n!function (name, definition) {\n  if (typeof module != 'undefined' && module.exports) module.exports = definition()\n  else if (typeof define == 'function' && define.amd) define(definition)\n  else this[name] = definition()\n}('bowser', function () {\n  /**\n    * See useragents.js for examples of navigator.userAgent\n    */\n\n  var t = true\n\n  function detect(ua) {\n\n    function getFirstMatch(regex) {\n      var match = ua.match(regex);\n      return (match && match.length > 1 && match[1]) || '';\n    }\n\n    function getSecondMatch(regex) {\n      var match = ua.match(regex);\n      return (match && match.length > 1 && match[2]) || '';\n    }\n\n    var iosdevice = getFirstMatch(/(ipod|iphone|ipad)/i).toLowerCase()\n      , likeAndroid = /like android/i.test(ua)\n      , android = !likeAndroid && /android/i.test(ua)\n      , chromeos = /CrOS/.test(ua)\n      , silk = /silk/i.test(ua)\n      , sailfish = /sailfish/i.test(ua)\n      , tizen = /tizen/i.test(ua)\n      , webos = /(web|hpw)os/i.test(ua)\n      , windowsphone = /windows phone/i.test(ua)\n      , windows = !windowsphone && /windows/i.test(ua)\n      , mac = !iosdevice && !silk && /macintosh/i.test(ua)\n      , linux = !android && !sailfish && !tizen && !webos && /linux/i.test(ua)\n      , edgeVersion = getFirstMatch(/edge\\/(\\d+(\\.\\d+)?)/i)\n      , versionIdentifier = getFirstMatch(/version\\/(\\d+(\\.\\d+)?)/i)\n      , tablet = /tablet/i.test(ua)\n      , mobile = !tablet && /[^-]mobi/i.test(ua)\n      , result\n\n    if (/opera|opr/i.test(ua)) {\n      result = {\n        name: 'Opera'\n      , opera: t\n      , version: versionIdentifier || getFirstMatch(/(?:opera|opr)[\\s\\/](\\d+(\\.\\d+)?)/i)\n      }\n    }\n    else if (/yabrowser/i.test(ua)) {\n      result = {\n        name: 'Yandex Browser'\n      , yandexbrowser: t\n      , version: versionIdentifier || getFirstMatch(/(?:yabrowser)[\\s\\/](\\d+(\\.\\d+)?)/i)\n      }\n    }\n    else if (windowsphone) {\n      result = {\n        name: 'Windows Phone'\n      , windowsphone: t\n      }\n      if (edgeVersion) {\n        result.msedge = t\n        result.version = edgeVersion\n      }\n      else {\n        result.msie = t\n        result.version = getFirstMatch(/iemobile\\/(\\d+(\\.\\d+)?)/i)\n      }\n    }\n    else if (/msie|trident/i.test(ua)) {\n      result = {\n        name: 'Internet Explorer'\n      , msie: t\n      , version: getFirstMatch(/(?:msie |rv:)(\\d+(\\.\\d+)?)/i)\n      }\n    } else if (chromeos) {\n      result = {\n        name: 'Chrome'\n      , chromeos: t\n      , chromeBook: t\n      , chrome: t\n      , version: getFirstMatch(/(?:chrome|crios|crmo)\\/(\\d+(\\.\\d+)?)/i)\n      }\n    } else if (/chrome.+? edge/i.test(ua)) {\n      result = {\n        name: 'Microsoft Edge'\n      , msedge: t\n      , version: edgeVersion\n      }\n    }\n    else if (/chrome|crios|crmo/i.test(ua)) {\n      result = {\n        name: 'Chrome'\n      , chrome: t\n      , version: getFirstMatch(/(?:chrome|crios|crmo)\\/(\\d+(\\.\\d+)?)/i)\n      }\n    }\n    else if (iosdevice) {\n      result = {\n        name : iosdevice == 'iphone' ? 'iPhone' : iosdevice == 'ipad' ? 'iPad' : 'iPod'\n      }\n      // WTF: version is not part of user agent in web apps\n      if (versionIdentifier) {\n        result.version = versionIdentifier\n      }\n    }\n    else if (sailfish) {\n      result = {\n        name: 'Sailfish'\n      , sailfish: t\n      , version: getFirstMatch(/sailfish\\s?browser\\/(\\d+(\\.\\d+)?)/i)\n      }\n    }\n    else if (/seamonkey\\//i.test(ua)) {\n      result = {\n        name: 'SeaMonkey'\n      , seamonkey: t\n      , version: getFirstMatch(/seamonkey\\/(\\d+(\\.\\d+)?)/i)\n      }\n    }\n    else if (/firefox|iceweasel/i.test(ua)) {\n      result = {\n        name: 'Firefox'\n      , firefox: t\n      , version: getFirstMatch(/(?:firefox|iceweasel)[ \\/](\\d+(\\.\\d+)?)/i)\n      }\n      if (/\\((mobile|tablet);[^\\)]*rv:[\\d\\.]+\\)/i.test(ua)) {\n        result.firefoxos = t\n      }\n    }\n    else if (silk) {\n      result =  {\n        name: 'Amazon Silk'\n      , silk: t\n      , version : getFirstMatch(/silk\\/(\\d+(\\.\\d+)?)/i)\n      }\n    }\n    else if (android) {\n      result = {\n        name: 'Android'\n      , version: versionIdentifier\n      }\n    }\n    else if (/phantom/i.test(ua)) {\n      result = {\n        name: 'PhantomJS'\n      , phantom: t\n      , version: getFirstMatch(/phantomjs\\/(\\d+(\\.\\d+)?)/i)\n      }\n    }\n    else if (/blackberry|\\bbb\\d+/i.test(ua) || /rim\\stablet/i.test(ua)) {\n      result = {\n        name: 'BlackBerry'\n      , blackberry: t\n      , version: versionIdentifier || getFirstMatch(/blackberry[\\d]+\\/(\\d+(\\.\\d+)?)/i)\n      }\n    }\n    else if (webos) {\n      result = {\n        name: 'WebOS'\n      , webos: t\n      , version: versionIdentifier || getFirstMatch(/w(?:eb)?osbrowser\\/(\\d+(\\.\\d+)?)/i)\n      };\n      /touchpad\\//i.test(ua) && (result.touchpad = t)\n    }\n    else if (/bada/i.test(ua)) {\n      result = {\n        name: 'Bada'\n      , bada: t\n      , version: getFirstMatch(/dolfin\\/(\\d+(\\.\\d+)?)/i)\n      };\n    }\n    else if (tizen) {\n      result = {\n        name: 'Tizen'\n      , tizen: t\n      , version: getFirstMatch(/(?:tizen\\s?)?browser\\/(\\d+(\\.\\d+)?)/i) || versionIdentifier\n      };\n    }\n    else if (/safari/i.test(ua)) {\n      result = {\n        name: 'Safari'\n      , safari: t\n      , version: versionIdentifier\n      }\n    }\n    else {\n      result = {\n        name: getFirstMatch(/^(.*)\\/(.*) /),\n        version: getSecondMatch(/^(.*)\\/(.*) /)\n     };\n   }\n\n    // set webkit or gecko flag for browsers based on these engines\n    if (!result.msedge && /(apple)?webkit/i.test(ua)) {\n      result.name = result.name || \"Webkit\"\n      result.webkit = t\n      if (!result.version && versionIdentifier) {\n        result.version = versionIdentifier\n      }\n    } else if (!result.opera && /gecko\\//i.test(ua)) {\n      result.name = result.name || \"Gecko\"\n      result.gecko = t\n      result.version = result.version || getFirstMatch(/gecko\\/(\\d+(\\.\\d+)?)/i)\n    }\n\n    // set OS flags for platforms that have multiple browsers\n    if (!result.msedge && (android || result.silk)) {\n      result.android = t\n    } else if (iosdevice) {\n      result[iosdevice] = t\n      result.ios = t\n    } else if (windows) {\n      result.windows = t\n    } else if (mac) {\n      result.mac = t\n    } else if (linux) {\n      result.linux = t\n    }\n\n    // OS version extraction\n    var osVersion = '';\n    if (result.windowsphone) {\n      osVersion = getFirstMatch(/windows phone (?:os)?\\s?(\\d+(\\.\\d+)*)/i);\n    } else if (iosdevice) {\n      osVersion = getFirstMatch(/os (\\d+([_\\s]\\d+)*) like mac os x/i);\n      osVersion = osVersion.replace(/[_\\s]/g, '.');\n    } else if (android) {\n      osVersion = getFirstMatch(/android[ \\/-](\\d+(\\.\\d+)*)/i);\n    } else if (result.webos) {\n      osVersion = getFirstMatch(/(?:web|hpw)os\\/(\\d+(\\.\\d+)*)/i);\n    } else if (result.blackberry) {\n      osVersion = getFirstMatch(/rim\\stablet\\sos\\s(\\d+(\\.\\d+)*)/i);\n    } else if (result.bada) {\n      osVersion = getFirstMatch(/bada\\/(\\d+(\\.\\d+)*)/i);\n    } else if (result.tizen) {\n      osVersion = getFirstMatch(/tizen[\\/\\s](\\d+(\\.\\d+)*)/i);\n    }\n    if (osVersion) {\n      result.osversion = osVersion;\n    }\n\n    // device type extraction\n    var osMajorVersion = osVersion.split('.')[0];\n    if (tablet || iosdevice == 'ipad' || (android && (osMajorVersion == 3 || (osMajorVersion == 4 && !mobile))) || result.silk) {\n      result.tablet = t\n    } else if (mobile || iosdevice == 'iphone' || iosdevice == 'ipod' || android || result.blackberry || result.webos || result.bada) {\n      result.mobile = t\n    }\n\n    // Graded Browser Support\n    // http://developer.yahoo.com/yui/articles/gbs\n    if (result.msedge ||\n        (result.msie && result.version >= 10) ||\n        (result.yandexbrowser && result.version >= 15) ||\n        (result.chrome && result.version >= 20) ||\n        (result.firefox && result.version >= 20.0) ||\n        (result.safari && result.version >= 6) ||\n        (result.opera && result.version >= 10.0) ||\n        (result.ios && result.osversion && result.osversion.split(\".\")[0] >= 6) ||\n        (result.blackberry && result.version >= 10.1)\n        ) {\n      result.a = t;\n    }\n    else if ((result.msie && result.version < 10) ||\n        (result.chrome && result.version < 20) ||\n        (result.firefox && result.version < 20.0) ||\n        (result.safari && result.version < 6) ||\n        (result.opera && result.version < 10.0) ||\n        (result.ios && result.osversion && result.osversion.split(\".\")[0] < 6)\n        ) {\n      result.c = t\n    } else result.x = t\n\n    return result\n  }\n\n  var bowser = detect(typeof navigator !== 'undefined' ? navigator.userAgent : '')\n\n  bowser.test = function (browserList) {\n    for (var i = 0; i < browserList.length; ++i) {\n      var browserItem = browserList[i];\n      if (typeof browserItem=== 'string') {\n        if (browserItem in bowser) {\n          return true;\n        }\n      }\n    }\n    return false;\n  }\n\n  /*\n   * Set our detect method to the main bowser object so we can\n   * reuse it to test other user agents.\n   * This is needed to implement future tests.\n   */\n  bowser._detect = detect;\n\n  return bowser\n});\n"
  },
  {
    "path": "src/static/js/vendors/farbtastic.ts",
    "content": "// @ts-nocheck\n// WARNING: This file has been modified from original.\n// TODO: Replace with https://github.com/Simonwep/pickr\n\n// Farbtastic 2.0 alpha\n// Original can be found at:\n// https://github.com/mattfarina/farbtastic/blob/71ca15f4a09c8e5a08a1b0d1cf37ef028adf22f0/src/farbtastic.js\n// Licensed under the terms of the GNU General Public License v2.0:\n// https://github.com/mattfarina/farbtastic/blob/71ca15f4a09c8e5a08a1b0d1cf37ef028adf22f0/LICENSE.txt\n// edited by Sebastian Castro <sebastian.castro@protonmail.com> on 2020-04-06\n\n(function ($) {\n\nvar __debug = false;\nvar __factor = 1;\n\n$.fn.farbtastic = function (options) {\n  $.farbtastic(this, options);\n  return this;\n};\n\n$.farbtastic = function (container, options) {\n  var container = $(container)[0];\n  return container.farbtastic || (container.farbtastic = new $._farbtastic(container, options));\n}\n\n$._farbtastic = function (container, options) {\n  var fb = this;\n\n  /////////////////////////////////////////////////////\n\n  /**\n   * Link to the given element(s) or callback.\n   */\n  fb.linkTo = function (callback) {\n    // Unbind previous nodes\n    if (typeof fb.callback == 'object') {\n      $(document.body).find(fb.callback).off('keyup').on('keyup', fb.updateValue);\n    }\n\n    // Reset color\n    fb.color = null;\n\n    // Bind callback or elements\n    if (typeof callback == 'function') {\n      fb.callback = callback;\n    }\n    else if (typeof callback == 'object' || typeof callback == 'string') {\n      fb.callback = $(document.body).find(callback);\n      fb.callback.on('keyup', fb.updateValue);\n      if (fb.callback[0].value) {\n        fb.setColor(fb.callback[0].value);\n      }\n    }\n    return this;\n  }\n  fb.updateValue = function (event) {\n    if (this.value && this.value != fb.color) {\n      fb.setColor(this.value);\n    }\n  }\n\n  /**\n   * Change color with HTML syntax #123456\n   */\n  fb.setColor = function (color) {\n    var unpack = fb.unpack(color);\n    if (fb.color != color && unpack) {\n      fb.color = color;\n      fb.rgb = unpack;\n      fb.hsl = fb.RGBToHSL(fb.rgb);\n      fb.updateDisplay();\n    }\n    return this;\n  }\n\n  /**\n   * Change color with HSL triplet [0..1, 0..1, 0..1]\n   */\n  fb.setHSL = function (hsl) {\n    fb.hsl = hsl;\n\n    var convertedHSL = [hsl[0]]\n    convertedHSL[1] = hsl[1]*__factor+((1-__factor)/2);\n    convertedHSL[2] = hsl[2]*__factor+((1-__factor)/2);\n\n    fb.rgb = fb.HSLToRGB(convertedHSL);\n    fb.color = fb.pack(fb.rgb);\n    fb.updateDisplay();\n    return this;\n  }\n\n  /////////////////////////////////////////////////////\n\n  /**\n   * Initialize the color picker widget.\n   */\n  fb.initWidget = function () {\n\n    // Insert markup and size accordingly.\n    var dim = {\n      width: options.width,\n      height: options.width\n    };\n    $(container)\n      .html(\n        '<div class=\"farbtastic\" style=\"position: relative\">' +\n          '<div class=\"farbtastic-solid\"></div>' +\n          '<canvas class=\"farbtastic-mask\"></canvas>' +\n          '<canvas class=\"farbtastic-overlay\"></canvas>' +\n        '</div>'\n      )\n      .find('*').attr(dim).css(dim).end()\n      .find('div>*').css('position', 'absolute');\n\n    // IE Fix: Recreate canvas elements with doc.createElement and excanvas.\n    browser.msie && $('canvas', container).each(function () {\n      // Fetch info.\n      var attr = { 'class': $(this).attr('class'), style: this.getAttribute('style') },\n          e = document.createElement('canvas');\n      // Replace element.\n      $(this).before($(e).attr(attr)).remove();\n      // Init with explorerCanvas.\n      G_vmlCanvasManager && G_vmlCanvasManager.initElement(e);\n      // Set explorerCanvas elements dimensions and absolute positioning.\n      $(e).attr(dim).css(dim).css('position', 'absolute')\n        .find('*').attr(dim).css(dim);\n    });\n\n    // Determine layout\n    fb.radius = (options.width - options.wheelWidth) / 2 - 1;\n    fb.square = Math.floor((fb.radius - options.wheelWidth / 2) * 0.7) - 1;\n    fb.mid = Math.floor(options.width / 2);\n    fb.markerSize = options.wheelWidth * 0.3;\n    fb.solidFill = $('.farbtastic-solid', container).css({\n      width: fb.square * 2 - 1,\n      height: fb.square * 2 - 1,\n      left: fb.mid - fb.square,\n      top: fb.mid - fb.square\n    });\n\n    // Set up drawing context.\n    fb.cnvMask = $('.farbtastic-mask', container);\n    fb.ctxMask = fb.cnvMask[0].getContext('2d');\n    fb.cnvOverlay = $('.farbtastic-overlay', container);\n    fb.ctxOverlay = fb.cnvOverlay[0].getContext('2d');\n    fb.ctxMask.translate(fb.mid, fb.mid);\n    fb.ctxOverlay.translate(fb.mid, fb.mid);\n\n    // Draw widget base layers.\n    fb.drawCircle();\n    fb.drawMask();\n  }\n\n  /**\n   * Draw the color wheel.\n   */\n  fb.drawCircle = function () {\n    var tm = +(new Date());\n    // Draw a hue circle with a bunch of gradient-stroked beziers.\n    // Have to use beziers, as gradient-stroked arcs don't work.\n    var n = 24,\n        r = fb.radius,\n        w = options.wheelWidth,\n        nudge = 8 / r / n * Math.PI, // Fudge factor for seams.\n        m = fb.ctxMask,\n        angle1 = 0, color1, d1;\n    m.save();\n    m.lineWidth = w / r;\n    m.scale(r, r);\n    // Each segment goes from angle1 to angle2.\n    for (var i = 0; i <= n; ++i) {\n      var d2 = i / n,\n          angle2 = d2 * Math.PI * 2,\n          // Endpoints\n          x1 = Math.sin(angle1), y1 = -Math.cos(angle1);\n          let x2 = Math.sin(angle2), y2 = -Math.cos(angle2),\n          // Midpoint chosen so that the endpoints are tangent to the circle.\n          am = (angle1 + angle2) / 2,\n          tan = 1 / Math.cos((angle2 - angle1) / 2),\n          xm = Math.sin(am) * tan, ym = -Math.cos(am) * tan,\n          // New color\n          color2 = fb.pack(fb.HSLToRGB([d2, 1, 0.5]));\n      if (i > 0) {\n        if (browser.msie) {\n          // IE's gradient calculations mess up the colors. Correct along the diagonals.\n          var corr = (1 + Math.min(Math.abs(Math.tan(angle1)), Math.abs(Math.tan(Math.PI / 2 - angle1)))) / n;\n          color1 = fb.pack(fb.HSLToRGB([d1 - 0.15 * corr, 1, 0.5]));\n          color2 = fb.pack(fb.HSLToRGB([d2 + 0.15 * corr, 1, 0.5]));\n          // Create gradient fill between the endpoints.\n          var grad = m.createLinearGradient(x1, y1, x2, y2);\n          grad.addColorStop(0, color1);\n          grad.addColorStop(1, color2);\n          m.fillStyle = grad;\n          // Draw quadratic curve segment as a fill.\n          var r1 = (r + w / 2) / r, r2 = (r - w / 2) / r; // inner/outer radius.\n          m.beginPath();\n          m.moveTo(x1 * r1, y1 * r1);\n          m.quadraticCurveTo(xm * r1, ym * r1, x2 * r1, y2 * r1);\n          m.lineTo(x2 * r2, y2 * r2);\n          m.quadraticCurveTo(xm * r2, ym * r2, x1 * r2, y1 * r2);\n          m.fill();\n        }\n        else {\n          // Create gradient fill between the endpoints.\n          var grad = m.createLinearGradient(x1, y1, x2, y2);\n          grad.addColorStop(0, color1);\n          grad.addColorStop(1, color2);\n          m.strokeStyle = grad;\n          // Draw quadratic curve segment.\n          m.beginPath();\n          m.moveTo(x1, y1);\n          m.quadraticCurveTo(xm, ym, x2, y2);\n          m.stroke();\n        }\n      }\n      // Prevent seams where curves join.\n      angle1 = angle2 - nudge; color1 = color2; d1 = d2;\n    }\n    m.restore();\n    __debug && $('body').append('<div>drawCircle '+ (+(new Date()) - tm) +'ms');\n  };\n\n  /**\n   * Draw the saturation/luminance mask.\n   */\n  fb.drawMask = function () {\n    var tm = +(new Date());\n\n    // Iterate over sat/lum space and calculate appropriate mask pixel values.\n    var size = fb.square * 2, sq = fb.square;\n    function calculateMask(sizex, sizey, outputPixel) {\n      var isx = 1 / sizex, isy = 1 / sizey;\n      for (var y = 0; y <= sizey; ++y) {\n        var l = 1 - y * isy;\n        for (var x = 0; x <= sizex; ++x) {\n          var s = 1 - x * isx;\n          // From sat/lum to alpha and color (grayscale)\n          var a = 1 - 2 * Math.min(l * s, (1 - l) * s);\n          var c = (a > 0) ? ((2 * l - 1 + a) * .5 / a) : 0;\n\n          a = a*__factor+(1-__factor)/2;\n          c = c*__factor+(1-__factor)/2;\n\n          outputPixel(x, y, c, a);\n        }\n      }\n    }\n\n    // Method #1: direct pixel access (new Canvas).\n    if (fb.ctxMask.getImageData) {\n      // Create half-resolution buffer.\n      var sz = Math.floor(size / 2);\n      var buffer = document.createElement('canvas');\n      buffer.width = buffer.height = sz + 1;\n      var ctx = buffer.getContext('2d');\n      var frame = ctx.getImageData(0, 0, sz + 1, sz + 1);\n\n      var i = 0;\n      calculateMask(sz, sz, function (x, y, c, a) {\n        frame.data[i++] = frame.data[i++] = frame.data[i++] = c * 255;\n        frame.data[i++] = a * 255;\n      });\n\n      ctx.putImageData(frame, 0, 0);\n      fb.ctxMask.drawImage(buffer, 0, 0, sz + 1, sz + 1, -sq, -sq, sq * 2, sq * 2);\n    }\n    // Method #2: drawing commands (old Canvas).\n    else if (!browser.msie) {\n      // Render directly at half-resolution\n      var sz = Math.floor(size / 2);\n      calculateMask(sz, sz, function (x, y, c, a) {\n        c = Math.round(c * 255);\n        fb.ctxMask.fillStyle = 'rgba(' + c + ', ' + c + ', ' + c + ', ' + a +')';\n        fb.ctxMask.fillRect(x * 2 - sq - 1, y * 2 - sq - 1, 2, 2);\n      });\n    }\n    // Method #3: vertical DXImageTransform gradient strips (IE).\n    else {\n      var cache_last, cache, w = 6; // Each strip is 6 pixels wide.\n      var sizex = Math.floor(size / w);\n      // 6 vertical pieces of gradient per strip.\n      calculateMask(sizex, 6, function (x, y, c, a) {\n        if (x == 0) {\n          cache_last = cache;\n          cache = [];\n        }\n        c = Math.round(c * 255);\n        a = Math.round(a * 255);\n        // We can only start outputting gradients once we have two rows of pixels.\n        if (y > 0) {\n          var c_last = cache_last[x][0],\n              a_last = cache_last[x][1],\n              color1 = fb.packDX(c_last, a_last),\n              color2 = fb.packDX(c, a),\n              y1 = Math.round(fb.mid + ((y - 1) * .333 - 1) * sq),\n              y2 = Math.round(fb.mid + (y * .333 - 1) * sq);\n          $('<div>').css({\n            position: 'absolute',\n            filter: \"progid:DXImageTransform.Microsoft.Gradient(StartColorStr=\"+ color1 +\", EndColorStr=\"+ color2 +\", GradientType=0)\",\n            top: y1,\n            height: y2 - y1,\n            // Avoid right-edge sticking out.\n            left: fb.mid + (x * w - sq - 1),\n            width: w - (x == sizex ? Math.round(w / 2) : 0)\n          }).appendTo(fb.cnvMask);\n        }\n        cache.push([c, a]);\n      });\n    }\n    __debug && $('body').append('<div>drawMask '+ (+(new Date()) - tm) +'ms');\n  }\n\n  /**\n   * Draw the selection markers.\n   */\n  fb.drawMarkers = function () {\n    // Determine marker dimensions\n    var sz = options.width;\n    var angle = fb.hsl[0] * 6.28,\n        x1 =  Math.sin(angle) * fb.radius,\n        y1 = -Math.cos(angle) * fb.radius,\n        x2 = 2 * fb.square * (.5 - fb.hsl[1]),\n        y2 = 2 * fb.square * (.5 - fb.hsl[2]);\n    var circles = [\n      { x: x1, y: y1, r: fb.markerSize + 1, c: 'rgb(0,0,0,.4)', lw: 2 },\n      { x: x1, y: y1, r: fb.markerSize, c: '#fff', lw: 2 },\n      { x: x2, y: y2, r: fb.markerSize + 1, c: 'rgb(0,0,0,.4)', lw: 2 },\n      { x: x2, y: y2, r: fb.markerSize, c: '#fff',     lw: 2 },\n    ];\n\n    // Update the overlay canvas.\n    fb.ctxOverlay.clearRect(-fb.mid, -fb.mid, sz, sz);\n    for (let i in circles) {\n      const c = circles[i];\n      fb.ctxOverlay.lineWidth = c.lw;\n      fb.ctxOverlay.strokeStyle = c.c;\n      fb.ctxOverlay.beginPath();\n      fb.ctxOverlay.arc(c.x, c.y, c.r, 0, Math.PI * 2, true);\n      fb.ctxOverlay.stroke();\n    }\n  }\n\n  /**\n   * Update the markers and styles\n   */\n  fb.updateDisplay = function () {\n    // Determine whether labels/markers should invert.\n    fb.invert = (fb.rgb[0] * 0.3 + fb.rgb[1] * .59 + fb.rgb[2] * .11) <= 0.6;\n\n    // Update the solid background fill.\n    fb.solidFill.css('backgroundColor', fb.pack(fb.HSLToRGB([fb.hsl[0], 1, 0.5])));\n\n    // Draw markers\n    fb.drawMarkers();\n\n    // Linked elements or callback\n    if (typeof fb.callback == 'object') {\n      // Set background/foreground color\n      $(document.body).find(fb.callback).css({\n        backgroundColor: fb.color,\n        color: fb.invert ? '#fff' : '#000'\n      });\n\n\n      // Change linked value\n      $(document.body).find(fb.callback).each(function() {\n        if ((typeof this.value == 'string') && this.value != fb.color) {\n          this.value = fb.color;\n        }\n      });\n    }\n    else if (typeof fb.callback == 'function') {\n      fb.callback.call(fb, fb.color);\n    }\n  }\n\n  /**\n   * Helper for returning coordinates relative to the center.\n   */\n  fb.widgetCoords = function (event) {\n    return {\n      x: event.pageX - fb.offset.left - fb.mid,\n      y: event.pageY - fb.offset.top - fb.mid\n    };\n  }\n\n  /**\n   * Mousedown handler\n   */\n  fb.mousedown = function (event) {\n    // Capture mouse\n    if (!$._farbtastic.dragging) {\n      $(document).on('mousemove', fb.mousemove).on('mouseup', fb.mouseup);\n      $._farbtastic.dragging = true;\n    }\n\n    // Update the stored offset for the widget.\n    fb.offset = $(container).offset();\n\n    // Check which area is being dragged\n    var pos = fb.widgetCoords(event);\n    fb.circleDrag = Math.max(Math.abs(pos.x), Math.abs(pos.y)) > (fb.square + 2);\n\n    // Process\n    fb.mousemove(event);\n    return false;\n  }\n\n  /**\n   * Mousemove handler\n   */\n  fb.mousemove = function (event) {\n    // Get coordinates relative to color picker center\n    var pos = fb.widgetCoords(event);\n\n    // Set new HSL parameters\n    if (fb.circleDrag) {\n      var hue = Math.atan2(pos.x, -pos.y) / 6.28;\n      fb.setHSL([(hue + 1) % 1, fb.hsl[1], fb.hsl[2]]);\n    }\n    else {\n      var sat = Math.max(0, Math.min(1, -(pos.x / fb.square / 2) + .5));\n      var lum = Math.max(0, Math.min(1, -(pos.y / fb.square / 2) + .5));\n      fb.setHSL([fb.hsl[0], sat, lum]);\n    }\n    return false;\n  }\n\n  /**\n   * Mouseup handler\n   */\n  fb.mouseup = function () {\n    // Uncapture mouse\n    $(document).off('mousemove', fb.mousemove);\n    $(document).off('mouseup',  fb.mouseup);\n    $._farbtastic.dragging = false;\n  }\n\n  /* Various color utility functions */\n  fb.dec2hex = function (x) {\n    return (x < 16 ? '0' : '') + x.toString(16);\n  }\n\n  fb.packDX = function (c, a) {\n    return '#' + fb.dec2hex(a) + fb.dec2hex(c) + fb.dec2hex(c) + fb.dec2hex(c);\n  };\n\n  fb.pack = function (rgb) {\n    var r = Math.round(rgb[0] * 255);\n    var g = Math.round(rgb[1] * 255);\n    var b = Math.round(rgb[2] * 255);\n    return '#' + fb.dec2hex(r) + fb.dec2hex(g) + fb.dec2hex(b);\n  };\n\n  fb.unpack = function (color) {\n    if (color.length == 7) {\n      function x(i) {\n        return parseInt(color.substring(i, i + 2), 16) / 255;\n      }\n      return [ x(1), x(3), x(5) ];\n    }\n    else if (color.length == 4) {\n      function x(i) {\n        return parseInt(color.substring(i, i + 1), 16) / 15;\n      }\n      return [ x(1), x(2), x(3) ];\n    }\n  };\n\n  fb.HSLToRGB = function (hsl) {\n    var m1, m2, r, g, b;\n    var h = hsl[0], s = hsl[1], l = hsl[2];\n    m2 = (l <= 0.5) ? l * (s + 1) : l + s - l * s;\n    m1 = l * 2 - m2;\n    return [\n      this.hueToRGB(m1, m2, h + 0.33333),\n      this.hueToRGB(m1, m2, h),\n      this.hueToRGB(m1, m2, h - 0.33333)\n    ];\n  };\n\n  fb.hueToRGB = function (m1, m2, h) {\n    h = (h + 1) % 1;\n    if (h * 6 < 1) return m1 + (m2 - m1) * h * 6;\n    if (h * 2 < 1) return m2;\n    if (h * 3 < 2) return m1 + (m2 - m1) * (0.66666 - h) * 6;\n    return m1;\n  };\n\n  fb.RGBToHSL = function (rgb) {\n    var r = rgb[0], g = rgb[1], b = rgb[2],\n        min = Math.min(r, g, b),\n        max = Math.max(r, g, b),\n        delta = max - min,\n        h = 0,\n        s = 0,\n        l = (min + max) / 2;\n    if (l > 0 && l < 1) {\n      s = delta / (l < 0.5 ? (2 * l) : (2 - 2 * l));\n    }\n    if (delta > 0) {\n      if (max == r && max != g) h += (g - b) / delta;\n      if (max == g && max != b) h += (2 + (b - r) / delta);\n      if (max == b && max != r) h += (4 + (r - g) / delta);\n      h /= 6;\n    }\n    return [h, s, l];\n  };\n\n  // Parse options.\n  if (!options.callback) {\n    options = { callback: options };\n  }\n  options = $.extend({\n    width: 300,\n    wheelWidth: (options.width || 300) / 10,\n    callback: null\n  }, options);\n\n  // Initialize.\n  fb.initWidget();\n\n  // Install mousedown handler (the others are set on the document on-demand)\n  $('canvas.farbtastic-overlay', container).on('mousedown',fb.mousedown);\n\n  // Set linked elements/callback\n  if (options.callback) {\n    fb.linkTo(options.callback);\n  }\n  // Set to gray.\n  fb.setColor('#808080');\n}\n\n})(jQuery);\n"
  },
  {
    "path": "src/static/js/vendors/gritter.ts",
    "content": "// @ts-nocheck\n// WARNING: This file has been modified from the Original\n\n/*\n * Gritter for jQuery\n * http://www.boedesign.com/\n *\n * Copyright (c) 2012 Jordan Boesch\n * Dual licensed under the MIT and GPL licenses.\n *\n * Date: February 24, 2012\n * Version: 1.7.4\n *\n * Edited by Sebastian Castro <sebastian.castro@protonmail.com> on 2020-03-31\n *\n * Edited by Richard Hansen <rhansen@rhansen.org> on 2020-10-19 to accept jQuery or DOM objects for\n * notification title and text, and to treat plain strings as text instead of HTML (to avoid XSS\n * vunlerabilities).\n */\n\n(function($){\n\t/**\n\t* Set it up as an object under the jQuery namespace\n\t*/\n\t$.gritter = {};\n\n\t/**\n\t* Set up global options that the user can over-ride\n\t*/\n\t$.gritter.options = {\n\t\tposition: '',\n\t\tclass_name: '', // could be set to 'gritter-light' to use white notifications\n\t\ttime: 3000 // hang on the screen for...\n\t}\n\n\t/**\n\t* Add a gritter notification to the screen\n\t* @see Gritter#add();\n\t*/\n\t$.gritter.add = function(params){\n\n\t\ttry {\n\t\t\treturn Gritter.add(params || {});\n\t\t} catch(e) {\n\n      const err = 'Gritter Error: ' + e;\n      (typeof(console) != 'undefined' && console.error) ?\n\t\t\t\tconsole.error(err, params) :\n\t\t\t\talert(err);\n\n\t\t}\n\n\t}\n\n\t/**\n\t* Remove a gritter notification from the screen\n\t* @see Gritter#removeSpecific();\n\t*/\n\t$.gritter.remove = function(id, params){\n\t\tGritter.removeSpecific(id.split('gritter-item-')[1], params || {});\n\t}\n\n\t/**\n\t* Remove all notifications\n\t* @see Gritter#stop();\n\t*/\n\t$.gritter.removeAll = function(params){\n\t\tGritter.stop(params || {});\n\t}\n\n\t/**\n\t* Big fat Gritter object\n\t* @constructor (not really since its object literal)\n\t*/\n\tvar Gritter = {\n\n\t\t// Public - options to over-ride with $.gritter.options in \"add\"\n\t\ttime: '',\n\n\t\t// Private - no touchy the private parts\n\t\t_custom_timer: 0,\n\t\t_item_count: 0,\n\t\t_is_setup: 0,\n\t\t_tpl_wrap_top: '<div id=\"gritter-container\" class=\"top\"></div>',\n\t\t_tpl_wrap_bottom: '<div id=\"gritter-container\" class=\"bottom\"></div>',\n\t\t_tpl_close: '',\n\t\t_tpl_title: $('<h3>').addClass('gritter-title'),\n\t\t_tpl_item: ($('<div>').addClass('popup gritter-item')\n\t\t\t\t\t\t\t\t.append($('<div>').addClass('popup-content')\n\t\t\t\t\t\t\t\t\t\t\t\t.append($('<div>').addClass('gritter-content'))\n\t\t\t\t\t\t\t\t\t\t\t\t.append($('<div>').addClass('gritter-close')\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t.append($('<i>').addClass('buttonicon buttonicon-times'))))),\n\n\n\t\t/**\n\t\t* Add a gritter notification to the screen\n\t\t* @param {Object} params The object that contains all the options for drawing the notification\n\t\t* @return {Integer} The specific numeric id to that gritter notification\n\t\t*/\n\t\tadd: function(params){\n\t\t\t// Handle straight text\n\t\t\tif(typeof(params) == 'string'){\n\t\t\t\tparams = {text:params};\n\t\t\t}\n\n\t\t\t// We might have some issues if we don't have a title or text!\n\t\t\tif(!params.text){\n\t\t\t\tthrow 'You must supply \"text\" parameter.';\n\t\t\t}\n\n\t\t\t// Check the options and set them once\n\t\t\tif(!this._is_setup){\n\t\t\t\tthis._runSetup();\n\t\t\t}\n\n\t\t\t// Basics\n\t\t\tvar title = params.title,\n\t\t\t\ttext = params.text,\n\t\t\t\timage = params.image || '',\n\t\t\t\tposition = params.position || 'top',\n\t\t\t\tsticky = params.sticky || false,\n\t\t\t\titem_class = params.class_name || $.gritter.options.class_name,\n\t\t\t\ttime_alive = params.time || '';\n\n\t\t\tthis._verifyWrapper();\n\n\t\t\tif (sticky) {\n\t\t\t\titem_class += \" sticky\";\n\t\t\t}\n\n\t\t\tthis._item_count++;\n\t\t\tvar number = this._item_count;\n\n\t\t\t// Assign callbacks\n\t\t\t$(['before_open', 'after_open', 'before_close', 'after_close']).each(function(i, val){\n\t\t\t\tGritter['_' + val + '_' + number] = ($.isFunction(params[val])) ? params[val] : function(){}\n\t\t\t});\n\n\t\t\t// Reset\n\t\t\tthis._custom_timer = 0;\n\n\t\t\t// A custom fade time set\n\t\t\tif(time_alive){\n\t\t\t\tthis._custom_timer = time_alive;\n\t\t\t}\n\n\t\t\t// String replacements on the template\n\t\t\tif(title){\n\t\t\t\ttitle = this._tpl_title.clone().append(\n\t\t\t\t\t\ttypeof title === 'string' ? document.createTextNode(title) : title);\n\t\t\t}else{\n\t\t\t\ttitle = '';\n\t\t\t}\n\n\t\t\tconst tmp = this._tpl_item.clone();\n\t\t\ttmp.attr('id', `gritter-item-${number}`);\n\t\t\ttmp.addClass(item_class);\n\t\t\ttmp.find('.gritter-content')\n\t\t\t\t\t.append(title)\n\t\t\t\t\t.append(typeof text === 'string' ? $('<p>').text(text) : text);\n\n\t\t\t// If it's false, don't show another gritter message\n\t\t\tif(this['_before_open_' + number]() === false){\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tif (['top', 'bottom'].indexOf(position) == -1) {\n\t\t\t\tposition = 'top';\n\t\t\t}\n\n\t\t\t$('#gritter-container.' + position).append(tmp);\n\n\t\t\tvar item = $('#gritter-item-' + this._item_count);\n\n\t\t\tsetTimeout(function() { item.addClass('popup-show'); }, 0);\n\t\t\tGritter['_after_open_' + number](item);\n\n\t\t\tif(!sticky){\n\t\t\t\tthis._setFadeTimer(item, number);\n\t\t\t\t// Bind the hover/unhover states\n\t\t\t\t$(item).on('mouseenter', function(event) {\n\t\t\t\t\tGritter._restoreItemIfFading($(this), number);\n\t\t\t\t});\n\t\t\t\t$(item).on('mouseleave', function(event) {\n\t\t\t\t\tGritter._setFadeTimer($(this), number);\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t// Clicking (X) makes the perdy thing close\n\t\t\t$(item).find('.gritter-close').click(function(){\n\t\t\t\tGritter.removeSpecific(number, {}, null, true);\n\t\t\t});\n\n\t\t\treturn number;\n\n\t\t},\n\n\t\t/**\n\t\t* If we don't have any more gritter notifications, get rid of the wrapper using this check\n\t\t* @private\n\t\t* @param {Integer} unique_id The ID of the element that was just deleted, use it for a callback\n\t\t* @param {Object} e The jQuery element that we're going to perform the remove() action on\n\t\t* @param {Boolean} manual_close Did we close the gritter dialog with the (X) button\n\t\t*/\n\t\t_countRemoveWrapper: function(unique_id, e, manual_close){\n\n\t\t\t// Remove it then run the callback function\n\t\t\te.remove();\n\t\t\tthis['_after_close_' + unique_id](e, manual_close);\n\n\t\t\t// Remove container if empty\n\t\t\t$('#gritter-container').each(function() {\n\t\t\t\tif ($(this).find('.gritter-item').length == 0) {\n\t\t\t\t\t$(this).remove();\n\t\t\t\t}\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t* Fade out an element after it's been on the screen for x amount of time\n\t\t* @private\n\t\t* @param {Object} e The jQuery element to get rid of\n\t\t* @param {Integer} unique_id The id of the element to remove\n\t\t* @param {Object} params An optional list of params.\n\t\t* @param {Boolean} unbind_events Unbind the mouseenter/mouseleave events if they click (X)\n\t\t*/\n\t\t_fade: function(e, unique_id, params, unbind_events){\n\n\t\t\tvar params = params || {},\n\t\t\t\tfade = (typeof(params.fade) != 'undefined') ? params.fade : true,\n\t\t\t\tmanual_close = unbind_events;\n\n\t\t\tthis['_before_close_' + unique_id](e, manual_close);\n\n\t\t\t// If this is true, then we are coming from clicking the (X)\n\t\t\tif(unbind_events){\n\t\t\t\te.unbind('mouseenter mouseleave');\n\t\t\t}\n\n\t\t\t// Fade it out or remove it\n\t\t\tif(fade){\n\t\t\t\te.removeClass('popup-show');\n\t\t\t\tsetTimeout(function() {\n\t\t\t\t\tGritter._countRemoveWrapper(unique_id, e, manual_close);\n\t\t\t\t}, 300)\n\t\t\t}\n\t\t\telse {\n\n\t\t\t\tthis._countRemoveWrapper(unique_id, e);\n\n\t\t\t}\n\n\t\t},\n\n\t\t/**\n\t\t* Remove a specific notification based on an ID\n\t\t* @param {Integer} unique_id The ID used to delete a specific notification\n\t\t* @param {Object} params A set of options passed in to determine how to get rid of it\n\t\t* @param {Object} e The jQuery element that we're \"fading\" then removing\n\t\t* @param {Boolean} unbind_events If we clicked on the (X) we set this to true to unbind mouseenter/mouseleave\n\t\t*/\n\t\tremoveSpecific: function(unique_id, params, e, unbind_events){\n\n\t\t\tif(!e){\n\t\t\t\tvar e = $('#gritter-item-' + unique_id);\n\t\t\t}\n\n\t\t\t// We set the fourth param to let the _fade function know to\n\t\t\t// unbind the \"mouseleave\" event.  Once you click (X) there's no going back!\n\t\t\tthis._fade(e, unique_id, params || {}, unbind_events);\n\n\t\t},\n\n\t\t/**\n\t\t* If the item is fading out and we hover over it, restore it!\n\t\t* @private\n\t\t* @param {Object} e The HTML element to remove\n\t\t* @param {Integer} unique_id The ID of the element\n\t\t*/\n\t\t_restoreItemIfFading: function(e, unique_id){\n\n\t\t\tclearTimeout(this['_int_id_' + unique_id]);\n\t\t\te.stop().css({ opacity: '', height: '' });\n\n\t\t},\n\n\t\t/**\n\t\t* Setup the global options - only once\n\t\t* @private\n\t\t*/\n\t\t_runSetup: function(){\n\n\t\t\tfor(let opt in $.gritter.options){\n\t\t\t\tthis[opt] = $.gritter.options[opt];\n\t\t\t}\n\t\t\tthis._is_setup = 1;\n\n\t\t},\n\n\t\t/**\n\t\t* Set the notification to fade out after a certain amount of time\n\t\t* @private\n\t\t* @param {Object} item The HTML element we're dealing with\n\t\t* @param {Integer} unique_id The ID of the element\n\t\t*/\n\t\t_setFadeTimer: function(item, unique_id){\n\n\t\t\tvar timer_str = (this._custom_timer) ? this._custom_timer : this.time;\n\t\t\tthis['_int_id_' + unique_id] = setTimeout(function(){\n\t\t\t\tGritter._fade(item, unique_id);\n\t\t\t}, timer_str);\n\n\t\t},\n\n\t\t/**\n\t\t* Bring everything to a halt\n\t\t* @param {Object} params A list of callback functions to pass when all notifications are removed\n\t\t*/\n\t\tstop: function(params){\n\n\t\t\t// callbacks (if passed)\n\t\t\tvar before_close = ($.isFunction(params.before_close)) ? params.before_close : function(){};\n\t\t\tvar after_close = ($.isFunction(params.after_close)) ? params.after_close : function(){};\n\n\t\t\tvar wrap = $('#gritter-container');\n\t\t\tbefore_close(wrap);\n\t\t\twrap.fadeOut(function(){\n\t\t\t\t$(this).remove();\n\t\t\t\tafter_close();\n\t\t\t});\n\n\t\t},\n\n\t\t/**\n\t\t* A check to make sure we have something to wrap our notices with\n\t\t* @private\n\t\t*/\n\t\t_verifyWrapper: function(){\n\t\t\tif ($('#gritter-container.top').length === 0) {\n\t\t\t\t$('#editorcontainerbox').append(this._tpl_wrap_top);\n\t\t\t}\n\n\t\t\tif ($('#gritter-container.bottom').length === 0) {\n\t\t\t\t$('#editorcontainerbox').append(this._tpl_wrap_bottom);\n\t\t\t}\n\t\t}\n\n\t}\n\n})(jQuery);\n\n// For Emacs:\n// Local Variables:\n// tab-width: 2\n// indent-tabs-mode: t\n// End:\n\n// vi: ts=2:noet:sw=2\n"
  },
  {
    "path": "src/static/js/vendors/html10n.ts",
    "content": "import {Func} from \"mocha\";\n\n\ntype PluralFunc = (n: number) => string\n\nexport class Html10n {\n  public language?: string\n  private rtl: string[]\n  private _pluralRules?: PluralFunc\n  public mt: MicroEvent\n  private loader: Loader | undefined\n  public translations: Map<string, any>\n  private macros: Map<string, Function>\n\n  constructor() {\n    this.language = undefined\n    this.rtl = [\"ar\",\"dv\",\"fa\",\"ha\",\"he\",\"ks\",\"ku\",\"ps\",\"ur\",\"yi\"]\n    this.mt = new MicroEvent()\n    this.translations = new Map()\n    this.macros = new Map()\n\n    this.macros.set('plural', (_key: string, param:string, opts: any)=>{\n      let str\n        , n = parseFloat(param);\n      if (isNaN(n))\n        return;\n\n      // initialize _pluralRules\n      if (this._pluralRules === undefined) {\n        this._pluralRules = this.getPluralRules(this.language!);\n      }\n      let index = this._pluralRules!(n);\n\n      // try to find a [zero|one|two] key if it's defined\n      if (n === 0 && ('zero') in opts) {\n        str = opts['zero'];\n      } else if (n == 1 && ('one') in opts) {\n        str = opts['one'];\n      } else if (n == 2 && ('two') in opts) {\n        str = opts['two'];\n      } else if (index in opts) {\n        str = opts[index];\n      }\n\n      return str;\n    })\n\n    document.addEventListener('DOMContentLoaded', ()=> {\n        this.index()\n      }, false)\n  }\n\n  bind(event: string, fct: Func) {\n    this.mt.bind(event, fct)\n  }\n\n  /**\n   * Get rules for plural forms (shared with JetPack), see:\n   * http://unicode.org/repos/cldr-tmp/trunk/diff/supplemental/language_plural_rules.html\n   * https://github.com/mozilla/addon-sdk/blob/master/python-lib/plural-rules-generator.p\n   *\n   * @param {string} lang\n   *    locale (language) used.\n   *\n   * @return {PluralFunc}\n   *    returns a function that gives the plural form name for a given integer:\n   *       var fun = getPluralRules('en');\n   *       fun(1)    -> 'one'\n   *       fun(0)    -> 'other'\n   *       fun(1000) -> 'other'.\n   */\n  getPluralRules(lang: string): PluralFunc {\n    const locales2rules = new Map([\n      ['af', 3],\n      ['ak', 4],\n      ['am', 4],\n      ['ar', 1],\n      ['asa', 3],\n      ['az', 0],\n      ['be', 11],\n      ['bem', 3],\n      ['bez', 3],\n      ['bg', 3],\n      ['bh', 4],\n      ['bm', 0],\n      ['bn', 3],\n      ['bo', 0],\n      ['br', 20],\n      ['brx', 3],\n      ['bs', 11],\n      ['ca', 3],\n      ['cgg', 3],\n      ['chr', 3],\n      ['cs', 12],\n      ['cy', 17],\n      ['da', 3],\n      ['de', 3],\n      ['dv', 3],\n      ['dz', 0],\n      ['ee', 3],\n      ['el', 3],\n      ['en', 3],\n      ['eo', 3],\n      ['es', 3],\n      ['et', 3],\n      ['eu', 3],\n      ['fa', 0],\n      ['ff', 5],\n      ['fi', 3],\n      ['fil', 4],\n      ['fo', 3],\n      ['fr', 5],\n      ['fur', 3],\n      ['fy', 3],\n      ['ga', 8],\n      ['gd', 24],\n      ['gl', 3],\n      ['gsw', 3],\n      ['gu', 3],\n      ['guw', 4],\n      ['gv', 23],\n      ['ha', 3],\n      ['haw', 3],\n      ['he', 2],\n      ['hi', 4],\n      ['hr', 11],\n      ['hu', 0],\n      ['id', 0],\n      ['ig', 0],\n      ['ii', 0],\n      ['is', 3],\n      ['it', 3],\n      ['iu', 7],\n      ['ja', 0],\n      ['jmc', 3],\n      ['jv', 0],\n      ['ka', 0],\n      ['kab', 5],\n      ['kaj', 3],\n      ['kcg', 3],\n      ['kde', 0],\n      ['kea', 0],\n      ['kk', 3],\n      ['kl', 3],\n      ['km', 0],\n      ['kn', 0],\n      ['ko', 0],\n      ['ksb', 3],\n      ['ksh', 21],\n      ['ku', 3],\n      ['kw', 7],\n      ['lag', 18],\n      ['lb', 3],\n      ['lg', 3],\n      ['ln', 4],\n      ['lo', 0],\n      ['lt', 10],\n      ['lv', 6],\n      ['mas', 3],\n      ['mg', 4],\n      ['mk', 16],\n      ['ml', 3],\n      ['mn', 3],\n      ['mo', 9],\n      ['mr', 3],\n      ['ms', 0],\n      ['mt', 15],\n      ['my', 0],\n      ['nah', 3],\n      ['naq', 7],\n      ['nb', 3],\n      ['nd', 3],\n      ['ne', 3],\n      ['nl', 3],\n      ['nn', 3],\n      ['no', 3],\n      ['nr', 3],\n      ['nso', 4],\n      ['ny', 3],\n      ['nyn', 3],\n      ['om', 3],\n      ['or', 3],\n      ['pa', 3],\n      ['pap', 3],\n      ['pl', 13],\n      ['ps', 3],\n      ['pt', 3],\n      ['rm', 3],\n      ['ro', 9],\n      ['rof', 3],\n      ['ru', 11],\n      ['rwk', 3],\n      ['sah', 0],\n      ['saq', 3],\n      ['se', 7],\n      ['seh', 3],\n      ['ses', 0],\n      ['sg', 0],\n      ['sh', 11],\n      ['shi', 19],\n      ['sk', 12],\n      ['sl', 14],\n      ['sma', 7],\n      ['smi', 7],\n      ['smj', 7],\n      ['smn', 7],\n      ['sms', 7],\n      ['sn', 3],\n      ['so', 3],\n      ['sq', 3],\n      ['sr', 11],\n      ['ss', 3],\n      ['ssy', 3],\n      ['st', 3],\n      ['sv', 3],\n      ['sw', 3],\n      ['syr', 3],\n      ['ta', 3],\n      ['te', 3],\n      ['teo', 3],\n      ['th', 0],\n      ['ti', 4],\n      ['tig', 3],\n      ['tk', 3],\n      ['tl', 4],\n      ['tn', 3],\n      ['to', 0],\n      ['tr', 0],\n      ['ts', 3],\n      ['tzm', 22],\n      ['uk', 11],\n      ['ur', 3],\n      ['ve', 3],\n      ['vi', 0],\n      ['vun', 3],\n      ['wa', 4],\n      ['wae', 3],\n      ['wo', 0],\n      ['xh', 3],\n      ['xog', 3],\n      ['yo', 0],\n      ['zh', 0],\n      ['zu', 3]\n    ])\n\n    function isIn(n: number, list: number[]) {\n      return list.indexOf(n) !== -1;\n    }\n    function isBetween(n: number, start: number, end: number) {\n      return start <= n && n <= end;\n    }\n\n    type PluralFunc = (n: number) => string\n\n\n    const pluralRules: {\n      [key: string]: PluralFunc\n    } =  {\n      '0': function() {\n        return 'other';\n      },\n      '1': function(n: number) {\n        if ((isBetween((n % 100), 3, 10)))\n          return 'few';\n        if (n === 0)\n          return 'zero';\n        if ((isBetween((n % 100), 11, 99)))\n          return 'many';\n        if (n == 2)\n          return 'two';\n        if (n == 1)\n          return 'one';\n        return 'other';\n      },\n      '2': function(n: number) {\n        if (n !== 0 && (n % 10) === 0)\n          return 'many';\n        if (n == 2)\n          return 'two';\n        if (n == 1)\n          return 'one';\n        return 'other';\n      },\n      '3': function(n: number) {\n        if (n == 1)\n          return 'one';\n        return 'other';\n      },\n      '4': function(n: number) {\n        if ((isBetween(n, 0, 1)))\n          return 'one';\n        return 'other';\n      },\n      '5': function(n: number) {\n        if ((isBetween(n, 0, 2)) && n != 2)\n          return 'one';\n        return 'other';\n      },\n      '6': function(n: number) {\n        if (n === 0)\n          return 'zero';\n        if ((n % 10) == 1 && (n % 100) != 11)\n          return 'one';\n        return 'other';\n      },\n      '7': function(n: number) {\n        if (n == 2)\n          return 'two';\n        if (n == 1)\n          return 'one';\n        return 'other';\n      },\n      '8': function(n: number) {\n        if ((isBetween(n, 3, 6)))\n          return 'few';\n        if ((isBetween(n, 7, 10)))\n          return 'many';\n        if (n == 2)\n          return 'two';\n        if (n == 1)\n          return 'one';\n        return 'other';\n      },\n      '9': function(n: number) {\n        if (n === 0 || n != 1 && (isBetween((n % 100), 1, 19)))\n          return 'few';\n        if (n == 1)\n          return 'one';\n        return 'other';\n      },\n      '10': function(n: number) {\n        if ((isBetween((n % 10), 2, 9)) && !(isBetween((n % 100), 11, 19)))\n          return 'few';\n        if ((n % 10) == 1 && !(isBetween((n % 100), 11, 19)))\n          return 'one';\n        return 'other';\n      },\n      '11': function(n: number) {\n        if ((isBetween((n % 10), 2, 4)) && !(isBetween((n % 100), 12, 14)))\n          return 'few';\n        if ((n % 10) === 0 ||\n          (isBetween((n % 10), 5, 9)) ||\n          (isBetween((n % 100), 11, 14)))\n          return 'many';\n        if ((n % 10) == 1 && (n % 100) != 11)\n          return 'one';\n        return 'other';\n      },\n      '12': function(n: number) {\n        if ((isBetween(n, 2, 4)))\n          return 'few';\n        if (n == 1)\n          return 'one';\n        return 'other';\n      },\n      '13': function(n: number) {\n        if ((isBetween((n % 10), 2, 4)) && !(isBetween((n % 100), 12, 14)))\n          return 'few';\n        if (n != 1 && (isBetween((n % 10), 0, 1)) ||\n          (isBetween((n % 10), 5, 9)) ||\n          (isBetween((n % 100), 12, 14)))\n          return 'many';\n        if (n == 1)\n          return 'one';\n        return 'other';\n      },\n      '14': function(n: number) {\n        if ((isBetween((n % 100), 3, 4)))\n          return 'few';\n        if ((n % 100) == 2)\n          return 'two';\n        if ((n % 100) == 1)\n          return 'one';\n        return 'other';\n      },\n      '15': function(n: number) {\n        if (n === 0 || (isBetween((n % 100), 2, 10)))\n          return 'few';\n        if ((isBetween((n % 100), 11, 19)))\n          return 'many';\n        if (n == 1)\n          return 'one';\n        return 'other';\n      },\n      '16': function(n: number) {\n        if ((n % 10) == 1 && n != 11)\n          return 'one';\n        return 'other';\n      },\n      '17': function(n: number) {\n        if (n == 3)\n          return 'few';\n        if (n === 0)\n          return 'zero';\n        if (n == 6)\n          return 'many';\n        if (n == 2)\n          return 'two';\n        if (n == 1)\n          return 'one';\n        return 'other';\n      },\n      '18': function(n: number) {\n        if (n === 0)\n          return 'zero';\n        if ((isBetween(n, 0, 2)) && n !== 0 && n != 2)\n          return 'one';\n        return 'other';\n      },\n      '19': function(n: number) {\n        if ((isBetween(n, 2, 10)))\n          return 'few';\n        if ((isBetween(n, 0, 1)))\n          return 'one';\n        return 'other';\n      },\n      '20': function(n: number) {\n        if ((isBetween((n % 10), 3, 4) || ((n % 10) == 9)) && !(\n          isBetween((n % 100), 10, 19) ||\n          isBetween((n % 100), 70, 79) ||\n          isBetween((n % 100), 90, 99)\n        ))\n          return 'few';\n        if ((n % 1000000) === 0 && n !== 0)\n          return 'many';\n        if ((n % 10) == 2 && !isIn((n % 100), [12, 72, 92]))\n          return 'two';\n        if ((n % 10) == 1 && !isIn((n % 100), [11, 71, 91]))\n          return 'one';\n        return 'other';\n      },\n      '21': function(n: number) {\n        if (n === 0)\n          return 'zero';\n        if (n == 1)\n          return 'one';\n        return 'other';\n      },\n      '22': function(n: number) {\n        if ((isBetween(n, 0, 1)) || (isBetween(n, 11, 99)))\n          return 'one';\n        return 'other';\n      },\n      '23': function(n: number) {\n        if ((isBetween((n % 10), 1, 2)) || (n % 20) === 0)\n          return 'one';\n        return 'other';\n      },\n      '24': function(n: number) {\n        if ((isBetween(n, 3, 10) || isBetween(n, 13, 19)))\n          return 'few';\n        if (isIn(n, [2, 12]))\n          return 'two';\n        if (isIn(n, [1, 11]))\n          return 'one';\n        return 'other';\n      }\n    };\n\n    const index = locales2rules.get(lang.replace(/-.*$/, ''));\n    // @ts-ignore\n    if (!(index in pluralRules)) {\n      console.warn('plural form unknown for [' + lang + ']');\n      return function() { return 'other'; };\n    }\n    // @ts-ignore\n    return pluralRules[index];\n  }\n\n  getTranslatableChildren(element: HTMLElement) {\n    return element.querySelectorAll('*[data-l10n-id]')\n  }\n\n  localize(langs: (string|undefined)[]|string) {\n    if ('string' === typeof langs) {\n      langs = [langs];\n    }\n    let i = 0\n    langs.forEach((lang) => {\n      if(!lang) return;\n      langs[i++] = lang;\n      if(~lang.indexOf('-')) langs[i++] = lang.substring(0, lang.indexOf('-'));\n    })\n\n    this.build(langs, (er: null, translations: Map<string, any>) =>{\n      this.translations = translations\n      this.translateElement(translations)\n      this.mt.trigger('localized')\n    })\n  }\n\n  /**\n   * Triggers the translation process\n   * for an element\n   * @param translations A hash of all translation strings\n   * @param element A DOM element, if omitted, the document element will be used\n   */\n  translateElement(translations: Map<string, any>, element?: HTMLElement) {\n    element = element || document.documentElement\n    const children = element ? this.getTranslatableChildren(element): document.childNodes\n\n    for (let child of children) {\n      this.translateNode(translations, child as HTMLElement)\n    }\n\n    // translate element itself if necessary\n    this.translateNode(translations, element)\n  }\n\n   asyncForEach(list: (string|undefined)[], iterator: any, cb: Function) {\n    let i = 0\n      , n = list.length\n    iterator(list[i], i, function each(err?: string) {\n      if(err) console.error(err)\n      i++\n      if (i < n) return iterator(list[i],i, each);\n      cb()\n    })\n  }\n\n  /**\n   * Builds a translation object from a list of langs (loads the necessary translations)\n   * @param langs Array - a list of langs sorted by priority (default langs should go last)\n   * @param cb Function - a callback that will be called once all langs have been loaded\n   */\n  build(langs: (string|undefined)[], cb: Function) {\n    const build = new Map<string, any>()\n\n    this.asyncForEach(langs,  (lang: string, _i: number, next:LoaderFunc)=> {\n      if(!lang) return next();\n      this.loader!.load(lang, next)\n    }, () =>{\n      let lang;\n      langs.reverse()\n\n      // loop through the priority array...\n      for (let i=0, n=langs.length; i < n; i++) {\n        lang = langs[i]\n        if(!lang) continue;\n        if(!langs.includes(lang)) {// uh, we don't have this lang availbable..\n          // then check for related langs\n          if(~lang.indexOf('-') != -1) {\n            lang = lang.split('-')[0];\n          }\n          let l: string|undefined = ''\n          for(l of langs) {\n            if(l && lang != l && l.indexOf(lang) === 0) {\n              lang = l\n              break;\n            }\n          }\n\n          // @ts-ignore\n          if(lang != l) continue;\n        }\n\n\n        // ... and apply all strings of the current lang in the list\n        // to our build object\n        //lang = \"de\"\n        if (this.loader!.langs.has(lang)) {\n          for (let string in this.loader!.langs.get(lang)) {\n            build.set(string,this.loader!.langs.get(lang)[string])\n          }\n          this.language = lang\n        } else {\n          const loaderLang = lang.split('-')[0]\n          for (let string in this.loader!.langs.get(loaderLang)) {\n            build.set(string,this.loader!.langs.get(loaderLang)[string])\n          }\n          this.language = loaderLang\n        }\n\n        // the last applied lang will be exposed as the\n        // lang the page was translated to\n      }\n      cb(null, build)\n    })\n  }\n\n  /**\n   * Returns the language that was last applied to the translations hash\n   * thus overriding most of the formerly applied langs\n   */\n  getLanguage() {\n    return this.language\n  }\n\n  /**\n   * Returns the direction of the language returned be html10n#getLanguage\n   */\n  getDirection() {\n    if(!this.language) return\n    const langCode = this.language.indexOf('-') == -1? this.language : this.language.substring(0, this.language.indexOf('-'))\n    return this.rtl.indexOf(langCode) == -1? 'ltr' : 'rtl'\n  }\n\n\n  /**\n   * Index all <link>s\n   */\n  index() {\n    // Find all <link>s\n    const links = document.getElementsByTagName('link')\n      , resources = []\n    for (let i=0, n=links.length; i < n; i++) {\n      if (links[i].type != 'application/l10n+json')\n        continue;\n      resources.push(links[i].href)\n    }\n    this.loader = new Loader(resources)\n    this.mt.trigger('indexed')\n  }\n\n  translateNode(translations: Map<string, any>, node: HTMLElement) {\n    const str: {\n      id?: string,\n      args?: any,\n      str?: string\n\n    } = {}\n\n    // get id\n    str.id = node.getAttribute('data-l10n-id') as string\n    if (!str.id) return\n\n    if(!translations.get(str.id)) return console.warn('Couldn\\'t find translation key '+str.id)\n\n    // get args\n    if(window.JSON) {\n      str.args = JSON.parse(node.getAttribute('data-l10n-args') as string)\n    }else{\n      try{\n        //str.args = eval(node.getAttribute('data-l10n-args') as string)\n        console.error(\"Old eval method invoked!!\")\n      }catch(e) {\n        console.warn('Couldn\\'t parse args for '+str.id)\n      }\n    }\n\n    str.str = this.get(str.id, str.args)\n\n    // get attribute name to apply str to\n    let prop\n      , index = str.id.lastIndexOf('.')\n      , attrList = // allowed attributes\n      { \"title\": 1\n        , \"innerHTML\": 1\n        , \"alt\": 1\n        , \"textContent\": 1\n        , \"value\": 1\n        , \"placeholder\": 1\n      }\n    if (index > 0 && str.id.substring(index + 1) in attrList) {\n      // an attribute has been specified (example: \"my_translation_key.placeholder\")\n      prop = str.id.substring(index + 1)\n    } else { // no attribute: assuming text content by default\n      prop = document.body.textContent ? 'textContent' : 'innerText'\n    }\n\n    // Apply translation\n    if (node.children.length === 0 || prop != 'textContent') {\n      // @ts-ignore\n      node[prop] = str.str!\n      node.setAttribute(\"aria-label\", str.str!); // Sets the aria-label\n      // The idea of the above is that we always have an aria value\n      // This might be a bit of an abrupt solution but let's see how it goes\n    } else {\n      let children = node.childNodes,\n        found = false\n      let i = 0, n = children.length;\n      for (; i < n; i++) {\n        if (children[i].nodeType === 3 && /\\S/.test(children[i].textContent!)) {\n          if (!found) {\n            children[i].nodeValue = str.str!\n            found = true\n          } else {\n            children[i].nodeValue = ''\n          }\n        }\n      }\n      if (!found) {\n        console.warn('Unexpected error: could not translate element content for key '+str.id, node)\n      }\n    }\n  }\n\n  get(id: string, args?:any) {\n    let translations = this.translations\n    if(!translations) return console.warn('No translations available (yet)')\n    if(!translations.get(id)) return console.warn('Could not find string '+id)\n\n    // apply macros\n    let str = translations.get(id)\n\n    str = this.substMacros(id, str, args)\n\n    // apply args\n    str = this.substArguments(str, args)\n\n    return str\n  }\n\n  substMacros(key: string, str:string, args:any) {\n    let regex = /\\{\\[\\s*([a-zA-Z]+)\\(([a-zA-Z]+)\\)((\\s*([a-zA-Z]+)\\: ?([ a-zA-Z{}]+),?)+)*\\s*\\]\\}/ //.exec('{[ plural(n) other: are {{n}}, one: is ]}')\n      , match\n\n    while(match = regex.exec(str)) {\n      // a macro has been found\n      // Note: at the moment, only one parameter is supported\n      let macroName = match[1]\n        , paramName = match[2]\n        , optv = match[3]\n        , opts: {[key:string]:any} = {}\n\n      if (!(this.macros.has(macroName))) continue\n\n      if(optv) {\n        optv.match(/(?=\\s*)([a-zA-Z]+)\\: ?([ a-zA-Z{}]+)(?=,?)/g)!.forEach(function(arg) {\n          const parts = arg.split(':')\n            , name = parts[0];\n          opts[name] = parts[1].trim()\n        })\n      }\n\n      let param\n      if (args && paramName in args) {\n        param = args[paramName]\n      } else if (paramName in this.translations) {\n        param = this.translations.get(paramName)\n      }\n\n      // there's no macro parser: it has to be defined in html10n.macros\n      let macro = this.macros.get(macroName)!\n      str = str.substring(0, match.index) + macro(key, param, opts) + str.substring(match.index+match[0].length)\n    }\n\n    return str\n  }\n\n  substArguments(str: string, args:any) {\n    let reArgs = /\\{\\{\\s*([a-zA-Z\\.]+)\\s*\\}\\}/\n      , match\n    let translations = this.translations;\n    while (match = reArgs.exec(str)) {\n      if (!match || match.length < 2)\n        return str // argument key not found\n\n      let arg = match[1]\n        , sub = ''\n      if (args && arg in args) {\n        sub = args[arg]\n      } else if (translations && arg in translations) {\n        sub = translations.get(arg)\n      } else {\n        console.warn('Could not find argument {{' + arg + '}}')\n        return str\n      }\n\n      str = str.substring(0, match.index) + sub + str.substring(match.index + match[0].length)\n    }\n\n    return str\n  }\n\n}\n\n\nclass MicroEvent {\n  private events: Map<string, Function[]>\n\n  constructor() {\n    this.events = new Map();\n  }\n\n  bind(event: string, fct: Func) {\n    if (this.events.get(event) === undefined) {\n      this.events.set(event, []);\n    }\n\n    this.events.get(event)!.push(fct);\n  }\n\n  unbind(event: string, fct: Func) {\n    if (this.events.get(event) === undefined) {\n      return;\n    }\n\n    const index = this.events.get(event)!.indexOf(fct);\n    if (index !== -1) {\n      this.events.get(event)!.splice(index, 1);\n    }\n  }\n\n  trigger(event: string, ...args: any[]) {\n    if (this.events.get(event) === undefined) {\n      return;\n    }\n\n    for (const fct of this.events.get(event)!) {\n      fct(...args);\n    }\n  }\n\n  mixin(destObject: any) {\n    const props = ['bind', 'unbind', 'trigger'];\n    if (destObject !== undefined) {\n      for (const prop of props) {\n        // @ts-ignore\n        destObject[prop] = this[prop];\n      }\n    }\n  }\n}\n\ntype LoaderFunc = () => void\n\ntype ErrorFunc = (data?:any)=>void\n\nclass Loader {\n  private resources: any\n  private cache: Map<string, any>\n  langs: Map<string, any>\n\n  constructor(resources: any) {\n    this.resources = resources;\n    this.cache = new Map();\n    this.langs = new Map();\n  }\n\n  load(lang: string, callback: LoaderFunc) {\n    if (this.langs.get(lang) !== undefined) {\n      callback();\n      return;\n    }\n\n    if (this.resources.length > 0) {\n      let reqs = 0\n      for (const resource of this.resources) {\n        this.fetch(resource, lang,  (e)=> {\n          reqs++;\n          if (e) console.warn(e)\n\n          if (reqs < this.resources.length) return;// Call back once all reqs are completed\n          callback && callback()\n        })\n      }\n    }\n  }\n\n  fetch(href: string, lang: string, callback: ErrorFunc) {\n\n    if (this.cache.get(href)) {\n      this.parse(lang, href, this.cache.get(href), callback)\n      return;\n    }\n\n    const xhr = new XMLHttpRequest();\n    xhr.open('GET', href, /*async: */true)\n    if (xhr.overrideMimeType) {\n      xhr.overrideMimeType('application/json; charset=utf-8');\n    }\n    xhr.onreadystatechange = ()=> {\n      if (xhr.readyState == 4) {\n        if (xhr.status == 200 || xhr.status === 0) {\n          const data = JSON.parse(xhr.responseText);\n          this.cache.set(href, data)\n          // Pass on the contents for parsing\n          this.parse(lang, href, data, callback)\n        } else {\n          callback(new Error('Failed to load '+href))\n        }\n      }\n    };\n    xhr.send(null);\n  }\n\n\n  parse(lang: string, href: string, data: {\n    [key: string]: string\n  }, callback: ErrorFunc) {\n    if ('object' !== typeof data) {\n      callback(new Error('A file couldn\\'t be parsed as json.'))\n      return\n    }\n\n    function getBcp47LangCode(browserLang: string) {\n      const bcp47Lang = browserLang.toLowerCase();\n\n      // Browser => BCP 47\n      const langCodeMap = new Map([\n        ['zh-cn', 'zh-hans-cn'],\n        ['zh-hk', 'zh-hant-hk'],\n        ['zh-mo', 'zh-hant-mo'],\n        ['zh-my', 'zh-hans-my'],\n        ['zh-sg', 'zh-hans-sg'],\n        ['zh-tw', 'zh-hant-tw'],\n      ])\n\n      return langCodeMap.get(bcp47Lang) ?? bcp47Lang;\n    }\n\n    // Issue #6129: Fix exceptions\n    // NOTE: translatewiki.net use all lowercase form by default ('en-gb' insted of 'en-GB')\n    function getJsonLangCode(bcp47Lang: string) {\n      const jsonLang = bcp47Lang.toLowerCase();\n      // BCP 47 => JSON\n      const langCodeMap = new Map([\n        ['sr-ec', 'sr-cyrl'],\n        ['sr-el', 'sr-latn'],\n        ['zh-hk', 'zh-hant-hk'],\n      ])\n\n      return langCodeMap.get(jsonLang) ?? jsonLang;\n    }\n\n    let bcp47LangCode = getBcp47LangCode(lang);\n    let jsonLangCode = getJsonLangCode(bcp47LangCode);\n\n    if (!data[jsonLangCode]) {\n      // lang not found\n      // This may be due to formatting (expected 'ru' but browser sent 'ru-RU')\n      // Set err msg before mutating lang (we may need this later)\n      const msg = 'Couldn\\'t find translations for ' + lang +\n        '(lowercase BCP 47 lang tag ' + bcp47LangCode +\n        ', JSON lang code ' + jsonLangCode + ')';\n      // Check for '-' (BCP 47 'ROOT-SCRIPT-REGION-VARIANT') and fallback until found data or ROOT\n      // - 'ROOT-SCRIPT-REGION': 'zh-Hans-CN'\n      // - 'ROOT-SCRIPT': 'zh-Hans'\n      // - 'ROOT-REGION': 'en-GB'\n      // - 'ROOT-VARIANT': 'be-tarask'\n      while (!data[jsonLangCode] && bcp47LangCode.lastIndexOf('-') > -1) {\n        // ROOT-SCRIPT-REGION-VARIANT formatting detected\n        bcp47LangCode = bcp47LangCode.substring(0, bcp47LangCode.lastIndexOf('-')); // set lang to ROOT lang\n        jsonLangCode = getJsonLangCode(bcp47LangCode);\n      }\n\n      if (!data[jsonLangCode]) {\n        // ROOT lang not found. (e.g 'zh')\n        // Loop through langs data. Maybe we have a variant? e.g (zh-hans)\n        let l; // langs item. Declare outside of loop\n\n        for (l in data) {\n          // Is not ROOT?\n          // And is variant of ROOT?\n          // (NOTE: index of ROOT equals 0 would cause unexpected ISO 639-1 vs. 639-3 issues,\n          // so append dash into query string)\n          // And is known lang?\n          if (bcp47LangCode != l && l.indexOf(lang + '-') === 0 && data[l]) {\n            bcp47LangCode = l; // set lang to ROOT-SCRIPT (e.g 'zh-hans')\n            jsonLangCode = getJsonLangCode(bcp47LangCode);\n            break;\n          }\n        }\n\n        // Did we find a variant? If not, return err.\n        if (bcp47LangCode != l) {\n          return callback(new Error(msg));\n        }\n      }\n    }\n\n\n    lang = jsonLangCode\n\n    if('string' === typeof data[lang]) {\n      // Import rule\n\n      // absolute path\n      let importUrl = data[lang];\n\n      // relative path\n      if(data[lang].indexOf(\"http\") != 0 && data[lang].indexOf(\"/\") != 0) {\n        importUrl = href+\"/../\"+data[lang]\n      }\n\n      this.fetch(importUrl, lang, callback)\n      return\n    }\n\n    if ('object' != typeof data[lang]) {\n      callback(new Error('Translations should be specified as JSON objects!'))\n      return\n    }\n\n    this.langs.set(lang,data[lang])\n    // TODO: Also store accompanying langs\n    callback()\n  }\n}\n\nconst html10n = new Html10n()\nexport default html10n\n\n// @ts-ignore\nwindow.html10n = html10n\n\n// gettext-like shortcut\nif (window._ === undefined){\n  // @ts-ignore\n  window._ = html10n.get;\n}\n"
  },
  {
    "path": "src/static/js/vendors/jquery.ts",
    "content": "// @ts-nocheck\n/*!\n * jQuery JavaScript Library v3.7.1\n * https://jquery.com/\n *\n * Copyright OpenJS Foundation and other contributors\n * Released under the MIT license\n * https://jquery.org/license\n *\n * Date: 2023-08-28T13:37Z\n */\n( function( global, factory ) {\n\n  \"use strict\";\n\n  if ( typeof module === \"object\" && typeof module.exports === \"object\" ) {\n\n    // For CommonJS and CommonJS-like environments where a proper `window`\n    // is present, execute the factory and get jQuery.\n    // For environments that do not have a `window` with a `document`\n    // (such as Node.js), expose a factory as module.exports.\n    // This accentuates the need for the creation of a real `window`.\n    // e.g. var jQuery = require(\"jquery\")(window);\n    // See ticket trac-14549 for more info.\n    module.exports = global.document ?\n      factory( global, true ) :\n      function( w ) {\n        if ( !w.document ) {\n          throw new Error( \"jQuery requires a window with a document\" );\n        }\n        return factory( w );\n      };\n  } else {\n    factory( global );\n  }\n\n// Pass this if window is not defined yet\n} )( typeof window !== \"undefined\" ? window : this, function( window, noGlobal ) {\n\n// Edge <= 12 - 13+, Firefox <=18 - 45+, IE 10 - 11, Safari 5.1 - 9+, iOS 6 - 9.1\n// throw exceptions when non-strict code (e.g., ASP.NET 4.5) accesses strict mode\n// arguments.callee.caller (trac-13335). But as of jQuery 3.0 (2016), strict mode should be common\n// enough that all such attempts are guarded in a try block.\n  \"use strict\";\n\n  var arr = [];\n\n  var getProto = Object.getPrototypeOf;\n\n  var slice = arr.slice;\n\n  var flat = arr.flat ? function( array ) {\n    return arr.flat.call( array );\n  } : function( array ) {\n    return arr.concat.apply( [], array );\n  };\n\n\n  var push = arr.push;\n\n  var indexOf = arr.indexOf;\n\n  var class2type = {};\n\n  var toString = class2type.toString;\n\n  var hasOwn = class2type.hasOwnProperty;\n\n  var fnToString = hasOwn.toString;\n\n  var ObjectFunctionString = fnToString.call( Object );\n\n  var support = {};\n\n  var isFunction = function isFunction( obj ) {\n\n    // Support: Chrome <=57, Firefox <=52\n    // In some browsers, typeof returns \"function\" for HTML <object> elements\n    // (i.e., `typeof document.createElement( \"object\" ) === \"function\"`).\n    // We don't want to classify *any* DOM node as a function.\n    // Support: QtWeb <=3.8.5, WebKit <=534.34, wkhtmltopdf tool <=0.12.5\n    // Plus for old WebKit, typeof returns \"function\" for HTML collections\n    // (e.g., `typeof document.getElementsByTagName(\"div\") === \"function\"`). (gh-4756)\n    return typeof obj === \"function\" && typeof obj.nodeType !== \"number\" &&\n      typeof obj.item !== \"function\";\n  };\n\n\n  var isWindow = function isWindow( obj ) {\n    return obj != null && obj === obj.window;\n  };\n\n\n  var document = window.document;\n\n\n\n  var preservedScriptAttributes = {\n    type: true,\n    src: true,\n    nonce: true,\n    noModule: true\n  };\n\n  function DOMEval( code, node, doc ) {\n    doc = doc || document;\n\n    var i, val,\n      script = doc.createElement( \"script\" );\n\n    script.text = code;\n    if ( node ) {\n      for ( i in preservedScriptAttributes ) {\n\n        // Support: Firefox 64+, Edge 18+\n        // Some browsers don't support the \"nonce\" property on scripts.\n        // On the other hand, just using `getAttribute` is not enough as\n        // the `nonce` attribute is reset to an empty string whenever it\n        // becomes browsing-context connected.\n        // See https://github.com/whatwg/html/issues/2369\n        // See https://html.spec.whatwg.org/#nonce-attributes\n        // The `node.getAttribute` check was added for the sake of\n        // `jQuery.globalEval` so that it can fake a nonce-containing node\n        // via an object.\n        val = node[ i ] || node.getAttribute && node.getAttribute( i );\n        if ( val ) {\n          script.setAttribute( i, val );\n        }\n      }\n    }\n    doc.head.appendChild( script ).parentNode.removeChild( script );\n  }\n\n\n  function toType( obj ) {\n    if ( obj == null ) {\n      return obj + \"\";\n    }\n\n    // Support: Android <=2.3 only (functionish RegExp)\n    return typeof obj === \"object\" || typeof obj === \"function\" ?\n      class2type[ toString.call( obj ) ] || \"object\" :\n      typeof obj;\n  }\n  /* global Symbol */\n// Defining this global in .eslintrc.json would create a danger of using the global\n// unguarded in another place, it seems safer to define global only for this module\n\n\n\n  var version = \"3.7.1\",\n\n    rhtmlSuffix = /HTML$/i,\n\n    // Define a local copy of jQuery\n    jQuery = function( selector, context ) {\n\n      // The jQuery object is actually just the init constructor 'enhanced'\n      // Need init if jQuery is called (just allow error to be thrown if not included)\n      return new jQuery.fn.init( selector, context );\n    };\n\n  jQuery.fn = jQuery.prototype = {\n\n    // The current version of jQuery being used\n    jquery: version,\n\n    constructor: jQuery,\n\n    // The default length of a jQuery object is 0\n    length: 0,\n\n    toArray: function() {\n      return slice.call( this );\n    },\n\n    // Get the Nth element in the matched element set OR\n    // Get the whole matched element set as a clean array\n    get: function( num ) {\n\n      // Return all the elements in a clean array\n      if ( num == null ) {\n        return slice.call( this );\n      }\n\n      // Return just the one element from the set\n      return num < 0 ? this[ num + this.length ] : this[ num ];\n    },\n\n    // Take an array of elements and push it onto the stack\n    // (returning the new matched element set)\n    pushStack: function( elems ) {\n\n      // Build a new jQuery matched element set\n      var ret = jQuery.merge( this.constructor(), elems );\n\n      // Add the old object onto the stack (as a reference)\n      ret.prevObject = this;\n\n      // Return the newly-formed element set\n      return ret;\n    },\n\n    // Execute a callback for every element in the matched set.\n    each: function( callback ) {\n      return jQuery.each( this, callback );\n    },\n\n    map: function( callback ) {\n      return this.pushStack( jQuery.map( this, function( elem, i ) {\n        return callback.call( elem, i, elem );\n      } ) );\n    },\n\n    slice: function() {\n      return this.pushStack( slice.apply( this, arguments ) );\n    },\n\n    first: function() {\n      return this.eq( 0 );\n    },\n\n    last: function() {\n      return this.eq( -1 );\n    },\n\n    even: function() {\n      return this.pushStack( jQuery.grep( this, function( _elem, i ) {\n        return ( i + 1 ) % 2;\n      } ) );\n    },\n\n    odd: function() {\n      return this.pushStack( jQuery.grep( this, function( _elem, i ) {\n        return i % 2;\n      } ) );\n    },\n\n    eq: function( i ) {\n      var len = this.length,\n        j = +i + ( i < 0 ? len : 0 );\n      return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] );\n    },\n\n    end: function() {\n      return this.prevObject || this.constructor();\n    },\n\n    // For internal use only.\n    // Behaves like an Array's method, not like a jQuery method.\n    push: push,\n    sort: arr.sort,\n    splice: arr.splice\n  };\n\n  jQuery.extend = jQuery.fn.extend = function() {\n    var options, name, src, copy, copyIsArray, clone,\n      target = arguments[ 0 ] || {},\n      i = 1,\n      length = arguments.length,\n      deep = false;\n\n    // Handle a deep copy situation\n    if ( typeof target === \"boolean\" ) {\n      deep = target;\n\n      // Skip the boolean and the target\n      target = arguments[ i ] || {};\n      i++;\n    }\n\n    // Handle case when target is a string or something (possible in deep copy)\n    if ( typeof target !== \"object\" && !isFunction( target ) ) {\n      target = {};\n    }\n\n    // Extend jQuery itself if only one argument is passed\n    if ( i === length ) {\n      target = this;\n      i--;\n    }\n\n    for ( ; i < length; i++ ) {\n\n      // Only deal with non-null/undefined values\n      if ( ( options = arguments[ i ] ) != null ) {\n\n        // Extend the base object\n        for ( name in options ) {\n          copy = options[ name ];\n\n          // Prevent Object.prototype pollution\n          // Prevent never-ending loop\n          if ( name === \"__proto__\" || target === copy ) {\n            continue;\n          }\n\n          // Recurse if we're merging plain objects or arrays\n          if ( deep && copy && ( jQuery.isPlainObject( copy ) ||\n            ( copyIsArray = Array.isArray( copy ) ) ) ) {\n            src = target[ name ];\n\n            // Ensure proper type for the source value\n            if ( copyIsArray && !Array.isArray( src ) ) {\n              clone = [];\n            } else if ( !copyIsArray && !jQuery.isPlainObject( src ) ) {\n              clone = {};\n            } else {\n              clone = src;\n            }\n            copyIsArray = false;\n\n            // Never move original objects, clone them\n            target[ name ] = jQuery.extend( deep, clone, copy );\n\n            // Don't bring in undefined values\n          } else if ( copy !== undefined ) {\n            target[ name ] = copy;\n          }\n        }\n      }\n    }\n\n    // Return the modified object\n    return target;\n  };\n\n  jQuery.extend( {\n\n    // Unique for each copy of jQuery on the page\n    expando: \"jQuery\" + ( version + Math.random() ).replace( /\\D/g, \"\" ),\n\n    // Assume jQuery is ready without the ready module\n    isReady: true,\n\n    error: function( msg ) {\n      throw new Error( msg );\n    },\n\n    noop: function() {},\n\n    isPlainObject: function( obj ) {\n      var proto, Ctor;\n\n      // Detect obvious negatives\n      // Use toString instead of jQuery.type to catch host objects\n      if ( !obj || toString.call( obj ) !== \"[object Object]\" ) {\n        return false;\n      }\n\n      proto = getProto( obj );\n\n      // Objects with no prototype (e.g., `Object.create( null )`) are plain\n      if ( !proto ) {\n        return true;\n      }\n\n      // Objects with prototype are plain iff they were constructed by a global Object function\n      Ctor = hasOwn.call( proto, \"constructor\" ) && proto.constructor;\n      return typeof Ctor === \"function\" && fnToString.call( Ctor ) === ObjectFunctionString;\n    },\n\n    isEmptyObject: function( obj ) {\n      var name;\n\n      for ( name in obj ) {\n        return false;\n      }\n      return true;\n    },\n\n    // Evaluates a script in a provided context; falls back to the global one\n    // if not specified.\n    globalEval: function( code, options, doc ) {\n      DOMEval( code, { nonce: options && options.nonce }, doc );\n    },\n\n    each: function( obj, callback ) {\n      var length, i = 0;\n\n      if ( isArrayLike( obj ) ) {\n        length = obj.length;\n        for ( ; i < length; i++ ) {\n          if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) {\n            break;\n          }\n        }\n      } else {\n        for ( i in obj ) {\n          if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) {\n            break;\n          }\n        }\n      }\n\n      return obj;\n    },\n\n\n    // Retrieve the text value of an array of DOM nodes\n    text: function( elem ) {\n      var node,\n        ret = \"\",\n        i = 0,\n        nodeType = elem.nodeType;\n\n      if ( !nodeType ) {\n\n        // If no nodeType, this is expected to be an array\n        while ( ( node = elem[ i++ ] ) ) {\n\n          // Do not traverse comment nodes\n          ret += jQuery.text( node );\n        }\n      }\n      if ( nodeType === 1 || nodeType === 11 ) {\n        return elem.textContent;\n      }\n      if ( nodeType === 9 ) {\n        return elem.documentElement.textContent;\n      }\n      if ( nodeType === 3 || nodeType === 4 ) {\n        return elem.nodeValue;\n      }\n\n      // Do not include comment or processing instruction nodes\n\n      return ret;\n    },\n\n    // results is for internal usage only\n    makeArray: function( arr, results ) {\n      var ret = results || [];\n\n      if ( arr != null ) {\n        if ( isArrayLike( Object( arr ) ) ) {\n          jQuery.merge( ret,\n            typeof arr === \"string\" ?\n              [ arr ] : arr\n          );\n        } else {\n          push.call( ret, arr );\n        }\n      }\n\n      return ret;\n    },\n\n    inArray: function( elem, arr, i ) {\n      return arr == null ? -1 : indexOf.call( arr, elem, i );\n    },\n\n    isXMLDoc: function( elem ) {\n      var namespace = elem && elem.namespaceURI,\n        docElem = elem && ( elem.ownerDocument || elem ).documentElement;\n\n      // Assume HTML when documentElement doesn't yet exist, such as inside\n      // document fragments.\n      return !rhtmlSuffix.test( namespace || docElem && docElem.nodeName || \"HTML\" );\n    },\n\n    // Support: Android <=4.0 only, PhantomJS 1 only\n    // push.apply(_, arraylike) throws on ancient WebKit\n    merge: function( first, second ) {\n      var len = +second.length,\n        j = 0,\n        i = first.length;\n\n      for ( ; j < len; j++ ) {\n        first[ i++ ] = second[ j ];\n      }\n\n      first.length = i;\n\n      return first;\n    },\n\n    grep: function( elems, callback, invert ) {\n      var callbackInverse,\n        matches = [],\n        i = 0,\n        length = elems.length,\n        callbackExpect = !invert;\n\n      // Go through the array, only saving the items\n      // that pass the validator function\n      for ( ; i < length; i++ ) {\n        callbackInverse = !callback( elems[ i ], i );\n        if ( callbackInverse !== callbackExpect ) {\n          matches.push( elems[ i ] );\n        }\n      }\n\n      return matches;\n    },\n\n    // arg is for internal usage only\n    map: function( elems, callback, arg ) {\n      var length, value,\n        i = 0,\n        ret = [];\n\n      // Go through the array, translating each of the items to their new values\n      if ( isArrayLike( elems ) ) {\n        length = elems.length;\n        for ( ; i < length; i++ ) {\n          value = callback( elems[ i ], i, arg );\n\n          if ( value != null ) {\n            ret.push( value );\n          }\n        }\n\n        // Go through every key on the object,\n      } else {\n        for ( i in elems ) {\n          value = callback( elems[ i ], i, arg );\n\n          if ( value != null ) {\n            ret.push( value );\n          }\n        }\n      }\n\n      // Flatten any nested arrays\n      return flat( ret );\n    },\n\n    // A global GUID counter for objects\n    guid: 1,\n\n    // jQuery.support is not used in Core but other projects attach their\n    // properties to it so it needs to exist.\n    support: support\n  } );\n\n  if ( typeof Symbol === \"function\" ) {\n    jQuery.fn[ Symbol.iterator ] = arr[ Symbol.iterator ];\n  }\n\n// Populate the class2type map\n  jQuery.each( \"Boolean Number String Function Array Date RegExp Object Error Symbol\".split( \" \" ),\n    function( _i, name ) {\n      class2type[ \"[object \" + name + \"]\" ] = name.toLowerCase();\n    } );\n\n  function isArrayLike( obj ) {\n\n    // Support: real iOS 8.2 only (not reproducible in simulator)\n    // `in` check used to prevent JIT error (gh-2145)\n    // hasOwn isn't used here due to false negatives\n    // regarding Nodelist length in IE\n    var length = !!obj && \"length\" in obj && obj.length,\n      type = toType( obj );\n\n    if ( isFunction( obj ) || isWindow( obj ) ) {\n      return false;\n    }\n\n    return type === \"array\" || length === 0 ||\n      typeof length === \"number\" && length > 0 && ( length - 1 ) in obj;\n  }\n\n\n  function nodeName( elem, name ) {\n\n    return elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase();\n\n  }\n  var pop = arr.pop;\n\n\n  var sort = arr.sort;\n\n\n  var splice = arr.splice;\n\n\n  var whitespace = \"[\\\\x20\\\\t\\\\r\\\\n\\\\f]\";\n\n\n  var rtrimCSS = new RegExp(\n    \"^\" + whitespace + \"+|((?:^|[^\\\\\\\\])(?:\\\\\\\\.)*)\" + whitespace + \"+$\",\n    \"g\"\n  );\n\n\n\n\n// Note: an element does not contain itself\n  jQuery.contains = function( a, b ) {\n    var bup = b && b.parentNode;\n\n    return a === bup || !!( bup && bup.nodeType === 1 && (\n\n      // Support: IE 9 - 11+\n      // IE doesn't have `contains` on SVG.\n      a.contains ?\n        a.contains( bup ) :\n        a.compareDocumentPosition && a.compareDocumentPosition( bup ) & 16\n    ) );\n  };\n\n\n\n\n// CSS string/identifier serialization\n// https://drafts.csswg.org/cssom/#common-serializing-idioms\n  var rcssescape = /([\\0-\\x1f\\x7f]|^-?\\d)|^-$|[^\\x80-\\uFFFF\\w-]/g;\n\n  function fcssescape( ch, asCodePoint ) {\n    if ( asCodePoint ) {\n\n      // U+0000 NULL becomes U+FFFD REPLACEMENT CHARACTER\n      if ( ch === \"\\0\" ) {\n        return \"\\uFFFD\";\n      }\n\n      // Control characters and (dependent upon position) numbers get escaped as code points\n      return ch.slice( 0, -1 ) + \"\\\\\" + ch.charCodeAt( ch.length - 1 ).toString( 16 ) + \" \";\n    }\n\n    // Other potentially-special ASCII characters get backslash-escaped\n    return \"\\\\\" + ch;\n  }\n\n  jQuery.escapeSelector = function( sel ) {\n    return ( sel + \"\" ).replace( rcssescape, fcssescape );\n  };\n\n\n\n\n  var preferredDoc = document,\n    pushNative = push;\n\n  ( function() {\n\n    var i,\n      Expr,\n      outermostContext,\n      sortInput,\n      hasDuplicate,\n      push = pushNative,\n\n      // Local document vars\n      document,\n      documentElement,\n      documentIsHTML,\n      rbuggyQSA,\n      matches,\n\n      // Instance-specific data\n      expando = jQuery.expando,\n      dirruns = 0,\n      done = 0,\n      classCache = createCache(),\n      tokenCache = createCache(),\n      compilerCache = createCache(),\n      nonnativeSelectorCache = createCache(),\n      sortOrder = function( a, b ) {\n        if ( a === b ) {\n          hasDuplicate = true;\n        }\n        return 0;\n      },\n\n      booleans = \"checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|\" +\n        \"loop|multiple|open|readonly|required|scoped\",\n\n      // Regular expressions\n\n      // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram\n      identifier = \"(?:\\\\\\\\[\\\\da-fA-F]{1,6}\" + whitespace +\n        \"?|\\\\\\\\[^\\\\r\\\\n\\\\f]|[\\\\w-]|[^\\0-\\\\x7f])+\",\n\n      // Attribute selectors: https://www.w3.org/TR/selectors/#attribute-selectors\n      attributes = \"\\\\[\" + whitespace + \"*(\" + identifier + \")(?:\" + whitespace +\n\n        // Operator (capture 2)\n        \"*([*^$|!~]?=)\" + whitespace +\n\n        // \"Attribute values must be CSS identifiers [capture 5] or strings [capture 3 or capture 4]\"\n        \"*(?:'((?:\\\\\\\\.|[^\\\\\\\\'])*)'|\\\"((?:\\\\\\\\.|[^\\\\\\\\\\\"])*)\\\"|(\" + identifier + \"))|)\" +\n        whitespace + \"*\\\\]\",\n\n      pseudos = \":(\" + identifier + \")(?:\\\\((\" +\n\n        // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments:\n        // 1. quoted (capture 3; capture 4 or capture 5)\n        \"('((?:\\\\\\\\.|[^\\\\\\\\'])*)'|\\\"((?:\\\\\\\\.|[^\\\\\\\\\\\"])*)\\\")|\" +\n\n        // 2. simple (capture 6)\n        \"((?:\\\\\\\\.|[^\\\\\\\\()[\\\\]]|\" + attributes + \")*)|\" +\n\n        // 3. anything else (capture 2)\n        \".*\" +\n        \")\\\\)|)\",\n\n      // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter\n      rwhitespace = new RegExp( whitespace + \"+\", \"g\" ),\n\n      rcomma = new RegExp( \"^\" + whitespace + \"*,\" + whitespace + \"*\" ),\n      rleadingCombinator = new RegExp( \"^\" + whitespace + \"*([>+~]|\" + whitespace + \")\" +\n        whitespace + \"*\" ),\n      rdescend = new RegExp( whitespace + \"|>\" ),\n\n      rpseudo = new RegExp( pseudos ),\n      ridentifier = new RegExp( \"^\" + identifier + \"$\" ),\n\n      matchExpr = {\n        ID: new RegExp( \"^#(\" + identifier + \")\" ),\n        CLASS: new RegExp( \"^\\\\.(\" + identifier + \")\" ),\n        TAG: new RegExp( \"^(\" + identifier + \"|[*])\" ),\n        ATTR: new RegExp( \"^\" + attributes ),\n        PSEUDO: new RegExp( \"^\" + pseudos ),\n        CHILD: new RegExp(\n          \"^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\\\(\" +\n          whitespace + \"*(even|odd|(([+-]|)(\\\\d*)n|)\" + whitespace + \"*(?:([+-]|)\" +\n          whitespace + \"*(\\\\d+)|))\" + whitespace + \"*\\\\)|)\", \"i\" ),\n        bool: new RegExp( \"^(?:\" + booleans + \")$\", \"i\" ),\n\n        // For use in libraries implementing .is()\n        // We use this for POS matching in `select`\n        needsContext: new RegExp( \"^\" + whitespace +\n          \"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\\\(\" + whitespace +\n          \"*((?:-\\\\d)?\\\\d*)\" + whitespace + \"*\\\\)|)(?=[^-]|$)\", \"i\" )\n      },\n\n      rinputs = /^(?:input|select|textarea|button)$/i,\n      rheader = /^h\\d$/i,\n\n      // Easily-parseable/retrievable ID or TAG or CLASS selectors\n      rquickExpr = /^(?:#([\\w-]+)|(\\w+)|\\.([\\w-]+))$/,\n\n      rsibling = /[+~]/,\n\n      // CSS escapes\n      // https://www.w3.org/TR/CSS21/syndata.html#escaped-characters\n      runescape = new RegExp( \"\\\\\\\\[\\\\da-fA-F]{1,6}\" + whitespace +\n        \"?|\\\\\\\\([^\\\\r\\\\n\\\\f])\", \"g\" ),\n      funescape = function( escape, nonHex ) {\n        var high = \"0x\" + escape.slice( 1 ) - 0x10000;\n\n        if ( nonHex ) {\n\n          // Strip the backslash prefix from a non-hex escape sequence\n          return nonHex;\n        }\n\n        // Replace a hexadecimal escape sequence with the encoded Unicode code point\n        // Support: IE <=11+\n        // For values outside the Basic Multilingual Plane (BMP), manually construct a\n        // surrogate pair\n        return high < 0 ?\n          String.fromCharCode( high + 0x10000 ) :\n          String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 );\n      },\n\n      // Used for iframes; see `setDocument`.\n      // Support: IE 9 - 11+, Edge 12 - 18+\n      // Removing the function wrapper causes a \"Permission Denied\"\n      // error in IE/Edge.\n      unloadHandler = function() {\n        setDocument();\n      },\n\n      inDisabledFieldset = addCombinator(\n        function( elem ) {\n          return elem.disabled === true && nodeName( elem, \"fieldset\" );\n        },\n        { dir: \"parentNode\", next: \"legend\" }\n      );\n\n// Support: IE <=9 only\n// Accessing document.activeElement can throw unexpectedly\n// https://bugs.jquery.com/ticket/13393\n    function safeActiveElement() {\n      try {\n        return document.activeElement;\n      } catch ( err ) { }\n    }\n\n// Optimize for push.apply( _, NodeList )\n    try {\n      push.apply(\n        ( arr = slice.call( preferredDoc.childNodes ) ),\n        preferredDoc.childNodes\n      );\n\n      // Support: Android <=4.0\n      // Detect silently failing push.apply\n      // eslint-disable-next-line no-unused-expressions\n      arr[ preferredDoc.childNodes.length ].nodeType;\n    } catch ( e ) {\n      push = {\n        apply: function( target, els ) {\n          pushNative.apply( target, slice.call( els ) );\n        },\n        call: function( target ) {\n          pushNative.apply( target, slice.call( arguments, 1 ) );\n        }\n      };\n    }\n\n    function find( selector, context, results, seed ) {\n      var m, i, elem, nid, match, groups, newSelector,\n        newContext = context && context.ownerDocument,\n\n        // nodeType defaults to 9, since context defaults to document\n        nodeType = context ? context.nodeType : 9;\n\n      results = results || [];\n\n      // Return early from calls with invalid selector or context\n      if ( typeof selector !== \"string\" || !selector ||\n        nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) {\n\n        return results;\n      }\n\n      // Try to shortcut find operations (as opposed to filters) in HTML documents\n      if ( !seed ) {\n        setDocument( context );\n        context = context || document;\n\n        if ( documentIsHTML ) {\n\n          // If the selector is sufficiently simple, try using a \"get*By*\" DOM method\n          // (excepting DocumentFragment context, where the methods don't exist)\n          if ( nodeType !== 11 && ( match = rquickExpr.exec( selector ) ) ) {\n\n            // ID selector\n            if ( ( m = match[ 1 ] ) ) {\n\n              // Document context\n              if ( nodeType === 9 ) {\n                if ( ( elem = context.getElementById( m ) ) ) {\n\n                  // Support: IE 9 only\n                  // getElementById can match elements by name instead of ID\n                  if ( elem.id === m ) {\n                    push.call( results, elem );\n                    return results;\n                  }\n                } else {\n                  return results;\n                }\n\n                // Element context\n              } else {\n\n                // Support: IE 9 only\n                // getElementById can match elements by name instead of ID\n                if ( newContext && ( elem = newContext.getElementById( m ) ) &&\n                  find.contains( context, elem ) &&\n                  elem.id === m ) {\n\n                  push.call( results, elem );\n                  return results;\n                }\n              }\n\n              // Type selector\n            } else if ( match[ 2 ] ) {\n              push.apply( results, context.getElementsByTagName( selector ) );\n              return results;\n\n              // Class selector\n            } else if ( ( m = match[ 3 ] ) && context.getElementsByClassName ) {\n              push.apply( results, context.getElementsByClassName( m ) );\n              return results;\n            }\n          }\n\n          // Take advantage of querySelectorAll\n          if ( !nonnativeSelectorCache[ selector + \" \" ] &&\n            ( !rbuggyQSA || !rbuggyQSA.test( selector ) ) ) {\n\n            newSelector = selector;\n            newContext = context;\n\n            // qSA considers elements outside a scoping root when evaluating child or\n            // descendant combinators, which is not what we want.\n            // In such cases, we work around the behavior by prefixing every selector in the\n            // list with an ID selector referencing the scope context.\n            // The technique has to be used as well when a leading combinator is used\n            // as such selectors are not recognized by querySelectorAll.\n            // Thanks to Andrew Dupont for this technique.\n            if ( nodeType === 1 &&\n              ( rdescend.test( selector ) || rleadingCombinator.test( selector ) ) ) {\n\n              // Expand context for sibling selectors\n              newContext = rsibling.test( selector ) && testContext( context.parentNode ) ||\n                context;\n\n              // We can use :scope instead of the ID hack if the browser\n              // supports it & if we're not changing the context.\n              // Support: IE 11+, Edge 17 - 18+\n              // IE/Edge sometimes throw a \"Permission denied\" error when\n              // strict-comparing two documents; shallow comparisons work.\n              // eslint-disable-next-line eqeqeq\n              if ( newContext != context || !support.scope ) {\n\n                // Capture the context ID, setting it first if necessary\n                if ( ( nid = context.getAttribute( \"id\" ) ) ) {\n                  nid = jQuery.escapeSelector( nid );\n                } else {\n                  context.setAttribute( \"id\", ( nid = expando ) );\n                }\n              }\n\n              // Prefix every selector in the list\n              groups = tokenize( selector );\n              i = groups.length;\n              while ( i-- ) {\n                groups[ i ] = ( nid ? \"#\" + nid : \":scope\" ) + \" \" +\n                  toSelector( groups[ i ] );\n              }\n              newSelector = groups.join( \",\" );\n            }\n\n            try {\n              push.apply( results,\n                newContext.querySelectorAll( newSelector )\n              );\n              return results;\n            } catch ( qsaError ) {\n              nonnativeSelectorCache( selector, true );\n            } finally {\n              if ( nid === expando ) {\n                context.removeAttribute( \"id\" );\n              }\n            }\n          }\n        }\n      }\n\n      // All others\n      return select( selector.replace( rtrimCSS, \"$1\" ), context, results, seed );\n    }\n\n    /**\n     * Create key-value caches of limited size\n     * @returns {function(string, object)} Returns the Object data after storing it on itself with\n     *\tproperty name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength)\n     *\tdeleting the oldest entry\n     */\n    function createCache() {\n      var keys = [];\n\n      function cache( key, value ) {\n\n        // Use (key + \" \") to avoid collision with native prototype properties\n        // (see https://github.com/jquery/sizzle/issues/157)\n        if ( keys.push( key + \" \" ) > Expr.cacheLength ) {\n\n          // Only keep the most recent entries\n          delete cache[ keys.shift() ];\n        }\n        return ( cache[ key + \" \" ] = value );\n      }\n      return cache;\n    }\n\n    /**\n     * Mark a function for special use by jQuery selector module\n     * @param {Function} fn The function to mark\n     */\n    function markFunction( fn ) {\n      fn[ expando ] = true;\n      return fn;\n    }\n\n    /**\n     * Support testing using an element\n     * @param {Function} fn Passed the created element and returns a boolean result\n     */\n    function assert( fn ) {\n      var el = document.createElement( \"fieldset\" );\n\n      try {\n        return !!fn( el );\n      } catch ( e ) {\n        return false;\n      } finally {\n\n        // Remove from its parent by default\n        if ( el.parentNode ) {\n          el.parentNode.removeChild( el );\n        }\n\n        // release memory in IE\n        el = null;\n      }\n    }\n\n    /**\n     * Returns a function to use in pseudos for input types\n     * @param {String} type\n     */\n    function createInputPseudo( type ) {\n      return function( elem ) {\n        return nodeName( elem, \"input\" ) && elem.type === type;\n      };\n    }\n\n    /**\n     * Returns a function to use in pseudos for buttons\n     * @param {String} type\n     */\n    function createButtonPseudo( type ) {\n      return function( elem ) {\n        return ( nodeName( elem, \"input\" ) || nodeName( elem, \"button\" ) ) &&\n          elem.type === type;\n      };\n    }\n\n    /**\n     * Returns a function to use in pseudos for :enabled/:disabled\n     * @param {Boolean} disabled true for :disabled; false for :enabled\n     */\n    function createDisabledPseudo( disabled ) {\n\n      // Known :disabled false positives: fieldset[disabled] > legend:nth-of-type(n+2) :can-disable\n      return function( elem ) {\n\n        // Only certain elements can match :enabled or :disabled\n        // https://html.spec.whatwg.org/multipage/scripting.html#selector-enabled\n        // https://html.spec.whatwg.org/multipage/scripting.html#selector-disabled\n        if ( \"form\" in elem ) {\n\n          // Check for inherited disabledness on relevant non-disabled elements:\n          // * listed form-associated elements in a disabled fieldset\n          //   https://html.spec.whatwg.org/multipage/forms.html#category-listed\n          //   https://html.spec.whatwg.org/multipage/forms.html#concept-fe-disabled\n          // * option elements in a disabled optgroup\n          //   https://html.spec.whatwg.org/multipage/forms.html#concept-option-disabled\n          // All such elements have a \"form\" property.\n          if ( elem.parentNode && elem.disabled === false ) {\n\n            // Option elements defer to a parent optgroup if present\n            if ( \"label\" in elem ) {\n              if ( \"label\" in elem.parentNode ) {\n                return elem.parentNode.disabled === disabled;\n              } else {\n                return elem.disabled === disabled;\n              }\n            }\n\n            // Support: IE 6 - 11+\n            // Use the isDisabled shortcut property to check for disabled fieldset ancestors\n            return elem.isDisabled === disabled ||\n\n              // Where there is no isDisabled, check manually\n              elem.isDisabled !== !disabled &&\n              inDisabledFieldset( elem ) === disabled;\n          }\n\n          return elem.disabled === disabled;\n\n          // Try to winnow out elements that can't be disabled before trusting the disabled property.\n          // Some victims get caught in our net (label, legend, menu, track), but it shouldn't\n          // even exist on them, let alone have a boolean value.\n        } else if ( \"label\" in elem ) {\n          return elem.disabled === disabled;\n        }\n\n        // Remaining elements are neither :enabled nor :disabled\n        return false;\n      };\n    }\n\n    /**\n     * Returns a function to use in pseudos for positionals\n     * @param {Function} fn\n     */\n    function createPositionalPseudo( fn ) {\n      return markFunction( function( argument ) {\n        argument = +argument;\n        return markFunction( function( seed, matches ) {\n          var j,\n            matchIndexes = fn( [], seed.length, argument ),\n            i = matchIndexes.length;\n\n          // Match elements found at the specified indexes\n          while ( i-- ) {\n            if ( seed[ ( j = matchIndexes[ i ] ) ] ) {\n              seed[ j ] = !( matches[ j ] = seed[ j ] );\n            }\n          }\n        } );\n      } );\n    }\n\n    /**\n     * Checks a node for validity as a jQuery selector context\n     * @param {Element|Object=} context\n     * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value\n     */\n    function testContext( context ) {\n      return context && typeof context.getElementsByTagName !== \"undefined\" && context;\n    }\n\n    /**\n     * Sets document-related variables once based on the current document\n     * @param {Element|Object} [node] An element or document object to use to set the document\n     * @returns {Object} Returns the current document\n     */\n    function setDocument( node ) {\n      var subWindow,\n        doc = node ? node.ownerDocument || node : preferredDoc;\n\n      // Return early if doc is invalid or already selected\n      // Support: IE 11+, Edge 17 - 18+\n      // IE/Edge sometimes throw a \"Permission denied\" error when strict-comparing\n      // two documents; shallow comparisons work.\n      // eslint-disable-next-line eqeqeq\n      if ( doc == document || doc.nodeType !== 9 || !doc.documentElement ) {\n        return document;\n      }\n\n      // Update global variables\n      document = doc;\n      documentElement = document.documentElement;\n      documentIsHTML = !jQuery.isXMLDoc( document );\n\n      // Support: iOS 7 only, IE 9 - 11+\n      // Older browsers didn't support unprefixed `matches`.\n      matches = documentElement.matches ||\n        documentElement.webkitMatchesSelector ||\n        documentElement.msMatchesSelector;\n\n      // Support: IE 9 - 11+, Edge 12 - 18+\n      // Accessing iframe documents after unload throws \"permission denied\" errors\n      // (see trac-13936).\n      // Limit the fix to IE & Edge Legacy; despite Edge 15+ implementing `matches`,\n      // all IE 9+ and Edge Legacy versions implement `msMatchesSelector` as well.\n      if ( documentElement.msMatchesSelector &&\n\n        // Support: IE 11+, Edge 17 - 18+\n        // IE/Edge sometimes throw a \"Permission denied\" error when strict-comparing\n        // two documents; shallow comparisons work.\n        // eslint-disable-next-line eqeqeq\n        preferredDoc != document &&\n        ( subWindow = document.defaultView ) && subWindow.top !== subWindow ) {\n\n        // Support: IE 9 - 11+, Edge 12 - 18+\n        subWindow.addEventListener( \"unload\", unloadHandler );\n      }\n\n      // Support: IE <10\n      // Check if getElementById returns elements by name\n      // The broken getElementById methods don't pick up programmatically-set names,\n      // so use a roundabout getElementsByName test\n      support.getById = assert( function( el ) {\n        documentElement.appendChild( el ).id = jQuery.expando;\n        return !document.getElementsByName ||\n          !document.getElementsByName( jQuery.expando ).length;\n      } );\n\n      // Support: IE 9 only\n      // Check to see if it's possible to do matchesSelector\n      // on a disconnected node.\n      support.disconnectedMatch = assert( function( el ) {\n        return matches.call( el, \"*\" );\n      } );\n\n      // Support: IE 9 - 11+, Edge 12 - 18+\n      // IE/Edge don't support the :scope pseudo-class.\n      support.scope = assert( function() {\n        return document.querySelectorAll( \":scope\" );\n      } );\n\n      // Support: Chrome 105 - 111 only, Safari 15.4 - 16.3 only\n      // Make sure the `:has()` argument is parsed unforgivingly.\n      // We include `*` in the test to detect buggy implementations that are\n      // _selectively_ forgiving (specifically when the list includes at least\n      // one valid selector).\n      // Note that we treat complete lack of support for `:has()` as if it were\n      // spec-compliant support, which is fine because use of `:has()` in such\n      // environments will fail in the qSA path and fall back to jQuery traversal\n      // anyway.\n      support.cssHas = assert( function() {\n        try {\n          document.querySelector( \":has(*,:jqfake)\" );\n          return false;\n        } catch ( e ) {\n          return true;\n        }\n      } );\n\n      // ID filter and find\n      if ( support.getById ) {\n        Expr.filter.ID = function( id ) {\n          var attrId = id.replace( runescape, funescape );\n          return function( elem ) {\n            return elem.getAttribute( \"id\" ) === attrId;\n          };\n        };\n        Expr.find.ID = function( id, context ) {\n          if ( typeof context.getElementById !== \"undefined\" && documentIsHTML ) {\n            var elem = context.getElementById( id );\n            return elem ? [ elem ] : [];\n          }\n        };\n      } else {\n        Expr.filter.ID =  function( id ) {\n          var attrId = id.replace( runescape, funescape );\n          return function( elem ) {\n            var node = typeof elem.getAttributeNode !== \"undefined\" &&\n              elem.getAttributeNode( \"id\" );\n            return node && node.value === attrId;\n          };\n        };\n\n        // Support: IE 6 - 7 only\n        // getElementById is not reliable as a find shortcut\n        Expr.find.ID = function( id, context ) {\n          if ( typeof context.getElementById !== \"undefined\" && documentIsHTML ) {\n            var node, i, elems,\n              elem = context.getElementById( id );\n\n            if ( elem ) {\n\n              // Verify the id attribute\n              node = elem.getAttributeNode( \"id\" );\n              if ( node && node.value === id ) {\n                return [ elem ];\n              }\n\n              // Fall back on getElementsByName\n              elems = context.getElementsByName( id );\n              i = 0;\n              while ( ( elem = elems[ i++ ] ) ) {\n                node = elem.getAttributeNode( \"id\" );\n                if ( node && node.value === id ) {\n                  return [ elem ];\n                }\n              }\n            }\n\n            return [];\n          }\n        };\n      }\n\n      // Tag\n      Expr.find.TAG = function( tag, context ) {\n        if ( typeof context.getElementsByTagName !== \"undefined\" ) {\n          return context.getElementsByTagName( tag );\n\n          // DocumentFragment nodes don't have gEBTN\n        } else {\n          return context.querySelectorAll( tag );\n        }\n      };\n\n      // Class\n      Expr.find.CLASS = function( className, context ) {\n        if ( typeof context.getElementsByClassName !== \"undefined\" && documentIsHTML ) {\n          return context.getElementsByClassName( className );\n        }\n      };\n\n      /* QSA/matchesSelector\n\t---------------------------------------------------------------------- */\n\n      // QSA and matchesSelector support\n\n      rbuggyQSA = [];\n\n      // Build QSA regex\n      // Regex strategy adopted from Diego Perini\n      assert( function( el ) {\n\n        var input;\n\n        documentElement.appendChild( el ).innerHTML =\n          \"<a id='\" + expando + \"' href='' disabled='disabled'></a>\" +\n          \"<select id='\" + expando + \"-\\r\\\\' disabled='disabled'>\" +\n          \"<option selected=''></option></select>\";\n\n        // Support: iOS <=7 - 8 only\n        // Boolean attributes and \"value\" are not treated correctly in some XML documents\n        if ( !el.querySelectorAll( \"[selected]\" ).length ) {\n          rbuggyQSA.push( \"\\\\[\" + whitespace + \"*(?:value|\" + booleans + \")\" );\n        }\n\n        // Support: iOS <=7 - 8 only\n        if ( !el.querySelectorAll( \"[id~=\" + expando + \"-]\" ).length ) {\n          rbuggyQSA.push( \"~=\" );\n        }\n\n        // Support: iOS 8 only\n        // https://bugs.webkit.org/show_bug.cgi?id=136851\n        // In-page `selector#id sibling-combinator selector` fails\n        if ( !el.querySelectorAll( \"a#\" + expando + \"+*\" ).length ) {\n          rbuggyQSA.push( \".#.+[+~]\" );\n        }\n\n        // Support: Chrome <=105+, Firefox <=104+, Safari <=15.4+\n        // In some of the document kinds, these selectors wouldn't work natively.\n        // This is probably OK but for backwards compatibility we want to maintain\n        // handling them through jQuery traversal in jQuery 3.x.\n        if ( !el.querySelectorAll( \":checked\" ).length ) {\n          rbuggyQSA.push( \":checked\" );\n        }\n\n        // Support: Windows 8 Native Apps\n        // The type and name attributes are restricted during .innerHTML assignment\n        input = document.createElement( \"input\" );\n        input.setAttribute( \"type\", \"hidden\" );\n        el.appendChild( input ).setAttribute( \"name\", \"D\" );\n\n        // Support: IE 9 - 11+\n        // IE's :disabled selector does not pick up the children of disabled fieldsets\n        // Support: Chrome <=105+, Firefox <=104+, Safari <=15.4+\n        // In some of the document kinds, these selectors wouldn't work natively.\n        // This is probably OK but for backwards compatibility we want to maintain\n        // handling them through jQuery traversal in jQuery 3.x.\n        documentElement.appendChild( el ).disabled = true;\n        if ( el.querySelectorAll( \":disabled\" ).length !== 2 ) {\n          rbuggyQSA.push( \":enabled\", \":disabled\" );\n        }\n\n        // Support: IE 11+, Edge 15 - 18+\n        // IE 11/Edge don't find elements on a `[name='']` query in some cases.\n        // Adding a temporary attribute to the document before the selection works\n        // around the issue.\n        // Interestingly, IE 10 & older don't seem to have the issue.\n        input = document.createElement( \"input\" );\n        input.setAttribute( \"name\", \"\" );\n        el.appendChild( input );\n        if ( !el.querySelectorAll( \"[name='']\" ).length ) {\n          rbuggyQSA.push( \"\\\\[\" + whitespace + \"*name\" + whitespace + \"*=\" +\n            whitespace + \"*(?:''|\\\"\\\")\" );\n        }\n      } );\n\n      if ( !support.cssHas ) {\n\n        // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+\n        // Our regular `try-catch` mechanism fails to detect natively-unsupported\n        // pseudo-classes inside `:has()` (such as `:has(:contains(\"Foo\"))`)\n        // in browsers that parse the `:has()` argument as a forgiving selector list.\n        // https://drafts.csswg.org/selectors/#relational now requires the argument\n        // to be parsed unforgivingly, but browsers have not yet fully adjusted.\n        rbuggyQSA.push( \":has\" );\n      }\n\n      rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join( \"|\" ) );\n\n      /* Sorting\n\t---------------------------------------------------------------------- */\n\n      // Document order sorting\n      sortOrder = function( a, b ) {\n\n        // Flag for duplicate removal\n        if ( a === b ) {\n          hasDuplicate = true;\n          return 0;\n        }\n\n        // Sort on method existence if only one input has compareDocumentPosition\n        var compare = !a.compareDocumentPosition - !b.compareDocumentPosition;\n        if ( compare ) {\n          return compare;\n        }\n\n        // Calculate position if both inputs belong to the same document\n        // Support: IE 11+, Edge 17 - 18+\n        // IE/Edge sometimes throw a \"Permission denied\" error when strict-comparing\n        // two documents; shallow comparisons work.\n        // eslint-disable-next-line eqeqeq\n        compare = ( a.ownerDocument || a ) == ( b.ownerDocument || b ) ?\n          a.compareDocumentPosition( b ) :\n\n          // Otherwise we know they are disconnected\n          1;\n\n        // Disconnected nodes\n        if ( compare & 1 ||\n          ( !support.sortDetached && b.compareDocumentPosition( a ) === compare ) ) {\n\n          // Choose the first element that is related to our preferred document\n          // Support: IE 11+, Edge 17 - 18+\n          // IE/Edge sometimes throw a \"Permission denied\" error when strict-comparing\n          // two documents; shallow comparisons work.\n          // eslint-disable-next-line eqeqeq\n          if ( a === document || a.ownerDocument == preferredDoc &&\n            find.contains( preferredDoc, a ) ) {\n            return -1;\n          }\n\n          // Support: IE 11+, Edge 17 - 18+\n          // IE/Edge sometimes throw a \"Permission denied\" error when strict-comparing\n          // two documents; shallow comparisons work.\n          // eslint-disable-next-line eqeqeq\n          if ( b === document || b.ownerDocument == preferredDoc &&\n            find.contains( preferredDoc, b ) ) {\n            return 1;\n          }\n\n          // Maintain original order\n          return sortInput ?\n            ( indexOf.call( sortInput, a ) - indexOf.call( sortInput, b ) ) :\n            0;\n        }\n\n        return compare & 4 ? -1 : 1;\n      };\n\n      return document;\n    }\n\n    find.matches = function( expr, elements ) {\n      return find( expr, null, null, elements );\n    };\n\n    find.matchesSelector = function( elem, expr ) {\n      setDocument( elem );\n\n      if ( documentIsHTML &&\n        !nonnativeSelectorCache[ expr + \" \" ] &&\n        ( !rbuggyQSA || !rbuggyQSA.test( expr ) ) ) {\n\n        try {\n          var ret = matches.call( elem, expr );\n\n          // IE 9's matchesSelector returns false on disconnected nodes\n          if ( ret || support.disconnectedMatch ||\n\n            // As well, disconnected nodes are said to be in a document\n            // fragment in IE 9\n            elem.document && elem.document.nodeType !== 11 ) {\n            return ret;\n          }\n        } catch ( e ) {\n          nonnativeSelectorCache( expr, true );\n        }\n      }\n\n      return find( expr, document, null, [ elem ] ).length > 0;\n    };\n\n    find.contains = function( context, elem ) {\n\n      // Set document vars if needed\n      // Support: IE 11+, Edge 17 - 18+\n      // IE/Edge sometimes throw a \"Permission denied\" error when strict-comparing\n      // two documents; shallow comparisons work.\n      // eslint-disable-next-line eqeqeq\n      if ( ( context.ownerDocument || context ) != document ) {\n        setDocument( context );\n      }\n      return jQuery.contains( context, elem );\n    };\n\n\n    find.attr = function( elem, name ) {\n\n      // Set document vars if needed\n      // Support: IE 11+, Edge 17 - 18+\n      // IE/Edge sometimes throw a \"Permission denied\" error when strict-comparing\n      // two documents; shallow comparisons work.\n      // eslint-disable-next-line eqeqeq\n      if ( ( elem.ownerDocument || elem ) != document ) {\n        setDocument( elem );\n      }\n\n      var fn = Expr.attrHandle[ name.toLowerCase() ],\n\n        // Don't get fooled by Object.prototype properties (see trac-13807)\n        val = fn && hasOwn.call( Expr.attrHandle, name.toLowerCase() ) ?\n          fn( elem, name, !documentIsHTML ) :\n          undefined;\n\n      if ( val !== undefined ) {\n        return val;\n      }\n\n      return elem.getAttribute( name );\n    };\n\n    find.error = function( msg ) {\n      throw new Error( \"Syntax error, unrecognized expression: \" + msg );\n    };\n\n    /**\n     * Document sorting and removing duplicates\n     * @param {ArrayLike} results\n     */\n    jQuery.uniqueSort = function( results ) {\n      var elem,\n        duplicates = [],\n        j = 0,\n        i = 0;\n\n      // Unless we *know* we can detect duplicates, assume their presence\n      //\n      // Support: Android <=4.0+\n      // Testing for detecting duplicates is unpredictable so instead assume we can't\n      // depend on duplicate detection in all browsers without a stable sort.\n      hasDuplicate = !support.sortStable;\n      sortInput = !support.sortStable && slice.call( results, 0 );\n      sort.call( results, sortOrder );\n\n      if ( hasDuplicate ) {\n        while ( ( elem = results[ i++ ] ) ) {\n          if ( elem === results[ i ] ) {\n            j = duplicates.push( i );\n          }\n        }\n        while ( j-- ) {\n          splice.call( results, duplicates[ j ], 1 );\n        }\n      }\n\n      // Clear input after sorting to release objects\n      // See https://github.com/jquery/sizzle/pull/225\n      sortInput = null;\n\n      return results;\n    };\n\n    jQuery.fn.uniqueSort = function() {\n      return this.pushStack( jQuery.uniqueSort( slice.apply( this ) ) );\n    };\n\n    Expr = jQuery.expr = {\n\n      // Can be adjusted by the user\n      cacheLength: 50,\n\n      createPseudo: markFunction,\n\n      match: matchExpr,\n\n      attrHandle: {},\n\n      find: {},\n\n      relative: {\n        \">\": { dir: \"parentNode\", first: true },\n        \" \": { dir: \"parentNode\" },\n        \"+\": { dir: \"previousSibling\", first: true },\n        \"~\": { dir: \"previousSibling\" }\n      },\n\n      preFilter: {\n        ATTR: function( match ) {\n          match[ 1 ] = match[ 1 ].replace( runescape, funescape );\n\n          // Move the given value to match[3] whether quoted or unquoted\n          match[ 3 ] = ( match[ 3 ] || match[ 4 ] || match[ 5 ] || \"\" )\n            .replace( runescape, funescape );\n\n          if ( match[ 2 ] === \"~=\" ) {\n            match[ 3 ] = \" \" + match[ 3 ] + \" \";\n          }\n\n          return match.slice( 0, 4 );\n        },\n\n        CHILD: function( match ) {\n\n          /* matches from matchExpr[\"CHILD\"]\n\t\t\t\t1 type (only|nth|...)\n\t\t\t\t2 what (child|of-type)\n\t\t\t\t3 argument (even|odd|\\d*|\\d*n([+-]\\d+)?|...)\n\t\t\t\t4 xn-component of xn+y argument ([+-]?\\d*n|)\n\t\t\t\t5 sign of xn-component\n\t\t\t\t6 x of xn-component\n\t\t\t\t7 sign of y-component\n\t\t\t\t8 y of y-component\n\t\t\t*/\n          match[ 1 ] = match[ 1 ].toLowerCase();\n\n          if ( match[ 1 ].slice( 0, 3 ) === \"nth\" ) {\n\n            // nth-* requires argument\n            if ( !match[ 3 ] ) {\n              find.error( match[ 0 ] );\n            }\n\n            // numeric x and y parameters for Expr.filter.CHILD\n            // remember that false/true cast respectively to 0/1\n            match[ 4 ] = +( match[ 4 ] ?\n                match[ 5 ] + ( match[ 6 ] || 1 ) :\n                2 * ( match[ 3 ] === \"even\" || match[ 3 ] === \"odd\" )\n            );\n            match[ 5 ] = +( ( match[ 7 ] + match[ 8 ] ) || match[ 3 ] === \"odd\" );\n\n            // other types prohibit arguments\n          } else if ( match[ 3 ] ) {\n            find.error( match[ 0 ] );\n          }\n\n          return match;\n        },\n\n        PSEUDO: function( match ) {\n          var excess,\n            unquoted = !match[ 6 ] && match[ 2 ];\n\n          if ( matchExpr.CHILD.test( match[ 0 ] ) ) {\n            return null;\n          }\n\n          // Accept quoted arguments as-is\n          if ( match[ 3 ] ) {\n            match[ 2 ] = match[ 4 ] || match[ 5 ] || \"\";\n\n            // Strip excess characters from unquoted arguments\n          } else if ( unquoted && rpseudo.test( unquoted ) &&\n\n            // Get excess from tokenize (recursively)\n            ( excess = tokenize( unquoted, true ) ) &&\n\n            // advance to the next closing parenthesis\n            ( excess = unquoted.indexOf( \")\", unquoted.length - excess ) - unquoted.length ) ) {\n\n            // excess is a negative index\n            match[ 0 ] = match[ 0 ].slice( 0, excess );\n            match[ 2 ] = unquoted.slice( 0, excess );\n          }\n\n          // Return only captures needed by the pseudo filter method (type and argument)\n          return match.slice( 0, 3 );\n        }\n      },\n\n      filter: {\n\n        TAG: function( nodeNameSelector ) {\n          var expectedNodeName = nodeNameSelector.replace( runescape, funescape ).toLowerCase();\n          return nodeNameSelector === \"*\" ?\n            function() {\n              return true;\n            } :\n            function( elem ) {\n              return nodeName( elem, expectedNodeName );\n            };\n        },\n\n        CLASS: function( className ) {\n          var pattern = classCache[ className + \" \" ];\n\n          return pattern ||\n            ( pattern = new RegExp( \"(^|\" + whitespace + \")\" + className +\n              \"(\" + whitespace + \"|$)\" ) ) &&\n            classCache( className, function( elem ) {\n              return pattern.test(\n                typeof elem.className === \"string\" && elem.className ||\n                typeof elem.getAttribute !== \"undefined\" &&\n                elem.getAttribute( \"class\" ) ||\n                \"\"\n              );\n            } );\n        },\n\n        ATTR: function( name, operator, check ) {\n          return function( elem ) {\n            var result = find.attr( elem, name );\n\n            if ( result == null ) {\n              return operator === \"!=\";\n            }\n            if ( !operator ) {\n              return true;\n            }\n\n            result += \"\";\n\n            if ( operator === \"=\" ) {\n              return result === check;\n            }\n            if ( operator === \"!=\" ) {\n              return result !== check;\n            }\n            if ( operator === \"^=\" ) {\n              return check && result.indexOf( check ) === 0;\n            }\n            if ( operator === \"*=\" ) {\n              return check && result.indexOf( check ) > -1;\n            }\n            if ( operator === \"$=\" ) {\n              return check && result.slice( -check.length ) === check;\n            }\n            if ( operator === \"~=\" ) {\n              return ( \" \" + result.replace( rwhitespace, \" \" ) + \" \" )\n                .indexOf( check ) > -1;\n            }\n            if ( operator === \"|=\" ) {\n              return result === check || result.slice( 0, check.length + 1 ) === check + \"-\";\n            }\n\n            return false;\n          };\n        },\n\n        CHILD: function( type, what, _argument, first, last ) {\n          var simple = type.slice( 0, 3 ) !== \"nth\",\n            forward = type.slice( -4 ) !== \"last\",\n            ofType = what === \"of-type\";\n\n          return first === 1 && last === 0 ?\n\n            // Shortcut for :nth-*(n)\n            function( elem ) {\n              return !!elem.parentNode;\n            } :\n\n            function( elem, _context, xml ) {\n              var cache, outerCache, node, nodeIndex, start,\n                dir = simple !== forward ? \"nextSibling\" : \"previousSibling\",\n                parent = elem.parentNode,\n                name = ofType && elem.nodeName.toLowerCase(),\n                useCache = !xml && !ofType,\n                diff = false;\n\n              if ( parent ) {\n\n                // :(first|last|only)-(child|of-type)\n                if ( simple ) {\n                  while ( dir ) {\n                    node = elem;\n                    while ( ( node = node[ dir ] ) ) {\n                      if ( ofType ?\n                        nodeName( node, name ) :\n                        node.nodeType === 1 ) {\n\n                        return false;\n                      }\n                    }\n\n                    // Reverse direction for :only-* (if we haven't yet done so)\n                    start = dir = type === \"only\" && !start && \"nextSibling\";\n                  }\n                  return true;\n                }\n\n                start = [ forward ? parent.firstChild : parent.lastChild ];\n\n                // non-xml :nth-child(...) stores cache data on `parent`\n                if ( forward && useCache ) {\n\n                  // Seek `elem` from a previously-cached index\n                  outerCache = parent[ expando ] || ( parent[ expando ] = {} );\n                  cache = outerCache[ type ] || [];\n                  nodeIndex = cache[ 0 ] === dirruns && cache[ 1 ];\n                  diff = nodeIndex && cache[ 2 ];\n                  node = nodeIndex && parent.childNodes[ nodeIndex ];\n\n                  while ( ( node = ++nodeIndex && node && node[ dir ] ||\n\n                    // Fallback to seeking `elem` from the start\n                    ( diff = nodeIndex = 0 ) || start.pop() ) ) {\n\n                    // When found, cache indexes on `parent` and break\n                    if ( node.nodeType === 1 && ++diff && node === elem ) {\n                      outerCache[ type ] = [ dirruns, nodeIndex, diff ];\n                      break;\n                    }\n                  }\n\n                } else {\n\n                  // Use previously-cached element index if available\n                  if ( useCache ) {\n                    outerCache = elem[ expando ] || ( elem[ expando ] = {} );\n                    cache = outerCache[ type ] || [];\n                    nodeIndex = cache[ 0 ] === dirruns && cache[ 1 ];\n                    diff = nodeIndex;\n                  }\n\n                  // xml :nth-child(...)\n                  // or :nth-last-child(...) or :nth(-last)?-of-type(...)\n                  if ( diff === false ) {\n\n                    // Use the same loop as above to seek `elem` from the start\n                    while ( ( node = ++nodeIndex && node && node[ dir ] ||\n                      ( diff = nodeIndex = 0 ) || start.pop() ) ) {\n\n                      if ( ( ofType ?\n                          nodeName( node, name ) :\n                          node.nodeType === 1 ) &&\n                        ++diff ) {\n\n                        // Cache the index of each encountered element\n                        if ( useCache ) {\n                          outerCache = node[ expando ] ||\n                            ( node[ expando ] = {} );\n                          outerCache[ type ] = [ dirruns, diff ];\n                        }\n\n                        if ( node === elem ) {\n                          break;\n                        }\n                      }\n                    }\n                  }\n                }\n\n                // Incorporate the offset, then check against cycle size\n                diff -= last;\n                return diff === first || ( diff % first === 0 && diff / first >= 0 );\n              }\n            };\n        },\n\n        PSEUDO: function( pseudo, argument ) {\n\n          // pseudo-class names are case-insensitive\n          // https://www.w3.org/TR/selectors/#pseudo-classes\n          // Prioritize by case sensitivity in case custom pseudos are added with uppercase letters\n          // Remember that setFilters inherits from pseudos\n          var args,\n            fn = Expr.pseudos[ pseudo ] || Expr.setFilters[ pseudo.toLowerCase() ] ||\n              find.error( \"unsupported pseudo: \" + pseudo );\n\n          // The user may use createPseudo to indicate that\n          // arguments are needed to create the filter function\n          // just as jQuery does\n          if ( fn[ expando ] ) {\n            return fn( argument );\n          }\n\n          // But maintain support for old signatures\n          if ( fn.length > 1 ) {\n            args = [ pseudo, pseudo, \"\", argument ];\n            return Expr.setFilters.hasOwnProperty( pseudo.toLowerCase() ) ?\n              markFunction( function( seed, matches ) {\n                var idx,\n                  matched = fn( seed, argument ),\n                  i = matched.length;\n                while ( i-- ) {\n                  idx = indexOf.call( seed, matched[ i ] );\n                  seed[ idx ] = !( matches[ idx ] = matched[ i ] );\n                }\n              } ) :\n              function( elem ) {\n                return fn( elem, 0, args );\n              };\n          }\n\n          return fn;\n        }\n      },\n\n      pseudos: {\n\n        // Potentially complex pseudos\n        not: markFunction( function( selector ) {\n\n          // Trim the selector passed to compile\n          // to avoid treating leading and trailing\n          // spaces as combinators\n          var input = [],\n            results = [],\n            matcher = compile( selector.replace( rtrimCSS, \"$1\" ) );\n\n          return matcher[ expando ] ?\n            markFunction( function( seed, matches, _context, xml ) {\n              var elem,\n                unmatched = matcher( seed, null, xml, [] ),\n                i = seed.length;\n\n              // Match elements unmatched by `matcher`\n              while ( i-- ) {\n                if ( ( elem = unmatched[ i ] ) ) {\n                  seed[ i ] = !( matches[ i ] = elem );\n                }\n              }\n            } ) :\n            function( elem, _context, xml ) {\n              input[ 0 ] = elem;\n              matcher( input, null, xml, results );\n\n              // Don't keep the element\n              // (see https://github.com/jquery/sizzle/issues/299)\n              input[ 0 ] = null;\n              return !results.pop();\n            };\n        } ),\n\n        has: markFunction( function( selector ) {\n          return function( elem ) {\n            return find( selector, elem ).length > 0;\n          };\n        } ),\n\n        contains: markFunction( function( text ) {\n          text = text.replace( runescape, funescape );\n          return function( elem ) {\n            return ( elem.textContent || jQuery.text( elem ) ).indexOf( text ) > -1;\n          };\n        } ),\n\n        // \"Whether an element is represented by a :lang() selector\n        // is based solely on the element's language value\n        // being equal to the identifier C,\n        // or beginning with the identifier C immediately followed by \"-\".\n        // The matching of C against the element's language value is performed case-insensitively.\n        // The identifier C does not have to be a valid language name.\"\n        // https://www.w3.org/TR/selectors/#lang-pseudo\n        lang: markFunction( function( lang ) {\n\n          // lang value must be a valid identifier\n          if ( !ridentifier.test( lang || \"\" ) ) {\n            find.error( \"unsupported lang: \" + lang );\n          }\n          lang = lang.replace( runescape, funescape ).toLowerCase();\n          return function( elem ) {\n            var elemLang;\n            do {\n              if ( ( elemLang = documentIsHTML ?\n                elem.lang :\n                elem.getAttribute( \"xml:lang\" ) || elem.getAttribute( \"lang\" ) ) ) {\n\n                elemLang = elemLang.toLowerCase();\n                return elemLang === lang || elemLang.indexOf( lang + \"-\" ) === 0;\n              }\n            } while ( ( elem = elem.parentNode ) && elem.nodeType === 1 );\n            return false;\n          };\n        } ),\n\n        // Miscellaneous\n        target: function( elem ) {\n          var hash = window.location && window.location.hash;\n          return hash && hash.slice( 1 ) === elem.id;\n        },\n\n        root: function( elem ) {\n          return elem === documentElement;\n        },\n\n        focus: function( elem ) {\n          return elem === safeActiveElement() &&\n            document.hasFocus() &&\n            !!( elem.type || elem.href || ~elem.tabIndex );\n        },\n\n        // Boolean properties\n        enabled: createDisabledPseudo( false ),\n        disabled: createDisabledPseudo( true ),\n\n        checked: function( elem ) {\n\n          // In CSS3, :checked should return both checked and selected elements\n          // https://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked\n          return ( nodeName( elem, \"input\" ) && !!elem.checked ) ||\n            ( nodeName( elem, \"option\" ) && !!elem.selected );\n        },\n\n        selected: function( elem ) {\n\n          // Support: IE <=11+\n          // Accessing the selectedIndex property\n          // forces the browser to treat the default option as\n          // selected when in an optgroup.\n          if ( elem.parentNode ) {\n            // eslint-disable-next-line no-unused-expressions\n            elem.parentNode.selectedIndex;\n          }\n\n          return elem.selected === true;\n        },\n\n        // Contents\n        empty: function( elem ) {\n\n          // https://www.w3.org/TR/selectors/#empty-pseudo\n          // :empty is negated by element (1) or content nodes (text: 3; cdata: 4; entity ref: 5),\n          //   but not by others (comment: 8; processing instruction: 7; etc.)\n          // nodeType < 6 works because attributes (2) do not appear as children\n          for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) {\n            if ( elem.nodeType < 6 ) {\n              return false;\n            }\n          }\n          return true;\n        },\n\n        parent: function( elem ) {\n          return !Expr.pseudos.empty( elem );\n        },\n\n        // Element/input types\n        header: function( elem ) {\n          return rheader.test( elem.nodeName );\n        },\n\n        input: function( elem ) {\n          return rinputs.test( elem.nodeName );\n        },\n\n        button: function( elem ) {\n          return nodeName( elem, \"input\" ) && elem.type === \"button\" ||\n            nodeName( elem, \"button\" );\n        },\n\n        text: function( elem ) {\n          var attr;\n          return nodeName( elem, \"input\" ) && elem.type === \"text\" &&\n\n            // Support: IE <10 only\n            // New HTML5 attribute values (e.g., \"search\") appear\n            // with elem.type === \"text\"\n            ( ( attr = elem.getAttribute( \"type\" ) ) == null ||\n              attr.toLowerCase() === \"text\" );\n        },\n\n        // Position-in-collection\n        first: createPositionalPseudo( function() {\n          return [ 0 ];\n        } ),\n\n        last: createPositionalPseudo( function( _matchIndexes, length ) {\n          return [ length - 1 ];\n        } ),\n\n        eq: createPositionalPseudo( function( _matchIndexes, length, argument ) {\n          return [ argument < 0 ? argument + length : argument ];\n        } ),\n\n        even: createPositionalPseudo( function( matchIndexes, length ) {\n          var i = 0;\n          for ( ; i < length; i += 2 ) {\n            matchIndexes.push( i );\n          }\n          return matchIndexes;\n        } ),\n\n        odd: createPositionalPseudo( function( matchIndexes, length ) {\n          var i = 1;\n          for ( ; i < length; i += 2 ) {\n            matchIndexes.push( i );\n          }\n          return matchIndexes;\n        } ),\n\n        lt: createPositionalPseudo( function( matchIndexes, length, argument ) {\n          var i;\n\n          if ( argument < 0 ) {\n            i = argument + length;\n          } else if ( argument > length ) {\n            i = length;\n          } else {\n            i = argument;\n          }\n\n          for ( ; --i >= 0; ) {\n            matchIndexes.push( i );\n          }\n          return matchIndexes;\n        } ),\n\n        gt: createPositionalPseudo( function( matchIndexes, length, argument ) {\n          var i = argument < 0 ? argument + length : argument;\n          for ( ; ++i < length; ) {\n            matchIndexes.push( i );\n          }\n          return matchIndexes;\n        } )\n      }\n    };\n\n    Expr.pseudos.nth = Expr.pseudos.eq;\n\n// Add button/input type pseudos\n    for ( i in { radio: true, checkbox: true, file: true, password: true, image: true } ) {\n      Expr.pseudos[ i ] = createInputPseudo( i );\n    }\n    for ( i in { submit: true, reset: true } ) {\n      Expr.pseudos[ i ] = createButtonPseudo( i );\n    }\n\n// Easy API for creating new setFilters\n    function setFilters() {}\n    setFilters.prototype = Expr.filters = Expr.pseudos;\n    Expr.setFilters = new setFilters();\n\n    function tokenize( selector, parseOnly ) {\n      var matched, match, tokens, type,\n        soFar, groups, preFilters,\n        cached = tokenCache[ selector + \" \" ];\n\n      if ( cached ) {\n        return parseOnly ? 0 : cached.slice( 0 );\n      }\n\n      soFar = selector;\n      groups = [];\n      preFilters = Expr.preFilter;\n\n      while ( soFar ) {\n\n        // Comma and first run\n        if ( !matched || ( match = rcomma.exec( soFar ) ) ) {\n          if ( match ) {\n\n            // Don't consume trailing commas as valid\n            soFar = soFar.slice( match[ 0 ].length ) || soFar;\n          }\n          groups.push( ( tokens = [] ) );\n        }\n\n        matched = false;\n\n        // Combinators\n        if ( ( match = rleadingCombinator.exec( soFar ) ) ) {\n          matched = match.shift();\n          tokens.push( {\n            value: matched,\n\n            // Cast descendant combinators to space\n            type: match[ 0 ].replace( rtrimCSS, \" \" )\n          } );\n          soFar = soFar.slice( matched.length );\n        }\n\n        // Filters\n        for ( type in Expr.filter ) {\n          if ( ( match = matchExpr[ type ].exec( soFar ) ) && ( !preFilters[ type ] ||\n            ( match = preFilters[ type ]( match ) ) ) ) {\n            matched = match.shift();\n            tokens.push( {\n              value: matched,\n              type: type,\n              matches: match\n            } );\n            soFar = soFar.slice( matched.length );\n          }\n        }\n\n        if ( !matched ) {\n          break;\n        }\n      }\n\n      // Return the length of the invalid excess\n      // if we're just parsing\n      // Otherwise, throw an error or return tokens\n      if ( parseOnly ) {\n        return soFar.length;\n      }\n\n      return soFar ?\n        find.error( selector ) :\n\n        // Cache the tokens\n        tokenCache( selector, groups ).slice( 0 );\n    }\n\n    function toSelector( tokens ) {\n      var i = 0,\n        len = tokens.length,\n        selector = \"\";\n      for ( ; i < len; i++ ) {\n        selector += tokens[ i ].value;\n      }\n      return selector;\n    }\n\n    function addCombinator( matcher, combinator, base ) {\n      var dir = combinator.dir,\n        skip = combinator.next,\n        key = skip || dir,\n        checkNonElements = base && key === \"parentNode\",\n        doneName = done++;\n\n      return combinator.first ?\n\n        // Check against closest ancestor/preceding element\n        function( elem, context, xml ) {\n          while ( ( elem = elem[ dir ] ) ) {\n            if ( elem.nodeType === 1 || checkNonElements ) {\n              return matcher( elem, context, xml );\n            }\n          }\n          return false;\n        } :\n\n        // Check against all ancestor/preceding elements\n        function( elem, context, xml ) {\n          var oldCache, outerCache,\n            newCache = [ dirruns, doneName ];\n\n          // We can't set arbitrary data on XML nodes, so they don't benefit from combinator caching\n          if ( xml ) {\n            while ( ( elem = elem[ dir ] ) ) {\n              if ( elem.nodeType === 1 || checkNonElements ) {\n                if ( matcher( elem, context, xml ) ) {\n                  return true;\n                }\n              }\n            }\n          } else {\n            while ( ( elem = elem[ dir ] ) ) {\n              if ( elem.nodeType === 1 || checkNonElements ) {\n                outerCache = elem[ expando ] || ( elem[ expando ] = {} );\n\n                if ( skip && nodeName( elem, skip ) ) {\n                  elem = elem[ dir ] || elem;\n                } else if ( ( oldCache = outerCache[ key ] ) &&\n                  oldCache[ 0 ] === dirruns && oldCache[ 1 ] === doneName ) {\n\n                  // Assign to newCache so results back-propagate to previous elements\n                  return ( newCache[ 2 ] = oldCache[ 2 ] );\n                } else {\n\n                  // Reuse newcache so results back-propagate to previous elements\n                  outerCache[ key ] = newCache;\n\n                  // A match means we're done; a fail means we have to keep checking\n                  if ( ( newCache[ 2 ] = matcher( elem, context, xml ) ) ) {\n                    return true;\n                  }\n                }\n              }\n            }\n          }\n          return false;\n        };\n    }\n\n    function elementMatcher( matchers ) {\n      return matchers.length > 1 ?\n        function( elem, context, xml ) {\n          var i = matchers.length;\n          while ( i-- ) {\n            if ( !matchers[ i ]( elem, context, xml ) ) {\n              return false;\n            }\n          }\n          return true;\n        } :\n        matchers[ 0 ];\n    }\n\n    function multipleContexts( selector, contexts, results ) {\n      var i = 0,\n        len = contexts.length;\n      for ( ; i < len; i++ ) {\n        find( selector, contexts[ i ], results );\n      }\n      return results;\n    }\n\n    function condense( unmatched, map, filter, context, xml ) {\n      var elem,\n        newUnmatched = [],\n        i = 0,\n        len = unmatched.length,\n        mapped = map != null;\n\n      for ( ; i < len; i++ ) {\n        if ( ( elem = unmatched[ i ] ) ) {\n          if ( !filter || filter( elem, context, xml ) ) {\n            newUnmatched.push( elem );\n            if ( mapped ) {\n              map.push( i );\n            }\n          }\n        }\n      }\n\n      return newUnmatched;\n    }\n\n    function setMatcher( preFilter, selector, matcher, postFilter, postFinder, postSelector ) {\n      if ( postFilter && !postFilter[ expando ] ) {\n        postFilter = setMatcher( postFilter );\n      }\n      if ( postFinder && !postFinder[ expando ] ) {\n        postFinder = setMatcher( postFinder, postSelector );\n      }\n      return markFunction( function( seed, results, context, xml ) {\n        var temp, i, elem, matcherOut,\n          preMap = [],\n          postMap = [],\n          preexisting = results.length,\n\n          // Get initial elements from seed or context\n          elems = seed ||\n            multipleContexts( selector || \"*\",\n              context.nodeType ? [ context ] : context, [] ),\n\n          // Prefilter to get matcher input, preserving a map for seed-results synchronization\n          matcherIn = preFilter && ( seed || !selector ) ?\n            condense( elems, preMap, preFilter, context, xml ) :\n            elems;\n\n        if ( matcher ) {\n\n          // If we have a postFinder, or filtered seed, or non-seed postFilter\n          // or preexisting results,\n          matcherOut = postFinder || ( seed ? preFilter : preexisting || postFilter ) ?\n\n            // ...intermediate processing is necessary\n            [] :\n\n            // ...otherwise use results directly\n            results;\n\n          // Find primary matches\n          matcher( matcherIn, matcherOut, context, xml );\n        } else {\n          matcherOut = matcherIn;\n        }\n\n        // Apply postFilter\n        if ( postFilter ) {\n          temp = condense( matcherOut, postMap );\n          postFilter( temp, [], context, xml );\n\n          // Un-match failing elements by moving them back to matcherIn\n          i = temp.length;\n          while ( i-- ) {\n            if ( ( elem = temp[ i ] ) ) {\n              matcherOut[ postMap[ i ] ] = !( matcherIn[ postMap[ i ] ] = elem );\n            }\n          }\n        }\n\n        if ( seed ) {\n          if ( postFinder || preFilter ) {\n            if ( postFinder ) {\n\n              // Get the final matcherOut by condensing this intermediate into postFinder contexts\n              temp = [];\n              i = matcherOut.length;\n              while ( i-- ) {\n                if ( ( elem = matcherOut[ i ] ) ) {\n\n                  // Restore matcherIn since elem is not yet a final match\n                  temp.push( ( matcherIn[ i ] = elem ) );\n                }\n              }\n              postFinder( null, ( matcherOut = [] ), temp, xml );\n            }\n\n            // Move matched elements from seed to results to keep them synchronized\n            i = matcherOut.length;\n            while ( i-- ) {\n              if ( ( elem = matcherOut[ i ] ) &&\n                ( temp = postFinder ? indexOf.call( seed, elem ) : preMap[ i ] ) > -1 ) {\n\n                seed[ temp ] = !( results[ temp ] = elem );\n              }\n            }\n          }\n\n          // Add elements to results, through postFinder if defined\n        } else {\n          matcherOut = condense(\n            matcherOut === results ?\n              matcherOut.splice( preexisting, matcherOut.length ) :\n              matcherOut\n          );\n          if ( postFinder ) {\n            postFinder( null, results, matcherOut, xml );\n          } else {\n            push.apply( results, matcherOut );\n          }\n        }\n      } );\n    }\n\n    function matcherFromTokens( tokens ) {\n      var checkContext, matcher, j,\n        len = tokens.length,\n        leadingRelative = Expr.relative[ tokens[ 0 ].type ],\n        implicitRelative = leadingRelative || Expr.relative[ \" \" ],\n        i = leadingRelative ? 1 : 0,\n\n        // The foundational matcher ensures that elements are reachable from top-level context(s)\n        matchContext = addCombinator( function( elem ) {\n          return elem === checkContext;\n        }, implicitRelative, true ),\n        matchAnyContext = addCombinator( function( elem ) {\n          return indexOf.call( checkContext, elem ) > -1;\n        }, implicitRelative, true ),\n        matchers = [ function( elem, context, xml ) {\n\n          // Support: IE 11+, Edge 17 - 18+\n          // IE/Edge sometimes throw a \"Permission denied\" error when strict-comparing\n          // two documents; shallow comparisons work.\n          // eslint-disable-next-line eqeqeq\n          var ret = ( !leadingRelative && ( xml || context != outermostContext ) ) || (\n            ( checkContext = context ).nodeType ?\n              matchContext( elem, context, xml ) :\n              matchAnyContext( elem, context, xml ) );\n\n          // Avoid hanging onto element\n          // (see https://github.com/jquery/sizzle/issues/299)\n          checkContext = null;\n          return ret;\n        } ];\n\n      for ( ; i < len; i++ ) {\n        if ( ( matcher = Expr.relative[ tokens[ i ].type ] ) ) {\n          matchers = [ addCombinator( elementMatcher( matchers ), matcher ) ];\n        } else {\n          matcher = Expr.filter[ tokens[ i ].type ].apply( null, tokens[ i ].matches );\n\n          // Return special upon seeing a positional matcher\n          if ( matcher[ expando ] ) {\n\n            // Find the next relative operator (if any) for proper handling\n            j = ++i;\n            for ( ; j < len; j++ ) {\n              if ( Expr.relative[ tokens[ j ].type ] ) {\n                break;\n              }\n            }\n            return setMatcher(\n              i > 1 && elementMatcher( matchers ),\n              i > 1 && toSelector(\n\n                // If the preceding token was a descendant combinator, insert an implicit any-element `*`\n                tokens.slice( 0, i - 1 )\n                  .concat( { value: tokens[ i - 2 ].type === \" \" ? \"*\" : \"\" } )\n              ).replace( rtrimCSS, \"$1\" ),\n              matcher,\n              i < j && matcherFromTokens( tokens.slice( i, j ) ),\n              j < len && matcherFromTokens( ( tokens = tokens.slice( j ) ) ),\n              j < len && toSelector( tokens )\n            );\n          }\n          matchers.push( matcher );\n        }\n      }\n\n      return elementMatcher( matchers );\n    }\n\n    function matcherFromGroupMatchers( elementMatchers, setMatchers ) {\n      var bySet = setMatchers.length > 0,\n        byElement = elementMatchers.length > 0,\n        superMatcher = function( seed, context, xml, results, outermost ) {\n          var elem, j, matcher,\n            matchedCount = 0,\n            i = \"0\",\n            unmatched = seed && [],\n            setMatched = [],\n            contextBackup = outermostContext,\n\n            // We must always have either seed elements or outermost context\n            elems = seed || byElement && Expr.find.TAG( \"*\", outermost ),\n\n            // Use integer dirruns iff this is the outermost matcher\n            dirrunsUnique = ( dirruns += contextBackup == null ? 1 : Math.random() || 0.1 ),\n            len = elems.length;\n\n          if ( outermost ) {\n\n            // Support: IE 11+, Edge 17 - 18+\n            // IE/Edge sometimes throw a \"Permission denied\" error when strict-comparing\n            // two documents; shallow comparisons work.\n            // eslint-disable-next-line eqeqeq\n            outermostContext = context == document || context || outermost;\n          }\n\n          // Add elements passing elementMatchers directly to results\n          // Support: iOS <=7 - 9 only\n          // Tolerate NodeList properties (IE: \"length\"; Safari: <number>) matching\n          // elements by id. (see trac-14142)\n          for ( ; i !== len && ( elem = elems[ i ] ) != null; i++ ) {\n            if ( byElement && elem ) {\n              j = 0;\n\n              // Support: IE 11+, Edge 17 - 18+\n              // IE/Edge sometimes throw a \"Permission denied\" error when strict-comparing\n              // two documents; shallow comparisons work.\n              // eslint-disable-next-line eqeqeq\n              if ( !context && elem.ownerDocument != document ) {\n                setDocument( elem );\n                xml = !documentIsHTML;\n              }\n              while ( ( matcher = elementMatchers[ j++ ] ) ) {\n                if ( matcher( elem, context || document, xml ) ) {\n                  push.call( results, elem );\n                  break;\n                }\n              }\n              if ( outermost ) {\n                dirruns = dirrunsUnique;\n              }\n            }\n\n            // Track unmatched elements for set filters\n            if ( bySet ) {\n\n              // They will have gone through all possible matchers\n              if ( ( elem = !matcher && elem ) ) {\n                matchedCount--;\n              }\n\n              // Lengthen the array for every element, matched or not\n              if ( seed ) {\n                unmatched.push( elem );\n              }\n            }\n          }\n\n          // `i` is now the count of elements visited above, and adding it to `matchedCount`\n          // makes the latter nonnegative.\n          matchedCount += i;\n\n          // Apply set filters to unmatched elements\n          // NOTE: This can be skipped if there are no unmatched elements (i.e., `matchedCount`\n          // equals `i`), unless we didn't visit _any_ elements in the above loop because we have\n          // no element matchers and no seed.\n          // Incrementing an initially-string \"0\" `i` allows `i` to remain a string only in that\n          // case, which will result in a \"00\" `matchedCount` that differs from `i` but is also\n          // numerically zero.\n          if ( bySet && i !== matchedCount ) {\n            j = 0;\n            while ( ( matcher = setMatchers[ j++ ] ) ) {\n              matcher( unmatched, setMatched, context, xml );\n            }\n\n            if ( seed ) {\n\n              // Reintegrate element matches to eliminate the need for sorting\n              if ( matchedCount > 0 ) {\n                while ( i-- ) {\n                  if ( !( unmatched[ i ] || setMatched[ i ] ) ) {\n                    setMatched[ i ] = pop.call( results );\n                  }\n                }\n              }\n\n              // Discard index placeholder values to get only actual matches\n              setMatched = condense( setMatched );\n            }\n\n            // Add matches to results\n            push.apply( results, setMatched );\n\n            // Seedless set matches succeeding multiple successful matchers stipulate sorting\n            if ( outermost && !seed && setMatched.length > 0 &&\n              ( matchedCount + setMatchers.length ) > 1 ) {\n\n              jQuery.uniqueSort( results );\n            }\n          }\n\n          // Override manipulation of globals by nested matchers\n          if ( outermost ) {\n            dirruns = dirrunsUnique;\n            outermostContext = contextBackup;\n          }\n\n          return unmatched;\n        };\n\n      return bySet ?\n        markFunction( superMatcher ) :\n        superMatcher;\n    }\n\n    function compile( selector, match /* Internal Use Only */ ) {\n      var i,\n        setMatchers = [],\n        elementMatchers = [],\n        cached = compilerCache[ selector + \" \" ];\n\n      if ( !cached ) {\n\n        // Generate a function of recursive functions that can be used to check each element\n        if ( !match ) {\n          match = tokenize( selector );\n        }\n        i = match.length;\n        while ( i-- ) {\n          cached = matcherFromTokens( match[ i ] );\n          if ( cached[ expando ] ) {\n            setMatchers.push( cached );\n          } else {\n            elementMatchers.push( cached );\n          }\n        }\n\n        // Cache the compiled function\n        cached = compilerCache( selector,\n          matcherFromGroupMatchers( elementMatchers, setMatchers ) );\n\n        // Save selector and tokenization\n        cached.selector = selector;\n      }\n      return cached;\n    }\n\n    /**\n     * A low-level selection function that works with jQuery's compiled\n     *  selector functions\n     * @param {String|Function} selector A selector or a pre-compiled\n     *  selector function built with jQuery selector compile\n     * @param {Element} context\n     * @param {Array} [results]\n     * @param {Array} [seed] A set of elements to match against\n     */\n    function select( selector, context, results, seed ) {\n      var i, tokens, token, type, find,\n        compiled = typeof selector === \"function\" && selector,\n        match = !seed && tokenize( ( selector = compiled.selector || selector ) );\n\n      results = results || [];\n\n      // Try to minimize operations if there is only one selector in the list and no seed\n      // (the latter of which guarantees us context)\n      if ( match.length === 1 ) {\n\n        // Reduce context if the leading compound selector is an ID\n        tokens = match[ 0 ] = match[ 0 ].slice( 0 );\n        if ( tokens.length > 2 && ( token = tokens[ 0 ] ).type === \"ID\" &&\n          context.nodeType === 9 && documentIsHTML && Expr.relative[ tokens[ 1 ].type ] ) {\n\n          context = ( Expr.find.ID(\n            token.matches[ 0 ].replace( runescape, funescape ),\n            context\n          ) || [] )[ 0 ];\n          if ( !context ) {\n            return results;\n\n            // Precompiled matchers will still verify ancestry, so step up a level\n          } else if ( compiled ) {\n            context = context.parentNode;\n          }\n\n          selector = selector.slice( tokens.shift().value.length );\n        }\n\n        // Fetch a seed set for right-to-left matching\n        i = matchExpr.needsContext.test( selector ) ? 0 : tokens.length;\n        while ( i-- ) {\n          token = tokens[ i ];\n\n          // Abort if we hit a combinator\n          if ( Expr.relative[ ( type = token.type ) ] ) {\n            break;\n          }\n          if ( ( find = Expr.find[ type ] ) ) {\n\n            // Search, expanding context for leading sibling combinators\n            if ( ( seed = find(\n              token.matches[ 0 ].replace( runescape, funescape ),\n              rsibling.test( tokens[ 0 ].type ) &&\n              testContext( context.parentNode ) || context\n            ) ) ) {\n\n              // If seed is empty or no tokens remain, we can return early\n              tokens.splice( i, 1 );\n              selector = seed.length && toSelector( tokens );\n              if ( !selector ) {\n                push.apply( results, seed );\n                return results;\n              }\n\n              break;\n            }\n          }\n        }\n      }\n\n      // Compile and execute a filtering function if one is not provided\n      // Provide `match` to avoid retokenization if we modified the selector above\n      ( compiled || compile( selector, match ) )(\n        seed,\n        context,\n        !documentIsHTML,\n        results,\n        !context || rsibling.test( selector ) && testContext( context.parentNode ) || context\n      );\n      return results;\n    }\n\n// One-time assignments\n\n// Support: Android <=4.0 - 4.1+\n// Sort stability\n    support.sortStable = expando.split( \"\" ).sort( sortOrder ).join( \"\" ) === expando;\n\n// Initialize against the default document\n    setDocument();\n\n// Support: Android <=4.0 - 4.1+\n// Detached nodes confoundingly follow *each other*\n    support.sortDetached = assert( function( el ) {\n\n      // Should return 1, but returns 4 (following)\n      return el.compareDocumentPosition( document.createElement( \"fieldset\" ) ) & 1;\n    } );\n\n    jQuery.find = find;\n\n// Deprecated\n    jQuery.expr[ \":\" ] = jQuery.expr.pseudos;\n    jQuery.unique = jQuery.uniqueSort;\n\n// These have always been private, but they used to be documented as part of\n// Sizzle so let's maintain them for now for backwards compatibility purposes.\n    find.compile = compile;\n    find.select = select;\n    find.setDocument = setDocument;\n    find.tokenize = tokenize;\n\n    find.escape = jQuery.escapeSelector;\n    find.getText = jQuery.text;\n    find.isXML = jQuery.isXMLDoc;\n    find.selectors = jQuery.expr;\n    find.support = jQuery.support;\n    find.uniqueSort = jQuery.uniqueSort;\n\n    /* eslint-enable */\n\n  } )();\n\n\n  var dir = function( elem, dir, until ) {\n    var matched = [],\n      truncate = until !== undefined;\n\n    while ( ( elem = elem[ dir ] ) && elem.nodeType !== 9 ) {\n      if ( elem.nodeType === 1 ) {\n        if ( truncate && jQuery( elem ).is( until ) ) {\n          break;\n        }\n        matched.push( elem );\n      }\n    }\n    return matched;\n  };\n\n\n  var siblings = function( n, elem ) {\n    var matched = [];\n\n    for ( ; n; n = n.nextSibling ) {\n      if ( n.nodeType === 1 && n !== elem ) {\n        matched.push( n );\n      }\n    }\n\n    return matched;\n  };\n\n\n  var rneedsContext = jQuery.expr.match.needsContext;\n\n  var rsingleTag = ( /^<([a-z][^\\/\\0>:\\x20\\t\\r\\n\\f]*)[\\x20\\t\\r\\n\\f]*\\/?>(?:<\\/\\1>|)$/i );\n\n\n\n// Implement the identical functionality for filter and not\n  function winnow( elements, qualifier, not ) {\n    if ( isFunction( qualifier ) ) {\n      return jQuery.grep( elements, function( elem, i ) {\n        return !!qualifier.call( elem, i, elem ) !== not;\n      } );\n    }\n\n    // Single element\n    if ( qualifier.nodeType ) {\n      return jQuery.grep( elements, function( elem ) {\n        return ( elem === qualifier ) !== not;\n      } );\n    }\n\n    // Arraylike of elements (jQuery, arguments, Array)\n    if ( typeof qualifier !== \"string\" ) {\n      return jQuery.grep( elements, function( elem ) {\n        return ( indexOf.call( qualifier, elem ) > -1 ) !== not;\n      } );\n    }\n\n    // Filtered directly for both simple and complex selectors\n    return jQuery.filter( qualifier, elements, not );\n  }\n\n  jQuery.filter = function( expr, elems, not ) {\n    var elem = elems[ 0 ];\n\n    if ( not ) {\n      expr = \":not(\" + expr + \")\";\n    }\n\n    if ( elems.length === 1 && elem.nodeType === 1 ) {\n      return jQuery.find.matchesSelector( elem, expr ) ? [ elem ] : [];\n    }\n\n    return jQuery.find.matches( expr, jQuery.grep( elems, function( elem ) {\n      return elem.nodeType === 1;\n    } ) );\n  };\n\n  jQuery.fn.extend( {\n    find: function( selector ) {\n      var i, ret,\n        len = this.length,\n        self = this;\n\n      if ( typeof selector !== \"string\" ) {\n        return this.pushStack( jQuery( selector ).filter( function() {\n          for ( i = 0; i < len; i++ ) {\n            if ( jQuery.contains( self[ i ], this ) ) {\n              return true;\n            }\n          }\n        } ) );\n      }\n\n      ret = this.pushStack( [] );\n\n      for ( i = 0; i < len; i++ ) {\n        jQuery.find( selector, self[ i ], ret );\n      }\n\n      return len > 1 ? jQuery.uniqueSort( ret ) : ret;\n    },\n    filter: function( selector ) {\n      return this.pushStack( winnow( this, selector || [], false ) );\n    },\n    not: function( selector ) {\n      return this.pushStack( winnow( this, selector || [], true ) );\n    },\n    is: function( selector ) {\n      return !!winnow(\n        this,\n\n        // If this is a positional/relative selector, check membership in the returned set\n        // so $(\"p:first\").is(\"p:last\") won't return true for a doc with two \"p\".\n        typeof selector === \"string\" && rneedsContext.test( selector ) ?\n          jQuery( selector ) :\n          selector || [],\n        false\n      ).length;\n    }\n  } );\n\n\n// Initialize a jQuery object\n\n\n// A central reference to the root jQuery(document)\n  var rootjQuery,\n\n    // A simple way to check for HTML strings\n    // Prioritize #id over <tag> to avoid XSS via location.hash (trac-9521)\n    // Strict HTML recognition (trac-11290: must start with <)\n    // Shortcut simple #id case for speed\n    rquickExpr = /^(?:\\s*(<[\\w\\W]+>)[^>]*|#([\\w-]+))$/,\n\n    init = jQuery.fn.init = function( selector, context, root ) {\n      var match, elem;\n\n      // HANDLE: $(\"\"), $(null), $(undefined), $(false)\n      if ( !selector ) {\n        return this;\n      }\n\n      // Method init() accepts an alternate rootjQuery\n      // so migrate can support jQuery.sub (gh-2101)\n      root = root || rootjQuery;\n\n      // Handle HTML strings\n      if ( typeof selector === \"string\" ) {\n        if ( selector[ 0 ] === \"<\" &&\n          selector[ selector.length - 1 ] === \">\" &&\n          selector.length >= 3 ) {\n\n          // Assume that strings that start and end with <> are HTML and skip the regex check\n          match = [ null, selector, null ];\n\n        } else {\n          match = rquickExpr.exec( selector );\n        }\n\n        // Match html or make sure no context is specified for #id\n        if ( match && ( match[ 1 ] || !context ) ) {\n\n          // HANDLE: $(html) -> $(array)\n          if ( match[ 1 ] ) {\n            context = context instanceof jQuery ? context[ 0 ] : context;\n\n            // Option to run scripts is true for back-compat\n            // Intentionally let the error be thrown if parseHTML is not present\n            jQuery.merge( this, jQuery.parseHTML(\n              match[ 1 ],\n              context && context.nodeType ? context.ownerDocument || context : document,\n              true\n            ) );\n\n            // HANDLE: $(html, props)\n            if ( rsingleTag.test( match[ 1 ] ) && jQuery.isPlainObject( context ) ) {\n              for ( match in context ) {\n\n                // Properties of context are called as methods if possible\n                if ( isFunction( this[ match ] ) ) {\n                  this[ match ]( context[ match ] );\n\n                  // ...and otherwise set as attributes\n                } else {\n                  this.attr( match, context[ match ] );\n                }\n              }\n            }\n\n            return this;\n\n            // HANDLE: $(#id)\n          } else {\n            elem = document.getElementById( match[ 2 ] );\n\n            if ( elem ) {\n\n              // Inject the element directly into the jQuery object\n              this[ 0 ] = elem;\n              this.length = 1;\n            }\n            return this;\n          }\n\n          // HANDLE: $(expr, $(...))\n        } else if ( !context || context.jquery ) {\n          return ( context || root ).find( selector );\n\n          // HANDLE: $(expr, context)\n          // (which is just equivalent to: $(context).find(expr)\n        } else {\n          return this.constructor( context ).find( selector );\n        }\n\n        // HANDLE: $(DOMElement)\n      } else if ( selector.nodeType ) {\n        this[ 0 ] = selector;\n        this.length = 1;\n        return this;\n\n        // HANDLE: $(function)\n        // Shortcut for document ready\n      } else if ( isFunction( selector ) ) {\n        return root.ready !== undefined ?\n          root.ready( selector ) :\n\n          // Execute immediately if ready is not present\n          selector( jQuery );\n      }\n\n      return jQuery.makeArray( selector, this );\n    };\n\n// Give the init function the jQuery prototype for later instantiation\n  init.prototype = jQuery.fn;\n\n// Initialize central reference\n  rootjQuery = jQuery( document );\n\n\n  var rparentsprev = /^(?:parents|prev(?:Until|All))/,\n\n    // Methods guaranteed to produce a unique set when starting from a unique set\n    guaranteedUnique = {\n      children: true,\n      contents: true,\n      next: true,\n      prev: true\n    };\n\n  jQuery.fn.extend( {\n    has: function( target ) {\n      var targets = jQuery( target, this ),\n        l = targets.length;\n\n      return this.filter( function() {\n        var i = 0;\n        for ( ; i < l; i++ ) {\n          if ( jQuery.contains( this, targets[ i ] ) ) {\n            return true;\n          }\n        }\n      } );\n    },\n\n    closest: function( selectors, context ) {\n      var cur,\n        i = 0,\n        l = this.length,\n        matched = [],\n        targets = typeof selectors !== \"string\" && jQuery( selectors );\n\n      // Positional selectors never match, since there's no _selection_ context\n      if ( !rneedsContext.test( selectors ) ) {\n        for ( ; i < l; i++ ) {\n          for ( cur = this[ i ]; cur && cur !== context; cur = cur.parentNode ) {\n\n            // Always skip document fragments\n            if ( cur.nodeType < 11 && ( targets ?\n              targets.index( cur ) > -1 :\n\n              // Don't pass non-elements to jQuery#find\n              cur.nodeType === 1 &&\n              jQuery.find.matchesSelector( cur, selectors ) ) ) {\n\n              matched.push( cur );\n              break;\n            }\n          }\n        }\n      }\n\n      return this.pushStack( matched.length > 1 ? jQuery.uniqueSort( matched ) : matched );\n    },\n\n    // Determine the position of an element within the set\n    index: function( elem ) {\n\n      // No argument, return index in parent\n      if ( !elem ) {\n        return ( this[ 0 ] && this[ 0 ].parentNode ) ? this.first().prevAll().length : -1;\n      }\n\n      // Index in selector\n      if ( typeof elem === \"string\" ) {\n        return indexOf.call( jQuery( elem ), this[ 0 ] );\n      }\n\n      // Locate the position of the desired element\n      return indexOf.call( this,\n\n        // If it receives a jQuery object, the first element is used\n        elem.jquery ? elem[ 0 ] : elem\n      );\n    },\n\n    add: function( selector, context ) {\n      return this.pushStack(\n        jQuery.uniqueSort(\n          jQuery.merge( this.get(), jQuery( selector, context ) )\n        )\n      );\n    },\n\n    addBack: function( selector ) {\n      return this.add( selector == null ?\n        this.prevObject : this.prevObject.filter( selector )\n      );\n    }\n  } );\n\n  function sibling( cur, dir ) {\n    while ( ( cur = cur[ dir ] ) && cur.nodeType !== 1 ) {}\n    return cur;\n  }\n\n  jQuery.each( {\n    parent: function( elem ) {\n      var parent = elem.parentNode;\n      return parent && parent.nodeType !== 11 ? parent : null;\n    },\n    parents: function( elem ) {\n      return dir( elem, \"parentNode\" );\n    },\n    parentsUntil: function( elem, _i, until ) {\n      return dir( elem, \"parentNode\", until );\n    },\n    next: function( elem ) {\n      return sibling( elem, \"nextSibling\" );\n    },\n    prev: function( elem ) {\n      return sibling( elem, \"previousSibling\" );\n    },\n    nextAll: function( elem ) {\n      return dir( elem, \"nextSibling\" );\n    },\n    prevAll: function( elem ) {\n      return dir( elem, \"previousSibling\" );\n    },\n    nextUntil: function( elem, _i, until ) {\n      return dir( elem, \"nextSibling\", until );\n    },\n    prevUntil: function( elem, _i, until ) {\n      return dir( elem, \"previousSibling\", until );\n    },\n    siblings: function( elem ) {\n      return siblings( ( elem.parentNode || {} ).firstChild, elem );\n    },\n    children: function( elem ) {\n      return siblings( elem.firstChild );\n    },\n    contents: function( elem ) {\n      if ( elem.contentDocument != null &&\n\n        // Support: IE 11+\n        // <object> elements with no `data` attribute has an object\n        // `contentDocument` with a `null` prototype.\n        getProto( elem.contentDocument ) ) {\n\n        return elem.contentDocument;\n      }\n\n      // Support: IE 9 - 11 only, iOS 7 only, Android Browser <=4.3 only\n      // Treat the template element as a regular one in browsers that\n      // don't support it.\n      if ( nodeName( elem, \"template\" ) ) {\n        elem = elem.content || elem;\n      }\n\n      return jQuery.merge( [], elem.childNodes );\n    }\n  }, function( name, fn ) {\n    jQuery.fn[ name ] = function( until, selector ) {\n      var matched = jQuery.map( this, fn, until );\n\n      if ( name.slice( -5 ) !== \"Until\" ) {\n        selector = until;\n      }\n\n      if ( selector && typeof selector === \"string\" ) {\n        matched = jQuery.filter( selector, matched );\n      }\n\n      if ( this.length > 1 ) {\n\n        // Remove duplicates\n        if ( !guaranteedUnique[ name ] ) {\n          jQuery.uniqueSort( matched );\n        }\n\n        // Reverse order for parents* and prev-derivatives\n        if ( rparentsprev.test( name ) ) {\n          matched.reverse();\n        }\n      }\n\n      return this.pushStack( matched );\n    };\n  } );\n  var rnothtmlwhite = ( /[^\\x20\\t\\r\\n\\f]+/g );\n\n\n\n// Convert String-formatted options into Object-formatted ones\n  function createOptions( options ) {\n    var object = {};\n    jQuery.each( options.match( rnothtmlwhite ) || [], function( _, flag ) {\n      object[ flag ] = true;\n    } );\n    return object;\n  }\n\n  /*\n * Create a callback list using the following parameters:\n *\n *\toptions: an optional list of space-separated options that will change how\n *\t\t\tthe callback list behaves or a more traditional option object\n *\n * By default a callback list will act like an event callback list and can be\n * \"fired\" multiple times.\n *\n * Possible options:\n *\n *\tonce:\t\t\twill ensure the callback list can only be fired once (like a Deferred)\n *\n *\tmemory:\t\t\twill keep track of previous values and will call any callback added\n *\t\t\t\t\tafter the list has been fired right away with the latest \"memorized\"\n *\t\t\t\t\tvalues (like a Deferred)\n *\n *\tunique:\t\t\twill ensure a callback can only be added once (no duplicate in the list)\n *\n *\tstopOnFalse:\tinterrupt callings when a callback returns false\n *\n */\n  jQuery.Callbacks = function( options ) {\n\n    // Convert options from String-formatted to Object-formatted if needed\n    // (we check in cache first)\n    options = typeof options === \"string\" ?\n      createOptions( options ) :\n      jQuery.extend( {}, options );\n\n    var // Flag to know if list is currently firing\n      firing,\n\n      // Last fire value for non-forgettable lists\n      memory,\n\n      // Flag to know if list was already fired\n      fired,\n\n      // Flag to prevent firing\n      locked,\n\n      // Actual callback list\n      list = [],\n\n      // Queue of execution data for repeatable lists\n      queue = [],\n\n      // Index of currently firing callback (modified by add/remove as needed)\n      firingIndex = -1,\n\n      // Fire callbacks\n      fire = function() {\n\n        // Enforce single-firing\n        locked = locked || options.once;\n\n        // Execute callbacks for all pending executions,\n        // respecting firingIndex overrides and runtime changes\n        fired = firing = true;\n        for ( ; queue.length; firingIndex = -1 ) {\n          memory = queue.shift();\n          while ( ++firingIndex < list.length ) {\n\n            // Run callback and check for early termination\n            if ( list[ firingIndex ].apply( memory[ 0 ], memory[ 1 ] ) === false &&\n              options.stopOnFalse ) {\n\n              // Jump to end and forget the data so .add doesn't re-fire\n              firingIndex = list.length;\n              memory = false;\n            }\n          }\n        }\n\n        // Forget the data if we're done with it\n        if ( !options.memory ) {\n          memory = false;\n        }\n\n        firing = false;\n\n        // Clean up if we're done firing for good\n        if ( locked ) {\n\n          // Keep an empty list if we have data for future add calls\n          if ( memory ) {\n            list = [];\n\n            // Otherwise, this object is spent\n          } else {\n            list = \"\";\n          }\n        }\n      },\n\n      // Actual Callbacks object\n      self = {\n\n        // Add a callback or a collection of callbacks to the list\n        add: function() {\n          if ( list ) {\n\n            // If we have memory from a past run, we should fire after adding\n            if ( memory && !firing ) {\n              firingIndex = list.length - 1;\n              queue.push( memory );\n            }\n\n            ( function add( args ) {\n              jQuery.each( args, function( _, arg ) {\n                if ( isFunction( arg ) ) {\n                  if ( !options.unique || !self.has( arg ) ) {\n                    list.push( arg );\n                  }\n                } else if ( arg && arg.length && toType( arg ) !== \"string\" ) {\n\n                  // Inspect recursively\n                  add( arg );\n                }\n              } );\n            } )( arguments );\n\n            if ( memory && !firing ) {\n              fire();\n            }\n          }\n          return this;\n        },\n\n        // Remove a callback from the list\n        remove: function() {\n          jQuery.each( arguments, function( _, arg ) {\n            var index;\n            while ( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) {\n              list.splice( index, 1 );\n\n              // Handle firing indexes\n              if ( index <= firingIndex ) {\n                firingIndex--;\n              }\n            }\n          } );\n          return this;\n        },\n\n        // Check if a given callback is in the list.\n        // If no argument is given, return whether or not list has callbacks attached.\n        has: function( fn ) {\n          return fn ?\n            jQuery.inArray( fn, list ) > -1 :\n            list.length > 0;\n        },\n\n        // Remove all callbacks from the list\n        empty: function() {\n          if ( list ) {\n            list = [];\n          }\n          return this;\n        },\n\n        // Disable .fire and .add\n        // Abort any current/pending executions\n        // Clear all callbacks and values\n        disable: function() {\n          locked = queue = [];\n          list = memory = \"\";\n          return this;\n        },\n        disabled: function() {\n          return !list;\n        },\n\n        // Disable .fire\n        // Also disable .add unless we have memory (since it would have no effect)\n        // Abort any pending executions\n        lock: function() {\n          locked = queue = [];\n          if ( !memory && !firing ) {\n            list = memory = \"\";\n          }\n          return this;\n        },\n        locked: function() {\n          return !!locked;\n        },\n\n        // Call all callbacks with the given context and arguments\n        fireWith: function( context, args ) {\n          if ( !locked ) {\n            args = args || [];\n            args = [ context, args.slice ? args.slice() : args ];\n            queue.push( args );\n            if ( !firing ) {\n              fire();\n            }\n          }\n          return this;\n        },\n\n        // Call all the callbacks with the given arguments\n        fire: function() {\n          self.fireWith( this, arguments );\n          return this;\n        },\n\n        // To know if the callbacks have already been called at least once\n        fired: function() {\n          return !!fired;\n        }\n      };\n\n    return self;\n  };\n\n\n  function Identity( v ) {\n    return v;\n  }\n  function Thrower( ex ) {\n    throw ex;\n  }\n\n  function adoptValue( value, resolve, reject, noValue ) {\n    var method;\n\n    try {\n\n      // Check for promise aspect first to privilege synchronous behavior\n      if ( value && isFunction( ( method = value.promise ) ) ) {\n        method.call( value ).done( resolve ).fail( reject );\n\n        // Other thenables\n      } else if ( value && isFunction( ( method = value.then ) ) ) {\n        method.call( value, resolve, reject );\n\n        // Other non-thenables\n      } else {\n\n        // Control `resolve` arguments by letting Array#slice cast boolean `noValue` to integer:\n        // * false: [ value ].slice( 0 ) => resolve( value )\n        // * true: [ value ].slice( 1 ) => resolve()\n        resolve.apply( undefined, [ value ].slice( noValue ) );\n      }\n\n      // For Promises/A+, convert exceptions into rejections\n      // Since jQuery.when doesn't unwrap thenables, we can skip the extra checks appearing in\n      // Deferred#then to conditionally suppress rejection.\n    } catch ( value ) {\n\n      // Support: Android 4.0 only\n      // Strict mode functions invoked without .call/.apply get global-object context\n      reject.apply( undefined, [ value ] );\n    }\n  }\n\n  jQuery.extend( {\n\n    Deferred: function( func ) {\n      var tuples = [\n\n          // action, add listener, callbacks,\n          // ... .then handlers, argument index, [final state]\n          [ \"notify\", \"progress\", jQuery.Callbacks( \"memory\" ),\n            jQuery.Callbacks( \"memory\" ), 2 ],\n          [ \"resolve\", \"done\", jQuery.Callbacks( \"once memory\" ),\n            jQuery.Callbacks( \"once memory\" ), 0, \"resolved\" ],\n          [ \"reject\", \"fail\", jQuery.Callbacks( \"once memory\" ),\n            jQuery.Callbacks( \"once memory\" ), 1, \"rejected\" ]\n        ],\n        state = \"pending\",\n        promise = {\n          state: function() {\n            return state;\n          },\n          always: function() {\n            deferred.done( arguments ).fail( arguments );\n            return this;\n          },\n          \"catch\": function( fn ) {\n            return promise.then( null, fn );\n          },\n\n          // Keep pipe for back-compat\n          pipe: function( /* fnDone, fnFail, fnProgress */ ) {\n            var fns = arguments;\n\n            return jQuery.Deferred( function( newDefer ) {\n              jQuery.each( tuples, function( _i, tuple ) {\n\n                // Map tuples (progress, done, fail) to arguments (done, fail, progress)\n                var fn = isFunction( fns[ tuple[ 4 ] ] ) && fns[ tuple[ 4 ] ];\n\n                // deferred.progress(function() { bind to newDefer or newDefer.notify })\n                // deferred.done(function() { bind to newDefer or newDefer.resolve })\n                // deferred.fail(function() { bind to newDefer or newDefer.reject })\n                deferred[ tuple[ 1 ] ]( function() {\n                  var returned = fn && fn.apply( this, arguments );\n                  if ( returned && isFunction( returned.promise ) ) {\n                    returned.promise()\n                      .progress( newDefer.notify )\n                      .done( newDefer.resolve )\n                      .fail( newDefer.reject );\n                  } else {\n                    newDefer[ tuple[ 0 ] + \"With\" ](\n                      this,\n                      fn ? [ returned ] : arguments\n                    );\n                  }\n                } );\n              } );\n              fns = null;\n            } ).promise();\n          },\n          then: function( onFulfilled, onRejected, onProgress ) {\n            var maxDepth = 0;\n            function resolve( depth, deferred, handler, special ) {\n              return function() {\n                var that = this,\n                  args = arguments,\n                  mightThrow = function() {\n                    var returned, then;\n\n                    // Support: Promises/A+ section 2.3.3.3.3\n                    // https://promisesaplus.com/#point-59\n                    // Ignore double-resolution attempts\n                    if ( depth < maxDepth ) {\n                      return;\n                    }\n\n                    returned = handler.apply( that, args );\n\n                    // Support: Promises/A+ section 2.3.1\n                    // https://promisesaplus.com/#point-48\n                    if ( returned === deferred.promise() ) {\n                      throw new TypeError( \"Thenable self-resolution\" );\n                    }\n\n                    // Support: Promises/A+ sections 2.3.3.1, 3.5\n                    // https://promisesaplus.com/#point-54\n                    // https://promisesaplus.com/#point-75\n                    // Retrieve `then` only once\n                    then = returned &&\n\n                      // Support: Promises/A+ section 2.3.4\n                      // https://promisesaplus.com/#point-64\n                      // Only check objects and functions for thenability\n                      ( typeof returned === \"object\" ||\n                        typeof returned === \"function\" ) &&\n                      returned.then;\n\n                    // Handle a returned thenable\n                    if ( isFunction( then ) ) {\n\n                      // Special processors (notify) just wait for resolution\n                      if ( special ) {\n                        then.call(\n                          returned,\n                          resolve( maxDepth, deferred, Identity, special ),\n                          resolve( maxDepth, deferred, Thrower, special )\n                        );\n\n                        // Normal processors (resolve) also hook into progress\n                      } else {\n\n                        // ...and disregard older resolution values\n                        maxDepth++;\n\n                        then.call(\n                          returned,\n                          resolve( maxDepth, deferred, Identity, special ),\n                          resolve( maxDepth, deferred, Thrower, special ),\n                          resolve( maxDepth, deferred, Identity,\n                            deferred.notifyWith )\n                        );\n                      }\n\n                      // Handle all other returned values\n                    } else {\n\n                      // Only substitute handlers pass on context\n                      // and multiple values (non-spec behavior)\n                      if ( handler !== Identity ) {\n                        that = undefined;\n                        args = [ returned ];\n                      }\n\n                      // Process the value(s)\n                      // Default process is resolve\n                      ( special || deferred.resolveWith )( that, args );\n                    }\n                  },\n\n                  // Only normal processors (resolve) catch and reject exceptions\n                  process = special ?\n                    mightThrow :\n                    function() {\n                      try {\n                        mightThrow();\n                      } catch ( e ) {\n\n                        if ( jQuery.Deferred.exceptionHook ) {\n                          jQuery.Deferred.exceptionHook( e,\n                            process.error );\n                        }\n\n                        // Support: Promises/A+ section 2.3.3.3.4.1\n                        // https://promisesaplus.com/#point-61\n                        // Ignore post-resolution exceptions\n                        if ( depth + 1 >= maxDepth ) {\n\n                          // Only substitute handlers pass on context\n                          // and multiple values (non-spec behavior)\n                          if ( handler !== Thrower ) {\n                            that = undefined;\n                            args = [ e ];\n                          }\n\n                          deferred.rejectWith( that, args );\n                        }\n                      }\n                    };\n\n                // Support: Promises/A+ section 2.3.3.3.1\n                // https://promisesaplus.com/#point-57\n                // Re-resolve promises immediately to dodge false rejection from\n                // subsequent errors\n                if ( depth ) {\n                  process();\n                } else {\n\n                  // Call an optional hook to record the error, in case of exception\n                  // since it's otherwise lost when execution goes async\n                  if ( jQuery.Deferred.getErrorHook ) {\n                    process.error = jQuery.Deferred.getErrorHook();\n\n                    // The deprecated alias of the above. While the name suggests\n                    // returning the stack, not an error instance, jQuery just passes\n                    // it directly to `console.warn` so both will work; an instance\n                    // just better cooperates with source maps.\n                  } else if ( jQuery.Deferred.getStackHook ) {\n                    process.error = jQuery.Deferred.getStackHook();\n                  }\n                  window.setTimeout( process );\n                }\n              };\n            }\n\n            return jQuery.Deferred( function( newDefer ) {\n\n              // progress_handlers.add( ... )\n              tuples[ 0 ][ 3 ].add(\n                resolve(\n                  0,\n                  newDefer,\n                  isFunction( onProgress ) ?\n                    onProgress :\n                    Identity,\n                  newDefer.notifyWith\n                )\n              );\n\n              // fulfilled_handlers.add( ... )\n              tuples[ 1 ][ 3 ].add(\n                resolve(\n                  0,\n                  newDefer,\n                  isFunction( onFulfilled ) ?\n                    onFulfilled :\n                    Identity\n                )\n              );\n\n              // rejected_handlers.add( ... )\n              tuples[ 2 ][ 3 ].add(\n                resolve(\n                  0,\n                  newDefer,\n                  isFunction( onRejected ) ?\n                    onRejected :\n                    Thrower\n                )\n              );\n            } ).promise();\n          },\n\n          // Get a promise for this deferred\n          // If obj is provided, the promise aspect is added to the object\n          promise: function( obj ) {\n            return obj != null ? jQuery.extend( obj, promise ) : promise;\n          }\n        },\n        deferred = {};\n\n      // Add list-specific methods\n      jQuery.each( tuples, function( i, tuple ) {\n        var list = tuple[ 2 ],\n          stateString = tuple[ 5 ];\n\n        // promise.progress = list.add\n        // promise.done = list.add\n        // promise.fail = list.add\n        promise[ tuple[ 1 ] ] = list.add;\n\n        // Handle state\n        if ( stateString ) {\n          list.add(\n            function() {\n\n              // state = \"resolved\" (i.e., fulfilled)\n              // state = \"rejected\"\n              state = stateString;\n            },\n\n            // rejected_callbacks.disable\n            // fulfilled_callbacks.disable\n            tuples[ 3 - i ][ 2 ].disable,\n\n            // rejected_handlers.disable\n            // fulfilled_handlers.disable\n            tuples[ 3 - i ][ 3 ].disable,\n\n            // progress_callbacks.lock\n            tuples[ 0 ][ 2 ].lock,\n\n            // progress_handlers.lock\n            tuples[ 0 ][ 3 ].lock\n          );\n        }\n\n        // progress_handlers.fire\n        // fulfilled_handlers.fire\n        // rejected_handlers.fire\n        list.add( tuple[ 3 ].fire );\n\n        // deferred.notify = function() { deferred.notifyWith(...) }\n        // deferred.resolve = function() { deferred.resolveWith(...) }\n        // deferred.reject = function() { deferred.rejectWith(...) }\n        deferred[ tuple[ 0 ] ] = function() {\n          deferred[ tuple[ 0 ] + \"With\" ]( this === deferred ? undefined : this, arguments );\n          return this;\n        };\n\n        // deferred.notifyWith = list.fireWith\n        // deferred.resolveWith = list.fireWith\n        // deferred.rejectWith = list.fireWith\n        deferred[ tuple[ 0 ] + \"With\" ] = list.fireWith;\n      } );\n\n      // Make the deferred a promise\n      promise.promise( deferred );\n\n      // Call given func if any\n      if ( func ) {\n        func.call( deferred, deferred );\n      }\n\n      // All done!\n      return deferred;\n    },\n\n    // Deferred helper\n    when: function( singleValue ) {\n      var\n\n        // count of uncompleted subordinates\n        remaining = arguments.length,\n\n        // count of unprocessed arguments\n        i = remaining,\n\n        // subordinate fulfillment data\n        resolveContexts = Array( i ),\n        resolveValues = slice.call( arguments ),\n\n        // the primary Deferred\n        primary = jQuery.Deferred(),\n\n        // subordinate callback factory\n        updateFunc = function( i ) {\n          return function( value ) {\n            resolveContexts[ i ] = this;\n            resolveValues[ i ] = arguments.length > 1 ? slice.call( arguments ) : value;\n            if ( !( --remaining ) ) {\n              primary.resolveWith( resolveContexts, resolveValues );\n            }\n          };\n        };\n\n      // Single- and empty arguments are adopted like Promise.resolve\n      if ( remaining <= 1 ) {\n        adoptValue( singleValue, primary.done( updateFunc( i ) ).resolve, primary.reject,\n          !remaining );\n\n        // Use .then() to unwrap secondary thenables (cf. gh-3000)\n        if ( primary.state() === \"pending\" ||\n          isFunction( resolveValues[ i ] && resolveValues[ i ].then ) ) {\n\n          return primary.then();\n        }\n      }\n\n      // Multiple arguments are aggregated like Promise.all array elements\n      while ( i-- ) {\n        adoptValue( resolveValues[ i ], updateFunc( i ), primary.reject );\n      }\n\n      return primary.promise();\n    }\n  } );\n\n\n// These usually indicate a programmer mistake during development,\n// warn about them ASAP rather than swallowing them by default.\n  var rerrorNames = /^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;\n\n// If `jQuery.Deferred.getErrorHook` is defined, `asyncError` is an error\n// captured before the async barrier to get the original error cause\n// which may otherwise be hidden.\n  jQuery.Deferred.exceptionHook = function( error, asyncError ) {\n\n    // Support: IE 8 - 9 only\n    // Console exists when dev tools are open, which can happen at any time\n    if ( window.console && window.console.warn && error && rerrorNames.test( error.name ) ) {\n      window.console.warn( \"jQuery.Deferred exception: \" + error.message,\n        error.stack, asyncError );\n    }\n  };\n\n\n\n\n  jQuery.readyException = function( error ) {\n    window.setTimeout( function() {\n      throw error;\n    } );\n  };\n\n\n\n\n// The deferred used on DOM ready\n  var readyList = jQuery.Deferred();\n\n  jQuery.fn.ready = function( fn ) {\n\n    readyList\n      .then( fn )\n\n      // Wrap jQuery.readyException in a function so that the lookup\n      // happens at the time of error handling instead of callback\n      // registration.\n      .catch( function( error ) {\n        jQuery.readyException( error );\n      } );\n\n    return this;\n  };\n\n  jQuery.extend( {\n\n    // Is the DOM ready to be used? Set to true once it occurs.\n    isReady: false,\n\n    // A counter to track how many items to wait for before\n    // the ready event fires. See trac-6781\n    readyWait: 1,\n\n    // Handle when the DOM is ready\n    ready: function( wait ) {\n\n      // Abort if there are pending holds or we're already ready\n      if ( wait === true ? --jQuery.readyWait : jQuery.isReady ) {\n        return;\n      }\n\n      // Remember that the DOM is ready\n      jQuery.isReady = true;\n\n      // If a normal DOM Ready event fired, decrement, and wait if need be\n      if ( wait !== true && --jQuery.readyWait > 0 ) {\n        return;\n      }\n\n      // If there are functions bound, to execute\n      readyList.resolveWith( document, [ jQuery ] );\n    }\n  } );\n\n  jQuery.ready.then = readyList.then;\n\n// The ready event handler and self cleanup method\n  function completed() {\n    document.removeEventListener( \"DOMContentLoaded\", completed );\n    window.removeEventListener( \"load\", completed );\n    jQuery.ready();\n  }\n\n// Catch cases where $(document).ready() is called\n// after the browser event has already occurred.\n// Support: IE <=9 - 10 only\n// Older IE sometimes signals \"interactive\" too soon\n  if ( document.readyState === \"complete\" ||\n    ( document.readyState !== \"loading\" && !document.documentElement.doScroll ) ) {\n\n    // Handle it asynchronously to allow scripts the opportunity to delay ready\n    window.setTimeout( jQuery.ready );\n\n  } else {\n\n    // Use the handy event callback\n    document.addEventListener( \"DOMContentLoaded\", completed );\n\n    // A fallback to window.onload, that will always work\n    window.addEventListener( \"load\", completed );\n  }\n\n\n\n\n// Multifunctional method to get and set values of a collection\n// The value/s can optionally be executed if it's a function\n  var access = function( elems, fn, key, value, chainable, emptyGet, raw ) {\n    var i = 0,\n      len = elems.length,\n      bulk = key == null;\n\n    // Sets many values\n    if ( toType( key ) === \"object\" ) {\n      chainable = true;\n      for ( i in key ) {\n        access( elems, fn, i, key[ i ], true, emptyGet, raw );\n      }\n\n      // Sets one value\n    } else if ( value !== undefined ) {\n      chainable = true;\n\n      if ( !isFunction( value ) ) {\n        raw = true;\n      }\n\n      if ( bulk ) {\n\n        // Bulk operations run against the entire set\n        if ( raw ) {\n          fn.call( elems, value );\n          fn = null;\n\n          // ...except when executing function values\n        } else {\n          bulk = fn;\n          fn = function( elem, _key, value ) {\n            return bulk.call( jQuery( elem ), value );\n          };\n        }\n      }\n\n      if ( fn ) {\n        for ( ; i < len; i++ ) {\n          fn(\n            elems[ i ], key, raw ?\n              value :\n              value.call( elems[ i ], i, fn( elems[ i ], key ) )\n          );\n        }\n      }\n    }\n\n    if ( chainable ) {\n      return elems;\n    }\n\n    // Gets\n    if ( bulk ) {\n      return fn.call( elems );\n    }\n\n    return len ? fn( elems[ 0 ], key ) : emptyGet;\n  };\n\n\n// Matches dashed string for camelizing\n  var rmsPrefix = /^-ms-/,\n    rdashAlpha = /-([a-z])/g;\n\n// Used by camelCase as callback to replace()\n  function fcamelCase( _all, letter ) {\n    return letter.toUpperCase();\n  }\n\n// Convert dashed to camelCase; used by the css and data modules\n// Support: IE <=9 - 11, Edge 12 - 15\n// Microsoft forgot to hump their vendor prefix (trac-9572)\n  function camelCase( string ) {\n    return string.replace( rmsPrefix, \"ms-\" ).replace( rdashAlpha, fcamelCase );\n  }\n  var acceptData = function( owner ) {\n\n    // Accepts only:\n    //  - Node\n    //    - Node.ELEMENT_NODE\n    //    - Node.DOCUMENT_NODE\n    //  - Object\n    //    - Any\n    return owner.nodeType === 1 || owner.nodeType === 9 || !( +owner.nodeType );\n  };\n\n\n\n\n  function Data() {\n    this.expando = jQuery.expando + Data.uid++;\n  }\n\n  Data.uid = 1;\n\n  Data.prototype = {\n\n    cache: function( owner ) {\n\n      // Check if the owner object already has a cache\n      var value = owner[ this.expando ];\n\n      // If not, create one\n      if ( !value ) {\n        value = {};\n\n        // We can accept data for non-element nodes in modern browsers,\n        // but we should not, see trac-8335.\n        // Always return an empty object.\n        if ( acceptData( owner ) ) {\n\n          // If it is a node unlikely to be stringify-ed or looped over\n          // use plain assignment\n          if ( owner.nodeType ) {\n            owner[ this.expando ] = value;\n\n            // Otherwise secure it in a non-enumerable property\n            // configurable must be true to allow the property to be\n            // deleted when data is removed\n          } else {\n            Object.defineProperty( owner, this.expando, {\n              value: value,\n              configurable: true\n            } );\n          }\n        }\n      }\n\n      return value;\n    },\n    set: function( owner, data, value ) {\n      var prop,\n        cache = this.cache( owner );\n\n      // Handle: [ owner, key, value ] args\n      // Always use camelCase key (gh-2257)\n      if ( typeof data === \"string\" ) {\n        cache[ camelCase( data ) ] = value;\n\n        // Handle: [ owner, { properties } ] args\n      } else {\n\n        // Copy the properties one-by-one to the cache object\n        for ( prop in data ) {\n          cache[ camelCase( prop ) ] = data[ prop ];\n        }\n      }\n      return cache;\n    },\n    get: function( owner, key ) {\n      return key === undefined ?\n        this.cache( owner ) :\n\n        // Always use camelCase key (gh-2257)\n        owner[ this.expando ] && owner[ this.expando ][ camelCase( key ) ];\n    },\n    access: function( owner, key, value ) {\n\n      // In cases where either:\n      //\n      //   1. No key was specified\n      //   2. A string key was specified, but no value provided\n      //\n      // Take the \"read\" path and allow the get method to determine\n      // which value to return, respectively either:\n      //\n      //   1. The entire cache object\n      //   2. The data stored at the key\n      //\n      if ( key === undefined ||\n        ( ( key && typeof key === \"string\" ) && value === undefined ) ) {\n\n        return this.get( owner, key );\n      }\n\n      // When the key is not a string, or both a key and value\n      // are specified, set or extend (existing objects) with either:\n      //\n      //   1. An object of properties\n      //   2. A key and value\n      //\n      this.set( owner, key, value );\n\n      // Since the \"set\" path can have two possible entry points\n      // return the expected data based on which path was taken[*]\n      return value !== undefined ? value : key;\n    },\n    remove: function( owner, key ) {\n      var i,\n        cache = owner[ this.expando ];\n\n      if ( cache === undefined ) {\n        return;\n      }\n\n      if ( key !== undefined ) {\n\n        // Support array or space separated string of keys\n        if ( Array.isArray( key ) ) {\n\n          // If key is an array of keys...\n          // We always set camelCase keys, so remove that.\n          key = key.map( camelCase );\n        } else {\n          key = camelCase( key );\n\n          // If a key with the spaces exists, use it.\n          // Otherwise, create an array by matching non-whitespace\n          key = key in cache ?\n            [ key ] :\n            ( key.match( rnothtmlwhite ) || [] );\n        }\n\n        i = key.length;\n\n        while ( i-- ) {\n          delete cache[ key[ i ] ];\n        }\n      }\n\n      // Remove the expando if there's no more data\n      if ( key === undefined || jQuery.isEmptyObject( cache ) ) {\n\n        // Support: Chrome <=35 - 45\n        // Webkit & Blink performance suffers when deleting properties\n        // from DOM nodes, so set to undefined instead\n        // https://bugs.chromium.org/p/chromium/issues/detail?id=378607 (bug restricted)\n        if ( owner.nodeType ) {\n          owner[ this.expando ] = undefined;\n        } else {\n          delete owner[ this.expando ];\n        }\n      }\n    },\n    hasData: function( owner ) {\n      var cache = owner[ this.expando ];\n      return cache !== undefined && !jQuery.isEmptyObject( cache );\n    }\n  };\n  var dataPriv = new Data();\n\n  var dataUser = new Data();\n\n\n\n//\tImplementation Summary\n//\n//\t1. Enforce API surface and semantic compatibility with 1.9.x branch\n//\t2. Improve the module's maintainability by reducing the storage\n//\t\tpaths to a single mechanism.\n//\t3. Use the same single mechanism to support \"private\" and \"user\" data.\n//\t4. _Never_ expose \"private\" data to user code (TODO: Drop _data, _removeData)\n//\t5. Avoid exposing implementation details on user objects (eg. expando properties)\n//\t6. Provide a clear path for implementation upgrade to WeakMap in 2014\n\n  var rbrace = /^(?:\\{[\\w\\W]*\\}|\\[[\\w\\W]*\\])$/,\n    rmultiDash = /[A-Z]/g;\n\n  function getData( data ) {\n    if ( data === \"true\" ) {\n      return true;\n    }\n\n    if ( data === \"false\" ) {\n      return false;\n    }\n\n    if ( data === \"null\" ) {\n      return null;\n    }\n\n    // Only convert to a number if it doesn't change the string\n    if ( data === +data + \"\" ) {\n      return +data;\n    }\n\n    if ( rbrace.test( data ) ) {\n      return JSON.parse( data );\n    }\n\n    return data;\n  }\n\n  function dataAttr( elem, key, data ) {\n    var name;\n\n    // If nothing was found internally, try to fetch any\n    // data from the HTML5 data-* attribute\n    if ( data === undefined && elem.nodeType === 1 ) {\n      name = \"data-\" + key.replace( rmultiDash, \"-$&\" ).toLowerCase();\n      data = elem.getAttribute( name );\n\n      if ( typeof data === \"string\" ) {\n        try {\n          data = getData( data );\n        } catch ( e ) {}\n\n        // Make sure we set the data so it isn't changed later\n        dataUser.set( elem, key, data );\n      } else {\n        data = undefined;\n      }\n    }\n    return data;\n  }\n\n  jQuery.extend( {\n    hasData: function( elem ) {\n      return dataUser.hasData( elem ) || dataPriv.hasData( elem );\n    },\n\n    data: function( elem, name, data ) {\n      return dataUser.access( elem, name, data );\n    },\n\n    removeData: function( elem, name ) {\n      dataUser.remove( elem, name );\n    },\n\n    // TODO: Now that all calls to _data and _removeData have been replaced\n    // with direct calls to dataPriv methods, these can be deprecated.\n    _data: function( elem, name, data ) {\n      return dataPriv.access( elem, name, data );\n    },\n\n    _removeData: function( elem, name ) {\n      dataPriv.remove( elem, name );\n    }\n  } );\n\n  jQuery.fn.extend( {\n    data: function( key, value ) {\n      var i, name, data,\n        elem = this[ 0 ],\n        attrs = elem && elem.attributes;\n\n      // Gets all values\n      if ( key === undefined ) {\n        if ( this.length ) {\n          data = dataUser.get( elem );\n\n          if ( elem.nodeType === 1 && !dataPriv.get( elem, \"hasDataAttrs\" ) ) {\n            i = attrs.length;\n            while ( i-- ) {\n\n              // Support: IE 11 only\n              // The attrs elements can be null (trac-14894)\n              if ( attrs[ i ] ) {\n                name = attrs[ i ].name;\n                if ( name.indexOf( \"data-\" ) === 0 ) {\n                  name = camelCase( name.slice( 5 ) );\n                  dataAttr( elem, name, data[ name ] );\n                }\n              }\n            }\n            dataPriv.set( elem, \"hasDataAttrs\", true );\n          }\n        }\n\n        return data;\n      }\n\n      // Sets multiple values\n      if ( typeof key === \"object\" ) {\n        return this.each( function() {\n          dataUser.set( this, key );\n        } );\n      }\n\n      return access( this, function( value ) {\n        var data;\n\n        // The calling jQuery object (element matches) is not empty\n        // (and therefore has an element appears at this[ 0 ]) and the\n        // `value` parameter was not undefined. An empty jQuery object\n        // will result in `undefined` for elem = this[ 0 ] which will\n        // throw an exception if an attempt to read a data cache is made.\n        if ( elem && value === undefined ) {\n\n          // Attempt to get data from the cache\n          // The key will always be camelCased in Data\n          data = dataUser.get( elem, key );\n          if ( data !== undefined ) {\n            return data;\n          }\n\n          // Attempt to \"discover\" the data in\n          // HTML5 custom data-* attrs\n          data = dataAttr( elem, key );\n          if ( data !== undefined ) {\n            return data;\n          }\n\n          // We tried really hard, but the data doesn't exist.\n          return;\n        }\n\n        // Set the data...\n        this.each( function() {\n\n          // We always store the camelCased key\n          dataUser.set( this, key, value );\n        } );\n      }, null, value, arguments.length > 1, null, true );\n    },\n\n    removeData: function( key ) {\n      return this.each( function() {\n        dataUser.remove( this, key );\n      } );\n    }\n  } );\n\n\n  jQuery.extend( {\n    queue: function( elem, type, data ) {\n      var queue;\n\n      if ( elem ) {\n        type = ( type || \"fx\" ) + \"queue\";\n        queue = dataPriv.get( elem, type );\n\n        // Speed up dequeue by getting out quickly if this is just a lookup\n        if ( data ) {\n          if ( !queue || Array.isArray( data ) ) {\n            queue = dataPriv.access( elem, type, jQuery.makeArray( data ) );\n          } else {\n            queue.push( data );\n          }\n        }\n        return queue || [];\n      }\n    },\n\n    dequeue: function( elem, type ) {\n      type = type || \"fx\";\n\n      var queue = jQuery.queue( elem, type ),\n        startLength = queue.length,\n        fn = queue.shift(),\n        hooks = jQuery._queueHooks( elem, type ),\n        next = function() {\n          jQuery.dequeue( elem, type );\n        };\n\n      // If the fx queue is dequeued, always remove the progress sentinel\n      if ( fn === \"inprogress\" ) {\n        fn = queue.shift();\n        startLength--;\n      }\n\n      if ( fn ) {\n\n        // Add a progress sentinel to prevent the fx queue from being\n        // automatically dequeued\n        if ( type === \"fx\" ) {\n          queue.unshift( \"inprogress\" );\n        }\n\n        // Clear up the last queue stop function\n        delete hooks.stop;\n        fn.call( elem, next, hooks );\n      }\n\n      if ( !startLength && hooks ) {\n        hooks.empty.fire();\n      }\n    },\n\n    // Not public - generate a queueHooks object, or return the current one\n    _queueHooks: function( elem, type ) {\n      var key = type + \"queueHooks\";\n      return dataPriv.get( elem, key ) || dataPriv.access( elem, key, {\n        empty: jQuery.Callbacks( \"once memory\" ).add( function() {\n          dataPriv.remove( elem, [ type + \"queue\", key ] );\n        } )\n      } );\n    }\n  } );\n\n  jQuery.fn.extend( {\n    queue: function( type, data ) {\n      var setter = 2;\n\n      if ( typeof type !== \"string\" ) {\n        data = type;\n        type = \"fx\";\n        setter--;\n      }\n\n      if ( arguments.length < setter ) {\n        return jQuery.queue( this[ 0 ], type );\n      }\n\n      return data === undefined ?\n        this :\n        this.each( function() {\n          var queue = jQuery.queue( this, type, data );\n\n          // Ensure a hooks for this queue\n          jQuery._queueHooks( this, type );\n\n          if ( type === \"fx\" && queue[ 0 ] !== \"inprogress\" ) {\n            jQuery.dequeue( this, type );\n          }\n        } );\n    },\n    dequeue: function( type ) {\n      return this.each( function() {\n        jQuery.dequeue( this, type );\n      } );\n    },\n    clearQueue: function( type ) {\n      return this.queue( type || \"fx\", [] );\n    },\n\n    // Get a promise resolved when queues of a certain type\n    // are emptied (fx is the type by default)\n    promise: function( type, obj ) {\n      var tmp,\n        count = 1,\n        defer = jQuery.Deferred(),\n        elements = this,\n        i = this.length,\n        resolve = function() {\n          if ( !( --count ) ) {\n            defer.resolveWith( elements, [ elements ] );\n          }\n        };\n\n      if ( typeof type !== \"string\" ) {\n        obj = type;\n        type = undefined;\n      }\n      type = type || \"fx\";\n\n      while ( i-- ) {\n        tmp = dataPriv.get( elements[ i ], type + \"queueHooks\" );\n        if ( tmp && tmp.empty ) {\n          count++;\n          tmp.empty.add( resolve );\n        }\n      }\n      resolve();\n      return defer.promise( obj );\n    }\n  } );\n  var pnum = ( /[+-]?(?:\\d*\\.|)\\d+(?:[eE][+-]?\\d+|)/ ).source;\n\n  var rcssNum = new RegExp( \"^(?:([+-])=|)(\" + pnum + \")([a-z%]*)$\", \"i\" );\n\n\n  var cssExpand = [ \"Top\", \"Right\", \"Bottom\", \"Left\" ];\n\n  var documentElement = document.documentElement;\n\n\n\n  var isAttached = function( elem ) {\n      return jQuery.contains( elem.ownerDocument, elem );\n    },\n    composed = { composed: true };\n\n  // Support: IE 9 - 11+, Edge 12 - 18+, iOS 10.0 - 10.2 only\n  // Check attachment across shadow DOM boundaries when possible (gh-3504)\n  // Support: iOS 10.0-10.2 only\n  // Early iOS 10 versions support `attachShadow` but not `getRootNode`,\n  // leading to errors. We need to check for `getRootNode`.\n  if ( documentElement.getRootNode ) {\n    isAttached = function( elem ) {\n      return jQuery.contains( elem.ownerDocument, elem ) ||\n        elem.getRootNode( composed ) === elem.ownerDocument;\n    };\n  }\n  var isHiddenWithinTree = function( elem, el ) {\n\n    // isHiddenWithinTree might be called from jQuery#filter function;\n    // in that case, element will be second argument\n    elem = el || elem;\n\n    // Inline style trumps all\n    return elem.style.display === \"none\" ||\n      elem.style.display === \"\" &&\n\n      // Otherwise, check computed style\n      // Support: Firefox <=43 - 45\n      // Disconnected elements can have computed display: none, so first confirm that elem is\n      // in the document.\n      isAttached( elem ) &&\n\n      jQuery.css( elem, \"display\" ) === \"none\";\n  };\n\n\n\n  function adjustCSS( elem, prop, valueParts, tween ) {\n    var adjusted, scale,\n      maxIterations = 20,\n      currentValue = tween ?\n        function() {\n          return tween.cur();\n        } :\n        function() {\n          return jQuery.css( elem, prop, \"\" );\n        },\n      initial = currentValue(),\n      unit = valueParts && valueParts[ 3 ] || ( jQuery.cssNumber[ prop ] ? \"\" : \"px\" ),\n\n      // Starting value computation is required for potential unit mismatches\n      initialInUnit = elem.nodeType &&\n        ( jQuery.cssNumber[ prop ] || unit !== \"px\" && +initial ) &&\n        rcssNum.exec( jQuery.css( elem, prop ) );\n\n    if ( initialInUnit && initialInUnit[ 3 ] !== unit ) {\n\n      // Support: Firefox <=54\n      // Halve the iteration target value to prevent interference from CSS upper bounds (gh-2144)\n      initial = initial / 2;\n\n      // Trust units reported by jQuery.css\n      unit = unit || initialInUnit[ 3 ];\n\n      // Iteratively approximate from a nonzero starting point\n      initialInUnit = +initial || 1;\n\n      while ( maxIterations-- ) {\n\n        // Evaluate and update our best guess (doubling guesses that zero out).\n        // Finish if the scale equals or crosses 1 (making the old*new product non-positive).\n        jQuery.style( elem, prop, initialInUnit + unit );\n        if ( ( 1 - scale ) * ( 1 - ( scale = currentValue() / initial || 0.5 ) ) <= 0 ) {\n          maxIterations = 0;\n        }\n        initialInUnit = initialInUnit / scale;\n\n      }\n\n      initialInUnit = initialInUnit * 2;\n      jQuery.style( elem, prop, initialInUnit + unit );\n\n      // Make sure we update the tween properties later on\n      valueParts = valueParts || [];\n    }\n\n    if ( valueParts ) {\n      initialInUnit = +initialInUnit || +initial || 0;\n\n      // Apply relative offset (+=/-=) if specified\n      adjusted = valueParts[ 1 ] ?\n        initialInUnit + ( valueParts[ 1 ] + 1 ) * valueParts[ 2 ] :\n        +valueParts[ 2 ];\n      if ( tween ) {\n        tween.unit = unit;\n        tween.start = initialInUnit;\n        tween.end = adjusted;\n      }\n    }\n    return adjusted;\n  }\n\n\n  var defaultDisplayMap = {};\n\n  function getDefaultDisplay( elem ) {\n    var temp,\n      doc = elem.ownerDocument,\n      nodeName = elem.nodeName,\n      display = defaultDisplayMap[ nodeName ];\n\n    if ( display ) {\n      return display;\n    }\n\n    temp = doc.body.appendChild( doc.createElement( nodeName ) );\n    display = jQuery.css( temp, \"display\" );\n\n    temp.parentNode.removeChild( temp );\n\n    if ( display === \"none\" ) {\n      display = \"block\";\n    }\n    defaultDisplayMap[ nodeName ] = display;\n\n    return display;\n  }\n\n  function showHide( elements, show ) {\n    var display, elem,\n      values = [],\n      index = 0,\n      length = elements.length;\n\n    // Determine new display value for elements that need to change\n    for ( ; index < length; index++ ) {\n      elem = elements[ index ];\n      if ( !elem.style ) {\n        continue;\n      }\n\n      display = elem.style.display;\n      if ( show ) {\n\n        // Since we force visibility upon cascade-hidden elements, an immediate (and slow)\n        // check is required in this first loop unless we have a nonempty display value (either\n        // inline or about-to-be-restored)\n        if ( display === \"none\" ) {\n          values[ index ] = dataPriv.get( elem, \"display\" ) || null;\n          if ( !values[ index ] ) {\n            elem.style.display = \"\";\n          }\n        }\n        if ( elem.style.display === \"\" && isHiddenWithinTree( elem ) ) {\n          values[ index ] = getDefaultDisplay( elem );\n        }\n      } else {\n        if ( display !== \"none\" ) {\n          values[ index ] = \"none\";\n\n          // Remember what we're overwriting\n          dataPriv.set( elem, \"display\", display );\n        }\n      }\n    }\n\n    // Set the display of the elements in a second loop to avoid constant reflow\n    for ( index = 0; index < length; index++ ) {\n      if ( values[ index ] != null ) {\n        elements[ index ].style.display = values[ index ];\n      }\n    }\n\n    return elements;\n  }\n\n  jQuery.fn.extend( {\n    show: function() {\n      return showHide( this, true );\n    },\n    hide: function() {\n      return showHide( this );\n    },\n    toggle: function( state ) {\n      if ( typeof state === \"boolean\" ) {\n        return state ? this.show() : this.hide();\n      }\n\n      return this.each( function() {\n        if ( isHiddenWithinTree( this ) ) {\n          jQuery( this ).show();\n        } else {\n          jQuery( this ).hide();\n        }\n      } );\n    }\n  } );\n  var rcheckableType = ( /^(?:checkbox|radio)$/i );\n\n  var rtagName = ( /<([a-z][^\\/\\0>\\x20\\t\\r\\n\\f]*)/i );\n\n  var rscriptType = ( /^$|^module$|\\/(?:java|ecma)script/i );\n\n\n\n  ( function() {\n    var fragment = document.createDocumentFragment(),\n      div = fragment.appendChild( document.createElement( \"div\" ) ),\n      input = document.createElement( \"input\" );\n\n    // Support: Android 4.0 - 4.3 only\n    // Check state lost if the name is set (trac-11217)\n    // Support: Windows Web Apps (WWA)\n    // `name` and `type` must use .setAttribute for WWA (trac-14901)\n    input.setAttribute( \"type\", \"radio\" );\n    input.setAttribute( \"checked\", \"checked\" );\n    input.setAttribute( \"name\", \"t\" );\n\n    div.appendChild( input );\n\n    // Support: Android <=4.1 only\n    // Older WebKit doesn't clone checked state correctly in fragments\n    support.checkClone = div.cloneNode( true ).cloneNode( true ).lastChild.checked;\n\n    // Support: IE <=11 only\n    // Make sure textarea (and checkbox) defaultValue is properly cloned\n    div.innerHTML = \"<textarea>x</textarea>\";\n    support.noCloneChecked = !!div.cloneNode( true ).lastChild.defaultValue;\n\n    // Support: IE <=9 only\n    // IE <=9 replaces <option> tags with their contents when inserted outside of\n    // the select element.\n    div.innerHTML = \"<option></option>\";\n    support.option = !!div.lastChild;\n  } )();\n\n\n// We have to close these tags to support XHTML (trac-13200)\n  var wrapMap = {\n\n    // XHTML parsers do not magically insert elements in the\n    // same way that tag soup parsers do. So we cannot shorten\n    // this by omitting <tbody> or other required elements.\n    thead: [ 1, \"<table>\", \"</table>\" ],\n    col: [ 2, \"<table><colgroup>\", \"</colgroup></table>\" ],\n    tr: [ 2, \"<table><tbody>\", \"</tbody></table>\" ],\n    td: [ 3, \"<table><tbody><tr>\", \"</tr></tbody></table>\" ],\n\n    _default: [ 0, \"\", \"\" ]\n  };\n\n  wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead;\n  wrapMap.th = wrapMap.td;\n\n// Support: IE <=9 only\n  if ( !support.option ) {\n    wrapMap.optgroup = wrapMap.option = [ 1, \"<select multiple='multiple'>\", \"</select>\" ];\n  }\n\n\n  function getAll( context, tag ) {\n\n    // Support: IE <=9 - 11 only\n    // Use typeof to avoid zero-argument method invocation on host objects (trac-15151)\n    var ret;\n\n    if ( typeof context.getElementsByTagName !== \"undefined\" ) {\n      ret = context.getElementsByTagName( tag || \"*\" );\n\n    } else if ( typeof context.querySelectorAll !== \"undefined\" ) {\n      ret = context.querySelectorAll( tag || \"*\" );\n\n    } else {\n      ret = [];\n    }\n\n    if ( tag === undefined || tag && nodeName( context, tag ) ) {\n      return jQuery.merge( [ context ], ret );\n    }\n\n    return ret;\n  }\n\n\n// Mark scripts as having already been evaluated\n  function setGlobalEval( elems, refElements ) {\n    var i = 0,\n      l = elems.length;\n\n    for ( ; i < l; i++ ) {\n      dataPriv.set(\n        elems[ i ],\n        \"globalEval\",\n        !refElements || dataPriv.get( refElements[ i ], \"globalEval\" )\n      );\n    }\n  }\n\n\n  var rhtml = /<|&#?\\w+;/;\n\n  function buildFragment( elems, context, scripts, selection, ignored ) {\n    var elem, tmp, tag, wrap, attached, j,\n      fragment = context.createDocumentFragment(),\n      nodes = [],\n      i = 0,\n      l = elems.length;\n\n    for ( ; i < l; i++ ) {\n      elem = elems[ i ];\n\n      if ( elem || elem === 0 ) {\n\n        // Add nodes directly\n        if ( toType( elem ) === \"object\" ) {\n\n          // Support: Android <=4.0 only, PhantomJS 1 only\n          // push.apply(_, arraylike) throws on ancient WebKit\n          jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem );\n\n          // Convert non-html into a text node\n        } else if ( !rhtml.test( elem ) ) {\n          nodes.push( context.createTextNode( elem ) );\n\n          // Convert html into DOM nodes\n        } else {\n          tmp = tmp || fragment.appendChild( context.createElement( \"div\" ) );\n\n          // Deserialize a standard representation\n          tag = ( rtagName.exec( elem ) || [ \"\", \"\" ] )[ 1 ].toLowerCase();\n          wrap = wrapMap[ tag ] || wrapMap._default;\n          tmp.innerHTML = wrap[ 1 ] + jQuery.htmlPrefilter( elem ) + wrap[ 2 ];\n\n          // Descend through wrappers to the right content\n          j = wrap[ 0 ];\n          while ( j-- ) {\n            tmp = tmp.lastChild;\n          }\n\n          // Support: Android <=4.0 only, PhantomJS 1 only\n          // push.apply(_, arraylike) throws on ancient WebKit\n          jQuery.merge( nodes, tmp.childNodes );\n\n          // Remember the top-level container\n          tmp = fragment.firstChild;\n\n          // Ensure the created nodes are orphaned (trac-12392)\n          tmp.textContent = \"\";\n        }\n      }\n    }\n\n    // Remove wrapper from fragment\n    fragment.textContent = \"\";\n\n    i = 0;\n    while ( ( elem = nodes[ i++ ] ) ) {\n\n      // Skip elements already in the context collection (trac-4087)\n      if ( selection && jQuery.inArray( elem, selection ) > -1 ) {\n        if ( ignored ) {\n          ignored.push( elem );\n        }\n        continue;\n      }\n\n      attached = isAttached( elem );\n\n      // Append to fragment\n      tmp = getAll( fragment.appendChild( elem ), \"script\" );\n\n      // Preserve script evaluation history\n      if ( attached ) {\n        setGlobalEval( tmp );\n      }\n\n      // Capture executables\n      if ( scripts ) {\n        j = 0;\n        while ( ( elem = tmp[ j++ ] ) ) {\n          if ( rscriptType.test( elem.type || \"\" ) ) {\n            scripts.push( elem );\n          }\n        }\n      }\n    }\n\n    return fragment;\n  }\n\n\n  var rtypenamespace = /^([^.]*)(?:\\.(.+)|)/;\n\n  function returnTrue() {\n    return true;\n  }\n\n  function returnFalse() {\n    return false;\n  }\n\n  function on( elem, types, selector, data, fn, one ) {\n    var origFn, type;\n\n    // Types can be a map of types/handlers\n    if ( typeof types === \"object\" ) {\n\n      // ( types-Object, selector, data )\n      if ( typeof selector !== \"string\" ) {\n\n        // ( types-Object, data )\n        data = data || selector;\n        selector = undefined;\n      }\n      for ( type in types ) {\n        on( elem, type, selector, data, types[ type ], one );\n      }\n      return elem;\n    }\n\n    if ( data == null && fn == null ) {\n\n      // ( types, fn )\n      fn = selector;\n      data = selector = undefined;\n    } else if ( fn == null ) {\n      if ( typeof selector === \"string\" ) {\n\n        // ( types, selector, fn )\n        fn = data;\n        data = undefined;\n      } else {\n\n        // ( types, data, fn )\n        fn = data;\n        data = selector;\n        selector = undefined;\n      }\n    }\n    if ( fn === false ) {\n      fn = returnFalse;\n    } else if ( !fn ) {\n      return elem;\n    }\n\n    if ( one === 1 ) {\n      origFn = fn;\n      fn = function( event ) {\n\n        // Can use an empty set, since event contains the info\n        jQuery().off( event );\n        return origFn.apply( this, arguments );\n      };\n\n      // Use same guid so caller can remove using origFn\n      fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ );\n    }\n    return elem.each( function() {\n      jQuery.event.add( this, types, fn, data, selector );\n    } );\n  }\n\n  /*\n * Helper functions for managing events -- not part of the public interface.\n * Props to Dean Edwards' addEvent library for many of the ideas.\n */\n  jQuery.event = {\n\n    global: {},\n\n    add: function( elem, types, handler, data, selector ) {\n\n      var handleObjIn, eventHandle, tmp,\n        events, t, handleObj,\n        special, handlers, type, namespaces, origType,\n        elemData = dataPriv.get( elem );\n\n      // Only attach events to objects that accept data\n      if ( !acceptData( elem ) ) {\n        return;\n      }\n\n      // Caller can pass in an object of custom data in lieu of the handler\n      if ( handler.handler ) {\n        handleObjIn = handler;\n        handler = handleObjIn.handler;\n        selector = handleObjIn.selector;\n      }\n\n      // Ensure that invalid selectors throw exceptions at attach time\n      // Evaluate against documentElement in case elem is a non-element node (e.g., document)\n      if ( selector ) {\n        jQuery.find.matchesSelector( documentElement, selector );\n      }\n\n      // Make sure that the handler has a unique ID, used to find/remove it later\n      if ( !handler.guid ) {\n        handler.guid = jQuery.guid++;\n      }\n\n      // Init the element's event structure and main handler, if this is the first\n      if ( !( events = elemData.events ) ) {\n        events = elemData.events = Object.create( null );\n      }\n      if ( !( eventHandle = elemData.handle ) ) {\n        eventHandle = elemData.handle = function( e ) {\n\n          // Discard the second event of a jQuery.event.trigger() and\n          // when an event is called after a page has unloaded\n          return typeof jQuery !== \"undefined\" && jQuery.event.triggered !== e.type ?\n            jQuery.event.dispatch.apply( elem, arguments ) : undefined;\n        };\n      }\n\n      // Handle multiple events separated by a space\n      types = ( types || \"\" ).match( rnothtmlwhite ) || [ \"\" ];\n      t = types.length;\n      while ( t-- ) {\n        tmp = rtypenamespace.exec( types[ t ] ) || [];\n        type = origType = tmp[ 1 ];\n        namespaces = ( tmp[ 2 ] || \"\" ).split( \".\" ).sort();\n\n        // There *must* be a type, no attaching namespace-only handlers\n        if ( !type ) {\n          continue;\n        }\n\n        // If event changes its type, use the special event handlers for the changed type\n        special = jQuery.event.special[ type ] || {};\n\n        // If selector defined, determine special event api type, otherwise given type\n        type = ( selector ? special.delegateType : special.bindType ) || type;\n\n        // Update special based on newly reset type\n        special = jQuery.event.special[ type ] || {};\n\n        // handleObj is passed to all event handlers\n        handleObj = jQuery.extend( {\n          type: type,\n          origType: origType,\n          data: data,\n          handler: handler,\n          guid: handler.guid,\n          selector: selector,\n          needsContext: selector && jQuery.expr.match.needsContext.test( selector ),\n          namespace: namespaces.join( \".\" )\n        }, handleObjIn );\n\n        // Init the event handler queue if we're the first\n        if ( !( handlers = events[ type ] ) ) {\n          handlers = events[ type ] = [];\n          handlers.delegateCount = 0;\n\n          // Only use addEventListener if the special events handler returns false\n          if ( !special.setup ||\n            special.setup.call( elem, data, namespaces, eventHandle ) === false ) {\n\n            if ( elem.addEventListener ) {\n              elem.addEventListener( type, eventHandle );\n            }\n          }\n        }\n\n        if ( special.add ) {\n          special.add.call( elem, handleObj );\n\n          if ( !handleObj.handler.guid ) {\n            handleObj.handler.guid = handler.guid;\n          }\n        }\n\n        // Add to the element's handler list, delegates in front\n        if ( selector ) {\n          handlers.splice( handlers.delegateCount++, 0, handleObj );\n        } else {\n          handlers.push( handleObj );\n        }\n\n        // Keep track of which events have ever been used, for event optimization\n        jQuery.event.global[ type ] = true;\n      }\n\n    },\n\n    // Detach an event or set of events from an element\n    remove: function( elem, types, handler, selector, mappedTypes ) {\n\n      var j, origCount, tmp,\n        events, t, handleObj,\n        special, handlers, type, namespaces, origType,\n        elemData = dataPriv.hasData( elem ) && dataPriv.get( elem );\n\n      if ( !elemData || !( events = elemData.events ) ) {\n        return;\n      }\n\n      // Once for each type.namespace in types; type may be omitted\n      types = ( types || \"\" ).match( rnothtmlwhite ) || [ \"\" ];\n      t = types.length;\n      while ( t-- ) {\n        tmp = rtypenamespace.exec( types[ t ] ) || [];\n        type = origType = tmp[ 1 ];\n        namespaces = ( tmp[ 2 ] || \"\" ).split( \".\" ).sort();\n\n        // Unbind all events (on this namespace, if provided) for the element\n        if ( !type ) {\n          for ( type in events ) {\n            jQuery.event.remove( elem, type + types[ t ], handler, selector, true );\n          }\n          continue;\n        }\n\n        special = jQuery.event.special[ type ] || {};\n        type = ( selector ? special.delegateType : special.bindType ) || type;\n        handlers = events[ type ] || [];\n        tmp = tmp[ 2 ] &&\n          new RegExp( \"(^|\\\\.)\" + namespaces.join( \"\\\\.(?:.*\\\\.|)\" ) + \"(\\\\.|$)\" );\n\n        // Remove matching events\n        origCount = j = handlers.length;\n        while ( j-- ) {\n          handleObj = handlers[ j ];\n\n          if ( ( mappedTypes || origType === handleObj.origType ) &&\n            ( !handler || handler.guid === handleObj.guid ) &&\n            ( !tmp || tmp.test( handleObj.namespace ) ) &&\n            ( !selector || selector === handleObj.selector ||\n              selector === \"**\" && handleObj.selector ) ) {\n            handlers.splice( j, 1 );\n\n            if ( handleObj.selector ) {\n              handlers.delegateCount--;\n            }\n            if ( special.remove ) {\n              special.remove.call( elem, handleObj );\n            }\n          }\n        }\n\n        // Remove generic event handler if we removed something and no more handlers exist\n        // (avoids potential for endless recursion during removal of special event handlers)\n        if ( origCount && !handlers.length ) {\n          if ( !special.teardown ||\n            special.teardown.call( elem, namespaces, elemData.handle ) === false ) {\n\n            jQuery.removeEvent( elem, type, elemData.handle );\n          }\n\n          delete events[ type ];\n        }\n      }\n\n      // Remove data and the expando if it's no longer used\n      if ( jQuery.isEmptyObject( events ) ) {\n        dataPriv.remove( elem, \"handle events\" );\n      }\n    },\n\n    dispatch: function( nativeEvent ) {\n\n      var i, j, ret, matched, handleObj, handlerQueue,\n        args = new Array( arguments.length ),\n\n        // Make a writable jQuery.Event from the native event object\n        event = jQuery.event.fix( nativeEvent ),\n\n        handlers = (\n          dataPriv.get( this, \"events\" ) || Object.create( null )\n        )[ event.type ] || [],\n        special = jQuery.event.special[ event.type ] || {};\n\n      // Use the fix-ed jQuery.Event rather than the (read-only) native event\n      args[ 0 ] = event;\n\n      for ( i = 1; i < arguments.length; i++ ) {\n        args[ i ] = arguments[ i ];\n      }\n\n      event.delegateTarget = this;\n\n      // Call the preDispatch hook for the mapped type, and let it bail if desired\n      if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) {\n        return;\n      }\n\n      // Determine handlers\n      handlerQueue = jQuery.event.handlers.call( this, event, handlers );\n\n      // Run delegates first; they may want to stop propagation beneath us\n      i = 0;\n      while ( ( matched = handlerQueue[ i++ ] ) && !event.isPropagationStopped() ) {\n        event.currentTarget = matched.elem;\n\n        j = 0;\n        while ( ( handleObj = matched.handlers[ j++ ] ) &&\n        !event.isImmediatePropagationStopped() ) {\n\n          // If the event is namespaced, then each handler is only invoked if it is\n          // specially universal or its namespaces are a superset of the event's.\n          if ( !event.rnamespace || handleObj.namespace === false ||\n            event.rnamespace.test( handleObj.namespace ) ) {\n\n            event.handleObj = handleObj;\n            event.data = handleObj.data;\n\n            ret = ( ( jQuery.event.special[ handleObj.origType ] || {} ).handle ||\n              handleObj.handler ).apply( matched.elem, args );\n\n            if ( ret !== undefined ) {\n              if ( ( event.result = ret ) === false ) {\n                event.preventDefault();\n                event.stopPropagation();\n              }\n            }\n          }\n        }\n      }\n\n      // Call the postDispatch hook for the mapped type\n      if ( special.postDispatch ) {\n        special.postDispatch.call( this, event );\n      }\n\n      return event.result;\n    },\n\n    handlers: function( event, handlers ) {\n      var i, handleObj, sel, matchedHandlers, matchedSelectors,\n        handlerQueue = [],\n        delegateCount = handlers.delegateCount,\n        cur = event.target;\n\n      // Find delegate handlers\n      if ( delegateCount &&\n\n        // Support: IE <=9\n        // Black-hole SVG <use> instance trees (trac-13180)\n        cur.nodeType &&\n\n        // Support: Firefox <=42\n        // Suppress spec-violating clicks indicating a non-primary pointer button (trac-3861)\n        // https://www.w3.org/TR/DOM-Level-3-Events/#event-type-click\n        // Support: IE 11 only\n        // ...but not arrow key \"clicks\" of radio inputs, which can have `button` -1 (gh-2343)\n        !( event.type === \"click\" && event.button >= 1 ) ) {\n\n        for ( ; cur !== this; cur = cur.parentNode || this ) {\n\n          // Don't check non-elements (trac-13208)\n          // Don't process clicks on disabled elements (trac-6911, trac-8165, trac-11382, trac-11764)\n          if ( cur.nodeType === 1 && !( event.type === \"click\" && cur.disabled === true ) ) {\n            matchedHandlers = [];\n            matchedSelectors = {};\n            for ( i = 0; i < delegateCount; i++ ) {\n              handleObj = handlers[ i ];\n\n              // Don't conflict with Object.prototype properties (trac-13203)\n              sel = handleObj.selector + \" \";\n\n              if ( matchedSelectors[ sel ] === undefined ) {\n                matchedSelectors[ sel ] = handleObj.needsContext ?\n                  jQuery( sel, this ).index( cur ) > -1 :\n                  jQuery.find( sel, this, null, [ cur ] ).length;\n              }\n              if ( matchedSelectors[ sel ] ) {\n                matchedHandlers.push( handleObj );\n              }\n            }\n            if ( matchedHandlers.length ) {\n              handlerQueue.push( { elem: cur, handlers: matchedHandlers } );\n            }\n          }\n        }\n      }\n\n      // Add the remaining (directly-bound) handlers\n      cur = this;\n      if ( delegateCount < handlers.length ) {\n        handlerQueue.push( { elem: cur, handlers: handlers.slice( delegateCount ) } );\n      }\n\n      return handlerQueue;\n    },\n\n    addProp: function( name, hook ) {\n      Object.defineProperty( jQuery.Event.prototype, name, {\n        enumerable: true,\n        configurable: true,\n\n        get: isFunction( hook ) ?\n          function() {\n            if ( this.originalEvent ) {\n              return hook( this.originalEvent );\n            }\n          } :\n          function() {\n            if ( this.originalEvent ) {\n              return this.originalEvent[ name ];\n            }\n          },\n\n        set: function( value ) {\n          Object.defineProperty( this, name, {\n            enumerable: true,\n            configurable: true,\n            writable: true,\n            value: value\n          } );\n        }\n      } );\n    },\n\n    fix: function( originalEvent ) {\n      return originalEvent[ jQuery.expando ] ?\n        originalEvent :\n        new jQuery.Event( originalEvent );\n    },\n\n    special: {\n      load: {\n\n        // Prevent triggered image.load events from bubbling to window.load\n        noBubble: true\n      },\n      click: {\n\n        // Utilize native event to ensure correct state for checkable inputs\n        setup: function( data ) {\n\n          // For mutual compressibility with _default, replace `this` access with a local var.\n          // `|| data` is dead code meant only to preserve the variable through minification.\n          var el = this || data;\n\n          // Claim the first handler\n          if ( rcheckableType.test( el.type ) &&\n            el.click && nodeName( el, \"input\" ) ) {\n\n            // dataPriv.set( el, \"click\", ... )\n            leverageNative( el, \"click\", true );\n          }\n\n          // Return false to allow normal processing in the caller\n          return false;\n        },\n        trigger: function( data ) {\n\n          // For mutual compressibility with _default, replace `this` access with a local var.\n          // `|| data` is dead code meant only to preserve the variable through minification.\n          var el = this || data;\n\n          // Force setup before triggering a click\n          if ( rcheckableType.test( el.type ) &&\n            el.click && nodeName( el, \"input\" ) ) {\n\n            leverageNative( el, \"click\" );\n          }\n\n          // Return non-false to allow normal event-path propagation\n          return true;\n        },\n\n        // For cross-browser consistency, suppress native .click() on links\n        // Also prevent it if we're currently inside a leveraged native-event stack\n        _default: function( event ) {\n          var target = event.target;\n          return rcheckableType.test( target.type ) &&\n            target.click && nodeName( target, \"input\" ) &&\n            dataPriv.get( target, \"click\" ) ||\n            nodeName( target, \"a\" );\n        }\n      },\n\n      beforeunload: {\n        postDispatch: function( event ) {\n\n          // Support: Firefox 20+\n          // Firefox doesn't alert if the returnValue field is not set.\n          if ( event.result !== undefined && event.originalEvent ) {\n            event.originalEvent.returnValue = event.result;\n          }\n        }\n      }\n    }\n  };\n\n// Ensure the presence of an event listener that handles manually-triggered\n// synthetic events by interrupting progress until reinvoked in response to\n// *native* events that it fires directly, ensuring that state changes have\n// already occurred before other listeners are invoked.\n  function leverageNative( el, type, isSetup ) {\n\n    // Missing `isSetup` indicates a trigger call, which must force setup through jQuery.event.add\n    if ( !isSetup ) {\n      if ( dataPriv.get( el, type ) === undefined ) {\n        jQuery.event.add( el, type, returnTrue );\n      }\n      return;\n    }\n\n    // Register the controller as a special universal handler for all event namespaces\n    dataPriv.set( el, type, false );\n    jQuery.event.add( el, type, {\n      namespace: false,\n      handler: function( event ) {\n        var result,\n          saved = dataPriv.get( this, type );\n\n        if ( ( event.isTrigger & 1 ) && this[ type ] ) {\n\n          // Interrupt processing of the outer synthetic .trigger()ed event\n          if ( !saved ) {\n\n            // Store arguments for use when handling the inner native event\n            // There will always be at least one argument (an event object), so this array\n            // will not be confused with a leftover capture object.\n            saved = slice.call( arguments );\n            dataPriv.set( this, type, saved );\n\n            // Trigger the native event and capture its result\n            this[ type ]();\n            result = dataPriv.get( this, type );\n            dataPriv.set( this, type, false );\n\n            if ( saved !== result ) {\n\n              // Cancel the outer synthetic event\n              event.stopImmediatePropagation();\n              event.preventDefault();\n\n              return result;\n            }\n\n            // If this is an inner synthetic event for an event with a bubbling surrogate\n            // (focus or blur), assume that the surrogate already propagated from triggering\n            // the native event and prevent that from happening again here.\n            // This technically gets the ordering wrong w.r.t. to `.trigger()` (in which the\n            // bubbling surrogate propagates *after* the non-bubbling base), but that seems\n            // less bad than duplication.\n          } else if ( ( jQuery.event.special[ type ] || {} ).delegateType ) {\n            event.stopPropagation();\n          }\n\n          // If this is a native event triggered above, everything is now in order\n          // Fire an inner synthetic event with the original arguments\n        } else if ( saved ) {\n\n          // ...and capture the result\n          dataPriv.set( this, type, jQuery.event.trigger(\n            saved[ 0 ],\n            saved.slice( 1 ),\n            this\n          ) );\n\n          // Abort handling of the native event by all jQuery handlers while allowing\n          // native handlers on the same element to run. On target, this is achieved\n          // by stopping immediate propagation just on the jQuery event. However,\n          // the native event is re-wrapped by a jQuery one on each level of the\n          // propagation so the only way to stop it for jQuery is to stop it for\n          // everyone via native `stopPropagation()`. This is not a problem for\n          // focus/blur which don't bubble, but it does also stop click on checkboxes\n          // and radios. We accept this limitation.\n          event.stopPropagation();\n          event.isImmediatePropagationStopped = returnTrue;\n        }\n      }\n    } );\n  }\n\n  jQuery.removeEvent = function( elem, type, handle ) {\n\n    // This \"if\" is needed for plain objects\n    if ( elem.removeEventListener ) {\n      elem.removeEventListener( type, handle );\n    }\n  };\n\n  jQuery.Event = function( src, props ) {\n\n    // Allow instantiation without the 'new' keyword\n    if ( !( this instanceof jQuery.Event ) ) {\n      return new jQuery.Event( src, props );\n    }\n\n    // Event object\n    if ( src && src.type ) {\n      this.originalEvent = src;\n      this.type = src.type;\n\n      // Events bubbling up the document may have been marked as prevented\n      // by a handler lower down the tree; reflect the correct value.\n      this.isDefaultPrevented = src.defaultPrevented ||\n      src.defaultPrevented === undefined &&\n\n      // Support: Android <=2.3 only\n      src.returnValue === false ?\n        returnTrue :\n        returnFalse;\n\n      // Create target properties\n      // Support: Safari <=6 - 7 only\n      // Target should not be a text node (trac-504, trac-13143)\n      this.target = ( src.target && src.target.nodeType === 3 ) ?\n        src.target.parentNode :\n        src.target;\n\n      this.currentTarget = src.currentTarget;\n      this.relatedTarget = src.relatedTarget;\n\n      // Event type\n    } else {\n      this.type = src;\n    }\n\n    // Put explicitly provided properties onto the event object\n    if ( props ) {\n      jQuery.extend( this, props );\n    }\n\n    // Create a timestamp if incoming event doesn't have one\n    this.timeStamp = src && src.timeStamp || Date.now();\n\n    // Mark it as fixed\n    this[ jQuery.expando ] = true;\n  };\n\n// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding\n// https://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html\n  jQuery.Event.prototype = {\n    constructor: jQuery.Event,\n    isDefaultPrevented: returnFalse,\n    isPropagationStopped: returnFalse,\n    isImmediatePropagationStopped: returnFalse,\n    isSimulated: false,\n\n    preventDefault: function() {\n      var e = this.originalEvent;\n\n      this.isDefaultPrevented = returnTrue;\n\n      if ( e && !this.isSimulated ) {\n        e.preventDefault();\n      }\n    },\n    stopPropagation: function() {\n      var e = this.originalEvent;\n\n      this.isPropagationStopped = returnTrue;\n\n      if ( e && !this.isSimulated ) {\n        e.stopPropagation();\n      }\n    },\n    stopImmediatePropagation: function() {\n      var e = this.originalEvent;\n\n      this.isImmediatePropagationStopped = returnTrue;\n\n      if ( e && !this.isSimulated ) {\n        e.stopImmediatePropagation();\n      }\n\n      this.stopPropagation();\n    }\n  };\n\n// Includes all common event props including KeyEvent and MouseEvent specific props\n  jQuery.each( {\n    altKey: true,\n    bubbles: true,\n    cancelable: true,\n    changedTouches: true,\n    ctrlKey: true,\n    detail: true,\n    eventPhase: true,\n    metaKey: true,\n    pageX: true,\n    pageY: true,\n    shiftKey: true,\n    view: true,\n    \"char\": true,\n    code: true,\n    charCode: true,\n    key: true,\n    keyCode: true,\n    button: true,\n    buttons: true,\n    clientX: true,\n    clientY: true,\n    offsetX: true,\n    offsetY: true,\n    pointerId: true,\n    pointerType: true,\n    screenX: true,\n    screenY: true,\n    targetTouches: true,\n    toElement: true,\n    touches: true,\n    which: true\n  }, jQuery.event.addProp );\n\n  jQuery.each( { focus: \"focusin\", blur: \"focusout\" }, function( type, delegateType ) {\n\n    function focusMappedHandler( nativeEvent ) {\n      if ( document.documentMode ) {\n\n        // Support: IE 11+\n        // Attach a single focusin/focusout handler on the document while someone wants\n        // focus/blur. This is because the former are synchronous in IE while the latter\n        // are async. In other browsers, all those handlers are invoked synchronously.\n\n        // `handle` from private data would already wrap the event, but we need\n        // to change the `type` here.\n        var handle = dataPriv.get( this, \"handle\" ),\n          event = jQuery.event.fix( nativeEvent );\n        event.type = nativeEvent.type === \"focusin\" ? \"focus\" : \"blur\";\n        event.isSimulated = true;\n\n        // First, handle focusin/focusout\n        handle( nativeEvent );\n\n        // ...then, handle focus/blur\n        //\n        // focus/blur don't bubble while focusin/focusout do; simulate the former by only\n        // invoking the handler at the lower level.\n        if ( event.target === event.currentTarget ) {\n\n          // The setup part calls `leverageNative`, which, in turn, calls\n          // `jQuery.event.add`, so event handle will already have been set\n          // by this point.\n          handle( event );\n        }\n      } else {\n\n        // For non-IE browsers, attach a single capturing handler on the document\n        // while someone wants focusin/focusout.\n        jQuery.event.simulate( delegateType, nativeEvent.target,\n          jQuery.event.fix( nativeEvent ) );\n      }\n    }\n\n    jQuery.event.special[ type ] = {\n\n      // Utilize native event if possible so blur/focus sequence is correct\n      setup: function() {\n\n        var attaches;\n\n        // Claim the first handler\n        // dataPriv.set( this, \"focus\", ... )\n        // dataPriv.set( this, \"blur\", ... )\n        leverageNative( this, type, true );\n\n        if ( document.documentMode ) {\n\n          // Support: IE 9 - 11+\n          // We use the same native handler for focusin & focus (and focusout & blur)\n          // so we need to coordinate setup & teardown parts between those events.\n          // Use `delegateType` as the key as `type` is already used by `leverageNative`.\n          attaches = dataPriv.get( this, delegateType );\n          if ( !attaches ) {\n            this.addEventListener( delegateType, focusMappedHandler );\n          }\n          dataPriv.set( this, delegateType, ( attaches || 0 ) + 1 );\n        } else {\n\n          // Return false to allow normal processing in the caller\n          return false;\n        }\n      },\n      trigger: function() {\n\n        // Force setup before trigger\n        leverageNative( this, type );\n\n        // Return non-false to allow normal event-path propagation\n        return true;\n      },\n\n      teardown: function() {\n        var attaches;\n\n        if ( document.documentMode ) {\n          attaches = dataPriv.get( this, delegateType ) - 1;\n          if ( !attaches ) {\n            this.removeEventListener( delegateType, focusMappedHandler );\n            dataPriv.remove( this, delegateType );\n          } else {\n            dataPriv.set( this, delegateType, attaches );\n          }\n        } else {\n\n          // Return false to indicate standard teardown should be applied\n          return false;\n        }\n      },\n\n      // Suppress native focus or blur if we're currently inside\n      // a leveraged native-event stack\n      _default: function( event ) {\n        return dataPriv.get( event.target, type );\n      },\n\n      delegateType: delegateType\n    };\n\n    // Support: Firefox <=44\n    // Firefox doesn't have focus(in | out) events\n    // Related ticket - https://bugzilla.mozilla.org/show_bug.cgi?id=687787\n    //\n    // Support: Chrome <=48 - 49, Safari <=9.0 - 9.1\n    // focus(in | out) events fire after focus & blur events,\n    // which is spec violation - http://www.w3.org/TR/DOM-Level-3-Events/#events-focusevent-event-order\n    // Related ticket - https://bugs.chromium.org/p/chromium/issues/detail?id=449857\n    //\n    // Support: IE 9 - 11+\n    // To preserve relative focusin/focus & focusout/blur event order guaranteed on the 3.x branch,\n    // attach a single handler for both events in IE.\n    jQuery.event.special[ delegateType ] = {\n      setup: function() {\n\n        // Handle: regular nodes (via `this.ownerDocument`), window\n        // (via `this.document`) & document (via `this`).\n        var doc = this.ownerDocument || this.document || this,\n          dataHolder = document.documentMode ? this : doc,\n          attaches = dataPriv.get( dataHolder, delegateType );\n\n        // Support: IE 9 - 11+\n        // We use the same native handler for focusin & focus (and focusout & blur)\n        // so we need to coordinate setup & teardown parts between those events.\n        // Use `delegateType` as the key as `type` is already used by `leverageNative`.\n        if ( !attaches ) {\n          if ( document.documentMode ) {\n            this.addEventListener( delegateType, focusMappedHandler );\n          } else {\n            doc.addEventListener( type, focusMappedHandler, true );\n          }\n        }\n        dataPriv.set( dataHolder, delegateType, ( attaches || 0 ) + 1 );\n      },\n      teardown: function() {\n        var doc = this.ownerDocument || this.document || this,\n          dataHolder = document.documentMode ? this : doc,\n          attaches = dataPriv.get( dataHolder, delegateType ) - 1;\n\n        if ( !attaches ) {\n          if ( document.documentMode ) {\n            this.removeEventListener( delegateType, focusMappedHandler );\n          } else {\n            doc.removeEventListener( type, focusMappedHandler, true );\n          }\n          dataPriv.remove( dataHolder, delegateType );\n        } else {\n          dataPriv.set( dataHolder, delegateType, attaches );\n        }\n      }\n    };\n  } );\n\n// Create mouseenter/leave events using mouseover/out and event-time checks\n// so that event delegation works in jQuery.\n// Do the same for pointerenter/pointerleave and pointerover/pointerout\n//\n// Support: Safari 7 only\n// Safari sends mouseenter too often; see:\n// https://bugs.chromium.org/p/chromium/issues/detail?id=470258\n// for the description of the bug (it existed in older Chrome versions as well).\n  jQuery.each( {\n    mouseenter: \"mouseover\",\n    mouseleave: \"mouseout\",\n    pointerenter: \"pointerover\",\n    pointerleave: \"pointerout\"\n  }, function( orig, fix ) {\n    jQuery.event.special[ orig ] = {\n      delegateType: fix,\n      bindType: fix,\n\n      handle: function( event ) {\n        var ret,\n          target = this,\n          related = event.relatedTarget,\n          handleObj = event.handleObj;\n\n        // For mouseenter/leave call the handler if related is outside the target.\n        // NB: No relatedTarget if the mouse left/entered the browser window\n        if ( !related || ( related !== target && !jQuery.contains( target, related ) ) ) {\n          event.type = handleObj.origType;\n          ret = handleObj.handler.apply( this, arguments );\n          event.type = fix;\n        }\n        return ret;\n      }\n    };\n  } );\n\n  jQuery.fn.extend( {\n\n    on: function( types, selector, data, fn ) {\n      return on( this, types, selector, data, fn );\n    },\n    one: function( types, selector, data, fn ) {\n      return on( this, types, selector, data, fn, 1 );\n    },\n    off: function( types, selector, fn ) {\n      var handleObj, type;\n      if ( types && types.preventDefault && types.handleObj ) {\n\n        // ( event )  dispatched jQuery.Event\n        handleObj = types.handleObj;\n        jQuery( types.delegateTarget ).off(\n          handleObj.namespace ?\n            handleObj.origType + \".\" + handleObj.namespace :\n            handleObj.origType,\n          handleObj.selector,\n          handleObj.handler\n        );\n        return this;\n      }\n      if ( typeof types === \"object\" ) {\n\n        // ( types-object [, selector] )\n        for ( type in types ) {\n          this.off( type, selector, types[ type ] );\n        }\n        return this;\n      }\n      if ( selector === false || typeof selector === \"function\" ) {\n\n        // ( types [, fn] )\n        fn = selector;\n        selector = undefined;\n      }\n      if ( fn === false ) {\n        fn = returnFalse;\n      }\n      return this.each( function() {\n        jQuery.event.remove( this, types, fn, selector );\n      } );\n    }\n  } );\n\n\n  var\n\n    // Support: IE <=10 - 11, Edge 12 - 13 only\n    // In IE/Edge using regex groups here causes severe slowdowns.\n    // See https://connect.microsoft.com/IE/feedback/details/1736512/\n    rnoInnerhtml = /<script|<style|<link/i,\n\n    // checked=\"checked\" or checked\n    rchecked = /checked\\s*(?:[^=]|=\\s*.checked.)/i,\n\n    rcleanScript = /^\\s*<!\\[CDATA\\[|\\]\\]>\\s*$/g;\n\n// Prefer a tbody over its parent table for containing new rows\n  function manipulationTarget( elem, content ) {\n    if ( nodeName( elem, \"table\" ) &&\n      nodeName( content.nodeType !== 11 ? content : content.firstChild, \"tr\" ) ) {\n\n      return jQuery( elem ).children( \"tbody\" )[ 0 ] || elem;\n    }\n\n    return elem;\n  }\n\n// Replace/restore the type attribute of script elements for safe DOM manipulation\n  function disableScript( elem ) {\n    elem.type = ( elem.getAttribute( \"type\" ) !== null ) + \"/\" + elem.type;\n    return elem;\n  }\n  function restoreScript( elem ) {\n    if ( ( elem.type || \"\" ).slice( 0, 5 ) === \"true/\" ) {\n      elem.type = elem.type.slice( 5 );\n    } else {\n      elem.removeAttribute( \"type\" );\n    }\n\n    return elem;\n  }\n\n  function cloneCopyEvent( src, dest ) {\n    var i, l, type, pdataOld, udataOld, udataCur, events;\n\n    if ( dest.nodeType !== 1 ) {\n      return;\n    }\n\n    // 1. Copy private data: events, handlers, etc.\n    if ( dataPriv.hasData( src ) ) {\n      pdataOld = dataPriv.get( src );\n      events = pdataOld.events;\n\n      if ( events ) {\n        dataPriv.remove( dest, \"handle events\" );\n\n        for ( type in events ) {\n          for ( i = 0, l = events[ type ].length; i < l; i++ ) {\n            jQuery.event.add( dest, type, events[ type ][ i ] );\n          }\n        }\n      }\n    }\n\n    // 2. Copy user data\n    if ( dataUser.hasData( src ) ) {\n      udataOld = dataUser.access( src );\n      udataCur = jQuery.extend( {}, udataOld );\n\n      dataUser.set( dest, udataCur );\n    }\n  }\n\n// Fix IE bugs, see support tests\n  function fixInput( src, dest ) {\n    var nodeName = dest.nodeName.toLowerCase();\n\n    // Fails to persist the checked state of a cloned checkbox or radio button.\n    if ( nodeName === \"input\" && rcheckableType.test( src.type ) ) {\n      dest.checked = src.checked;\n\n      // Fails to return the selected option to the default selected state when cloning options\n    } else if ( nodeName === \"input\" || nodeName === \"textarea\" ) {\n      dest.defaultValue = src.defaultValue;\n    }\n  }\n\n  function domManip( collection, args, callback, ignored ) {\n\n    // Flatten any nested arrays\n    args = flat( args );\n\n    var fragment, first, scripts, hasScripts, node, doc,\n      i = 0,\n      l = collection.length,\n      iNoClone = l - 1,\n      value = args[ 0 ],\n      valueIsFunction = isFunction( value );\n\n    // We can't cloneNode fragments that contain checked, in WebKit\n    if ( valueIsFunction ||\n      ( l > 1 && typeof value === \"string\" &&\n        !support.checkClone && rchecked.test( value ) ) ) {\n      return collection.each( function( index ) {\n        var self = collection.eq( index );\n        if ( valueIsFunction ) {\n          args[ 0 ] = value.call( this, index, self.html() );\n        }\n        domManip( self, args, callback, ignored );\n      } );\n    }\n\n    if ( l ) {\n      fragment = buildFragment( args, collection[ 0 ].ownerDocument, false, collection, ignored );\n      first = fragment.firstChild;\n\n      if ( fragment.childNodes.length === 1 ) {\n        fragment = first;\n      }\n\n      // Require either new content or an interest in ignored elements to invoke the callback\n      if ( first || ignored ) {\n        scripts = jQuery.map( getAll( fragment, \"script\" ), disableScript );\n        hasScripts = scripts.length;\n\n        // Use the original fragment for the last item\n        // instead of the first because it can end up\n        // being emptied incorrectly in certain situations (trac-8070).\n        for ( ; i < l; i++ ) {\n          node = fragment;\n\n          if ( i !== iNoClone ) {\n            node = jQuery.clone( node, true, true );\n\n            // Keep references to cloned scripts for later restoration\n            if ( hasScripts ) {\n\n              // Support: Android <=4.0 only, PhantomJS 1 only\n              // push.apply(_, arraylike) throws on ancient WebKit\n              jQuery.merge( scripts, getAll( node, \"script\" ) );\n            }\n          }\n\n          callback.call( collection[ i ], node, i );\n        }\n\n        if ( hasScripts ) {\n          doc = scripts[ scripts.length - 1 ].ownerDocument;\n\n          // Re-enable scripts\n          jQuery.map( scripts, restoreScript );\n\n          // Evaluate executable scripts on first document insertion\n          for ( i = 0; i < hasScripts; i++ ) {\n            node = scripts[ i ];\n            if ( rscriptType.test( node.type || \"\" ) &&\n              !dataPriv.access( node, \"globalEval\" ) &&\n              jQuery.contains( doc, node ) ) {\n\n              if ( node.src && ( node.type || \"\" ).toLowerCase()  !== \"module\" ) {\n\n                // Optional AJAX dependency, but won't run scripts if not present\n                if ( jQuery._evalUrl && !node.noModule ) {\n                  jQuery._evalUrl( node.src, {\n                    nonce: node.nonce || node.getAttribute( \"nonce\" )\n                  }, doc );\n                }\n              } else {\n\n                // Unwrap a CDATA section containing script contents. This shouldn't be\n                // needed as in XML documents they're already not visible when\n                // inspecting element contents and in HTML documents they have no\n                // meaning but we're preserving that logic for backwards compatibility.\n                // This will be removed completely in 4.0. See gh-4904.\n                DOMEval( node.textContent.replace( rcleanScript, \"\" ), node, doc );\n              }\n            }\n          }\n        }\n      }\n    }\n\n    return collection;\n  }\n\n  function remove( elem, selector, keepData ) {\n    var node,\n      nodes = selector ? jQuery.filter( selector, elem ) : elem,\n      i = 0;\n\n    for ( ; ( node = nodes[ i ] ) != null; i++ ) {\n      if ( !keepData && node.nodeType === 1 ) {\n        jQuery.cleanData( getAll( node ) );\n      }\n\n      if ( node.parentNode ) {\n        if ( keepData && isAttached( node ) ) {\n          setGlobalEval( getAll( node, \"script\" ) );\n        }\n        node.parentNode.removeChild( node );\n      }\n    }\n\n    return elem;\n  }\n\n  jQuery.extend( {\n    htmlPrefilter: function( html ) {\n      return html;\n    },\n\n    clone: function( elem, dataAndEvents, deepDataAndEvents ) {\n      var i, l, srcElements, destElements,\n        clone = elem.cloneNode( true ),\n        inPage = isAttached( elem );\n\n      // Fix IE cloning issues\n      if ( !support.noCloneChecked && ( elem.nodeType === 1 || elem.nodeType === 11 ) &&\n        !jQuery.isXMLDoc( elem ) ) {\n\n        // We eschew jQuery#find here for performance reasons:\n        // https://jsperf.com/getall-vs-sizzle/2\n        destElements = getAll( clone );\n        srcElements = getAll( elem );\n\n        for ( i = 0, l = srcElements.length; i < l; i++ ) {\n          fixInput( srcElements[ i ], destElements[ i ] );\n        }\n      }\n\n      // Copy the events from the original to the clone\n      if ( dataAndEvents ) {\n        if ( deepDataAndEvents ) {\n          srcElements = srcElements || getAll( elem );\n          destElements = destElements || getAll( clone );\n\n          for ( i = 0, l = srcElements.length; i < l; i++ ) {\n            cloneCopyEvent( srcElements[ i ], destElements[ i ] );\n          }\n        } else {\n          cloneCopyEvent( elem, clone );\n        }\n      }\n\n      // Preserve script evaluation history\n      destElements = getAll( clone, \"script\" );\n      if ( destElements.length > 0 ) {\n        setGlobalEval( destElements, !inPage && getAll( elem, \"script\" ) );\n      }\n\n      // Return the cloned set\n      return clone;\n    },\n\n    cleanData: function( elems ) {\n      var data, elem, type,\n        special = jQuery.event.special,\n        i = 0;\n\n      for ( ; ( elem = elems[ i ] ) !== undefined; i++ ) {\n        if ( acceptData( elem ) ) {\n          if ( ( data = elem[ dataPriv.expando ] ) ) {\n            if ( data.events ) {\n              for ( type in data.events ) {\n                if ( special[ type ] ) {\n                  jQuery.event.remove( elem, type );\n\n                  // This is a shortcut to avoid jQuery.event.remove's overhead\n                } else {\n                  jQuery.removeEvent( elem, type, data.handle );\n                }\n              }\n            }\n\n            // Support: Chrome <=35 - 45+\n            // Assign undefined instead of using delete, see Data#remove\n            elem[ dataPriv.expando ] = undefined;\n          }\n          if ( elem[ dataUser.expando ] ) {\n\n            // Support: Chrome <=35 - 45+\n            // Assign undefined instead of using delete, see Data#remove\n            elem[ dataUser.expando ] = undefined;\n          }\n        }\n      }\n    }\n  } );\n\n  jQuery.fn.extend( {\n    detach: function( selector ) {\n      return remove( this, selector, true );\n    },\n\n    remove: function( selector ) {\n      return remove( this, selector );\n    },\n\n    text: function( value ) {\n      return access( this, function( value ) {\n        return value === undefined ?\n          jQuery.text( this ) :\n          this.empty().each( function() {\n            if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) {\n              this.textContent = value;\n            }\n          } );\n      }, null, value, arguments.length );\n    },\n\n    append: function() {\n      return domManip( this, arguments, function( elem ) {\n        if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) {\n          var target = manipulationTarget( this, elem );\n          target.appendChild( elem );\n        }\n      } );\n    },\n\n    prepend: function() {\n      return domManip( this, arguments, function( elem ) {\n        if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) {\n          var target = manipulationTarget( this, elem );\n          target.insertBefore( elem, target.firstChild );\n        }\n      } );\n    },\n\n    before: function() {\n      return domManip( this, arguments, function( elem ) {\n        if ( this.parentNode ) {\n          this.parentNode.insertBefore( elem, this );\n        }\n      } );\n    },\n\n    after: function() {\n      return domManip( this, arguments, function( elem ) {\n        if ( this.parentNode ) {\n          this.parentNode.insertBefore( elem, this.nextSibling );\n        }\n      } );\n    },\n\n    empty: function() {\n      var elem,\n        i = 0;\n\n      for ( ; ( elem = this[ i ] ) != null; i++ ) {\n        if ( elem.nodeType === 1 ) {\n\n          // Prevent memory leaks\n          jQuery.cleanData( getAll( elem, false ) );\n\n          // Remove any remaining nodes\n          elem.textContent = \"\";\n        }\n      }\n\n      return this;\n    },\n\n    clone: function( dataAndEvents, deepDataAndEvents ) {\n      dataAndEvents = dataAndEvents == null ? false : dataAndEvents;\n      deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents;\n\n      return this.map( function() {\n        return jQuery.clone( this, dataAndEvents, deepDataAndEvents );\n      } );\n    },\n\n    html: function( value ) {\n      return access( this, function( value ) {\n        var elem = this[ 0 ] || {},\n          i = 0,\n          l = this.length;\n\n        if ( value === undefined && elem.nodeType === 1 ) {\n          return elem.innerHTML;\n        }\n\n        // See if we can take a shortcut and just use innerHTML\n        if ( typeof value === \"string\" && !rnoInnerhtml.test( value ) &&\n          !wrapMap[ ( rtagName.exec( value ) || [ \"\", \"\" ] )[ 1 ].toLowerCase() ] ) {\n\n          value = jQuery.htmlPrefilter( value );\n\n          try {\n            for ( ; i < l; i++ ) {\n              elem = this[ i ] || {};\n\n              // Remove element nodes and prevent memory leaks\n              if ( elem.nodeType === 1 ) {\n                jQuery.cleanData( getAll( elem, false ) );\n                elem.innerHTML = value;\n              }\n            }\n\n            elem = 0;\n\n            // If using innerHTML throws an exception, use the fallback method\n          } catch ( e ) {}\n        }\n\n        if ( elem ) {\n          this.empty().append( value );\n        }\n      }, null, value, arguments.length );\n    },\n\n    replaceWith: function() {\n      var ignored = [];\n\n      // Make the changes, replacing each non-ignored context element with the new content\n      return domManip( this, arguments, function( elem ) {\n        var parent = this.parentNode;\n\n        if ( jQuery.inArray( this, ignored ) < 0 ) {\n          jQuery.cleanData( getAll( this ) );\n          if ( parent ) {\n            parent.replaceChild( elem, this );\n          }\n        }\n\n        // Force callback invocation\n      }, ignored );\n    }\n  } );\n\n  jQuery.each( {\n    appendTo: \"append\",\n    prependTo: \"prepend\",\n    insertBefore: \"before\",\n    insertAfter: \"after\",\n    replaceAll: \"replaceWith\"\n  }, function( name, original ) {\n    jQuery.fn[ name ] = function( selector ) {\n      var elems,\n        ret = [],\n        insert = jQuery( selector ),\n        last = insert.length - 1,\n        i = 0;\n\n      for ( ; i <= last; i++ ) {\n        elems = i === last ? this : this.clone( true );\n        jQuery( insert[ i ] )[ original ]( elems );\n\n        // Support: Android <=4.0 only, PhantomJS 1 only\n        // .get() because push.apply(_, arraylike) throws on ancient WebKit\n        push.apply( ret, elems.get() );\n      }\n\n      return this.pushStack( ret );\n    };\n  } );\n  var rnumnonpx = new RegExp( \"^(\" + pnum + \")(?!px)[a-z%]+$\", \"i\" );\n\n  var rcustomProp = /^--/;\n\n\n  var getStyles = function( elem ) {\n\n    // Support: IE <=11 only, Firefox <=30 (trac-15098, trac-14150)\n    // IE throws on elements created in popups\n    // FF meanwhile throws on frame elements through \"defaultView.getComputedStyle\"\n    var view = elem.ownerDocument.defaultView;\n\n    if ( !view || !view.opener ) {\n      view = window;\n    }\n\n    return view.getComputedStyle( elem );\n  };\n\n  var swap = function( elem, options, callback ) {\n    var ret, name,\n      old = {};\n\n    // Remember the old values, and insert the new ones\n    for ( name in options ) {\n      old[ name ] = elem.style[ name ];\n      elem.style[ name ] = options[ name ];\n    }\n\n    ret = callback.call( elem );\n\n    // Revert the old values\n    for ( name in options ) {\n      elem.style[ name ] = old[ name ];\n    }\n\n    return ret;\n  };\n\n\n  var rboxStyle = new RegExp( cssExpand.join( \"|\" ), \"i\" );\n\n\n\n  ( function() {\n\n    // Executing both pixelPosition & boxSizingReliable tests require only one layout\n    // so they're executed at the same time to save the second computation.\n    function computeStyleTests() {\n\n      // This is a singleton, we need to execute it only once\n      if ( !div ) {\n        return;\n      }\n\n      container.style.cssText = \"position:absolute;left:-11111px;width:60px;\" +\n        \"margin-top:1px;padding:0;border:0\";\n      div.style.cssText =\n        \"position:relative;display:block;box-sizing:border-box;overflow:scroll;\" +\n        \"margin:auto;border:1px;padding:1px;\" +\n        \"width:60%;top:1%\";\n      documentElement.appendChild( container ).appendChild( div );\n\n      var divStyle = window.getComputedStyle( div );\n      pixelPositionVal = divStyle.top !== \"1%\";\n\n      // Support: Android 4.0 - 4.3 only, Firefox <=3 - 44\n      reliableMarginLeftVal = roundPixelMeasures( divStyle.marginLeft ) === 12;\n\n      // Support: Android 4.0 - 4.3 only, Safari <=9.1 - 10.1, iOS <=7.0 - 9.3\n      // Some styles come back with percentage values, even though they shouldn't\n      div.style.right = \"60%\";\n      pixelBoxStylesVal = roundPixelMeasures( divStyle.right ) === 36;\n\n      // Support: IE 9 - 11 only\n      // Detect misreporting of content dimensions for box-sizing:border-box elements\n      boxSizingReliableVal = roundPixelMeasures( divStyle.width ) === 36;\n\n      // Support: IE 9 only\n      // Detect overflow:scroll screwiness (gh-3699)\n      // Support: Chrome <=64\n      // Don't get tricked when zoom affects offsetWidth (gh-4029)\n      div.style.position = \"absolute\";\n      scrollboxSizeVal = roundPixelMeasures( div.offsetWidth / 3 ) === 12;\n\n      documentElement.removeChild( container );\n\n      // Nullify the div so it wouldn't be stored in the memory and\n      // it will also be a sign that checks already performed\n      div = null;\n    }\n\n    function roundPixelMeasures( measure ) {\n      return Math.round( parseFloat( measure ) );\n    }\n\n    var pixelPositionVal, boxSizingReliableVal, scrollboxSizeVal, pixelBoxStylesVal,\n      reliableTrDimensionsVal, reliableMarginLeftVal,\n      container = document.createElement( \"div\" ),\n      div = document.createElement( \"div\" );\n\n    // Finish early in limited (non-browser) environments\n    if ( !div.style ) {\n      return;\n    }\n\n    // Support: IE <=9 - 11 only\n    // Style of cloned element affects source element cloned (trac-8908)\n    div.style.backgroundClip = \"content-box\";\n    div.cloneNode( true ).style.backgroundClip = \"\";\n    support.clearCloneStyle = div.style.backgroundClip === \"content-box\";\n\n    jQuery.extend( support, {\n      boxSizingReliable: function() {\n        computeStyleTests();\n        return boxSizingReliableVal;\n      },\n      pixelBoxStyles: function() {\n        computeStyleTests();\n        return pixelBoxStylesVal;\n      },\n      pixelPosition: function() {\n        computeStyleTests();\n        return pixelPositionVal;\n      },\n      reliableMarginLeft: function() {\n        computeStyleTests();\n        return reliableMarginLeftVal;\n      },\n      scrollboxSize: function() {\n        computeStyleTests();\n        return scrollboxSizeVal;\n      },\n\n      // Support: IE 9 - 11+, Edge 15 - 18+\n      // IE/Edge misreport `getComputedStyle` of table rows with width/height\n      // set in CSS while `offset*` properties report correct values.\n      // Behavior in IE 9 is more subtle than in newer versions & it passes\n      // some versions of this test; make sure not to make it pass there!\n      //\n      // Support: Firefox 70+\n      // Only Firefox includes border widths\n      // in computed dimensions. (gh-4529)\n      reliableTrDimensions: function() {\n        var table, tr, trChild, trStyle;\n        if ( reliableTrDimensionsVal == null ) {\n          table = document.createElement( \"table\" );\n          tr = document.createElement( \"tr\" );\n          trChild = document.createElement( \"div\" );\n\n          table.style.cssText = \"position:absolute;left:-11111px;border-collapse:separate\";\n          tr.style.cssText = \"box-sizing:content-box;border:1px solid\";\n\n          // Support: Chrome 86+\n          // Height set through cssText does not get applied.\n          // Computed height then comes back as 0.\n          tr.style.height = \"1px\";\n          trChild.style.height = \"9px\";\n\n          // Support: Android 8 Chrome 86+\n          // In our bodyBackground.html iframe,\n          // display for all div elements is set to \"inline\",\n          // which causes a problem only in Android 8 Chrome 86.\n          // Ensuring the div is `display: block`\n          // gets around this issue.\n          trChild.style.display = \"block\";\n\n          documentElement\n            .appendChild( table )\n            .appendChild( tr )\n            .appendChild( trChild );\n\n          trStyle = window.getComputedStyle( tr );\n          reliableTrDimensionsVal = ( parseInt( trStyle.height, 10 ) +\n            parseInt( trStyle.borderTopWidth, 10 ) +\n            parseInt( trStyle.borderBottomWidth, 10 ) ) === tr.offsetHeight;\n\n          documentElement.removeChild( table );\n        }\n        return reliableTrDimensionsVal;\n      }\n    } );\n  } )();\n\n\n  function curCSS( elem, name, computed ) {\n    var width, minWidth, maxWidth, ret,\n      isCustomProp = rcustomProp.test( name ),\n\n      // Support: Firefox 51+\n      // Retrieving style before computed somehow\n      // fixes an issue with getting wrong values\n      // on detached elements\n      style = elem.style;\n\n    computed = computed || getStyles( elem );\n\n    // getPropertyValue is needed for:\n    //   .css('filter') (IE 9 only, trac-12537)\n    //   .css('--customProperty) (gh-3144)\n    if ( computed ) {\n\n      // Support: IE <=9 - 11+\n      // IE only supports `\"float\"` in `getPropertyValue`; in computed styles\n      // it's only available as `\"cssFloat\"`. We no longer modify properties\n      // sent to `.css()` apart from camelCasing, so we need to check both.\n      // Normally, this would create difference in behavior: if\n      // `getPropertyValue` returns an empty string, the value returned\n      // by `.css()` would be `undefined`. This is usually the case for\n      // disconnected elements. However, in IE even disconnected elements\n      // with no styles return `\"none\"` for `getPropertyValue( \"float\" )`\n      ret = computed.getPropertyValue( name ) || computed[ name ];\n\n      if ( isCustomProp && ret ) {\n\n        // Support: Firefox 105+, Chrome <=105+\n        // Spec requires trimming whitespace for custom properties (gh-4926).\n        // Firefox only trims leading whitespace. Chrome just collapses\n        // both leading & trailing whitespace to a single space.\n        //\n        // Fall back to `undefined` if empty string returned.\n        // This collapses a missing definition with property defined\n        // and set to an empty string but there's no standard API\n        // allowing us to differentiate them without a performance penalty\n        // and returning `undefined` aligns with older jQuery.\n        //\n        // rtrimCSS treats U+000D CARRIAGE RETURN and U+000C FORM FEED\n        // as whitespace while CSS does not, but this is not a problem\n        // because CSS preprocessing replaces them with U+000A LINE FEED\n        // (which *is* CSS whitespace)\n        // https://www.w3.org/TR/css-syntax-3/#input-preprocessing\n        ret = ret.replace( rtrimCSS, \"$1\" ) || undefined;\n      }\n\n      if ( ret === \"\" && !isAttached( elem ) ) {\n        ret = jQuery.style( elem, name );\n      }\n\n      // A tribute to the \"awesome hack by Dean Edwards\"\n      // Android Browser returns percentage for some values,\n      // but width seems to be reliably pixels.\n      // This is against the CSSOM draft spec:\n      // https://drafts.csswg.org/cssom/#resolved-values\n      if ( !support.pixelBoxStyles() && rnumnonpx.test( ret ) && rboxStyle.test( name ) ) {\n\n        // Remember the original values\n        width = style.width;\n        minWidth = style.minWidth;\n        maxWidth = style.maxWidth;\n\n        // Put in the new values to get a computed value out\n        style.minWidth = style.maxWidth = style.width = ret;\n        ret = computed.width;\n\n        // Revert the changed values\n        style.width = width;\n        style.minWidth = minWidth;\n        style.maxWidth = maxWidth;\n      }\n    }\n\n    return ret !== undefined ?\n\n      // Support: IE <=9 - 11 only\n      // IE returns zIndex value as an integer.\n      ret + \"\" :\n      ret;\n  }\n\n\n  function addGetHookIf( conditionFn, hookFn ) {\n\n    // Define the hook, we'll check on the first run if it's really needed.\n    return {\n      get: function() {\n        if ( conditionFn() ) {\n\n          // Hook not needed (or it's not possible to use it due\n          // to missing dependency), remove it.\n          delete this.get;\n          return;\n        }\n\n        // Hook needed; redefine it so that the support test is not executed again.\n        return ( this.get = hookFn ).apply( this, arguments );\n      }\n    };\n  }\n\n\n  var cssPrefixes = [ \"Webkit\", \"Moz\", \"ms\" ],\n    emptyStyle = document.createElement( \"div\" ).style,\n    vendorProps = {};\n\n// Return a vendor-prefixed property or undefined\n  function vendorPropName( name ) {\n\n    // Check for vendor prefixed names\n    var capName = name[ 0 ].toUpperCase() + name.slice( 1 ),\n      i = cssPrefixes.length;\n\n    while ( i-- ) {\n      name = cssPrefixes[ i ] + capName;\n      if ( name in emptyStyle ) {\n        return name;\n      }\n    }\n  }\n\n// Return a potentially-mapped jQuery.cssProps or vendor prefixed property\n  function finalPropName( name ) {\n    var final = jQuery.cssProps[ name ] || vendorProps[ name ];\n\n    if ( final ) {\n      return final;\n    }\n    if ( name in emptyStyle ) {\n      return name;\n    }\n    return vendorProps[ name ] = vendorPropName( name ) || name;\n  }\n\n\n  var\n\n    // Swappable if display is none or starts with table\n    // except \"table\", \"table-cell\", or \"table-caption\"\n    // See here for display values: https://developer.mozilla.org/en-US/docs/CSS/display\n    rdisplayswap = /^(none|table(?!-c[ea]).+)/,\n    cssShow = { position: \"absolute\", visibility: \"hidden\", display: \"block\" },\n    cssNormalTransform = {\n      letterSpacing: \"0\",\n      fontWeight: \"400\"\n    };\n\n  function setPositiveNumber( _elem, value, subtract ) {\n\n    // Any relative (+/-) values have already been\n    // normalized at this point\n    var matches = rcssNum.exec( value );\n    return matches ?\n\n      // Guard against undefined \"subtract\", e.g., when used as in cssHooks\n      Math.max( 0, matches[ 2 ] - ( subtract || 0 ) ) + ( matches[ 3 ] || \"px\" ) :\n      value;\n  }\n\n  function boxModelAdjustment( elem, dimension, box, isBorderBox, styles, computedVal ) {\n    var i = dimension === \"width\" ? 1 : 0,\n      extra = 0,\n      delta = 0,\n      marginDelta = 0;\n\n    // Adjustment may not be necessary\n    if ( box === ( isBorderBox ? \"border\" : \"content\" ) ) {\n      return 0;\n    }\n\n    for ( ; i < 4; i += 2 ) {\n\n      // Both box models exclude margin\n      // Count margin delta separately to only add it after scroll gutter adjustment.\n      // This is needed to make negative margins work with `outerHeight( true )` (gh-3982).\n      if ( box === \"margin\" ) {\n        marginDelta += jQuery.css( elem, box + cssExpand[ i ], true, styles );\n      }\n\n      // If we get here with a content-box, we're seeking \"padding\" or \"border\" or \"margin\"\n      if ( !isBorderBox ) {\n\n        // Add padding\n        delta += jQuery.css( elem, \"padding\" + cssExpand[ i ], true, styles );\n\n        // For \"border\" or \"margin\", add border\n        if ( box !== \"padding\" ) {\n          delta += jQuery.css( elem, \"border\" + cssExpand[ i ] + \"Width\", true, styles );\n\n          // But still keep track of it otherwise\n        } else {\n          extra += jQuery.css( elem, \"border\" + cssExpand[ i ] + \"Width\", true, styles );\n        }\n\n        // If we get here with a border-box (content + padding + border), we're seeking \"content\" or\n        // \"padding\" or \"margin\"\n      } else {\n\n        // For \"content\", subtract padding\n        if ( box === \"content\" ) {\n          delta -= jQuery.css( elem, \"padding\" + cssExpand[ i ], true, styles );\n        }\n\n        // For \"content\" or \"padding\", subtract border\n        if ( box !== \"margin\" ) {\n          delta -= jQuery.css( elem, \"border\" + cssExpand[ i ] + \"Width\", true, styles );\n        }\n      }\n    }\n\n    // Account for positive content-box scroll gutter when requested by providing computedVal\n    if ( !isBorderBox && computedVal >= 0 ) {\n\n      // offsetWidth/offsetHeight is a rounded sum of content, padding, scroll gutter, and border\n      // Assuming integer scroll gutter, subtract the rest and round down\n      delta += Math.max( 0, Math.ceil(\n        elem[ \"offset\" + dimension[ 0 ].toUpperCase() + dimension.slice( 1 ) ] -\n        computedVal -\n        delta -\n        extra -\n        0.5\n\n        // If offsetWidth/offsetHeight is unknown, then we can't determine content-box scroll gutter\n        // Use an explicit zero to avoid NaN (gh-3964)\n      ) ) || 0;\n    }\n\n    return delta + marginDelta;\n  }\n\n  function getWidthOrHeight( elem, dimension, extra ) {\n\n    // Start with computed style\n    var styles = getStyles( elem ),\n\n      // To avoid forcing a reflow, only fetch boxSizing if we need it (gh-4322).\n      // Fake content-box until we know it's needed to know the true value.\n      boxSizingNeeded = !support.boxSizingReliable() || extra,\n      isBorderBox = boxSizingNeeded &&\n        jQuery.css( elem, \"boxSizing\", false, styles ) === \"border-box\",\n      valueIsBorderBox = isBorderBox,\n\n      val = curCSS( elem, dimension, styles ),\n      offsetProp = \"offset\" + dimension[ 0 ].toUpperCase() + dimension.slice( 1 );\n\n    // Support: Firefox <=54\n    // Return a confounding non-pixel value or feign ignorance, as appropriate.\n    if ( rnumnonpx.test( val ) ) {\n      if ( !extra ) {\n        return val;\n      }\n      val = \"auto\";\n    }\n\n\n    // Support: IE 9 - 11 only\n    // Use offsetWidth/offsetHeight for when box sizing is unreliable.\n    // In those cases, the computed value can be trusted to be border-box.\n    if ( ( !support.boxSizingReliable() && isBorderBox ||\n\n        // Support: IE 10 - 11+, Edge 15 - 18+\n        // IE/Edge misreport `getComputedStyle` of table rows with width/height\n        // set in CSS while `offset*` properties report correct values.\n        // Interestingly, in some cases IE 9 doesn't suffer from this issue.\n        !support.reliableTrDimensions() && nodeName( elem, \"tr\" ) ||\n\n        // Fall back to offsetWidth/offsetHeight when value is \"auto\"\n        // This happens for inline elements with no explicit setting (gh-3571)\n        val === \"auto\" ||\n\n        // Support: Android <=4.1 - 4.3 only\n        // Also use offsetWidth/offsetHeight for misreported inline dimensions (gh-3602)\n        !parseFloat( val ) && jQuery.css( elem, \"display\", false, styles ) === \"inline\" ) &&\n\n      // Make sure the element is visible & connected\n      elem.getClientRects().length ) {\n\n      isBorderBox = jQuery.css( elem, \"boxSizing\", false, styles ) === \"border-box\";\n\n      // Where available, offsetWidth/offsetHeight approximate border box dimensions.\n      // Where not available (e.g., SVG), assume unreliable box-sizing and interpret the\n      // retrieved value as a content box dimension.\n      valueIsBorderBox = offsetProp in elem;\n      if ( valueIsBorderBox ) {\n        val = elem[ offsetProp ];\n      }\n    }\n\n    // Normalize \"\" and auto\n    val = parseFloat( val ) || 0;\n\n    // Adjust for the element's box model\n    return ( val +\n      boxModelAdjustment(\n        elem,\n        dimension,\n        extra || ( isBorderBox ? \"border\" : \"content\" ),\n        valueIsBorderBox,\n        styles,\n\n        // Provide the current computed size to request scroll gutter calculation (gh-3589)\n        val\n      )\n    ) + \"px\";\n  }\n\n  jQuery.extend( {\n\n    // Add in style property hooks for overriding the default\n    // behavior of getting and setting a style property\n    cssHooks: {\n      opacity: {\n        get: function( elem, computed ) {\n          if ( computed ) {\n\n            // We should always get a number back from opacity\n            var ret = curCSS( elem, \"opacity\" );\n            return ret === \"\" ? \"1\" : ret;\n          }\n        }\n      }\n    },\n\n    // Don't automatically add \"px\" to these possibly-unitless properties\n    cssNumber: {\n      animationIterationCount: true,\n      aspectRatio: true,\n      borderImageSlice: true,\n      columnCount: true,\n      flexGrow: true,\n      flexShrink: true,\n      fontWeight: true,\n      gridArea: true,\n      gridColumn: true,\n      gridColumnEnd: true,\n      gridColumnStart: true,\n      gridRow: true,\n      gridRowEnd: true,\n      gridRowStart: true,\n      lineHeight: true,\n      opacity: true,\n      order: true,\n      orphans: true,\n      scale: true,\n      widows: true,\n      zIndex: true,\n      zoom: true,\n\n      // SVG-related\n      fillOpacity: true,\n      floodOpacity: true,\n      stopOpacity: true,\n      strokeMiterlimit: true,\n      strokeOpacity: true\n    },\n\n    // Add in properties whose names you wish to fix before\n    // setting or getting the value\n    cssProps: {},\n\n    // Get and set the style property on a DOM Node\n    style: function( elem, name, value, extra ) {\n\n      // Don't set styles on text and comment nodes\n      if ( !elem || elem.nodeType === 3 || elem.nodeType === 8 || !elem.style ) {\n        return;\n      }\n\n      // Make sure that we're working with the right name\n      var ret, type, hooks,\n        origName = camelCase( name ),\n        isCustomProp = rcustomProp.test( name ),\n        style = elem.style;\n\n      // Make sure that we're working with the right name. We don't\n      // want to query the value if it is a CSS custom property\n      // since they are user-defined.\n      if ( !isCustomProp ) {\n        name = finalPropName( origName );\n      }\n\n      // Gets hook for the prefixed version, then unprefixed version\n      hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ];\n\n      // Check if we're setting a value\n      if ( value !== undefined ) {\n        type = typeof value;\n\n        // Convert \"+=\" or \"-=\" to relative numbers (trac-7345)\n        if ( type === \"string\" && ( ret = rcssNum.exec( value ) ) && ret[ 1 ] ) {\n          value = adjustCSS( elem, name, ret );\n\n          // Fixes bug trac-9237\n          type = \"number\";\n        }\n\n        // Make sure that null and NaN values aren't set (trac-7116)\n        if ( value == null || value !== value ) {\n          return;\n        }\n\n        // If a number was passed in, add the unit (except for certain CSS properties)\n        // The isCustomProp check can be removed in jQuery 4.0 when we only auto-append\n        // \"px\" to a few hardcoded values.\n        if ( type === \"number\" && !isCustomProp ) {\n          value += ret && ret[ 3 ] || ( jQuery.cssNumber[ origName ] ? \"\" : \"px\" );\n        }\n\n        // background-* props affect original clone's values\n        if ( !support.clearCloneStyle && value === \"\" && name.indexOf( \"background\" ) === 0 ) {\n          style[ name ] = \"inherit\";\n        }\n\n        // If a hook was provided, use that value, otherwise just set the specified value\n        if ( !hooks || !( \"set\" in hooks ) ||\n          ( value = hooks.set( elem, value, extra ) ) !== undefined ) {\n\n          if ( isCustomProp ) {\n            style.setProperty( name, value );\n          } else {\n            style[ name ] = value;\n          }\n        }\n\n      } else {\n\n        // If a hook was provided get the non-computed value from there\n        if ( hooks && \"get\" in hooks &&\n          ( ret = hooks.get( elem, false, extra ) ) !== undefined ) {\n\n          return ret;\n        }\n\n        // Otherwise just get the value from the style object\n        return style[ name ];\n      }\n    },\n\n    css: function( elem, name, extra, styles ) {\n      var val, num, hooks,\n        origName = camelCase( name ),\n        isCustomProp = rcustomProp.test( name );\n\n      // Make sure that we're working with the right name. We don't\n      // want to modify the value if it is a CSS custom property\n      // since they are user-defined.\n      if ( !isCustomProp ) {\n        name = finalPropName( origName );\n      }\n\n      // Try prefixed name followed by the unprefixed name\n      hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ];\n\n      // If a hook was provided get the computed value from there\n      if ( hooks && \"get\" in hooks ) {\n        val = hooks.get( elem, true, extra );\n      }\n\n      // Otherwise, if a way to get the computed value exists, use that\n      if ( val === undefined ) {\n        val = curCSS( elem, name, styles );\n      }\n\n      // Convert \"normal\" to computed value\n      if ( val === \"normal\" && name in cssNormalTransform ) {\n        val = cssNormalTransform[ name ];\n      }\n\n      // Make numeric if forced or a qualifier was provided and val looks numeric\n      if ( extra === \"\" || extra ) {\n        num = parseFloat( val );\n        return extra === true || isFinite( num ) ? num || 0 : val;\n      }\n\n      return val;\n    }\n  } );\n\n  jQuery.each( [ \"height\", \"width\" ], function( _i, dimension ) {\n    jQuery.cssHooks[ dimension ] = {\n      get: function( elem, computed, extra ) {\n        if ( computed ) {\n\n          // Certain elements can have dimension info if we invisibly show them\n          // but it must have a current display style that would benefit\n          return rdisplayswap.test( jQuery.css( elem, \"display\" ) ) &&\n\n          // Support: Safari 8+\n          // Table columns in Safari have non-zero offsetWidth & zero\n          // getBoundingClientRect().width unless display is changed.\n          // Support: IE <=11 only\n          // Running getBoundingClientRect on a disconnected node\n          // in IE throws an error.\n          ( !elem.getClientRects().length || !elem.getBoundingClientRect().width ) ?\n            swap( elem, cssShow, function() {\n              return getWidthOrHeight( elem, dimension, extra );\n            } ) :\n            getWidthOrHeight( elem, dimension, extra );\n        }\n      },\n\n      set: function( elem, value, extra ) {\n        var matches,\n          styles = getStyles( elem ),\n\n          // Only read styles.position if the test has a chance to fail\n          // to avoid forcing a reflow.\n          scrollboxSizeBuggy = !support.scrollboxSize() &&\n            styles.position === \"absolute\",\n\n          // To avoid forcing a reflow, only fetch boxSizing if we need it (gh-3991)\n          boxSizingNeeded = scrollboxSizeBuggy || extra,\n          isBorderBox = boxSizingNeeded &&\n            jQuery.css( elem, \"boxSizing\", false, styles ) === \"border-box\",\n          subtract = extra ?\n            boxModelAdjustment(\n              elem,\n              dimension,\n              extra,\n              isBorderBox,\n              styles\n            ) :\n            0;\n\n        // Account for unreliable border-box dimensions by comparing offset* to computed and\n        // faking a content-box to get border and padding (gh-3699)\n        if ( isBorderBox && scrollboxSizeBuggy ) {\n          subtract -= Math.ceil(\n            elem[ \"offset\" + dimension[ 0 ].toUpperCase() + dimension.slice( 1 ) ] -\n            parseFloat( styles[ dimension ] ) -\n            boxModelAdjustment( elem, dimension, \"border\", false, styles ) -\n            0.5\n          );\n        }\n\n        // Convert to pixels if value adjustment is needed\n        if ( subtract && ( matches = rcssNum.exec( value ) ) &&\n          ( matches[ 3 ] || \"px\" ) !== \"px\" ) {\n\n          elem.style[ dimension ] = value;\n          value = jQuery.css( elem, dimension );\n        }\n\n        return setPositiveNumber( elem, value, subtract );\n      }\n    };\n  } );\n\n  jQuery.cssHooks.marginLeft = addGetHookIf( support.reliableMarginLeft,\n    function( elem, computed ) {\n      if ( computed ) {\n        return ( parseFloat( curCSS( elem, \"marginLeft\" ) ) ||\n          elem.getBoundingClientRect().left -\n          swap( elem, { marginLeft: 0 }, function() {\n            return elem.getBoundingClientRect().left;\n          } )\n        ) + \"px\";\n      }\n    }\n  );\n\n// These hooks are used by animate to expand properties\n  jQuery.each( {\n    margin: \"\",\n    padding: \"\",\n    border: \"Width\"\n  }, function( prefix, suffix ) {\n    jQuery.cssHooks[ prefix + suffix ] = {\n      expand: function( value ) {\n        var i = 0,\n          expanded = {},\n\n          // Assumes a single number if not a string\n          parts = typeof value === \"string\" ? value.split( \" \" ) : [ value ];\n\n        for ( ; i < 4; i++ ) {\n          expanded[ prefix + cssExpand[ i ] + suffix ] =\n            parts[ i ] || parts[ i - 2 ] || parts[ 0 ];\n        }\n\n        return expanded;\n      }\n    };\n\n    if ( prefix !== \"margin\" ) {\n      jQuery.cssHooks[ prefix + suffix ].set = setPositiveNumber;\n    }\n  } );\n\n  jQuery.fn.extend( {\n    css: function( name, value ) {\n      return access( this, function( elem, name, value ) {\n        var styles, len,\n          map = {},\n          i = 0;\n\n        if ( Array.isArray( name ) ) {\n          styles = getStyles( elem );\n          len = name.length;\n\n          for ( ; i < len; i++ ) {\n            map[ name[ i ] ] = jQuery.css( elem, name[ i ], false, styles );\n          }\n\n          return map;\n        }\n\n        return value !== undefined ?\n          jQuery.style( elem, name, value ) :\n          jQuery.css( elem, name );\n      }, name, value, arguments.length > 1 );\n    }\n  } );\n\n\n  function Tween( elem, options, prop, end, easing ) {\n    return new Tween.prototype.init( elem, options, prop, end, easing );\n  }\n  jQuery.Tween = Tween;\n\n  Tween.prototype = {\n    constructor: Tween,\n    init: function( elem, options, prop, end, easing, unit ) {\n      this.elem = elem;\n      this.prop = prop;\n      this.easing = easing || jQuery.easing._default;\n      this.options = options;\n      this.start = this.now = this.cur();\n      this.end = end;\n      this.unit = unit || ( jQuery.cssNumber[ prop ] ? \"\" : \"px\" );\n    },\n    cur: function() {\n      var hooks = Tween.propHooks[ this.prop ];\n\n      return hooks && hooks.get ?\n        hooks.get( this ) :\n        Tween.propHooks._default.get( this );\n    },\n    run: function( percent ) {\n      var eased,\n        hooks = Tween.propHooks[ this.prop ];\n\n      if ( this.options.duration ) {\n        this.pos = eased = jQuery.easing[ this.easing ](\n          percent, this.options.duration * percent, 0, 1, this.options.duration\n        );\n      } else {\n        this.pos = eased = percent;\n      }\n      this.now = ( this.end - this.start ) * eased + this.start;\n\n      if ( this.options.step ) {\n        this.options.step.call( this.elem, this.now, this );\n      }\n\n      if ( hooks && hooks.set ) {\n        hooks.set( this );\n      } else {\n        Tween.propHooks._default.set( this );\n      }\n      return this;\n    }\n  };\n\n  Tween.prototype.init.prototype = Tween.prototype;\n\n  Tween.propHooks = {\n    _default: {\n      get: function( tween ) {\n        var result;\n\n        // Use a property on the element directly when it is not a DOM element,\n        // or when there is no matching style property that exists.\n        if ( tween.elem.nodeType !== 1 ||\n          tween.elem[ tween.prop ] != null && tween.elem.style[ tween.prop ] == null ) {\n          return tween.elem[ tween.prop ];\n        }\n\n        // Passing an empty string as a 3rd parameter to .css will automatically\n        // attempt a parseFloat and fallback to a string if the parse fails.\n        // Simple values such as \"10px\" are parsed to Float;\n        // complex values such as \"rotate(1rad)\" are returned as-is.\n        result = jQuery.css( tween.elem, tween.prop, \"\" );\n\n        // Empty strings, null, undefined and \"auto\" are converted to 0.\n        return !result || result === \"auto\" ? 0 : result;\n      },\n      set: function( tween ) {\n\n        // Use step hook for back compat.\n        // Use cssHook if its there.\n        // Use .style if available and use plain properties where available.\n        if ( jQuery.fx.step[ tween.prop ] ) {\n          jQuery.fx.step[ tween.prop ]( tween );\n        } else if ( tween.elem.nodeType === 1 && (\n          jQuery.cssHooks[ tween.prop ] ||\n          tween.elem.style[ finalPropName( tween.prop ) ] != null ) ) {\n          jQuery.style( tween.elem, tween.prop, tween.now + tween.unit );\n        } else {\n          tween.elem[ tween.prop ] = tween.now;\n        }\n      }\n    }\n  };\n\n// Support: IE <=9 only\n// Panic based approach to setting things on disconnected nodes\n  Tween.propHooks.scrollTop = Tween.propHooks.scrollLeft = {\n    set: function( tween ) {\n      if ( tween.elem.nodeType && tween.elem.parentNode ) {\n        tween.elem[ tween.prop ] = tween.now;\n      }\n    }\n  };\n\n  jQuery.easing = {\n    linear: function( p ) {\n      return p;\n    },\n    swing: function( p ) {\n      return 0.5 - Math.cos( p * Math.PI ) / 2;\n    },\n    _default: \"swing\"\n  };\n\n  jQuery.fx = Tween.prototype.init;\n\n// Back compat <1.8 extension point\n  jQuery.fx.step = {};\n\n\n\n\n  var\n    fxNow, inProgress,\n    rfxtypes = /^(?:toggle|show|hide)$/,\n    rrun = /queueHooks$/;\n\n  function schedule() {\n    if ( inProgress ) {\n      if ( document.hidden === false && window.requestAnimationFrame ) {\n        window.requestAnimationFrame( schedule );\n      } else {\n        window.setTimeout( schedule, jQuery.fx.interval );\n      }\n\n      jQuery.fx.tick();\n    }\n  }\n\n// Animations created synchronously will run synchronously\n  function createFxNow() {\n    window.setTimeout( function() {\n      fxNow = undefined;\n    } );\n    return ( fxNow = Date.now() );\n  }\n\n// Generate parameters to create a standard animation\n  function genFx( type, includeWidth ) {\n    var which,\n      i = 0,\n      attrs = { height: type };\n\n    // If we include width, step value is 1 to do all cssExpand values,\n    // otherwise step value is 2 to skip over Left and Right\n    includeWidth = includeWidth ? 1 : 0;\n    for ( ; i < 4; i += 2 - includeWidth ) {\n      which = cssExpand[ i ];\n      attrs[ \"margin\" + which ] = attrs[ \"padding\" + which ] = type;\n    }\n\n    if ( includeWidth ) {\n      attrs.opacity = attrs.width = type;\n    }\n\n    return attrs;\n  }\n\n  function createTween( value, prop, animation ) {\n    var tween,\n      collection = ( Animation.tweeners[ prop ] || [] ).concat( Animation.tweeners[ \"*\" ] ),\n      index = 0,\n      length = collection.length;\n    for ( ; index < length; index++ ) {\n      if ( ( tween = collection[ index ].call( animation, prop, value ) ) ) {\n\n        // We're done with this property\n        return tween;\n      }\n    }\n  }\n\n  function defaultPrefilter( elem, props, opts ) {\n    var prop, value, toggle, hooks, oldfire, propTween, restoreDisplay, display,\n      isBox = \"width\" in props || \"height\" in props,\n      anim = this,\n      orig = {},\n      style = elem.style,\n      hidden = elem.nodeType && isHiddenWithinTree( elem ),\n      dataShow = dataPriv.get( elem, \"fxshow\" );\n\n    // Queue-skipping animations hijack the fx hooks\n    if ( !opts.queue ) {\n      hooks = jQuery._queueHooks( elem, \"fx\" );\n      if ( hooks.unqueued == null ) {\n        hooks.unqueued = 0;\n        oldfire = hooks.empty.fire;\n        hooks.empty.fire = function() {\n          if ( !hooks.unqueued ) {\n            oldfire();\n          }\n        };\n      }\n      hooks.unqueued++;\n\n      anim.always( function() {\n\n        // Ensure the complete handler is called before this completes\n        anim.always( function() {\n          hooks.unqueued--;\n          if ( !jQuery.queue( elem, \"fx\" ).length ) {\n            hooks.empty.fire();\n          }\n        } );\n      } );\n    }\n\n    // Detect show/hide animations\n    for ( prop in props ) {\n      value = props[ prop ];\n      if ( rfxtypes.test( value ) ) {\n        delete props[ prop ];\n        toggle = toggle || value === \"toggle\";\n        if ( value === ( hidden ? \"hide\" : \"show\" ) ) {\n\n          // Pretend to be hidden if this is a \"show\" and\n          // there is still data from a stopped show/hide\n          if ( value === \"show\" && dataShow && dataShow[ prop ] !== undefined ) {\n            hidden = true;\n\n            // Ignore all other no-op show/hide data\n          } else {\n            continue;\n          }\n        }\n        orig[ prop ] = dataShow && dataShow[ prop ] || jQuery.style( elem, prop );\n      }\n    }\n\n    // Bail out if this is a no-op like .hide().hide()\n    propTween = !jQuery.isEmptyObject( props );\n    if ( !propTween && jQuery.isEmptyObject( orig ) ) {\n      return;\n    }\n\n    // Restrict \"overflow\" and \"display\" styles during box animations\n    if ( isBox && elem.nodeType === 1 ) {\n\n      // Support: IE <=9 - 11, Edge 12 - 15\n      // Record all 3 overflow attributes because IE does not infer the shorthand\n      // from identically-valued overflowX and overflowY and Edge just mirrors\n      // the overflowX value there.\n      opts.overflow = [ style.overflow, style.overflowX, style.overflowY ];\n\n      // Identify a display type, preferring old show/hide data over the CSS cascade\n      restoreDisplay = dataShow && dataShow.display;\n      if ( restoreDisplay == null ) {\n        restoreDisplay = dataPriv.get( elem, \"display\" );\n      }\n      display = jQuery.css( elem, \"display\" );\n      if ( display === \"none\" ) {\n        if ( restoreDisplay ) {\n          display = restoreDisplay;\n        } else {\n\n          // Get nonempty value(s) by temporarily forcing visibility\n          showHide( [ elem ], true );\n          restoreDisplay = elem.style.display || restoreDisplay;\n          display = jQuery.css( elem, \"display\" );\n          showHide( [ elem ] );\n        }\n      }\n\n      // Animate inline elements as inline-block\n      if ( display === \"inline\" || display === \"inline-block\" && restoreDisplay != null ) {\n        if ( jQuery.css( elem, \"float\" ) === \"none\" ) {\n\n          // Restore the original display value at the end of pure show/hide animations\n          if ( !propTween ) {\n            anim.done( function() {\n              style.display = restoreDisplay;\n            } );\n            if ( restoreDisplay == null ) {\n              display = style.display;\n              restoreDisplay = display === \"none\" ? \"\" : display;\n            }\n          }\n          style.display = \"inline-block\";\n        }\n      }\n    }\n\n    if ( opts.overflow ) {\n      style.overflow = \"hidden\";\n      anim.always( function() {\n        style.overflow = opts.overflow[ 0 ];\n        style.overflowX = opts.overflow[ 1 ];\n        style.overflowY = opts.overflow[ 2 ];\n      } );\n    }\n\n    // Implement show/hide animations\n    propTween = false;\n    for ( prop in orig ) {\n\n      // General show/hide setup for this element animation\n      if ( !propTween ) {\n        if ( dataShow ) {\n          if ( \"hidden\" in dataShow ) {\n            hidden = dataShow.hidden;\n          }\n        } else {\n          dataShow = dataPriv.access( elem, \"fxshow\", { display: restoreDisplay } );\n        }\n\n        // Store hidden/visible for toggle so `.stop().toggle()` \"reverses\"\n        if ( toggle ) {\n          dataShow.hidden = !hidden;\n        }\n\n        // Show elements before animating them\n        if ( hidden ) {\n          showHide( [ elem ], true );\n        }\n\n        /* eslint-disable no-loop-func */\n\n        anim.done( function() {\n\n          /* eslint-enable no-loop-func */\n\n          // The final step of a \"hide\" animation is actually hiding the element\n          if ( !hidden ) {\n            showHide( [ elem ] );\n          }\n          dataPriv.remove( elem, \"fxshow\" );\n          for ( prop in orig ) {\n            jQuery.style( elem, prop, orig[ prop ] );\n          }\n        } );\n      }\n\n      // Per-property setup\n      propTween = createTween( hidden ? dataShow[ prop ] : 0, prop, anim );\n      if ( !( prop in dataShow ) ) {\n        dataShow[ prop ] = propTween.start;\n        if ( hidden ) {\n          propTween.end = propTween.start;\n          propTween.start = 0;\n        }\n      }\n    }\n  }\n\n  function propFilter( props, specialEasing ) {\n    var index, name, easing, value, hooks;\n\n    // camelCase, specialEasing and expand cssHook pass\n    for ( index in props ) {\n      name = camelCase( index );\n      easing = specialEasing[ name ];\n      value = props[ index ];\n      if ( Array.isArray( value ) ) {\n        easing = value[ 1 ];\n        value = props[ index ] = value[ 0 ];\n      }\n\n      if ( index !== name ) {\n        props[ name ] = value;\n        delete props[ index ];\n      }\n\n      hooks = jQuery.cssHooks[ name ];\n      if ( hooks && \"expand\" in hooks ) {\n        value = hooks.expand( value );\n        delete props[ name ];\n\n        // Not quite $.extend, this won't overwrite existing keys.\n        // Reusing 'index' because we have the correct \"name\"\n        for ( index in value ) {\n          if ( !( index in props ) ) {\n            props[ index ] = value[ index ];\n            specialEasing[ index ] = easing;\n          }\n        }\n      } else {\n        specialEasing[ name ] = easing;\n      }\n    }\n  }\n\n  function Animation( elem, properties, options ) {\n    var result,\n      stopped,\n      index = 0,\n      length = Animation.prefilters.length,\n      deferred = jQuery.Deferred().always( function() {\n\n        // Don't match elem in the :animated selector\n        delete tick.elem;\n      } ),\n      tick = function() {\n        if ( stopped ) {\n          return false;\n        }\n        var currentTime = fxNow || createFxNow(),\n          remaining = Math.max( 0, animation.startTime + animation.duration - currentTime ),\n\n          // Support: Android 2.3 only\n          // Archaic crash bug won't allow us to use `1 - ( 0.5 || 0 )` (trac-12497)\n          temp = remaining / animation.duration || 0,\n          percent = 1 - temp,\n          index = 0,\n          length = animation.tweens.length;\n\n        for ( ; index < length; index++ ) {\n          animation.tweens[ index ].run( percent );\n        }\n\n        deferred.notifyWith( elem, [ animation, percent, remaining ] );\n\n        // If there's more to do, yield\n        if ( percent < 1 && length ) {\n          return remaining;\n        }\n\n        // If this was an empty animation, synthesize a final progress notification\n        if ( !length ) {\n          deferred.notifyWith( elem, [ animation, 1, 0 ] );\n        }\n\n        // Resolve the animation and report its conclusion\n        deferred.resolveWith( elem, [ animation ] );\n        return false;\n      },\n      animation = deferred.promise( {\n        elem: elem,\n        props: jQuery.extend( {}, properties ),\n        opts: jQuery.extend( true, {\n          specialEasing: {},\n          easing: jQuery.easing._default\n        }, options ),\n        originalProperties: properties,\n        originalOptions: options,\n        startTime: fxNow || createFxNow(),\n        duration: options.duration,\n        tweens: [],\n        createTween: function( prop, end ) {\n          var tween = jQuery.Tween( elem, animation.opts, prop, end,\n            animation.opts.specialEasing[ prop ] || animation.opts.easing );\n          animation.tweens.push( tween );\n          return tween;\n        },\n        stop: function( gotoEnd ) {\n          var index = 0,\n\n            // If we are going to the end, we want to run all the tweens\n            // otherwise we skip this part\n            length = gotoEnd ? animation.tweens.length : 0;\n          if ( stopped ) {\n            return this;\n          }\n          stopped = true;\n          for ( ; index < length; index++ ) {\n            animation.tweens[ index ].run( 1 );\n          }\n\n          // Resolve when we played the last frame; otherwise, reject\n          if ( gotoEnd ) {\n            deferred.notifyWith( elem, [ animation, 1, 0 ] );\n            deferred.resolveWith( elem, [ animation, gotoEnd ] );\n          } else {\n            deferred.rejectWith( elem, [ animation, gotoEnd ] );\n          }\n          return this;\n        }\n      } ),\n      props = animation.props;\n\n    propFilter( props, animation.opts.specialEasing );\n\n    for ( ; index < length; index++ ) {\n      result = Animation.prefilters[ index ].call( animation, elem, props, animation.opts );\n      if ( result ) {\n        if ( isFunction( result.stop ) ) {\n          jQuery._queueHooks( animation.elem, animation.opts.queue ).stop =\n            result.stop.bind( result );\n        }\n        return result;\n      }\n    }\n\n    jQuery.map( props, createTween, animation );\n\n    if ( isFunction( animation.opts.start ) ) {\n      animation.opts.start.call( elem, animation );\n    }\n\n    // Attach callbacks from options\n    animation\n      .progress( animation.opts.progress )\n      .done( animation.opts.done, animation.opts.complete )\n      .fail( animation.opts.fail )\n      .always( animation.opts.always );\n\n    jQuery.fx.timer(\n      jQuery.extend( tick, {\n        elem: elem,\n        anim: animation,\n        queue: animation.opts.queue\n      } )\n    );\n\n    return animation;\n  }\n\n  jQuery.Animation = jQuery.extend( Animation, {\n\n    tweeners: {\n      \"*\": [ function( prop, value ) {\n        var tween = this.createTween( prop, value );\n        adjustCSS( tween.elem, prop, rcssNum.exec( value ), tween );\n        return tween;\n      } ]\n    },\n\n    tweener: function( props, callback ) {\n      if ( isFunction( props ) ) {\n        callback = props;\n        props = [ \"*\" ];\n      } else {\n        props = props.match( rnothtmlwhite );\n      }\n\n      var prop,\n        index = 0,\n        length = props.length;\n\n      for ( ; index < length; index++ ) {\n        prop = props[ index ];\n        Animation.tweeners[ prop ] = Animation.tweeners[ prop ] || [];\n        Animation.tweeners[ prop ].unshift( callback );\n      }\n    },\n\n    prefilters: [ defaultPrefilter ],\n\n    prefilter: function( callback, prepend ) {\n      if ( prepend ) {\n        Animation.prefilters.unshift( callback );\n      } else {\n        Animation.prefilters.push( callback );\n      }\n    }\n  } );\n\n  jQuery.speed = function( speed, easing, fn ) {\n    var opt = speed && typeof speed === \"object\" ? jQuery.extend( {}, speed ) : {\n      complete: fn || !fn && easing ||\n        isFunction( speed ) && speed,\n      duration: speed,\n      easing: fn && easing || easing && !isFunction( easing ) && easing\n    };\n\n    // Go to the end state if fx are off\n    if ( jQuery.fx.off ) {\n      opt.duration = 0;\n\n    } else {\n      if ( typeof opt.duration !== \"number\" ) {\n        if ( opt.duration in jQuery.fx.speeds ) {\n          opt.duration = jQuery.fx.speeds[ opt.duration ];\n\n        } else {\n          opt.duration = jQuery.fx.speeds._default;\n        }\n      }\n    }\n\n    // Normalize opt.queue - true/undefined/null -> \"fx\"\n    if ( opt.queue == null || opt.queue === true ) {\n      opt.queue = \"fx\";\n    }\n\n    // Queueing\n    opt.old = opt.complete;\n\n    opt.complete = function() {\n      if ( isFunction( opt.old ) ) {\n        opt.old.call( this );\n      }\n\n      if ( opt.queue ) {\n        jQuery.dequeue( this, opt.queue );\n      }\n    };\n\n    return opt;\n  };\n\n  jQuery.fn.extend( {\n    fadeTo: function( speed, to, easing, callback ) {\n\n      // Show any hidden elements after setting opacity to 0\n      return this.filter( isHiddenWithinTree ).css( \"opacity\", 0 ).show()\n\n        // Animate to the value specified\n        .end().animate( { opacity: to }, speed, easing, callback );\n    },\n    animate: function( prop, speed, easing, callback ) {\n      var empty = jQuery.isEmptyObject( prop ),\n        optall = jQuery.speed( speed, easing, callback ),\n        doAnimation = function() {\n\n          // Operate on a copy of prop so per-property easing won't be lost\n          var anim = Animation( this, jQuery.extend( {}, prop ), optall );\n\n          // Empty animations, or finishing resolves immediately\n          if ( empty || dataPriv.get( this, \"finish\" ) ) {\n            anim.stop( true );\n          }\n        };\n\n      doAnimation.finish = doAnimation;\n\n      return empty || optall.queue === false ?\n        this.each( doAnimation ) :\n        this.queue( optall.queue, doAnimation );\n    },\n    stop: function( type, clearQueue, gotoEnd ) {\n      var stopQueue = function( hooks ) {\n        var stop = hooks.stop;\n        delete hooks.stop;\n        stop( gotoEnd );\n      };\n\n      if ( typeof type !== \"string\" ) {\n        gotoEnd = clearQueue;\n        clearQueue = type;\n        type = undefined;\n      }\n      if ( clearQueue ) {\n        this.queue( type || \"fx\", [] );\n      }\n\n      return this.each( function() {\n        var dequeue = true,\n          index = type != null && type + \"queueHooks\",\n          timers = jQuery.timers,\n          data = dataPriv.get( this );\n\n        if ( index ) {\n          if ( data[ index ] && data[ index ].stop ) {\n            stopQueue( data[ index ] );\n          }\n        } else {\n          for ( index in data ) {\n            if ( data[ index ] && data[ index ].stop && rrun.test( index ) ) {\n              stopQueue( data[ index ] );\n            }\n          }\n        }\n\n        for ( index = timers.length; index--; ) {\n          if ( timers[ index ].elem === this &&\n            ( type == null || timers[ index ].queue === type ) ) {\n\n            timers[ index ].anim.stop( gotoEnd );\n            dequeue = false;\n            timers.splice( index, 1 );\n          }\n        }\n\n        // Start the next in the queue if the last step wasn't forced.\n        // Timers currently will call their complete callbacks, which\n        // will dequeue but only if they were gotoEnd.\n        if ( dequeue || !gotoEnd ) {\n          jQuery.dequeue( this, type );\n        }\n      } );\n    },\n    finish: function( type ) {\n      if ( type !== false ) {\n        type = type || \"fx\";\n      }\n      return this.each( function() {\n        var index,\n          data = dataPriv.get( this ),\n          queue = data[ type + \"queue\" ],\n          hooks = data[ type + \"queueHooks\" ],\n          timers = jQuery.timers,\n          length = queue ? queue.length : 0;\n\n        // Enable finishing flag on private data\n        data.finish = true;\n\n        // Empty the queue first\n        jQuery.queue( this, type, [] );\n\n        if ( hooks && hooks.stop ) {\n          hooks.stop.call( this, true );\n        }\n\n        // Look for any active animations, and finish them\n        for ( index = timers.length; index--; ) {\n          if ( timers[ index ].elem === this && timers[ index ].queue === type ) {\n            timers[ index ].anim.stop( true );\n            timers.splice( index, 1 );\n          }\n        }\n\n        // Look for any animations in the old queue and finish them\n        for ( index = 0; index < length; index++ ) {\n          if ( queue[ index ] && queue[ index ].finish ) {\n            queue[ index ].finish.call( this );\n          }\n        }\n\n        // Turn off finishing flag\n        delete data.finish;\n      } );\n    }\n  } );\n\n  jQuery.each( [ \"toggle\", \"show\", \"hide\" ], function( _i, name ) {\n    var cssFn = jQuery.fn[ name ];\n    jQuery.fn[ name ] = function( speed, easing, callback ) {\n      return speed == null || typeof speed === \"boolean\" ?\n        cssFn.apply( this, arguments ) :\n        this.animate( genFx( name, true ), speed, easing, callback );\n    };\n  } );\n\n// Generate shortcuts for custom animations\n  jQuery.each( {\n    slideDown: genFx( \"show\" ),\n    slideUp: genFx( \"hide\" ),\n    slideToggle: genFx( \"toggle\" ),\n    fadeIn: { opacity: \"show\" },\n    fadeOut: { opacity: \"hide\" },\n    fadeToggle: { opacity: \"toggle\" }\n  }, function( name, props ) {\n    jQuery.fn[ name ] = function( speed, easing, callback ) {\n      return this.animate( props, speed, easing, callback );\n    };\n  } );\n\n  jQuery.timers = [];\n  jQuery.fx.tick = function() {\n    var timer,\n      i = 0,\n      timers = jQuery.timers;\n\n    fxNow = Date.now();\n\n    for ( ; i < timers.length; i++ ) {\n      timer = timers[ i ];\n\n      // Run the timer and safely remove it when done (allowing for external removal)\n      if ( !timer() && timers[ i ] === timer ) {\n        timers.splice( i--, 1 );\n      }\n    }\n\n    if ( !timers.length ) {\n      jQuery.fx.stop();\n    }\n    fxNow = undefined;\n  };\n\n  jQuery.fx.timer = function( timer ) {\n    jQuery.timers.push( timer );\n    jQuery.fx.start();\n  };\n\n  jQuery.fx.interval = 13;\n  jQuery.fx.start = function() {\n    if ( inProgress ) {\n      return;\n    }\n\n    inProgress = true;\n    schedule();\n  };\n\n  jQuery.fx.stop = function() {\n    inProgress = null;\n  };\n\n  jQuery.fx.speeds = {\n    slow: 600,\n    fast: 200,\n\n    // Default speed\n    _default: 400\n  };\n\n\n// Based off of the plugin by Clint Helfers, with permission.\n  jQuery.fn.delay = function( time, type ) {\n    time = jQuery.fx ? jQuery.fx.speeds[ time ] || time : time;\n    type = type || \"fx\";\n\n    return this.queue( type, function( next, hooks ) {\n      var timeout = window.setTimeout( next, time );\n      hooks.stop = function() {\n        window.clearTimeout( timeout );\n      };\n    } );\n  };\n\n\n  ( function() {\n    var input = document.createElement( \"input\" ),\n      select = document.createElement( \"select\" ),\n      opt = select.appendChild( document.createElement( \"option\" ) );\n\n    input.type = \"checkbox\";\n\n    // Support: Android <=4.3 only\n    // Default value for a checkbox should be \"on\"\n    support.checkOn = input.value !== \"\";\n\n    // Support: IE <=11 only\n    // Must access selectedIndex to make default options select\n    support.optSelected = opt.selected;\n\n    // Support: IE <=11 only\n    // An input loses its value after becoming a radio\n    input = document.createElement( \"input\" );\n    input.value = \"t\";\n    input.type = \"radio\";\n    support.radioValue = input.value === \"t\";\n  } )();\n\n\n  var boolHook,\n    attrHandle = jQuery.expr.attrHandle;\n\n  jQuery.fn.extend( {\n    attr: function( name, value ) {\n      return access( this, jQuery.attr, name, value, arguments.length > 1 );\n    },\n\n    removeAttr: function( name ) {\n      return this.each( function() {\n        jQuery.removeAttr( this, name );\n      } );\n    }\n  } );\n\n  jQuery.extend( {\n    attr: function( elem, name, value ) {\n      var ret, hooks,\n        nType = elem.nodeType;\n\n      // Don't get/set attributes on text, comment and attribute nodes\n      if ( nType === 3 || nType === 8 || nType === 2 ) {\n        return;\n      }\n\n      // Fallback to prop when attributes are not supported\n      if ( typeof elem.getAttribute === \"undefined\" ) {\n        return jQuery.prop( elem, name, value );\n      }\n\n      // Attribute hooks are determined by the lowercase version\n      // Grab necessary hook if one is defined\n      if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) {\n        hooks = jQuery.attrHooks[ name.toLowerCase() ] ||\n          ( jQuery.expr.match.bool.test( name ) ? boolHook : undefined );\n      }\n\n      if ( value !== undefined ) {\n        if ( value === null ) {\n          jQuery.removeAttr( elem, name );\n          return;\n        }\n\n        if ( hooks && \"set\" in hooks &&\n          ( ret = hooks.set( elem, value, name ) ) !== undefined ) {\n          return ret;\n        }\n\n        elem.setAttribute( name, value + \"\" );\n        return value;\n      }\n\n      if ( hooks && \"get\" in hooks && ( ret = hooks.get( elem, name ) ) !== null ) {\n        return ret;\n      }\n\n      ret = jQuery.find.attr( elem, name );\n\n      // Non-existent attributes return null, we normalize to undefined\n      return ret == null ? undefined : ret;\n    },\n\n    attrHooks: {\n      type: {\n        set: function( elem, value ) {\n          if ( !support.radioValue && value === \"radio\" &&\n            nodeName( elem, \"input\" ) ) {\n            var val = elem.value;\n            elem.setAttribute( \"type\", value );\n            if ( val ) {\n              elem.value = val;\n            }\n            return value;\n          }\n        }\n      }\n    },\n\n    removeAttr: function( elem, value ) {\n      var name,\n        i = 0,\n\n        // Attribute names can contain non-HTML whitespace characters\n        // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2\n        attrNames = value && value.match( rnothtmlwhite );\n\n      if ( attrNames && elem.nodeType === 1 ) {\n        while ( ( name = attrNames[ i++ ] ) ) {\n          elem.removeAttribute( name );\n        }\n      }\n    }\n  } );\n\n// Hooks for boolean attributes\n  boolHook = {\n    set: function( elem, value, name ) {\n      if ( value === false ) {\n\n        // Remove boolean attributes when set to false\n        jQuery.removeAttr( elem, name );\n      } else {\n        elem.setAttribute( name, name );\n      }\n      return name;\n    }\n  };\n\n  jQuery.each( jQuery.expr.match.bool.source.match( /\\w+/g ), function( _i, name ) {\n    var getter = attrHandle[ name ] || jQuery.find.attr;\n\n    attrHandle[ name ] = function( elem, name, isXML ) {\n      var ret, handle,\n        lowercaseName = name.toLowerCase();\n\n      if ( !isXML ) {\n\n        // Avoid an infinite loop by temporarily removing this function from the getter\n        handle = attrHandle[ lowercaseName ];\n        attrHandle[ lowercaseName ] = ret;\n        ret = getter( elem, name, isXML ) != null ?\n          lowercaseName :\n          null;\n        attrHandle[ lowercaseName ] = handle;\n      }\n      return ret;\n    };\n  } );\n\n\n\n\n  var rfocusable = /^(?:input|select|textarea|button)$/i,\n    rclickable = /^(?:a|area)$/i;\n\n  jQuery.fn.extend( {\n    prop: function( name, value ) {\n      return access( this, jQuery.prop, name, value, arguments.length > 1 );\n    },\n\n    removeProp: function( name ) {\n      return this.each( function() {\n        delete this[ jQuery.propFix[ name ] || name ];\n      } );\n    }\n  } );\n\n  jQuery.extend( {\n    prop: function( elem, name, value ) {\n      var ret, hooks,\n        nType = elem.nodeType;\n\n      // Don't get/set properties on text, comment and attribute nodes\n      if ( nType === 3 || nType === 8 || nType === 2 ) {\n        return;\n      }\n\n      if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) {\n\n        // Fix name and attach hooks\n        name = jQuery.propFix[ name ] || name;\n        hooks = jQuery.propHooks[ name ];\n      }\n\n      if ( value !== undefined ) {\n        if ( hooks && \"set\" in hooks &&\n          ( ret = hooks.set( elem, value, name ) ) !== undefined ) {\n          return ret;\n        }\n\n        return ( elem[ name ] = value );\n      }\n\n      if ( hooks && \"get\" in hooks && ( ret = hooks.get( elem, name ) ) !== null ) {\n        return ret;\n      }\n\n      return elem[ name ];\n    },\n\n    propHooks: {\n      tabIndex: {\n        get: function( elem ) {\n\n          // Support: IE <=9 - 11 only\n          // elem.tabIndex doesn't always return the\n          // correct value when it hasn't been explicitly set\n          // Use proper attribute retrieval (trac-12072)\n          var tabindex = jQuery.find.attr( elem, \"tabindex\" );\n\n          if ( tabindex ) {\n            return parseInt( tabindex, 10 );\n          }\n\n          if (\n            rfocusable.test( elem.nodeName ) ||\n            rclickable.test( elem.nodeName ) &&\n            elem.href\n          ) {\n            return 0;\n          }\n\n          return -1;\n        }\n      }\n    },\n\n    propFix: {\n      \"for\": \"htmlFor\",\n      \"class\": \"className\"\n    }\n  } );\n\n// Support: IE <=11 only\n// Accessing the selectedIndex property\n// forces the browser to respect setting selected\n// on the option\n// The getter ensures a default option is selected\n// when in an optgroup\n// eslint rule \"no-unused-expressions\" is disabled for this code\n// since it considers such accessions noop\n  if ( !support.optSelected ) {\n    jQuery.propHooks.selected = {\n      get: function( elem ) {\n\n        /* eslint no-unused-expressions: \"off\" */\n\n        var parent = elem.parentNode;\n        if ( parent && parent.parentNode ) {\n          parent.parentNode.selectedIndex;\n        }\n        return null;\n      },\n      set: function( elem ) {\n\n        /* eslint no-unused-expressions: \"off\" */\n\n        var parent = elem.parentNode;\n        if ( parent ) {\n          parent.selectedIndex;\n\n          if ( parent.parentNode ) {\n            parent.parentNode.selectedIndex;\n          }\n        }\n      }\n    };\n  }\n\n  jQuery.each( [\n    \"tabIndex\",\n    \"readOnly\",\n    \"maxLength\",\n    \"cellSpacing\",\n    \"cellPadding\",\n    \"rowSpan\",\n    \"colSpan\",\n    \"useMap\",\n    \"frameBorder\",\n    \"contentEditable\"\n  ], function() {\n    jQuery.propFix[ this.toLowerCase() ] = this;\n  } );\n\n\n\n\n  // Strip and collapse whitespace according to HTML spec\n  // https://infra.spec.whatwg.org/#strip-and-collapse-ascii-whitespace\n  function stripAndCollapse( value ) {\n    var tokens = value.match( rnothtmlwhite ) || [];\n    return tokens.join( \" \" );\n  }\n\n\n  function getClass( elem ) {\n    return elem.getAttribute && elem.getAttribute( \"class\" ) || \"\";\n  }\n\n  function classesToArray( value ) {\n    if ( Array.isArray( value ) ) {\n      return value;\n    }\n    if ( typeof value === \"string\" ) {\n      return value.match( rnothtmlwhite ) || [];\n    }\n    return [];\n  }\n\n  jQuery.fn.extend( {\n    addClass: function( value ) {\n      var classNames, cur, curValue, className, i, finalValue;\n\n      if ( isFunction( value ) ) {\n        return this.each( function( j ) {\n          jQuery( this ).addClass( value.call( this, j, getClass( this ) ) );\n        } );\n      }\n\n      classNames = classesToArray( value );\n\n      if ( classNames.length ) {\n        return this.each( function() {\n          curValue = getClass( this );\n          cur = this.nodeType === 1 && ( \" \" + stripAndCollapse( curValue ) + \" \" );\n\n          if ( cur ) {\n            for ( i = 0; i < classNames.length; i++ ) {\n              className = classNames[ i ];\n              if ( cur.indexOf( \" \" + className + \" \" ) < 0 ) {\n                cur += className + \" \";\n              }\n            }\n\n            // Only assign if different to avoid unneeded rendering.\n            finalValue = stripAndCollapse( cur );\n            if ( curValue !== finalValue ) {\n              this.setAttribute( \"class\", finalValue );\n            }\n          }\n        } );\n      }\n\n      return this;\n    },\n\n    removeClass: function( value ) {\n      var classNames, cur, curValue, className, i, finalValue;\n\n      if ( isFunction( value ) ) {\n        return this.each( function( j ) {\n          jQuery( this ).removeClass( value.call( this, j, getClass( this ) ) );\n        } );\n      }\n\n      if ( !arguments.length ) {\n        return this.attr( \"class\", \"\" );\n      }\n\n      classNames = classesToArray( value );\n\n      if ( classNames.length ) {\n        return this.each( function() {\n          curValue = getClass( this );\n\n          // This expression is here for better compressibility (see addClass)\n          cur = this.nodeType === 1 && ( \" \" + stripAndCollapse( curValue ) + \" \" );\n\n          if ( cur ) {\n            for ( i = 0; i < classNames.length; i++ ) {\n              className = classNames[ i ];\n\n              // Remove *all* instances\n              while ( cur.indexOf( \" \" + className + \" \" ) > -1 ) {\n                cur = cur.replace( \" \" + className + \" \", \" \" );\n              }\n            }\n\n            // Only assign if different to avoid unneeded rendering.\n            finalValue = stripAndCollapse( cur );\n            if ( curValue !== finalValue ) {\n              this.setAttribute( \"class\", finalValue );\n            }\n          }\n        } );\n      }\n\n      return this;\n    },\n\n    toggleClass: function( value, stateVal ) {\n      var classNames, className, i, self,\n        type = typeof value,\n        isValidValue = type === \"string\" || Array.isArray( value );\n\n      if ( isFunction( value ) ) {\n        return this.each( function( i ) {\n          jQuery( this ).toggleClass(\n            value.call( this, i, getClass( this ), stateVal ),\n            stateVal\n          );\n        } );\n      }\n\n      if ( typeof stateVal === \"boolean\" && isValidValue ) {\n        return stateVal ? this.addClass( value ) : this.removeClass( value );\n      }\n\n      classNames = classesToArray( value );\n\n      return this.each( function() {\n        if ( isValidValue ) {\n\n          // Toggle individual class names\n          self = jQuery( this );\n\n          for ( i = 0; i < classNames.length; i++ ) {\n            className = classNames[ i ];\n\n            // Check each className given, space separated list\n            if ( self.hasClass( className ) ) {\n              self.removeClass( className );\n            } else {\n              self.addClass( className );\n            }\n          }\n\n          // Toggle whole class name\n        } else if ( value === undefined || type === \"boolean\" ) {\n          className = getClass( this );\n          if ( className ) {\n\n            // Store className if set\n            dataPriv.set( this, \"__className__\", className );\n          }\n\n          // If the element has a class name or if we're passed `false`,\n          // then remove the whole classname (if there was one, the above saved it).\n          // Otherwise bring back whatever was previously saved (if anything),\n          // falling back to the empty string if nothing was stored.\n          if ( this.setAttribute ) {\n            this.setAttribute( \"class\",\n              className || value === false ?\n                \"\" :\n                dataPriv.get( this, \"__className__\" ) || \"\"\n            );\n          }\n        }\n      } );\n    },\n\n    hasClass: function( selector ) {\n      var className, elem,\n        i = 0;\n\n      className = \" \" + selector + \" \";\n      while ( ( elem = this[ i++ ] ) ) {\n        if ( elem.nodeType === 1 &&\n          ( \" \" + stripAndCollapse( getClass( elem ) ) + \" \" ).indexOf( className ) > -1 ) {\n          return true;\n        }\n      }\n\n      return false;\n    }\n  } );\n\n\n\n\n  var rreturn = /\\r/g;\n\n  jQuery.fn.extend( {\n    val: function( value ) {\n      var hooks, ret, valueIsFunction,\n        elem = this[ 0 ];\n\n      if ( !arguments.length ) {\n        if ( elem ) {\n          hooks = jQuery.valHooks[ elem.type ] ||\n            jQuery.valHooks[ elem.nodeName.toLowerCase() ];\n\n          if ( hooks &&\n            \"get\" in hooks &&\n            ( ret = hooks.get( elem, \"value\" ) ) !== undefined\n          ) {\n            return ret;\n          }\n\n          ret = elem.value;\n\n          // Handle most common string cases\n          if ( typeof ret === \"string\" ) {\n            return ret.replace( rreturn, \"\" );\n          }\n\n          // Handle cases where value is null/undef or number\n          return ret == null ? \"\" : ret;\n        }\n\n        return;\n      }\n\n      valueIsFunction = isFunction( value );\n\n      return this.each( function( i ) {\n        var val;\n\n        if ( this.nodeType !== 1 ) {\n          return;\n        }\n\n        if ( valueIsFunction ) {\n          val = value.call( this, i, jQuery( this ).val() );\n        } else {\n          val = value;\n        }\n\n        // Treat null/undefined as \"\"; convert numbers to string\n        if ( val == null ) {\n          val = \"\";\n\n        } else if ( typeof val === \"number\" ) {\n          val += \"\";\n\n        } else if ( Array.isArray( val ) ) {\n          val = jQuery.map( val, function( value ) {\n            return value == null ? \"\" : value + \"\";\n          } );\n        }\n\n        hooks = jQuery.valHooks[ this.type ] || jQuery.valHooks[ this.nodeName.toLowerCase() ];\n\n        // If set returns undefined, fall back to normal setting\n        if ( !hooks || !( \"set\" in hooks ) || hooks.set( this, val, \"value\" ) === undefined ) {\n          this.value = val;\n        }\n      } );\n    }\n  } );\n\n  jQuery.extend( {\n    valHooks: {\n      option: {\n        get: function( elem ) {\n\n          var val = jQuery.find.attr( elem, \"value\" );\n          return val != null ?\n            val :\n\n            // Support: IE <=10 - 11 only\n            // option.text throws exceptions (trac-14686, trac-14858)\n            // Strip and collapse whitespace\n            // https://html.spec.whatwg.org/#strip-and-collapse-whitespace\n            stripAndCollapse( jQuery.text( elem ) );\n        }\n      },\n      select: {\n        get: function( elem ) {\n          var value, option, i,\n            options = elem.options,\n            index = elem.selectedIndex,\n            one = elem.type === \"select-one\",\n            values = one ? null : [],\n            max = one ? index + 1 : options.length;\n\n          if ( index < 0 ) {\n            i = max;\n\n          } else {\n            i = one ? index : 0;\n          }\n\n          // Loop through all the selected options\n          for ( ; i < max; i++ ) {\n            option = options[ i ];\n\n            // Support: IE <=9 only\n            // IE8-9 doesn't update selected after form reset (trac-2551)\n            if ( ( option.selected || i === index ) &&\n\n              // Don't return options that are disabled or in a disabled optgroup\n              !option.disabled &&\n              ( !option.parentNode.disabled ||\n                !nodeName( option.parentNode, \"optgroup\" ) ) ) {\n\n              // Get the specific value for the option\n              value = jQuery( option ).val();\n\n              // We don't need an array for one selects\n              if ( one ) {\n                return value;\n              }\n\n              // Multi-Selects return an array\n              values.push( value );\n            }\n          }\n\n          return values;\n        },\n\n        set: function( elem, value ) {\n          var optionSet, option,\n            options = elem.options,\n            values = jQuery.makeArray( value ),\n            i = options.length;\n\n          while ( i-- ) {\n            option = options[ i ];\n\n            /* eslint-disable no-cond-assign */\n\n            if ( option.selected =\n              jQuery.inArray( jQuery.valHooks.option.get( option ), values ) > -1\n            ) {\n              optionSet = true;\n            }\n\n            /* eslint-enable no-cond-assign */\n          }\n\n          // Force browsers to behave consistently when non-matching value is set\n          if ( !optionSet ) {\n            elem.selectedIndex = -1;\n          }\n          return values;\n        }\n      }\n    }\n  } );\n\n// Radios and checkboxes getter/setter\n  jQuery.each( [ \"radio\", \"checkbox\" ], function() {\n    jQuery.valHooks[ this ] = {\n      set: function( elem, value ) {\n        if ( Array.isArray( value ) ) {\n          return ( elem.checked = jQuery.inArray( jQuery( elem ).val(), value ) > -1 );\n        }\n      }\n    };\n    if ( !support.checkOn ) {\n      jQuery.valHooks[ this ].get = function( elem ) {\n        return elem.getAttribute( \"value\" ) === null ? \"on\" : elem.value;\n      };\n    }\n  } );\n\n\n\n\n// Return jQuery for attributes-only inclusion\n  var location = window.location;\n\n  var nonce = { guid: Date.now() };\n\n  var rquery = ( /\\?/ );\n\n\n\n// Cross-browser xml parsing\n  jQuery.parseXML = function( data ) {\n    var xml, parserErrorElem;\n    if ( !data || typeof data !== \"string\" ) {\n      return null;\n    }\n\n    // Support: IE 9 - 11 only\n    // IE throws on parseFromString with invalid input.\n    try {\n      xml = ( new window.DOMParser() ).parseFromString( data, \"text/xml\" );\n    } catch ( e ) {}\n\n    parserErrorElem = xml && xml.getElementsByTagName( \"parsererror\" )[ 0 ];\n    if ( !xml || parserErrorElem ) {\n      jQuery.error( \"Invalid XML: \" + (\n        parserErrorElem ?\n          jQuery.map( parserErrorElem.childNodes, function( el ) {\n            return el.textContent;\n          } ).join( \"\\n\" ) :\n          data\n      ) );\n    }\n    return xml;\n  };\n\n\n  var rfocusMorph = /^(?:focusinfocus|focusoutblur)$/,\n    stopPropagationCallback = function( e ) {\n      e.stopPropagation();\n    };\n\n  jQuery.extend( jQuery.event, {\n\n    trigger: function( event, data, elem, onlyHandlers ) {\n\n      var i, cur, tmp, bubbleType, ontype, handle, special, lastElement,\n        eventPath = [ elem || document ],\n        type = hasOwn.call( event, \"type\" ) ? event.type : event,\n        namespaces = hasOwn.call( event, \"namespace\" ) ? event.namespace.split( \".\" ) : [];\n\n      cur = lastElement = tmp = elem = elem || document;\n\n      // Don't do events on text and comment nodes\n      if ( elem.nodeType === 3 || elem.nodeType === 8 ) {\n        return;\n      }\n\n      // focus/blur morphs to focusin/out; ensure we're not firing them right now\n      if ( rfocusMorph.test( type + jQuery.event.triggered ) ) {\n        return;\n      }\n\n      if ( type.indexOf( \".\" ) > -1 ) {\n\n        // Namespaced trigger; create a regexp to match event type in handle()\n        namespaces = type.split( \".\" );\n        type = namespaces.shift();\n        namespaces.sort();\n      }\n      ontype = type.indexOf( \":\" ) < 0 && \"on\" + type;\n\n      // Caller can pass in a jQuery.Event object, Object, or just an event type string\n      event = event[ jQuery.expando ] ?\n        event :\n        new jQuery.Event( type, typeof event === \"object\" && event );\n\n      // Trigger bitmask: & 1 for native handlers; & 2 for jQuery (always true)\n      event.isTrigger = onlyHandlers ? 2 : 3;\n      event.namespace = namespaces.join( \".\" );\n      event.rnamespace = event.namespace ?\n        new RegExp( \"(^|\\\\.)\" + namespaces.join( \"\\\\.(?:.*\\\\.|)\" ) + \"(\\\\.|$)\" ) :\n        null;\n\n      // Clean up the event in case it is being reused\n      event.result = undefined;\n      if ( !event.target ) {\n        event.target = elem;\n      }\n\n      // Clone any incoming data and prepend the event, creating the handler arg list\n      data = data == null ?\n        [ event ] :\n        jQuery.makeArray( data, [ event ] );\n\n      // Allow special events to draw outside the lines\n      special = jQuery.event.special[ type ] || {};\n      if ( !onlyHandlers && special.trigger && special.trigger.apply( elem, data ) === false ) {\n        return;\n      }\n\n      // Determine event propagation path in advance, per W3C events spec (trac-9951)\n      // Bubble up to document, then to window; watch for a global ownerDocument var (trac-9724)\n      if ( !onlyHandlers && !special.noBubble && !isWindow( elem ) ) {\n\n        bubbleType = special.delegateType || type;\n        if ( !rfocusMorph.test( bubbleType + type ) ) {\n          cur = cur.parentNode;\n        }\n        for ( ; cur; cur = cur.parentNode ) {\n          eventPath.push( cur );\n          tmp = cur;\n        }\n\n        // Only add window if we got to document (e.g., not plain obj or detached DOM)\n        if ( tmp === ( elem.ownerDocument || document ) ) {\n          eventPath.push( tmp.defaultView || tmp.parentWindow || window );\n        }\n      }\n\n      // Fire handlers on the event path\n      i = 0;\n      while ( ( cur = eventPath[ i++ ] ) && !event.isPropagationStopped() ) {\n        lastElement = cur;\n        event.type = i > 1 ?\n          bubbleType :\n          special.bindType || type;\n\n        // jQuery handler\n        handle = ( dataPriv.get( cur, \"events\" ) || Object.create( null ) )[ event.type ] &&\n          dataPriv.get( cur, \"handle\" );\n        if ( handle ) {\n          handle.apply( cur, data );\n        }\n\n        // Native handler\n        handle = ontype && cur[ ontype ];\n        if ( handle && handle.apply && acceptData( cur ) ) {\n          event.result = handle.apply( cur, data );\n          if ( event.result === false ) {\n            event.preventDefault();\n          }\n        }\n      }\n      event.type = type;\n\n      // If nobody prevented the default action, do it now\n      if ( !onlyHandlers && !event.isDefaultPrevented() ) {\n\n        if ( ( !special._default ||\n            special._default.apply( eventPath.pop(), data ) === false ) &&\n          acceptData( elem ) ) {\n\n          // Call a native DOM method on the target with the same name as the event.\n          // Don't do default actions on window, that's where global variables be (trac-6170)\n          if ( ontype && isFunction( elem[ type ] ) && !isWindow( elem ) ) {\n\n            // Don't re-trigger an onFOO event when we call its FOO() method\n            tmp = elem[ ontype ];\n\n            if ( tmp ) {\n              elem[ ontype ] = null;\n            }\n\n            // Prevent re-triggering of the same event, since we already bubbled it above\n            jQuery.event.triggered = type;\n\n            if ( event.isPropagationStopped() ) {\n              lastElement.addEventListener( type, stopPropagationCallback );\n            }\n\n            elem[ type ]();\n\n            if ( event.isPropagationStopped() ) {\n              lastElement.removeEventListener( type, stopPropagationCallback );\n            }\n\n            jQuery.event.triggered = undefined;\n\n            if ( tmp ) {\n              elem[ ontype ] = tmp;\n            }\n          }\n        }\n      }\n\n      return event.result;\n    },\n\n    // Piggyback on a donor event to simulate a different one\n    // Used only for `focus(in | out)` events\n    simulate: function( type, elem, event ) {\n      var e = jQuery.extend(\n        new jQuery.Event(),\n        event,\n        {\n          type: type,\n          isSimulated: true\n        }\n      );\n\n      jQuery.event.trigger( e, null, elem );\n    }\n\n  } );\n\n  jQuery.fn.extend( {\n\n    trigger: function( type, data ) {\n      return this.each( function() {\n        jQuery.event.trigger( type, data, this );\n      } );\n    },\n    triggerHandler: function( type, data ) {\n      var elem = this[ 0 ];\n      if ( elem ) {\n        return jQuery.event.trigger( type, data, elem, true );\n      }\n    }\n  } );\n\n\n  var\n    rbracket = /\\[\\]$/,\n    rCRLF = /\\r?\\n/g,\n    rsubmitterTypes = /^(?:submit|button|image|reset|file)$/i,\n    rsubmittable = /^(?:input|select|textarea|keygen)/i;\n\n  function buildParams( prefix, obj, traditional, add ) {\n    var name;\n\n    if ( Array.isArray( obj ) ) {\n\n      // Serialize array item.\n      jQuery.each( obj, function( i, v ) {\n        if ( traditional || rbracket.test( prefix ) ) {\n\n          // Treat each array item as a scalar.\n          add( prefix, v );\n\n        } else {\n\n          // Item is non-scalar (array or object), encode its numeric index.\n          buildParams(\n            prefix + \"[\" + ( typeof v === \"object\" && v != null ? i : \"\" ) + \"]\",\n            v,\n            traditional,\n            add\n          );\n        }\n      } );\n\n    } else if ( !traditional && toType( obj ) === \"object\" ) {\n\n      // Serialize object item.\n      for ( name in obj ) {\n        buildParams( prefix + \"[\" + name + \"]\", obj[ name ], traditional, add );\n      }\n\n    } else {\n\n      // Serialize scalar item.\n      add( prefix, obj );\n    }\n  }\n\n// Serialize an array of form elements or a set of\n// key/values into a query string\n  jQuery.param = function( a, traditional ) {\n    var prefix,\n      s = [],\n      add = function( key, valueOrFunction ) {\n\n        // If value is a function, invoke it and use its return value\n        var value = isFunction( valueOrFunction ) ?\n          valueOrFunction() :\n          valueOrFunction;\n\n        s[ s.length ] = encodeURIComponent( key ) + \"=\" +\n          encodeURIComponent( value == null ? \"\" : value );\n      };\n\n    if ( a == null ) {\n      return \"\";\n    }\n\n    // If an array was passed in, assume that it is an array of form elements.\n    if ( Array.isArray( a ) || ( a.jquery && !jQuery.isPlainObject( a ) ) ) {\n\n      // Serialize the form elements\n      jQuery.each( a, function() {\n        add( this.name, this.value );\n      } );\n\n    } else {\n\n      // If traditional, encode the \"old\" way (the way 1.3.2 or older\n      // did it), otherwise encode params recursively.\n      for ( prefix in a ) {\n        buildParams( prefix, a[ prefix ], traditional, add );\n      }\n    }\n\n    // Return the resulting serialization\n    return s.join( \"&\" );\n  };\n\n  jQuery.fn.extend( {\n    serialize: function() {\n      return jQuery.param( this.serializeArray() );\n    },\n    serializeArray: function() {\n      return this.map( function() {\n\n        // Can add propHook for \"elements\" to filter or add form elements\n        var elements = jQuery.prop( this, \"elements\" );\n        return elements ? jQuery.makeArray( elements ) : this;\n      } ).filter( function() {\n        var type = this.type;\n\n        // Use .is( \":disabled\" ) so that fieldset[disabled] works\n        return this.name && !jQuery( this ).is( \":disabled\" ) &&\n          rsubmittable.test( this.nodeName ) && !rsubmitterTypes.test( type ) &&\n          ( this.checked || !rcheckableType.test( type ) );\n      } ).map( function( _i, elem ) {\n        var val = jQuery( this ).val();\n\n        if ( val == null ) {\n          return null;\n        }\n\n        if ( Array.isArray( val ) ) {\n          return jQuery.map( val, function( val ) {\n            return { name: elem.name, value: val.replace( rCRLF, \"\\r\\n\" ) };\n          } );\n        }\n\n        return { name: elem.name, value: val.replace( rCRLF, \"\\r\\n\" ) };\n      } ).get();\n    }\n  } );\n\n\n  var\n    r20 = /%20/g,\n    rhash = /#.*$/,\n    rantiCache = /([?&])_=[^&]*/,\n    rheaders = /^(.*?):[ \\t]*([^\\r\\n]*)$/mg,\n\n    // trac-7653, trac-8125, trac-8152: local protocol detection\n    rlocalProtocol = /^(?:about|app|app-storage|.+-extension|file|res|widget):$/,\n    rnoContent = /^(?:GET|HEAD)$/,\n    rprotocol = /^\\/\\//,\n\n    /* Prefilters\n\t * 1) They are useful to introduce custom dataTypes (see ajax/jsonp.js for an example)\n\t * 2) These are called:\n\t *    - BEFORE asking for a transport\n\t *    - AFTER param serialization (s.data is a string if s.processData is true)\n\t * 3) key is the dataType\n\t * 4) the catchall symbol \"*\" can be used\n\t * 5) execution will start with transport dataType and THEN continue down to \"*\" if needed\n\t */\n    prefilters = {},\n\n    /* Transports bindings\n\t * 1) key is the dataType\n\t * 2) the catchall symbol \"*\" can be used\n\t * 3) selection will start with transport dataType and THEN go to \"*\" if needed\n\t */\n    transports = {},\n\n    // Avoid comment-prolog char sequence (trac-10098); must appease lint and evade compression\n    allTypes = \"*/\".concat( \"*\" ),\n\n    // Anchor tag for parsing the document origin\n    originAnchor = document.createElement( \"a\" );\n\n  originAnchor.href = location.href;\n\n// Base \"constructor\" for jQuery.ajaxPrefilter and jQuery.ajaxTransport\n  function addToPrefiltersOrTransports( structure ) {\n\n    // dataTypeExpression is optional and defaults to \"*\"\n    return function( dataTypeExpression, func ) {\n\n      if ( typeof dataTypeExpression !== \"string\" ) {\n        func = dataTypeExpression;\n        dataTypeExpression = \"*\";\n      }\n\n      var dataType,\n        i = 0,\n        dataTypes = dataTypeExpression.toLowerCase().match( rnothtmlwhite ) || [];\n\n      if ( isFunction( func ) ) {\n\n        // For each dataType in the dataTypeExpression\n        while ( ( dataType = dataTypes[ i++ ] ) ) {\n\n          // Prepend if requested\n          if ( dataType[ 0 ] === \"+\" ) {\n            dataType = dataType.slice( 1 ) || \"*\";\n            ( structure[ dataType ] = structure[ dataType ] || [] ).unshift( func );\n\n            // Otherwise append\n          } else {\n            ( structure[ dataType ] = structure[ dataType ] || [] ).push( func );\n          }\n        }\n      }\n    };\n  }\n\n// Base inspection function for prefilters and transports\n  function inspectPrefiltersOrTransports( structure, options, originalOptions, jqXHR ) {\n\n    var inspected = {},\n      seekingTransport = ( structure === transports );\n\n    function inspect( dataType ) {\n      var selected;\n      inspected[ dataType ] = true;\n      jQuery.each( structure[ dataType ] || [], function( _, prefilterOrFactory ) {\n        var dataTypeOrTransport = prefilterOrFactory( options, originalOptions, jqXHR );\n        if ( typeof dataTypeOrTransport === \"string\" &&\n          !seekingTransport && !inspected[ dataTypeOrTransport ] ) {\n\n          options.dataTypes.unshift( dataTypeOrTransport );\n          inspect( dataTypeOrTransport );\n          return false;\n        } else if ( seekingTransport ) {\n          return !( selected = dataTypeOrTransport );\n        }\n      } );\n      return selected;\n    }\n\n    return inspect( options.dataTypes[ 0 ] ) || !inspected[ \"*\" ] && inspect( \"*\" );\n  }\n\n// A special extend for ajax options\n// that takes \"flat\" options (not to be deep extended)\n// Fixes trac-9887\n  function ajaxExtend( target, src ) {\n    var key, deep,\n      flatOptions = jQuery.ajaxSettings.flatOptions || {};\n\n    for ( key in src ) {\n      if ( src[ key ] !== undefined ) {\n        ( flatOptions[ key ] ? target : ( deep || ( deep = {} ) ) )[ key ] = src[ key ];\n      }\n    }\n    if ( deep ) {\n      jQuery.extend( true, target, deep );\n    }\n\n    return target;\n  }\n\n  /* Handles responses to an ajax request:\n * - finds the right dataType (mediates between content-type and expected dataType)\n * - returns the corresponding response\n */\n  function ajaxHandleResponses( s, jqXHR, responses ) {\n\n    var ct, type, finalDataType, firstDataType,\n      contents = s.contents,\n      dataTypes = s.dataTypes;\n\n    // Remove auto dataType and get content-type in the process\n    while ( dataTypes[ 0 ] === \"*\" ) {\n      dataTypes.shift();\n      if ( ct === undefined ) {\n        ct = s.mimeType || jqXHR.getResponseHeader( \"Content-Type\" );\n      }\n    }\n\n    // Check if we're dealing with a known content-type\n    if ( ct ) {\n      for ( type in contents ) {\n        if ( contents[ type ] && contents[ type ].test( ct ) ) {\n          dataTypes.unshift( type );\n          break;\n        }\n      }\n    }\n\n    // Check to see if we have a response for the expected dataType\n    if ( dataTypes[ 0 ] in responses ) {\n      finalDataType = dataTypes[ 0 ];\n    } else {\n\n      // Try convertible dataTypes\n      for ( type in responses ) {\n        if ( !dataTypes[ 0 ] || s.converters[ type + \" \" + dataTypes[ 0 ] ] ) {\n          finalDataType = type;\n          break;\n        }\n        if ( !firstDataType ) {\n          firstDataType = type;\n        }\n      }\n\n      // Or just use first one\n      finalDataType = finalDataType || firstDataType;\n    }\n\n    // If we found a dataType\n    // We add the dataType to the list if needed\n    // and return the corresponding response\n    if ( finalDataType ) {\n      if ( finalDataType !== dataTypes[ 0 ] ) {\n        dataTypes.unshift( finalDataType );\n      }\n      return responses[ finalDataType ];\n    }\n  }\n\n  /* Chain conversions given the request and the original response\n * Also sets the responseXXX fields on the jqXHR instance\n */\n  function ajaxConvert( s, response, jqXHR, isSuccess ) {\n    var conv2, current, conv, tmp, prev,\n      converters = {},\n\n      // Work with a copy of dataTypes in case we need to modify it for conversion\n      dataTypes = s.dataTypes.slice();\n\n    // Create converters map with lowercased keys\n    if ( dataTypes[ 1 ] ) {\n      for ( conv in s.converters ) {\n        converters[ conv.toLowerCase() ] = s.converters[ conv ];\n      }\n    }\n\n    current = dataTypes.shift();\n\n    // Convert to each sequential dataType\n    while ( current ) {\n\n      if ( s.responseFields[ current ] ) {\n        jqXHR[ s.responseFields[ current ] ] = response;\n      }\n\n      // Apply the dataFilter if provided\n      if ( !prev && isSuccess && s.dataFilter ) {\n        response = s.dataFilter( response, s.dataType );\n      }\n\n      prev = current;\n      current = dataTypes.shift();\n\n      if ( current ) {\n\n        // There's only work to do if current dataType is non-auto\n        if ( current === \"*\" ) {\n\n          current = prev;\n\n          // Convert response if prev dataType is non-auto and differs from current\n        } else if ( prev !== \"*\" && prev !== current ) {\n\n          // Seek a direct converter\n          conv = converters[ prev + \" \" + current ] || converters[ \"* \" + current ];\n\n          // If none found, seek a pair\n          if ( !conv ) {\n            for ( conv2 in converters ) {\n\n              // If conv2 outputs current\n              tmp = conv2.split( \" \" );\n              if ( tmp[ 1 ] === current ) {\n\n                // If prev can be converted to accepted input\n                conv = converters[ prev + \" \" + tmp[ 0 ] ] ||\n                  converters[ \"* \" + tmp[ 0 ] ];\n                if ( conv ) {\n\n                  // Condense equivalence converters\n                  if ( conv === true ) {\n                    conv = converters[ conv2 ];\n\n                    // Otherwise, insert the intermediate dataType\n                  } else if ( converters[ conv2 ] !== true ) {\n                    current = tmp[ 0 ];\n                    dataTypes.unshift( tmp[ 1 ] );\n                  }\n                  break;\n                }\n              }\n            }\n          }\n\n          // Apply converter (if not an equivalence)\n          if ( conv !== true ) {\n\n            // Unless errors are allowed to bubble, catch and return them\n            if ( conv && s.throws ) {\n              response = conv( response );\n            } else {\n              try {\n                response = conv( response );\n              } catch ( e ) {\n                return {\n                  state: \"parsererror\",\n                  error: conv ? e : \"No conversion from \" + prev + \" to \" + current\n                };\n              }\n            }\n          }\n        }\n      }\n    }\n\n    return { state: \"success\", data: response };\n  }\n\n  jQuery.extend( {\n\n    // Counter for holding the number of active queries\n    active: 0,\n\n    // Last-Modified header cache for next request\n    lastModified: {},\n    etag: {},\n\n    ajaxSettings: {\n      url: location.href,\n      type: \"GET\",\n      isLocal: rlocalProtocol.test( location.protocol ),\n      global: true,\n      processData: true,\n      async: true,\n      contentType: \"application/x-www-form-urlencoded; charset=UTF-8\",\n\n      /*\n\t\ttimeout: 0,\n\t\tdata: null,\n\t\tdataType: null,\n\t\tusername: null,\n\t\tpassword: null,\n\t\tcache: null,\n\t\tthrows: false,\n\t\ttraditional: false,\n\t\theaders: {},\n\t\t*/\n\n      accepts: {\n        \"*\": allTypes,\n        text: \"text/plain\",\n        html: \"text/html\",\n        xml: \"application/xml, text/xml\",\n        json: \"application/json, text/javascript\"\n      },\n\n      contents: {\n        xml: /\\bxml\\b/,\n        html: /\\bhtml/,\n        json: /\\bjson\\b/\n      },\n\n      responseFields: {\n        xml: \"responseXML\",\n        text: \"responseText\",\n        json: \"responseJSON\"\n      },\n\n      // Data converters\n      // Keys separate source (or catchall \"*\") and destination types with a single space\n      converters: {\n\n        // Convert anything to text\n        \"* text\": String,\n\n        // Text to html (true = no transformation)\n        \"text html\": true,\n\n        // Evaluate text as a json expression\n        \"text json\": JSON.parse,\n\n        // Parse text as xml\n        \"text xml\": jQuery.parseXML\n      },\n\n      // For options that shouldn't be deep extended:\n      // you can add your own custom options here if\n      // and when you create one that shouldn't be\n      // deep extended (see ajaxExtend)\n      flatOptions: {\n        url: true,\n        context: true\n      }\n    },\n\n    // Creates a full fledged settings object into target\n    // with both ajaxSettings and settings fields.\n    // If target is omitted, writes into ajaxSettings.\n    ajaxSetup: function( target, settings ) {\n      return settings ?\n\n        // Building a settings object\n        ajaxExtend( ajaxExtend( target, jQuery.ajaxSettings ), settings ) :\n\n        // Extending ajaxSettings\n        ajaxExtend( jQuery.ajaxSettings, target );\n    },\n\n    ajaxPrefilter: addToPrefiltersOrTransports( prefilters ),\n    ajaxTransport: addToPrefiltersOrTransports( transports ),\n\n    // Main method\n    ajax: function( url, options ) {\n\n      // If url is an object, simulate pre-1.5 signature\n      if ( typeof url === \"object\" ) {\n        options = url;\n        url = undefined;\n      }\n\n      // Force options to be an object\n      options = options || {};\n\n      var transport,\n\n        // URL without anti-cache param\n        cacheURL,\n\n        // Response headers\n        responseHeadersString,\n        responseHeaders,\n\n        // timeout handle\n        timeoutTimer,\n\n        // Url cleanup var\n        urlAnchor,\n\n        // Request state (becomes false upon send and true upon completion)\n        completed,\n\n        // To know if global events are to be dispatched\n        fireGlobals,\n\n        // Loop variable\n        i,\n\n        // uncached part of the url\n        uncached,\n\n        // Create the final options object\n        s = jQuery.ajaxSetup( {}, options ),\n\n        // Callbacks context\n        callbackContext = s.context || s,\n\n        // Context for global events is callbackContext if it is a DOM node or jQuery collection\n        globalEventContext = s.context &&\n        ( callbackContext.nodeType || callbackContext.jquery ) ?\n          jQuery( callbackContext ) :\n          jQuery.event,\n\n        // Deferreds\n        deferred = jQuery.Deferred(),\n        completeDeferred = jQuery.Callbacks( \"once memory\" ),\n\n        // Status-dependent callbacks\n        statusCode = s.statusCode || {},\n\n        // Headers (they are sent all at once)\n        requestHeaders = {},\n        requestHeadersNames = {},\n\n        // Default abort message\n        strAbort = \"canceled\",\n\n        // Fake xhr\n        jqXHR = {\n          readyState: 0,\n\n          // Builds headers hashtable if needed\n          getResponseHeader: function( key ) {\n            var match;\n            if ( completed ) {\n              if ( !responseHeaders ) {\n                responseHeaders = {};\n                while ( ( match = rheaders.exec( responseHeadersString ) ) ) {\n                  responseHeaders[ match[ 1 ].toLowerCase() + \" \" ] =\n                    ( responseHeaders[ match[ 1 ].toLowerCase() + \" \" ] || [] )\n                      .concat( match[ 2 ] );\n                }\n              }\n              match = responseHeaders[ key.toLowerCase() + \" \" ];\n            }\n            return match == null ? null : match.join( \", \" );\n          },\n\n          // Raw string\n          getAllResponseHeaders: function() {\n            return completed ? responseHeadersString : null;\n          },\n\n          // Caches the header\n          setRequestHeader: function( name, value ) {\n            if ( completed == null ) {\n              name = requestHeadersNames[ name.toLowerCase() ] =\n                requestHeadersNames[ name.toLowerCase() ] || name;\n              requestHeaders[ name ] = value;\n            }\n            return this;\n          },\n\n          // Overrides response content-type header\n          overrideMimeType: function( type ) {\n            if ( completed == null ) {\n              s.mimeType = type;\n            }\n            return this;\n          },\n\n          // Status-dependent callbacks\n          statusCode: function( map ) {\n            var code;\n            if ( map ) {\n              if ( completed ) {\n\n                // Execute the appropriate callbacks\n                jqXHR.always( map[ jqXHR.status ] );\n              } else {\n\n                // Lazy-add the new callbacks in a way that preserves old ones\n                for ( code in map ) {\n                  statusCode[ code ] = [ statusCode[ code ], map[ code ] ];\n                }\n              }\n            }\n            return this;\n          },\n\n          // Cancel the request\n          abort: function( statusText ) {\n            var finalText = statusText || strAbort;\n            if ( transport ) {\n              transport.abort( finalText );\n            }\n            done( 0, finalText );\n            return this;\n          }\n        };\n\n      // Attach deferreds\n      deferred.promise( jqXHR );\n\n      // Add protocol if not provided (prefilters might expect it)\n      // Handle falsy url in the settings object (trac-10093: consistency with old signature)\n      // We also use the url parameter if available\n      s.url = ( ( url || s.url || location.href ) + \"\" )\n        .replace( rprotocol, location.protocol + \"//\" );\n\n      // Alias method option to type as per ticket trac-12004\n      s.type = options.method || options.type || s.method || s.type;\n\n      // Extract dataTypes list\n      s.dataTypes = ( s.dataType || \"*\" ).toLowerCase().match( rnothtmlwhite ) || [ \"\" ];\n\n      // A cross-domain request is in order when the origin doesn't match the current origin.\n      if ( s.crossDomain == null ) {\n        urlAnchor = document.createElement( \"a\" );\n\n        // Support: IE <=8 - 11, Edge 12 - 15\n        // IE throws exception on accessing the href property if url is malformed,\n        // e.g. http://example.com:80x/\n        try {\n          urlAnchor.href = s.url;\n\n          // Support: IE <=8 - 11 only\n          // Anchor's host property isn't correctly set when s.url is relative\n          urlAnchor.href = urlAnchor.href;\n          s.crossDomain = originAnchor.protocol + \"//\" + originAnchor.host !==\n            urlAnchor.protocol + \"//\" + urlAnchor.host;\n        } catch ( e ) {\n\n          // If there is an error parsing the URL, assume it is crossDomain,\n          // it can be rejected by the transport if it is invalid\n          s.crossDomain = true;\n        }\n      }\n\n      // Convert data if not already a string\n      if ( s.data && s.processData && typeof s.data !== \"string\" ) {\n        s.data = jQuery.param( s.data, s.traditional );\n      }\n\n      // Apply prefilters\n      inspectPrefiltersOrTransports( prefilters, s, options, jqXHR );\n\n      // If request was aborted inside a prefilter, stop there\n      if ( completed ) {\n        return jqXHR;\n      }\n\n      // We can fire global events as of now if asked to\n      // Don't fire events if jQuery.event is undefined in an AMD-usage scenario (trac-15118)\n      fireGlobals = jQuery.event && s.global;\n\n      // Watch for a new set of requests\n      if ( fireGlobals && jQuery.active++ === 0 ) {\n        jQuery.event.trigger( \"ajaxStart\" );\n      }\n\n      // Uppercase the type\n      s.type = s.type.toUpperCase();\n\n      // Determine if request has content\n      s.hasContent = !rnoContent.test( s.type );\n\n      // Save the URL in case we're toying with the If-Modified-Since\n      // and/or If-None-Match header later on\n      // Remove hash to simplify url manipulation\n      cacheURL = s.url.replace( rhash, \"\" );\n\n      // More options handling for requests with no content\n      if ( !s.hasContent ) {\n\n        // Remember the hash so we can put it back\n        uncached = s.url.slice( cacheURL.length );\n\n        // If data is available and should be processed, append data to url\n        if ( s.data && ( s.processData || typeof s.data === \"string\" ) ) {\n          cacheURL += ( rquery.test( cacheURL ) ? \"&\" : \"?\" ) + s.data;\n\n          // trac-9682: remove data so that it's not used in an eventual retry\n          delete s.data;\n        }\n\n        // Add or update anti-cache param if needed\n        if ( s.cache === false ) {\n          cacheURL = cacheURL.replace( rantiCache, \"$1\" );\n          uncached = ( rquery.test( cacheURL ) ? \"&\" : \"?\" ) + \"_=\" + ( nonce.guid++ ) +\n            uncached;\n        }\n\n        // Put hash and anti-cache on the URL that will be requested (gh-1732)\n        s.url = cacheURL + uncached;\n\n        // Change '%20' to '+' if this is encoded form body content (gh-2658)\n      } else if ( s.data && s.processData &&\n        ( s.contentType || \"\" ).indexOf( \"application/x-www-form-urlencoded\" ) === 0 ) {\n        s.data = s.data.replace( r20, \"+\" );\n      }\n\n      // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode.\n      if ( s.ifModified ) {\n        if ( jQuery.lastModified[ cacheURL ] ) {\n          jqXHR.setRequestHeader( \"If-Modified-Since\", jQuery.lastModified[ cacheURL ] );\n        }\n        if ( jQuery.etag[ cacheURL ] ) {\n          jqXHR.setRequestHeader( \"If-None-Match\", jQuery.etag[ cacheURL ] );\n        }\n      }\n\n      // Set the correct header, if data is being sent\n      if ( s.data && s.hasContent && s.contentType !== false || options.contentType ) {\n        jqXHR.setRequestHeader( \"Content-Type\", s.contentType );\n      }\n\n      // Set the Accepts header for the server, depending on the dataType\n      jqXHR.setRequestHeader(\n        \"Accept\",\n        s.dataTypes[ 0 ] && s.accepts[ s.dataTypes[ 0 ] ] ?\n          s.accepts[ s.dataTypes[ 0 ] ] +\n          ( s.dataTypes[ 0 ] !== \"*\" ? \", \" + allTypes + \"; q=0.01\" : \"\" ) :\n          s.accepts[ \"*\" ]\n      );\n\n      // Check for headers option\n      for ( i in s.headers ) {\n        jqXHR.setRequestHeader( i, s.headers[ i ] );\n      }\n\n      // Allow custom headers/mimetypes and early abort\n      if ( s.beforeSend &&\n        ( s.beforeSend.call( callbackContext, jqXHR, s ) === false || completed ) ) {\n\n        // Abort if not done already and return\n        return jqXHR.abort();\n      }\n\n      // Aborting is no longer a cancellation\n      strAbort = \"abort\";\n\n      // Install callbacks on deferreds\n      completeDeferred.add( s.complete );\n      jqXHR.done( s.success );\n      jqXHR.fail( s.error );\n\n      // Get transport\n      transport = inspectPrefiltersOrTransports( transports, s, options, jqXHR );\n\n      // If no transport, we auto-abort\n      if ( !transport ) {\n        done( -1, \"No Transport\" );\n      } else {\n        jqXHR.readyState = 1;\n\n        // Send global event\n        if ( fireGlobals ) {\n          globalEventContext.trigger( \"ajaxSend\", [ jqXHR, s ] );\n        }\n\n        // If request was aborted inside ajaxSend, stop there\n        if ( completed ) {\n          return jqXHR;\n        }\n\n        // Timeout\n        if ( s.async && s.timeout > 0 ) {\n          timeoutTimer = window.setTimeout( function() {\n            jqXHR.abort( \"timeout\" );\n          }, s.timeout );\n        }\n\n        try {\n          completed = false;\n          transport.send( requestHeaders, done );\n        } catch ( e ) {\n\n          // Rethrow post-completion exceptions\n          if ( completed ) {\n            throw e;\n          }\n\n          // Propagate others as results\n          done( -1, e );\n        }\n      }\n\n      // Callback for when everything is done\n      function done( status, nativeStatusText, responses, headers ) {\n        var isSuccess, success, error, response, modified,\n          statusText = nativeStatusText;\n\n        // Ignore repeat invocations\n        if ( completed ) {\n          return;\n        }\n\n        completed = true;\n\n        // Clear timeout if it exists\n        if ( timeoutTimer ) {\n          window.clearTimeout( timeoutTimer );\n        }\n\n        // Dereference transport for early garbage collection\n        // (no matter how long the jqXHR object will be used)\n        transport = undefined;\n\n        // Cache response headers\n        responseHeadersString = headers || \"\";\n\n        // Set readyState\n        jqXHR.readyState = status > 0 ? 4 : 0;\n\n        // Determine if successful\n        isSuccess = status >= 200 && status < 300 || status === 304;\n\n        // Get response data\n        if ( responses ) {\n          response = ajaxHandleResponses( s, jqXHR, responses );\n        }\n\n        // Use a noop converter for missing script but not if jsonp\n        if ( !isSuccess &&\n          jQuery.inArray( \"script\", s.dataTypes ) > -1 &&\n          jQuery.inArray( \"json\", s.dataTypes ) < 0 ) {\n          s.converters[ \"text script\" ] = function() {};\n        }\n\n        // Convert no matter what (that way responseXXX fields are always set)\n        response = ajaxConvert( s, response, jqXHR, isSuccess );\n\n        // If successful, handle type chaining\n        if ( isSuccess ) {\n\n          // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode.\n          if ( s.ifModified ) {\n            modified = jqXHR.getResponseHeader( \"Last-Modified\" );\n            if ( modified ) {\n              jQuery.lastModified[ cacheURL ] = modified;\n            }\n            modified = jqXHR.getResponseHeader( \"etag\" );\n            if ( modified ) {\n              jQuery.etag[ cacheURL ] = modified;\n            }\n          }\n\n          // if no content\n          if ( status === 204 || s.type === \"HEAD\" ) {\n            statusText = \"nocontent\";\n\n            // if not modified\n          } else if ( status === 304 ) {\n            statusText = \"notmodified\";\n\n            // If we have data, let's convert it\n          } else {\n            statusText = response.state;\n            success = response.data;\n            error = response.error;\n            isSuccess = !error;\n          }\n        } else {\n\n          // Extract error from statusText and normalize for non-aborts\n          error = statusText;\n          if ( status || !statusText ) {\n            statusText = \"error\";\n            if ( status < 0 ) {\n              status = 0;\n            }\n          }\n        }\n\n        // Set data for the fake xhr object\n        jqXHR.status = status;\n        jqXHR.statusText = ( nativeStatusText || statusText ) + \"\";\n\n        // Success/Error\n        if ( isSuccess ) {\n          deferred.resolveWith( callbackContext, [ success, statusText, jqXHR ] );\n        } else {\n          deferred.rejectWith( callbackContext, [ jqXHR, statusText, error ] );\n        }\n\n        // Status-dependent callbacks\n        jqXHR.statusCode( statusCode );\n        statusCode = undefined;\n\n        if ( fireGlobals ) {\n          globalEventContext.trigger( isSuccess ? \"ajaxSuccess\" : \"ajaxError\",\n            [ jqXHR, s, isSuccess ? success : error ] );\n        }\n\n        // Complete\n        completeDeferred.fireWith( callbackContext, [ jqXHR, statusText ] );\n\n        if ( fireGlobals ) {\n          globalEventContext.trigger( \"ajaxComplete\", [ jqXHR, s ] );\n\n          // Handle the global AJAX counter\n          if ( !( --jQuery.active ) ) {\n            jQuery.event.trigger( \"ajaxStop\" );\n          }\n        }\n      }\n\n      return jqXHR;\n    },\n\n    getJSON: function( url, data, callback ) {\n      return jQuery.get( url, data, callback, \"json\" );\n    },\n\n    getScript: function( url, callback ) {\n      return jQuery.get( url, undefined, callback, \"script\" );\n    }\n  } );\n\n  jQuery.each( [ \"get\", \"post\" ], function( _i, method ) {\n    jQuery[ method ] = function( url, data, callback, type ) {\n\n      // Shift arguments if data argument was omitted\n      if ( isFunction( data ) ) {\n        type = type || callback;\n        callback = data;\n        data = undefined;\n      }\n\n      // The url can be an options object (which then must have .url)\n      return jQuery.ajax( jQuery.extend( {\n        url: url,\n        type: method,\n        dataType: type,\n        data: data,\n        success: callback\n      }, jQuery.isPlainObject( url ) && url ) );\n    };\n  } );\n\n  jQuery.ajaxPrefilter( function( s ) {\n    var i;\n    for ( i in s.headers ) {\n      if ( i.toLowerCase() === \"content-type\" ) {\n        s.contentType = s.headers[ i ] || \"\";\n      }\n    }\n  } );\n\n\n  jQuery._evalUrl = function( url, options, doc ) {\n    return jQuery.ajax( {\n      url: url,\n\n      // Make this explicit, since user can override this through ajaxSetup (trac-11264)\n      type: \"GET\",\n      dataType: \"script\",\n      cache: true,\n      async: false,\n      global: false,\n\n      // Only evaluate the response if it is successful (gh-4126)\n      // dataFilter is not invoked for failure responses, so using it instead\n      // of the default converter is kludgy but it works.\n      converters: {\n        \"text script\": function() {}\n      },\n      dataFilter: function( response ) {\n        jQuery.globalEval( response, options, doc );\n      }\n    } );\n  };\n\n\n  jQuery.fn.extend( {\n    wrapAll: function( html ) {\n      var wrap;\n\n      if ( this[ 0 ] ) {\n        if ( isFunction( html ) ) {\n          html = html.call( this[ 0 ] );\n        }\n\n        // The elements to wrap the target around\n        wrap = jQuery( html, this[ 0 ].ownerDocument ).eq( 0 ).clone( true );\n\n        if ( this[ 0 ].parentNode ) {\n          wrap.insertBefore( this[ 0 ] );\n        }\n\n        wrap.map( function() {\n          var elem = this;\n\n          while ( elem.firstElementChild ) {\n            elem = elem.firstElementChild;\n          }\n\n          return elem;\n        } ).append( this );\n      }\n\n      return this;\n    },\n\n    wrapInner: function( html ) {\n      if ( isFunction( html ) ) {\n        return this.each( function( i ) {\n          jQuery( this ).wrapInner( html.call( this, i ) );\n        } );\n      }\n\n      return this.each( function() {\n        var self = jQuery( this ),\n          contents = self.contents();\n\n        if ( contents.length ) {\n          contents.wrapAll( html );\n\n        } else {\n          self.append( html );\n        }\n      } );\n    },\n\n    wrap: function( html ) {\n      var htmlIsFunction = isFunction( html );\n\n      return this.each( function( i ) {\n        jQuery( this ).wrapAll( htmlIsFunction ? html.call( this, i ) : html );\n      } );\n    },\n\n    unwrap: function( selector ) {\n      this.parent( selector ).not( \"body\" ).each( function() {\n        jQuery( this ).replaceWith( this.childNodes );\n      } );\n      return this;\n    }\n  } );\n\n\n  jQuery.expr.pseudos.hidden = function( elem ) {\n    return !jQuery.expr.pseudos.visible( elem );\n  };\n  jQuery.expr.pseudos.visible = function( elem ) {\n    return !!( elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length );\n  };\n\n\n\n\n  jQuery.ajaxSettings.xhr = function() {\n    try {\n      return new window.XMLHttpRequest();\n    } catch ( e ) {}\n  };\n\n  var xhrSuccessStatus = {\n\n      // File protocol always yields status code 0, assume 200\n      0: 200,\n\n      // Support: IE <=9 only\n      // trac-1450: sometimes IE returns 1223 when it should be 204\n      1223: 204\n    },\n    xhrSupported = jQuery.ajaxSettings.xhr();\n\n  support.cors = !!xhrSupported && ( \"withCredentials\" in xhrSupported );\n  support.ajax = xhrSupported = !!xhrSupported;\n\n  jQuery.ajaxTransport( function( options ) {\n    var callback, errorCallback;\n\n    // Cross domain only allowed if supported through XMLHttpRequest\n    if ( support.cors || xhrSupported && !options.crossDomain ) {\n      return {\n        send: function( headers, complete ) {\n          var i,\n            xhr = options.xhr();\n\n          xhr.open(\n            options.type,\n            options.url,\n            options.async,\n            options.username,\n            options.password\n          );\n\n          // Apply custom fields if provided\n          if ( options.xhrFields ) {\n            for ( i in options.xhrFields ) {\n              xhr[ i ] = options.xhrFields[ i ];\n            }\n          }\n\n          // Override mime type if needed\n          if ( options.mimeType && xhr.overrideMimeType ) {\n            xhr.overrideMimeType( options.mimeType );\n          }\n\n          // X-Requested-With header\n          // For cross-domain requests, seeing as conditions for a preflight are\n          // akin to a jigsaw puzzle, we simply never set it to be sure.\n          // (it can always be set on a per-request basis or even using ajaxSetup)\n          // For same-domain requests, won't change header if already provided.\n          if ( !options.crossDomain && !headers[ \"X-Requested-With\" ] ) {\n            headers[ \"X-Requested-With\" ] = \"XMLHttpRequest\";\n          }\n\n          // Set headers\n          for ( i in headers ) {\n            xhr.setRequestHeader( i, headers[ i ] );\n          }\n\n          // Callback\n          callback = function( type ) {\n            return function() {\n              if ( callback ) {\n                callback = errorCallback = xhr.onload =\n                  xhr.onerror = xhr.onabort = xhr.ontimeout =\n                    xhr.onreadystatechange = null;\n\n                if ( type === \"abort\" ) {\n                  xhr.abort();\n                } else if ( type === \"error\" ) {\n\n                  // Support: IE <=9 only\n                  // On a manual native abort, IE9 throws\n                  // errors on any property access that is not readyState\n                  if ( typeof xhr.status !== \"number\" ) {\n                    complete( 0, \"error\" );\n                  } else {\n                    complete(\n\n                      // File: protocol always yields status 0; see trac-8605, trac-14207\n                      xhr.status,\n                      xhr.statusText\n                    );\n                  }\n                } else {\n                  complete(\n                    xhrSuccessStatus[ xhr.status ] || xhr.status,\n                    xhr.statusText,\n\n                    // Support: IE <=9 only\n                    // IE9 has no XHR2 but throws on binary (trac-11426)\n                    // For XHR2 non-text, let the caller handle it (gh-2498)\n                    ( xhr.responseType || \"text\" ) !== \"text\"  ||\n                    typeof xhr.responseText !== \"string\" ?\n                      { binary: xhr.response } :\n                      { text: xhr.responseText },\n                    xhr.getAllResponseHeaders()\n                  );\n                }\n              }\n            };\n          };\n\n          // Listen to events\n          xhr.onload = callback();\n          errorCallback = xhr.onerror = xhr.ontimeout = callback( \"error\" );\n\n          // Support: IE 9 only\n          // Use onreadystatechange to replace onabort\n          // to handle uncaught aborts\n          if ( xhr.onabort !== undefined ) {\n            xhr.onabort = errorCallback;\n          } else {\n            xhr.onreadystatechange = function() {\n\n              // Check readyState before timeout as it changes\n              if ( xhr.readyState === 4 ) {\n\n                // Allow onerror to be called first,\n                // but that will not handle a native abort\n                // Also, save errorCallback to a variable\n                // as xhr.onerror cannot be accessed\n                window.setTimeout( function() {\n                  if ( callback ) {\n                    errorCallback();\n                  }\n                } );\n              }\n            };\n          }\n\n          // Create the abort callback\n          callback = callback( \"abort\" );\n\n          try {\n\n            // Do send the request (this may raise an exception)\n            xhr.send( options.hasContent && options.data || null );\n          } catch ( e ) {\n\n            // trac-14683: Only rethrow if this hasn't been notified as an error yet\n            if ( callback ) {\n              throw e;\n            }\n          }\n        },\n\n        abort: function() {\n          if ( callback ) {\n            callback();\n          }\n        }\n      };\n    }\n  } );\n\n\n\n\n// Prevent auto-execution of scripts when no explicit dataType was provided (See gh-2432)\n  jQuery.ajaxPrefilter( function( s ) {\n    if ( s.crossDomain ) {\n      s.contents.script = false;\n    }\n  } );\n\n// Install script dataType\n  jQuery.ajaxSetup( {\n    accepts: {\n      script: \"text/javascript, application/javascript, \" +\n        \"application/ecmascript, application/x-ecmascript\"\n    },\n    contents: {\n      script: /\\b(?:java|ecma)script\\b/\n    },\n    converters: {\n      \"text script\": function( text ) {\n        jQuery.globalEval( text );\n        return text;\n      }\n    }\n  } );\n\n// Handle cache's special case and crossDomain\n  jQuery.ajaxPrefilter( \"script\", function( s ) {\n    if ( s.cache === undefined ) {\n      s.cache = false;\n    }\n    if ( s.crossDomain ) {\n      s.type = \"GET\";\n    }\n  } );\n\n// Bind script tag hack transport\n  jQuery.ajaxTransport( \"script\", function( s ) {\n\n    // This transport only deals with cross domain or forced-by-attrs requests\n    if ( s.crossDomain || s.scriptAttrs ) {\n      var script, callback;\n      return {\n        send: function( _, complete ) {\n          script = jQuery( \"<script>\" )\n            .attr( s.scriptAttrs || {} )\n            .prop( { charset: s.scriptCharset, src: s.url } )\n            .on( \"load error\", callback = function( evt ) {\n              script.remove();\n              callback = null;\n              if ( evt ) {\n                complete( evt.type === \"error\" ? 404 : 200, evt.type );\n              }\n            } );\n\n          // Use native DOM manipulation to avoid our domManip AJAX trickery\n          document.head.appendChild( script[ 0 ] );\n        },\n        abort: function() {\n          if ( callback ) {\n            callback();\n          }\n        }\n      };\n    }\n  } );\n\n\n\n\n  var oldCallbacks = [],\n    rjsonp = /(=)\\?(?=&|$)|\\?\\?/;\n\n// Default jsonp settings\n  jQuery.ajaxSetup( {\n    jsonp: \"callback\",\n    jsonpCallback: function() {\n      var callback = oldCallbacks.pop() || ( jQuery.expando + \"_\" + ( nonce.guid++ ) );\n      this[ callback ] = true;\n      return callback;\n    }\n  } );\n\n// Detect, normalize options and install callbacks for jsonp requests\n  jQuery.ajaxPrefilter( \"json jsonp\", function( s, originalSettings, jqXHR ) {\n\n    var callbackName, overwritten, responseContainer,\n      jsonProp = s.jsonp !== false && ( rjsonp.test( s.url ) ?\n          \"url\" :\n          typeof s.data === \"string\" &&\n          ( s.contentType || \"\" )\n            .indexOf( \"application/x-www-form-urlencoded\" ) === 0 &&\n          rjsonp.test( s.data ) && \"data\"\n      );\n\n    // Handle iff the expected data type is \"jsonp\" or we have a parameter to set\n    if ( jsonProp || s.dataTypes[ 0 ] === \"jsonp\" ) {\n\n      // Get callback name, remembering preexisting value associated with it\n      callbackName = s.jsonpCallback = isFunction( s.jsonpCallback ) ?\n        s.jsonpCallback() :\n        s.jsonpCallback;\n\n      // Insert callback into url or form data\n      if ( jsonProp ) {\n        s[ jsonProp ] = s[ jsonProp ].replace( rjsonp, \"$1\" + callbackName );\n      } else if ( s.jsonp !== false ) {\n        s.url += ( rquery.test( s.url ) ? \"&\" : \"?\" ) + s.jsonp + \"=\" + callbackName;\n      }\n\n      // Use data converter to retrieve json after script execution\n      s.converters[ \"script json\" ] = function() {\n        if ( !responseContainer ) {\n          jQuery.error( callbackName + \" was not called\" );\n        }\n        return responseContainer[ 0 ];\n      };\n\n      // Force json dataType\n      s.dataTypes[ 0 ] = \"json\";\n\n      // Install callback\n      overwritten = window[ callbackName ];\n      window[ callbackName ] = function() {\n        responseContainer = arguments;\n      };\n\n      // Clean-up function (fires after converters)\n      jqXHR.always( function() {\n\n        // If previous value didn't exist - remove it\n        if ( overwritten === undefined ) {\n          jQuery( window ).removeProp( callbackName );\n\n          // Otherwise restore preexisting value\n        } else {\n          window[ callbackName ] = overwritten;\n        }\n\n        // Save back as free\n        if ( s[ callbackName ] ) {\n\n          // Make sure that re-using the options doesn't screw things around\n          s.jsonpCallback = originalSettings.jsonpCallback;\n\n          // Save the callback name for future use\n          oldCallbacks.push( callbackName );\n        }\n\n        // Call if it was a function and we have a response\n        if ( responseContainer && isFunction( overwritten ) ) {\n          overwritten( responseContainer[ 0 ] );\n        }\n\n        responseContainer = overwritten = undefined;\n      } );\n\n      // Delegate to script\n      return \"script\";\n    }\n  } );\n\n\n\n\n// Support: Safari 8 only\n// In Safari 8 documents created via document.implementation.createHTMLDocument\n// collapse sibling forms: the second one becomes a child of the first one.\n// Because of that, this security measure has to be disabled in Safari 8.\n// https://bugs.webkit.org/show_bug.cgi?id=137337\n  support.createHTMLDocument = ( function() {\n    var body = document.implementation.createHTMLDocument( \"\" ).body;\n    body.innerHTML = \"<form></form><form></form>\";\n    return body.childNodes.length === 2;\n  } )();\n\n\n// Argument \"data\" should be string of html\n// context (optional): If specified, the fragment will be created in this context,\n// defaults to document\n// keepScripts (optional): If true, will include scripts passed in the html string\n  jQuery.parseHTML = function( data, context, keepScripts ) {\n    if ( typeof data !== \"string\" ) {\n      return [];\n    }\n    if ( typeof context === \"boolean\" ) {\n      keepScripts = context;\n      context = false;\n    }\n\n    var base, parsed, scripts;\n\n    if ( !context ) {\n\n      // Stop scripts or inline event handlers from being executed immediately\n      // by using document.implementation\n      if ( support.createHTMLDocument ) {\n        context = document.implementation.createHTMLDocument( \"\" );\n\n        // Set the base href for the created document\n        // so any parsed elements with URLs\n        // are based on the document's URL (gh-2965)\n        base = context.createElement( \"base\" );\n        base.href = document.location.href;\n        context.head.appendChild( base );\n      } else {\n        context = document;\n      }\n    }\n\n    parsed = rsingleTag.exec( data );\n    scripts = !keepScripts && [];\n\n    // Single tag\n    if ( parsed ) {\n      return [ context.createElement( parsed[ 1 ] ) ];\n    }\n\n    parsed = buildFragment( [ data ], context, scripts );\n\n    if ( scripts && scripts.length ) {\n      jQuery( scripts ).remove();\n    }\n\n    return jQuery.merge( [], parsed.childNodes );\n  };\n\n\n  /**\n   * Load a url into a page\n   */\n  jQuery.fn.load = function( url, params, callback ) {\n    var selector, type, response,\n      self = this,\n      off = url.indexOf( \" \" );\n\n    if ( off > -1 ) {\n      selector = stripAndCollapse( url.slice( off ) );\n      url = url.slice( 0, off );\n    }\n\n    // If it's a function\n    if ( isFunction( params ) ) {\n\n      // We assume that it's the callback\n      callback = params;\n      params = undefined;\n\n      // Otherwise, build a param string\n    } else if ( params && typeof params === \"object\" ) {\n      type = \"POST\";\n    }\n\n    // If we have elements to modify, make the request\n    if ( self.length > 0 ) {\n      jQuery.ajax( {\n        url: url,\n\n        // If \"type\" variable is undefined, then \"GET\" method will be used.\n        // Make value of this field explicit since\n        // user can override it through ajaxSetup method\n        type: type || \"GET\",\n        dataType: \"html\",\n        data: params\n      } ).done( function( responseText ) {\n\n        // Save response for use in complete callback\n        response = arguments;\n\n        self.html( selector ?\n\n          // If a selector was specified, locate the right elements in a dummy div\n          // Exclude scripts to avoid IE 'Permission Denied' errors\n          jQuery( \"<div>\" ).append( jQuery.parseHTML( responseText ) ).find( selector ) :\n\n          // Otherwise use the full result\n          responseText );\n\n        // If the request succeeds, this function gets \"data\", \"status\", \"jqXHR\"\n        // but they are ignored because response was set above.\n        // If it fails, this function gets \"jqXHR\", \"status\", \"error\"\n      } ).always( callback && function( jqXHR, status ) {\n        self.each( function() {\n          callback.apply( this, response || [ jqXHR.responseText, status, jqXHR ] );\n        } );\n      } );\n    }\n\n    return this;\n  };\n\n\n\n\n  jQuery.expr.pseudos.animated = function( elem ) {\n    return jQuery.grep( jQuery.timers, function( fn ) {\n      return elem === fn.elem;\n    } ).length;\n  };\n\n\n\n\n  jQuery.offset = {\n    setOffset: function( elem, options, i ) {\n      var curPosition, curLeft, curCSSTop, curTop, curOffset, curCSSLeft, calculatePosition,\n        position = jQuery.css( elem, \"position\" ),\n        curElem = jQuery( elem ),\n        props = {};\n\n      // Set position first, in-case top/left are set even on static elem\n      if ( position === \"static\" ) {\n        elem.style.position = \"relative\";\n      }\n\n      curOffset = curElem.offset();\n      curCSSTop = jQuery.css( elem, \"top\" );\n      curCSSLeft = jQuery.css( elem, \"left\" );\n      calculatePosition = ( position === \"absolute\" || position === \"fixed\" ) &&\n        ( curCSSTop + curCSSLeft ).indexOf( \"auto\" ) > -1;\n\n      // Need to be able to calculate position if either\n      // top or left is auto and position is either absolute or fixed\n      if ( calculatePosition ) {\n        curPosition = curElem.position();\n        curTop = curPosition.top;\n        curLeft = curPosition.left;\n\n      } else {\n        curTop = parseFloat( curCSSTop ) || 0;\n        curLeft = parseFloat( curCSSLeft ) || 0;\n      }\n\n      if ( isFunction( options ) ) {\n\n        // Use jQuery.extend here to allow modification of coordinates argument (gh-1848)\n        options = options.call( elem, i, jQuery.extend( {}, curOffset ) );\n      }\n\n      if ( options.top != null ) {\n        props.top = ( options.top - curOffset.top ) + curTop;\n      }\n      if ( options.left != null ) {\n        props.left = ( options.left - curOffset.left ) + curLeft;\n      }\n\n      if ( \"using\" in options ) {\n        options.using.call( elem, props );\n\n      } else {\n        curElem.css( props );\n      }\n    }\n  };\n\n  jQuery.fn.extend( {\n\n    // offset() relates an element's border box to the document origin\n    offset: function( options ) {\n\n      // Preserve chaining for setter\n      if ( arguments.length ) {\n        return options === undefined ?\n          this :\n          this.each( function( i ) {\n            jQuery.offset.setOffset( this, options, i );\n          } );\n      }\n\n      var rect, win,\n        elem = this[ 0 ];\n\n      if ( !elem ) {\n        return;\n      }\n\n      // Return zeros for disconnected and hidden (display: none) elements (gh-2310)\n      // Support: IE <=11 only\n      // Running getBoundingClientRect on a\n      // disconnected node in IE throws an error\n      if ( !elem.getClientRects().length ) {\n        return { top: 0, left: 0 };\n      }\n\n      // Get document-relative position by adding viewport scroll to viewport-relative gBCR\n      rect = elem.getBoundingClientRect();\n      win = elem.ownerDocument.defaultView;\n      return {\n        top: rect.top + win.pageYOffset,\n        left: rect.left + win.pageXOffset\n      };\n    },\n\n    // position() relates an element's margin box to its offset parent's padding box\n    // This corresponds to the behavior of CSS absolute positioning\n    position: function() {\n      if ( !this[ 0 ] ) {\n        return;\n      }\n\n      var offsetParent, offset, doc,\n        elem = this[ 0 ],\n        parentOffset = { top: 0, left: 0 };\n\n      // position:fixed elements are offset from the viewport, which itself always has zero offset\n      if ( jQuery.css( elem, \"position\" ) === \"fixed\" ) {\n\n        // Assume position:fixed implies availability of getBoundingClientRect\n        offset = elem.getBoundingClientRect();\n\n      } else {\n        offset = this.offset();\n\n        // Account for the *real* offset parent, which can be the document or its root element\n        // when a statically positioned element is identified\n        doc = elem.ownerDocument;\n        offsetParent = elem.offsetParent || doc.documentElement;\n        while ( offsetParent &&\n        ( offsetParent === doc.body || offsetParent === doc.documentElement ) &&\n        jQuery.css( offsetParent, \"position\" ) === \"static\" ) {\n\n          offsetParent = offsetParent.parentNode;\n        }\n        if ( offsetParent && offsetParent !== elem && offsetParent.nodeType === 1 ) {\n\n          // Incorporate borders into its offset, since they are outside its content origin\n          parentOffset = jQuery( offsetParent ).offset();\n          parentOffset.top += jQuery.css( offsetParent, \"borderTopWidth\", true );\n          parentOffset.left += jQuery.css( offsetParent, \"borderLeftWidth\", true );\n        }\n      }\n\n      // Subtract parent offsets and element margins\n      return {\n        top: offset.top - parentOffset.top - jQuery.css( elem, \"marginTop\", true ),\n        left: offset.left - parentOffset.left - jQuery.css( elem, \"marginLeft\", true )\n      };\n    },\n\n    // This method will return documentElement in the following cases:\n    // 1) For the element inside the iframe without offsetParent, this method will return\n    //    documentElement of the parent window\n    // 2) For the hidden or detached element\n    // 3) For body or html element, i.e. in case of the html node - it will return itself\n    //\n    // but those exceptions were never presented as a real life use-cases\n    // and might be considered as more preferable results.\n    //\n    // This logic, however, is not guaranteed and can change at any point in the future\n    offsetParent: function() {\n      return this.map( function() {\n        var offsetParent = this.offsetParent;\n\n        while ( offsetParent && jQuery.css( offsetParent, \"position\" ) === \"static\" ) {\n          offsetParent = offsetParent.offsetParent;\n        }\n\n        return offsetParent || documentElement;\n      } );\n    }\n  } );\n\n// Create scrollLeft and scrollTop methods\n  jQuery.each( { scrollLeft: \"pageXOffset\", scrollTop: \"pageYOffset\" }, function( method, prop ) {\n    var top = \"pageYOffset\" === prop;\n\n    jQuery.fn[ method ] = function( val ) {\n      return access( this, function( elem, method, val ) {\n\n        // Coalesce documents and windows\n        var win;\n        if ( isWindow( elem ) ) {\n          win = elem;\n        } else if ( elem.nodeType === 9 ) {\n          win = elem.defaultView;\n        }\n\n        if ( val === undefined ) {\n          return win ? win[ prop ] : elem[ method ];\n        }\n\n        if ( win ) {\n          win.scrollTo(\n            !top ? val : win.pageXOffset,\n            top ? val : win.pageYOffset\n          );\n\n        } else {\n          elem[ method ] = val;\n        }\n      }, method, val, arguments.length );\n    };\n  } );\n\n// Support: Safari <=7 - 9.1, Chrome <=37 - 49\n// Add the top/left cssHooks using jQuery.fn.position\n// Webkit bug: https://bugs.webkit.org/show_bug.cgi?id=29084\n// Blink bug: https://bugs.chromium.org/p/chromium/issues/detail?id=589347\n// getComputedStyle returns percent when specified for top/left/bottom/right;\n// rather than make the css module depend on the offset module, just check for it here\n  jQuery.each( [ \"top\", \"left\" ], function( _i, prop ) {\n    jQuery.cssHooks[ prop ] = addGetHookIf( support.pixelPosition,\n      function( elem, computed ) {\n        if ( computed ) {\n          computed = curCSS( elem, prop );\n\n          // If curCSS returns percentage, fallback to offset\n          return rnumnonpx.test( computed ) ?\n            jQuery( elem ).position()[ prop ] + \"px\" :\n            computed;\n        }\n      }\n    );\n  } );\n\n\n// Create innerHeight, innerWidth, height, width, outerHeight and outerWidth methods\n  jQuery.each( { Height: \"height\", Width: \"width\" }, function( name, type ) {\n    jQuery.each( {\n      padding: \"inner\" + name,\n      content: type,\n      \"\": \"outer\" + name\n    }, function( defaultExtra, funcName ) {\n\n      // Margin is only for outerHeight, outerWidth\n      jQuery.fn[ funcName ] = function( margin, value ) {\n        var chainable = arguments.length && ( defaultExtra || typeof margin !== \"boolean\" ),\n          extra = defaultExtra || ( margin === true || value === true ? \"margin\" : \"border\" );\n\n        return access( this, function( elem, type, value ) {\n          var doc;\n\n          if ( isWindow( elem ) ) {\n\n            // $( window ).outerWidth/Height return w/h including scrollbars (gh-1729)\n            return funcName.indexOf( \"outer\" ) === 0 ?\n              elem[ \"inner\" + name ] :\n              elem.document.documentElement[ \"client\" + name ];\n          }\n\n          // Get document width or height\n          if ( elem.nodeType === 9 ) {\n            doc = elem.documentElement;\n\n            // Either scroll[Width/Height] or offset[Width/Height] or client[Width/Height],\n            // whichever is greatest\n            return Math.max(\n              elem.body[ \"scroll\" + name ], doc[ \"scroll\" + name ],\n              elem.body[ \"offset\" + name ], doc[ \"offset\" + name ],\n              doc[ \"client\" + name ]\n            );\n          }\n\n          return value === undefined ?\n\n            // Get width or height on the element, requesting but not forcing parseFloat\n            jQuery.css( elem, type, extra ) :\n\n            // Set width or height on the element\n            jQuery.style( elem, type, value, extra );\n        }, type, chainable ? margin : undefined, chainable );\n      };\n    } );\n  } );\n\n\n  jQuery.each( [\n    \"ajaxStart\",\n    \"ajaxStop\",\n    \"ajaxComplete\",\n    \"ajaxError\",\n    \"ajaxSuccess\",\n    \"ajaxSend\"\n  ], function( _i, type ) {\n    jQuery.fn[ type ] = function( fn ) {\n      return this.on( type, fn );\n    };\n  } );\n\n\n\n\n  jQuery.fn.extend( {\n\n    bind: function( types, data, fn ) {\n      return this.on( types, null, data, fn );\n    },\n    unbind: function( types, fn ) {\n      return this.off( types, null, fn );\n    },\n\n    delegate: function( selector, types, data, fn ) {\n      return this.on( types, selector, data, fn );\n    },\n    undelegate: function( selector, types, fn ) {\n\n      // ( namespace ) or ( selector, types [, fn] )\n      return arguments.length === 1 ?\n        this.off( selector, \"**\" ) :\n        this.off( types, selector || \"**\", fn );\n    },\n\n    hover: function( fnOver, fnOut ) {\n      return this\n        .on( \"mouseenter\", fnOver )\n        .on( \"mouseleave\", fnOut || fnOver );\n    }\n  } );\n\n  jQuery.each(\n    ( \"blur focus focusin focusout resize scroll click dblclick \" +\n      \"mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave \" +\n      \"change select submit keydown keypress keyup contextmenu\" ).split( \" \" ),\n    function( _i, name ) {\n\n      // Handle event binding\n      jQuery.fn[ name ] = function( data, fn ) {\n        return arguments.length > 0 ?\n          this.on( name, null, data, fn ) :\n          this.trigger( name );\n      };\n    }\n  );\n\n\n\n\n// Support: Android <=4.0 only\n// Make sure we trim BOM and NBSP\n// Require that the \"whitespace run\" starts from a non-whitespace\n// to avoid O(N^2) behavior when the engine would try matching \"\\s+$\" at each space position.\n  var rtrim = /^[\\s\\uFEFF\\xA0]+|([^\\s\\uFEFF\\xA0])[\\s\\uFEFF\\xA0]+$/g;\n\n// Bind a function to a context, optionally partially applying any\n// arguments.\n// jQuery.proxy is deprecated to promote standards (specifically Function#bind)\n// However, it is not slated for removal any time soon\n  jQuery.proxy = function( fn, context ) {\n    var tmp, args, proxy;\n\n    if ( typeof context === \"string\" ) {\n      tmp = fn[ context ];\n      context = fn;\n      fn = tmp;\n    }\n\n    // Quick check to determine if target is callable, in the spec\n    // this throws a TypeError, but we will just return undefined.\n    if ( !isFunction( fn ) ) {\n      return undefined;\n    }\n\n    // Simulated bind\n    args = slice.call( arguments, 2 );\n    proxy = function() {\n      return fn.apply( context || this, args.concat( slice.call( arguments ) ) );\n    };\n\n    // Set the guid of unique handler to the same of original handler, so it can be removed\n    proxy.guid = fn.guid = fn.guid || jQuery.guid++;\n\n    return proxy;\n  };\n\n  jQuery.holdReady = function( hold ) {\n    if ( hold ) {\n      jQuery.readyWait++;\n    } else {\n      jQuery.ready( true );\n    }\n  };\n  jQuery.isArray = Array.isArray;\n  jQuery.parseJSON = JSON.parse;\n  jQuery.nodeName = nodeName;\n  jQuery.isFunction = isFunction;\n  jQuery.isWindow = isWindow;\n  jQuery.camelCase = camelCase;\n  jQuery.type = toType;\n\n  jQuery.now = Date.now;\n\n  jQuery.isNumeric = function( obj ) {\n\n    // As of jQuery 3.0, isNumeric is limited to\n    // strings and numbers (primitives or objects)\n    // that can be coerced to finite numbers (gh-2662)\n    var type = jQuery.type( obj );\n    return ( type === \"number\" || type === \"string\" ) &&\n\n      // parseFloat NaNs numeric-cast false positives (\"\")\n      // ...but misinterprets leading-number strings, particularly hex literals (\"0x...\")\n      // subtraction forces infinities to NaN\n      !isNaN( obj - parseFloat( obj ) );\n  };\n\n  jQuery.trim = function( text ) {\n    return text == null ?\n      \"\" :\n      ( text + \"\" ).replace( rtrim, \"$1\" );\n  };\n\n\n\n// Register as a named AMD module, since jQuery can be concatenated with other\n// files that may use define, but not via a proper concatenation script that\n// understands anonymous AMD modules. A named AMD is safest and most robust\n// way to register. Lowercase jquery is used because AMD module names are\n// derived from file names, and jQuery is normally delivered in a lowercase\n// file name. Do this after creating the global so that if an AMD module wants\n// to call noConflict to hide this version of jQuery, it will work.\n\n// Note that for maximum portability, libraries that are not jQuery should\n// declare themselves as anonymous modules, and avoid setting a global if an\n// AMD loader is present. jQuery is a special case. For more information, see\n// https://github.com/jrburke/requirejs/wiki/Updating-existing-libraries#wiki-anon\n\n  if ( typeof define === \"function\" && define.amd ) {\n    define( \"jquery\", [], function() {\n      return jQuery;\n    } );\n  }\n\n\n\n\n  var\n\n    // Map over jQuery in case of overwrite\n    _jQuery = window.jQuery,\n\n    // Map over the $ in case of overwrite\n    _$ = window.$;\n\n  jQuery.noConflict = function( deep ) {\n    if ( window.$ === jQuery ) {\n      window.$ = _$;\n    }\n\n    if ( deep && window.jQuery === jQuery ) {\n      window.jQuery = _jQuery;\n    }\n\n    return jQuery;\n  };\n\n// Expose jQuery and $ identifiers, even in AMD\n// (trac-7102#comment:10, https://github.com/jquery/jquery/pull/557)\n// and CommonJS for browser emulators (trac-13566)\n  if ( typeof noGlobal === \"undefined\" ) {\n    window.jQuery = window.$ = jQuery;\n  }\n  return jQuery;\n} );\n"
  },
  {
    "path": "src/static/js/vendors/nice-select.ts",
    "content": "// @ts-nocheck\n// WARNING: This file has been modified from the Original\n// TODO: Nice Select seems relatively abandoned, we should consider other options.\n\n/*  jQuery Nice Select - v1.1.0\n    https://github.com/hernansartorio/jquery-nice-select\n    Made by Hernán Sartorio  */\n\n(function($) {\n\n  $.fn.niceSelect = function(method) {\n\n    // Methods\n    if (typeof method == 'string') {\n      if (method == 'update') {\n        this.each(function() {\n          var $select = $(this);\n          var $dropdown = $(this).next('.nice-select');\n          var open = $dropdown.hasClass('open');\n\n          if ($dropdown.length) {\n            $dropdown.remove();\n            create_nice_select($select);\n\n            if (open) {\n              $select.next().trigger('click');\n            }\n          }\n        });\n      } else if (method == 'destroy') {\n        this.each(function() {\n          var $select = $(this);\n          var $dropdown = $(this).next('.nice-select');\n\n          if ($dropdown.length) {\n            $dropdown.remove();\n            $select.css('display', '');\n          }\n        });\n        if ($('.nice-select').length == 0) {\n          $(document).off('.nice_select');\n        }\n      } else {\n        console.log('Method \"' + method + '\" does not exist.')\n      }\n      return this;\n    }\n\n    // Hide native select\n    this.hide();\n\n    // Create custom markup\n    this.each(function() {\n      var $select = $(this);\n\n      if (!$select.next().hasClass('nice-select')) {\n        create_nice_select($select);\n      }\n    });\n\n    function create_nice_select($select) {\n      $select.after($('<div></div>')\n        .addClass('nice-select')\n        .addClass($select.attr('class') || '')\n        .addClass($select.attr('disabled') ? 'disabled' : '')\n        .attr('tabindex', $select.attr('disabled') ? null : '0')\n        .html('<span class=\"current\"></span><ul class=\"list thin-scrollbar\"></ul>')\n      );\n\n      var $dropdown = $select.next();\n      var $options = $select.find('option');\n      var $selected = $select.find('option:selected');\n\n      $dropdown.find('.current').html($selected.data('display') || $selected.text());\n\n      $options.each(function(i) {\n        var $option = $(this);\n        var display = $option.data('display');\n\n        $dropdown.find('ul').append($('<li></li>')\n          .attr('data-value', $option.val())\n          .attr('data-display', (display || null))\n          .addClass('option' +\n            ($option.is(':selected') ? ' selected' : '') +\n            ($option.is(':disabled') ? ' disabled' : ''))\n          .html($option.text())\n        );\n      });\n    }\n\n    /* Event listeners */\n\n    // Unbind existing events in case that the plugin has been initialized before\n    $(document).off('.nice_select');\n\n    // Open/close\n    $(document).on('click.nice_select', '.nice-select', function(event) {\n      var $dropdown = $(this);\n\n      $('.nice-select').not($dropdown).removeClass('open');\n\n      $dropdown.toggleClass('open');\n\n      if ($dropdown.hasClass('open')) {\n        $dropdown.find('.option');\n        $dropdown.find('.focus').removeClass('focus');\n        $dropdown.find('.selected').addClass('focus');\n        if ($dropdown.closest('.toolbar').length > 0) {\n          $dropdown.find('.list').css('left', $dropdown.offset().left);\n          $dropdown.find('.list').css('top', $dropdown.offset().top + $dropdown.outerHeight());\n          $dropdown.find('.list').css('min-width', $dropdown.outerWidth() + 'px');\n        }\n\n        let $listHeight = $dropdown.find('.list').outerHeight();\n        let $top = $dropdown.parent().offset().top;\n        let $bottom = $('body').height() - $top;\n        let $maxListHeight = $bottom - $dropdown.outerHeight() - 20;\n        if ($maxListHeight < 200) {\n          $dropdown.addClass('reverse');\n          $maxListHeight = 250;\n        } else {\n          $dropdown.removeClass('reverse')\n        }\n        $dropdown.find('.list').css('max-height', $maxListHeight + 'px');\n\n      } else {\n        $dropdown.trigger('focus');\n      }\n    });\n\n    // Close when clicking outside\n    $(document).on('click.nice_select', function(event) {\n      if ($(event.target).closest('.nice-select').length === 0) {\n        $('.nice-select').removeClass('open').find('.option');\n      }\n    });\n\n    // Option click\n    $(document).on('click.nice_select', '.nice-select .option:not(.disabled)', function(event) {\n      var $option = $(this);\n      var $dropdown = $option.closest('.nice-select');\n\n      $dropdown.find('.selected').removeClass('selected');\n      $option.addClass('selected');\n\n      var text = $option.data('display') || $option.text();\n      $dropdown.find('.current').text(text);\n\n      $dropdown.prev('select').val($option.data('value')).trigger('change');\n    });\n\n    // Keyboard events\n    $(document).on('keydown.nice_select', '.nice-select', function(event) {\n      var $dropdown = $(this);\n      var $focused_option = $($dropdown.find('.focus') || $dropdown.find('.list .option.selected'));\n\n      // Space or Enter\n      if (event.keyCode == 32 || event.keyCode == 13) {\n        if ($dropdown.hasClass('open')) {\n          $focused_option.trigger('click');\n        } else {\n          $dropdown.trigger('click');\n        }\n        return false;\n      // Down\n      } else if (event.keyCode == 40) {\n        if (!$dropdown.hasClass('open')) {\n          $dropdown.trigger('click');\n        } else {\n          var $next = $focused_option.nextAll('.option:not(.disabled)').first();\n          if ($next.length > 0) {\n            $dropdown.find('.focus').removeClass('focus');\n            $next.addClass('focus');\n          }\n        }\n        return false;\n      // Up\n      } else if (event.keyCode == 38) {\n        if (!$dropdown.hasClass('open')) {\n          $dropdown.trigger('click');\n        } else {\n          var $prev = $focused_option.prevAll('.option:not(.disabled)').first();\n          if ($prev.length > 0) {\n            $dropdown.find('.focus').removeClass('focus');\n            $prev.addClass('focus');\n          }\n        }\n        return false;\n      // Esc\n      } else if (event.keyCode == 27) {\n        if ($dropdown.hasClass('open')) {\n          $dropdown.trigger('click');\n        }\n      // Tab\n      } else if (event.keyCode == 9) {\n        if ($dropdown.hasClass('open')) {\n          return false;\n        }\n      }\n    });\n\n    // Detect CSS pointer-events support, for IE <= 10. From Modernizr.\n    var style = document.createElement('a').style;\n    style.cssText = 'pointer-events:auto';\n    if (style.pointerEvents !== 'auto') {\n      $('html').addClass('no-csspointerevents');\n    }\n\n    return this;\n\n  };\n\n}(jQuery));\n"
  },
  {
    "path": "src/static/js/welcome.ts",
    "content": "const checkmark = '<svg width=\"28\" height=\"28\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"3\" stroke=\"currentColor\"><path vector-effect=\"non-scaling-stroke\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"m4.5 12.75 6 6 9-13.5\"/></svg>';\n\nfunction getCookie(name: string) {\n  const value = `; ${document.cookie}`;\n  const parts = value.split(`; ${name}=`);\n  if (parts.length === 2) { // @ts-ignore\n    return parts.pop().split(';').shift();\n  }\n}\n\n\nfunction handleTransferOfSession() {\n  const transferNowButton = document.querySelector('[data-l10n-id=\"index.transferSessionNow\"]')! as HTMLButtonElement;\n\n  transferNowButton.addEventListener('click', async () => {\n    transferNowButton.style.display = 'inline-flex';\n    transferNowButton.style.alignItems = 'center';\n    transferNowButton.style.justifyContent = 'center';\n    transferNowButton.innerHTML = `${checkmark}`;\n    transferNowButton.disabled = true;\n\n    const responseWithId = await fetch(\"./tokenTransfer\", {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\"\n      },\n      body: JSON.stringify({\n        prefsHttp: getCookie('prefsHttp'),\n        token: getCookie('token'),\n      })\n    })\n\n    const copyLinkSection = document.getElementById('copy-link-section')\n    if (!copyLinkSection) return;\n    copyLinkSection.style.display = 'block';\n\n    const copyButton = document.querySelector('#copy-link-section .btn-secondary') as HTMLButtonElement\n    const responseData = await responseWithId.json();\n    copyButton.addEventListener('click', async ()=>{\n      await navigator.clipboard.writeText(responseData.id);\n      copyButton.style.display = 'inline-flex';\n      copyButton.style.alignItems = 'center';\n      copyButton.style.justifyContent = 'center';\n      copyButton.innerHTML = `${checkmark}`;\n      copyButton.disabled = true;\n    })\n  });\n}\n\n\nconst handleSettingsButtonClick = () => {\n  const settingsButton = document.querySelector('.settings-button')!;\n  const settingsDialog = document.getElementById('settings-dialog') as HTMLDialogElement;\n  let initialSettingsHtml: string;\n\n  settingsDialog.addEventListener('click', (e) => {\n    if (e.target === settingsDialog) {\n      settingsDialog.close();\n      settingsDialog.innerHTML = initialSettingsHtml;\n      handleMenuBarClicked();\n      handleTransferOfSession();\n    }\n  });\n\n  settingsButton.addEventListener('click', () => {\n    initialSettingsHtml = settingsDialog.innerHTML;\n    settingsDialog.showModal();\n  });\n};\n\n\nconst handleMenuBarClicked = () => {\n  const menuBar = document.getElementById('button-bar')!;\n  menuBar.querySelectorAll('button').forEach((button, index)=>{\n    button.addEventListener('click', ()=>{\n      menuBar.querySelectorAll('button').forEach((btn)=>btn.classList.remove('active-btn'));\n      button.classList.add('active-btn');\n\n      const sections: NodeListOf<HTMLDivElement> = document.querySelectorAll('#settings-dialog > div');\n      sections.forEach((section, index)=>index >= 1 && (section.style.display = 'none'));\n      (sections[index +1] as HTMLElement).style.display = 'block';\n    });\n  })\n\n  const transferSessionButton = document.getElementById('transferSessionButton')\n  const codeInputField = document.getElementById('codeInput') as HTMLInputElement\n  if (transferSessionButton) {\n    transferSessionButton.addEventListener('click', ()=>{\n      const code = codeInputField.value\n      fetch(\"./tokenTransfer/\"+code, {\n        method: 'GET'\n      })\n        .then(res => res.json())\n        .then(()=>{\n          window.location.reload()\n        })\n    });\n  }\n\n  if (codeInputField) {\n    codeInputField.addEventListener('input', (e)=>{\n      if ((e.target as HTMLInputElement).value?.length === 36) {\n          transferSessionButton?.removeAttribute('disabled');\n      } else {\n          transferSessionButton?.setAttribute('disabled', 'true');\n      }\n    })\n  }\n\n}\n\nwindow.addEventListener('load', () => {\n  handleSettingsButtonClick();\n  handleMenuBarClicked();\n  handleTransferOfSession();\n});\n"
  },
  {
    "path": "src/static/robots.txt",
    "content": "User-agent: *\nDisallow: /p/\n"
  },
  {
    "path": "src/static/skins/colibris/index.css",
    "content": "@import url(\"./src/components/buttons.css\");\n\n:root {\n  --etherpad-color: #64d29b;\n  --etherpad-color-dark: #4a5d5c;\n  --etherpad-border: oklch(92.8% 0.006 264.531);\n  --muted-text: oklch(44.6% 0.03 256.802);\n  --muted-border: oklch(87.2% 0.01 258.338);\n  --muted-background: hsl(240 4.8% 95.9%);\n  --ep-color: rgb(22 163 74);\n  --warm-green-olive: #7c9a3e;\n  --warm-green-moss: #8fae4a;\n  --warm-green-lime: #b7c96c;\n  --warm-green-avocado: #6e8b3d;\n  --warm-green-spring: #a3c85a;\n}\n\n\nbody {\n  border-top: 0;\n  background: oklch(98.5% 0.002 247.839);\n  display: flex;\n  flex-direction: column;\n}\n\nh1 {\n  margin: auto 0 0;\n  font-size: 26px;\n}\n\n.mission-statement, .pad-datalist {\n  display: block;\n}\n\n.mission-statement h2 {\n  font-weight: 700;\n  font-size: 2.25rem;\n  text-align: center;\n  margin: 0;\n  padding-top: 4rem;\n}\n\n.mission-statement p {\n  color: var(--muted-text);\n  font-size: 20px;\n  text-align: center;\n  max-width: 40%;\n  margin: auto;\n}\n\n#wrapper {\n  border-top: 0;\n  margin-top: 0;\n  padding: 0;\n  background: unset;\n  box-shadow: none;\n}\n\n#inner {\n  display: flex;\n  flex-direction: column;\n  position: relative;\n  margin-top: 20px;\n  margin-bottom: 20px;\n  max-width: 80%;\n}\n\n#label {\n  font-size: 0.875rem;\n  line-height: 1.25rem;\n  font-weight: 700;\n  color: rgb(55 65 81);\n  margin-bottom: 0.5rem;\n  margin-top: 0;\n}\n\n#go2Name {\n  order: 1;\n}\n\n#padname, #go2Name, #go2Name [type=\"submit\"], #button, #button:hover {\n  all: unset;\n}\n\n\n#padname {\n  width: 100%;\n  padding: 0.5rem 0.75rem;\n  border: 1px solid #d1d5db;\n  border-radius: 0.375rem;\n  font-size: 1rem;\n  margin-bottom: 0.5rem;\n  outline: none;\n  transition: border 0.2s;\n}\n\n#padname {\n  box-sizing: border-box;\n  width: 100%;\n  color: var(--muted-text);\n  border: 1px solid var(--muted-border);\n  border-radius: 5px;\n}\n\n#button, #button:hover, #go2Name [type=\"submit\"], #transferSessionButton {\n  order: 2;\n  margin-top: 0.5rem;\n  line-height: 1.25rem;\n  background: white;\n  border: 1px solid var(--muted-border);\n  text-align: center;\n  padding-top: 0.5rem;\n  padding-bottom: 0.5rem;\n  font-size: 14px;\n  font-weight: 700;\n  border-radius: 5px;\n  cursor: pointer;\n}\n\n#go2Name [type=\"submit\"]:hover, #transferSessionButton {\n  background-color: oklch(52.7% 0.154 150.069)\n}\n\n#transferSessionButton:disabled {\n  opacity: 0.5;\n}\n\n#button, #button:hover {\n  order: 2;\n}\n\n#button:hover {\n  background-color: var(--muted-background);\n}\n\n#go2Name input {\n  width: 100%;\n}\n\n\n#go2Name [type=\"submit\"], #transferSessionButton {\n  display: block;\n  background-color: var(--ep-color);\n  color: white;\n  width: 100%;\n}\n\n\nbody nav {\n  display: flex;\n  border-bottom-width: 1px;\n  border-bottom-style: solid;\n  border-bottom-color: var(--etherpad-border);\n  padding: 1rem 1.5rem;\n}\n\n.logo-box svg {\n  width: 1.25rem;\n  height: 1.25rem;\n  color: #fff;\n}\n\n.logo-box {\n  width: 2rem;\n  height: 2rem;\n  background: #16a34a;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  margin-right: 1rem;\n}\n\n\n#wrapper, .pad-datalist {\n  width: 100%;\n  max-width: 28rem;\n  background: #fff;\n  border: 1px solid #e5e7eb;\n  border-radius: 0.75rem;\n  box-shadow: 0 1px 2px 0 #0001;\n  margin: 2rem auto auto;\n}\n\n.pad-datalist {\n  max-width: 56rem;\n  margin-bottom: 1rem;\n}\n\n.break-column {\n  flex-basis: 100%;\n  width: 0;\n}\n\nul {\n  list-style-type: none;\n}\n\n.recent-pad {\n  padding: 0.75rem 1.5rem;\n  display: flex;\n  position: relative;\n  flex-direction: column;\n}\n\n.body {\n  flex-grow: 1;\n  background: linear-gradient(\n    to bottom right,\n    #d1fae5,   /* emerald-100 */\n    #f0fdfa,   /* teal-50 */\n    #dbeafe,   /* blue-100 */\n    #c7d2fe    /* indigo-200 */\n  );\n}\n\n.recent-pad:hover a {\n  color: var(--ep-color);\n}\n\n.recent-pad-arrow {\n  position: absolute;\n  right: 1rem;\n}\n\n.recent-pad a {\n  font-size: 0.875rem;\n  line-height: 1.25rem;\n  font-weight: 800;\n}\n\na, a:visited, a:hover, a:active {\n  color: inherit;\n}\n\n.pad-datalist h2 {\n  border-bottom: 1px solid var(--muted-border);\n  padding: 1rem 1.5rem;\n  border-bottom-width: 1px;\n  border-bottom-style: solid;\n  border-bottom-color: #e5e7eb;\n}\n\n#settings-dialog::backdrop {\n    background: rgba(0, 0, 0, 0.45);\n    backdrop-filter: blur(2px);\n}\n\n.card-content {\n  padding: 1.5rem;\n}\n\n#codeInput {\n  height: auto;\n  position: static;\n  border: 1px solid var(--muted-border);\n  border-radius: 0.375rem;\n  font-size: 1rem;\n  outline: none;\n  transition: border 0.2s;\n}\n\n@media (max-width: 640px) {\n  #inner {\n    max-width: 100%;\n    padding: 0 1rem;\n  }\n\n  .mission-statement p {\n    max-width: 100%;\n  }\n\n  .pad-datalist {\n    max-width: 90%;\n  }\n\n  .mission-statement h2 {\n    font-size: 1.5rem;\n  }\n}\n"
  },
  {
    "path": "src/static/skins/colibris/index.js",
    "content": "'use strict';\n\nwindow.addEventListener('pageshow', (event) => {\n  if (event.persisted) {\n    if (document.readyState === 'complete' || document.readyState === 'interactive') {\n      window.customStart();\n    } else {\n      window.addEventListener('DOMContentLoaded', window.customStart, {once: true});\n    }\n  }\n});\n\nwindow.customStart = () => {\n  const recentPadList = document.getElementById('recent-pads');\n  if (recentPadList) {\n    recentPadList.replaceChildren();\n  }\n  // define your javascript here\n  // jquery is available - except index.js\n  // you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/\n  const divHoldingPlaceHolderLabel = document\n      .querySelector('[data-l10n-id=\"index.placeholderPadEnter\"]');\n\n  const observer = new MutationObserver(() => {\n    document.querySelector('#go2Name input')\n        .setAttribute('placeholder', divHoldingPlaceHolderLabel.textContent);\n  });\n\n  observer\n      .observe(divHoldingPlaceHolderLabel, {childList: true, subtree: true, characterData: true});\n\n\n  const recentPadListHeading = document.querySelector('[data-l10n-id=\"index.recentPads\"]');\n  const recentPadsFromLocalStorage = localStorage.getItem('recentPads');\n  let recentPadListData = [];\n  if (recentPadsFromLocalStorage != null) {\n    recentPadListData = JSON.parse(recentPadsFromLocalStorage);\n  }\n\n  // Remove duplicates based on pad name and sort by timestamp\n  recentPadListData = recentPadListData.filter(\n      (pad, index, self) => index === self.findIndex((p) => p.name === pad.name)\n  ).sort((a, b) => new Date(a.timestamp) > new Date(b.timestamp) ? -1 : 1);\n\n  if (recentPadList && recentPadListData.length === 0) {\n    const parentStyle = recentPadList.parentElement.style;\n    recentPadListHeading.setAttribute('data-l10n-id', 'index.recentPadsEmpty');\n    parentStyle.display = 'flex';\n    parentStyle.justifyContent = 'center';\n    parentStyle.alignItems = 'center';\n    parentStyle.maxHeight = '100%';\n    recentPadList.remove();\n  } else if (recentPadList) {\n    /**\n     * @typedef {Object} Pad\n     * @property {string} name\n     */\n\n    /**\n     * @param {Pad} pad\n     */\n\n    const arrowIcon = '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-arrow-right w-4 h-4 text-gray-400\"><path d=\"M5 12h14\"></path><path d=\"m12 5 7 7-7 7\"></path></svg>';\n    const clockIcon = '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-clock w-3 h-3\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle><polyline points=\"12 6 12 12 16 14\"></polyline></svg>';\n    const personalIcon = '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-users w-3 h-3\"><path d=\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\"></path><circle cx=\"9\" cy=\"7\" r=\"4\"></circle><path d=\"M22 21v-2a4 4 0 0 0-3-3.87\"></path><path d=\"M16 3.13a4 4 0 0 1 0 7.75\"></path></svg>';\n    recentPadListData.forEach((pad) => {\n      const li = document.createElement('li');\n\n\n      li.style.cursor = 'pointer';\n\n      li.className = 'recent-pad';\n      const padPath = `${window.location.href}p/${pad.name}`;\n      const link = document.createElement('a');\n      link.style.textDecoration = 'none';\n\n      link.href = padPath;\n      link.innerText = pad.name;\n      li.appendChild(link);\n\n\n      const arrowIconElement = document.createElement('span');\n      arrowIconElement.className = 'recent-pad-arrow';\n      arrowIconElement.innerHTML = arrowIcon;\n      li.appendChild(arrowIconElement);\n\n      const nextRow = document.createElement('div');\n\n      nextRow.style.display = 'flex';\n      nextRow.style.gap = '10px';\n      nextRow.style.marginTop = '10px';\n\n      const clockIconElement = document.createElement('span');\n      clockIconElement.className = 'recent-pad-clock';\n      clockIconElement.innerHTML = clockIcon;\n\n      nextRow.appendChild(clockIconElement);\n\n      const time = new Date(pad.timestamp);\n      const userLocale = navigator.language || 'en-US';\n\n      const formattedTime = time.toLocaleDateString(userLocale, {\n        year: 'numeric',\n        month: '2-digit',\n        day: '2-digit',\n        hour: '2-digit',\n        minute: '2-digit',\n      });\n      const timeElement = document.createElement('span');\n      timeElement.className = 'recent-pad-time';\n      timeElement.innerText = formattedTime;\n\n      nextRow.appendChild(timeElement);\n\n      const personalIconElement = document.createElement('span');\n      personalIconElement.className = 'recent-pad-personal';\n      personalIconElement.innerHTML = personalIcon;\n\n      personalIconElement.style.marginLeft = '5px';\n\n      const members = document.createElement('span');\n      members.className = 'recent-pad-members';\n      members.innerText = pad.members;\n\n\n      nextRow.appendChild(personalIconElement);\n      nextRow.appendChild(members);\n      li.appendChild(nextRow);\n\n      li.addEventListener('click', () => {\n        window.location.href = padPath;\n      });\n\n      // https://v0.dev/chat/etherpad-design-clone-qZnwOrVRXxH\n      recentPadList.appendChild(li);\n    });\n  }\n};\n"
  },
  {
    "path": "src/static/skins/colibris/pad.css",
    "content": "@import url(\"src/general.css\");\n@import url(\"src/layout.css\");\n@import url(\"src/pad-editor.css\");\n\n@import url(\"src/components/scrollbars.css\");\n@import url(\"src/components/buttons.css\");\n@import url(\"src/components/popup.css\");\n\n@import url(\"src/components/chat.css\");\n@import url(\"src/components/sidediv.css\");\n@import url(\"src/components/gritter.css\");\n@import url(\"src/components/table-of-content.css\");\n@import url(\"src/components/toolbar.css\");\n@import url(\"src/components/users.css\");\n@import url(\"src/components/form.css\");\n@import url(\"src/components/import-export.css\");\n\n@import url(\"src/plugins/brightcolorpicker.css\");\n@import url(\"src/plugins/font_color.css\");\n@import url(\"src/plugins/tables2.css\");\n@import url(\"src/plugins/set_title_on_pad.css\");\n@import url(\"src/plugins/author_hover.css\");\n@import url(\"src/plugins/comments.css\");\n\n@import url(\"src/pad-variants.css\");\n\n/* -----------------------------------------------------------------\n * COLORS\n * If you want to change main colors, please replace following CSS variables\n * -----------------------------------------------------------------\n */\n\n:root {\n  --super-dark-color: #485365; /*#374256;*/\n  --dark-color: #576273; /*#4d5d77*/\n\n  --primary-color: #64d29b;\n  --middle-color: #d2d2d2; /* kind of grey, use for border for examples */\n\n  --light-color: #f2f3f4; /*#f9f9f9;*/\n  --super-light-color: white;\n\n  --text-color: var(--super-dark-color);\n  --text-soft-color: var(--dark-color);\n  --border-color: var(--middle-color);\n  --bg-soft-color: var(--light-color);\n  --bg-color: var(--super-light-color);\n\n  --toolbar-border: none;\n\n  --main-font-family: Quicksand, Cantarell, \"Open Sans\", \"Helvetica Neue\", sans-serif;\n\n  /* Those padding like an external padding. Basic padding of 15px is always applied */\n  --editor-horizontal-padding: 40px;\n  --editor-vertical-padding: 25px;\n}\n\n@media (max-width:1000px) {\n  :root {\n    --editor-horizontal-padding: 0px;\n    --editor-vertical-padding: 0px;\n  }\n}\n\n/* Default scrollbar values */\nbody {\n  --scrollbar-bg: var(--light-color);\n  --scrollbar-track: var(--super-light-color);\n  --scrollbar-thumb: var(--dark-color);\n}\n"
  },
  {
    "path": "src/static/skins/colibris/pad.js",
    "content": "'use strict';\n\nconst MAX_PADS_IN_HISTORY = 3;\n\nwindow.customStart = () => {\n  $('#pad_title').show();\n  $('.buttonicon').on('mousedown', function () { $(this).parent().addClass('pressed'); });\n  $('.buttonicon').on('mouseup', function () { $(this).parent().removeClass('pressed'); });\n\n  const pathSegments = window.location.pathname.split('/');\n  const padName = pathSegments[pathSegments.length - 1];\n  const recentPads = localStorage.getItem('recentPads');\n  if (recentPads == null) {\n    localStorage.setItem('recentPads', JSON.stringify([]));\n  }\n  const recentPadsList = JSON.parse(localStorage.getItem('recentPads'));\n  if (!recentPadsList.some((pad) => pad.name === padName)) {\n    if (recentPadsList.length >= MAX_PADS_IN_HISTORY) {\n      recentPadsList.shift(); // Remove the oldest pad if we have more than 10\n    }\n    recentPadsList.push({\n      name: padName,\n      timestamp: new Date().toISOString(), // Store the timestamp for sorting\n      members: 1,\n    });\n    localStorage.setItem('recentPads', JSON.stringify(recentPadsList));\n  } else {\n    // Update the timestamp if the pad already exists\n    const existingPad = recentPadsList.find((pad) => pad.name === padName);\n    if (existingPad) {\n      existingPad.timestamp = new Date().toISOString();\n    }\n    localStorage.setItem('recentPads', JSON.stringify(recentPadsList));\n  }\n};\n"
  },
  {
    "path": "src/static/skins/colibris/src/components/buttons.css",
    "content": "button, .btn\n{\n  padding: 5px 20px;\n  border-radius: 4px;\n  line-height: 1.5;\n  width: auto;\n  border: none;\n  font-weight: bold;\n  position: relative;\n  background: none;\n  cursor: pointer;\n}\n\n.btn-primary\n{\n  background-color: #64d29b;\n  background-color: var(--primary-color);\n  color: #ffffff;\n  color: var(--bg-color);\n}\n.btn-default {\n  color: #485365;\n  color: var(--text-color);\n}\n\n/* Sekundär (outlined) */\n.btn-secondary {\n  background: transparent;\n  color: #1f8a3e;\n  border: 2px solid #1f8a3e;\n  box-shadow: none;\n}\n\n.active-btn {\n  text-underline-offset: 10px;\n  text-decoration: underline;\n  text-decoration-style: solid;\n  text-decoration-thickness: 2px;\n  text-decoration-color: #1f8a3e;\n  cursor: pointer;\n}\n\n\n.btn-secondary:hover {\n  background: #1f8a3e;\n  color: #fff;\n  box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 1px 2px rgba(0,0,0,0.12);\n  transform: translateY(-1px);\n}\n\n.btn-secondary:disabled {\n  background: transparent;\n  color: #aaa;\n  border-color: #aaa;\n  box-shadow: none;\n  cursor: not-allowed;\n}\n"
  },
  {
    "path": "src/static/skins/colibris/src/components/chat.css",
    "content": "#chatbox {\n  background-color: transparent !important;\n  color: var(--text-color);\n}\n.chat-content {\n  background: none;\n  padding: 0;\n  width: 400px;\n  height: 300px;\n  background-color: #f2f3f4;\n  background-color: var(--bg-soft-color);\n}\n\n.chat-content, #chaticon {\n  box-shadow: 0 0 0 1px rgba(99, 114, 130, 0.16), 0 8px 16px rgba(27, 39, 51, 0.08);\n  border: none;\n}\n\n#chaticon {\n  padding: 10px 20px;\n  background-color: #f2f3f4;\n  background-color: var(--bg-soft-color);\n  color: #485365;\n  color: var(--text-color);\n  right: 30px;\n}\n\n#chatbox.stickyChat .chat-content {\n  border: none;\n  box-shadow: none;\n  background-color: #f2f3f4;\n  background-color: var(--bg-soft-color);\n}\n\n#titlebar {\n  bottom: 0;\n  line-height: 44px;\n  height: 44px;\n  padding: 0 7px;\n  z-index: 20000;\n}\n\n#titlelabel, #chatlabel {\n  text-transform: uppercase;\n  font-weight: bold;\n}\n\n#titlebar #titlelabel { font-size: 16px; }\n#chatlabel { margin-right: 15px; }\n\n#chattext {\n  padding: 0;\n  border-top: 1px solid #ffffff;\n  border-top: 1px solid var(--bg-color);\n  border-bottom: 1px solid #ffffff;\n  border-bottom: 1px solid var(--bg-color);\n  background-color: inherit;\n  color: inherit;\n}\n#chattext p {\n  padding: 4px 10px;\n}\n#chattext:not(.authorColors) p:first-child {\n  padding-top: 10px;\n}\n#chattext:not(.authorColors) p:last-child {\n  padding-bottom: 10px;\n}\n\n#chatinputbox {\n  padding: 8px;\n}\n#chatinputbox #chatinput {\n  background-color: #ffffff;\n  background-color: var(--bg-color);\n}\n\n@media (prefers-reduced-motion) {\n  .chat-content {\n    transform: scale(1);\n    transition: none;\n  }\n}\n\n@media (max-width: 800px) {\n  #chaticon {\n    right: 0;\n  }\n\n  .stick-to-screen-btn { display: none; }\n}\n"
  },
  {
    "path": "src/static/skins/colibris/src/components/form.css",
    "content": "input[type=\"text\"], select, textarea, .nice-select {\n  border-radius: 3px;\n  box-shadow: none;\n  border: none;\n  outline: 0;\n}\n\ninput[type=\"text\"], textarea {\n  padding: 8px 10px;\n  background-color: #f2f3f4;\n  background-color: var(--bg-soft-color);\n  border: none;\n  color: #485365;\n  color: var(--text-color);\n}\ninput[type=\"text\"]::placeholder, textarea::placeholder {\n  color: #576273;\n  color: var(--text-soft-color);\n}\nselect, .nice-select {\n  background-color: #f2f3f4;\n  background-color: var(--bg-soft-color);\n  border: 1px solid var(--bg-soft-color);\n  padding: 4px 10px;\n  padding-right: 25px;\n  font-weight: bold;\n  line-height: inherit;\n}\nselect:hover, .nice-select:hover {\n  border-color: var(--bg-soft-color)\n}\n.nice-select .list {\n  background-color: #f2f3f4;\n  background-color: var(--bg-soft-color);\n}\n.nice-select .option:hover,.nice-select .option.focus,.nice-select .option.selected.focus {\n  background-color: #ffffff;\n  background-color: var(--bg-color);\n}\n.nice-select .option {\n  padding: 0 15px;\n}\n.popup .nice-select .list {\n  right: 0;\n  left: auto;\n}\n\n\n/* Checkboxes\n   ========================================================================== */\n/* Remove default checkbox */\n[type=\"checkbox\"]:not(:checked),\n[type=\"checkbox\"]:checked {\n  position: absolute;\n  opacity: 0;\n  pointer-events: none;\n}\n\n[type=\"checkbox\"] + label {\n  position: relative;\n  padding-left: 2.5rem;\n  cursor: pointer;\n  display: inline-block;\n  height: 1.4rem;\n  line-height: 1.4rem;\n  font-size: 1rem;\n}\n\n[type=\"checkbox\"] + label:before,\n[type=\"checkbox\"] + label:after {\n  content: '';\n  position: absolute;\n  margin-top: 4px;\n  transition: all .2s ease-in-out;\n}\n\n/* BEFORE, the container*/\n[type=\"checkbox\"] + label:before {\n  width: 24px;\n  height: 14px;\n  top: 0;\n  left: 0;\n  border-radius: 6px;\n  border: 2px solid #576273;\n  border: 2px solid var(--text-soft-color);\n  background-color: #f2f3f4;\n  background-color: var(--bg-soft-color);\n  opacity: .7;\n}\n[type=\"checkbox\"]:checked + label:before {\n  background-color: transparent;\n  border-color: #64d29b;\n  border-color: var(--primary-color);\n}\n\n/* AFTER, the circle moving */\n[type=\"checkbox\"] + label:after {\n  width: 16px;\n  height: 16px;\n  border-radius: 50%;\n  background-color: #576273;\n  background-color: var(--text-soft-color);\n  top: -1px;\n  left: -3px;\n}\n[type=\"checkbox\"]:checked + label:after {\n  background-color: #64d29b;\n  background-color: var(--primary-color);\n  transform: translateX(14px);\n}\n\n[type=\"checkbox\"]:checked:disabled + label,\n[type=\"checkbox\"]:checked:disabled + label:before,\n[type=\"checkbox\"]:checked:disabled + label:after {\n  cursor: not-allowed;\n  opacity: .4;\n}\n"
  },
  {
    "path": "src/static/skins/colibris/src/components/gritter.css",
    "content": ".gritter-item:not(.error) .popup-content{\n  background-color: #64d29b;\n  background-color: var(--primary-color);\n  color: #ffffff;\n  color: var(--super-light-color);\n}\n.gritter-item .popup-content {\n  padding: 15px;\n  box-shadow: 0 0 0 1px rgba(99, 114, 130, 0.16), 0 8px 16px rgba(27, 39, 51, 0.08);\n}\n#gritter-container.bottom .gritter-item .popup-content {\n  margin-top: 10px;\n}\n#gritter-container.top .gritter-item .popup-content {\n  margin-bottom: 10px;\n}\n.gritter-item p {\n  margin: 0 !important;\n}\n.gritter-item .gritter-title {\n  margin-bottom: 10px;\n}\n.gritter-item .gritter-close {\n  margin-left: 15px;\n  margin-right: 0px;\n}\n.gritter-item:not(.error) .gritter-close .buttonicon {\n  color: #ffffff;\n  color: var(--super-light-color);\n}\n\n/* CHAT GRIITER ITEM */\n.gritter-item.chat-gritter-msg:not(.error) .popup-content {\n  background-color: white;\n  background-color: var(--bg-color);\n  color: #485365;\n  color: var(--text-color);\n}\n.gritter-item.chat-gritter-msg .gritter-content {\n  text-align: left;\n}\n.gritter-item.chat-gritter-msg .author-name {\n  font-weight: bold;\n  margin-right: 5px;\n}\n.gritter-item.chat-gritter-msg:not(.error) .gritter-close .buttonicon {\n  color: #485365;\n  color: var(--text-color);\n}\n\n.gritter-item.saved-revision {\n  max-width: 600px;\n}\n\n#gritter-container.top .gritter-item.popup > .popup-content {\n  transform: scale(0.8) translateY(-100px);\n}\n#gritter-container.bottom .gritter-item.popup > .popup-content {\n  transform: scale(0.8) translateY(0px);\n}\n\n.gritter-item.popup.popup-show > .popup-content {\n  transform: scale(1) translateY(0) !important;\n  transition: all 0.4s cubic-bezier(0.74, -0.05, 0.27, 1.75) !important;\n}\n@media (prefers-reduced-motion) {\n  #gritter-container.top .gritter-item.popup > .popup-content {\n    transform: scale(1) translateY(0px) !important;\n  }\n  #gritter-container.bottom .gritter-item.popup > .popup-content {\n    transform: scale(1) translateY(0px) !important;\n  }\n  .gritter-item.popup.popup-show > .popup-content {\n    transform: scale(1) translateY(0px) !important;\n    transition: none;\n  }\n}\n\n/* for ep_deleted_after_delay */\n.gritter-item #close_expiration_notif {\n  display: none;\n}\n"
  },
  {
    "path": "src/static/skins/colibris/src/components/import-export.css",
    "content": "#importmessageabiword {\n  font-style: italic;\n  color: #64d29b;\n  color: var(--primary-color);\n}\n#importmessageabiword > a {\n  font-weight: bold;\n  text-decoration: underline;\n  color: #64d29b;\n  color: var(--primary-color);\n}\n\n#importmessagefail {\n  margin-top: 10px;\n}\n\n#importsubmitinput[disabled] { opacity: .6; }"
  },
  {
    "path": "src/static/skins/colibris/src/components/popup.css",
    "content": ".popup-content {\n  border-radius: 5px;\n  padding: 25px;\n  background: none;\n  background-color: #ffffff;\n  background-color: var(--bg-color);\n  color: #576273;\n  color: var(--text-soft-color);\n  border: none;\n  box-shadow: 0 0 0 1px rgba(99, 114, 130, 0.16), 0 8px 16px rgba(27, 39, 51, 0.08);\n}\n\n#mycolorpicker, #users {\n  min-width: 0;\n}\n\n.popup h1 {\n  margin-bottom: 20px;\n  font-size: 1.6rem;\n}\n\n.popup h2 {\n  margin-bottom: 15px;\n  margin-top: 20px;\n  color: #485365;\n  color: var(--text-color);\n}\n\n.popup:not(.comment-modal) p {\n  margin: 10px 0;\n}\n\n.popup .dropdowns-container .dropdown-line {\n  margin-top: 15px;\n}\n.popup .dropdowns-container label {\n  width: 120px;\n  display: inline-block;\n}\n.popup .dropdowns-container .nice-select {\n  min-width: 180px;\n}\n\n@media (prefers-reduced-motion) {\n  .popup>.popup-content {\n    transform: scale(1);\n    transition: none;\n  }\n  .nice-select .list {\n    transform: scale(1) translateY(0px);\n    -webkit-transform: scale(1) translateY(0px);\n    -ms-transform: scale(1) translateY(0px);\n    transition: none;\n  }\n}\n\n@media (max-width: 800px) {\n  .popup-content {\n    padding: 1rem;\n    box-shadow: 0 0 0 1px rgba(99, 114, 130, 0.16), -1px 1px 16px 3px rgba(27, 39, 51, 0.12);\n  }\n  .popup .dropdowns-container select {\n    min-width: 0;\n  }\n}\n\n/* SKIN Variants Popup */\n#skin-variants {\n  bottom: 0;\n  left: 0;\n  right: auto;\n  top: auto;\n}\n#skin-variants .popup-content > p {\n  margin-top: 25px;\n}\n#skin-variants-result{\n  background-color: #f2f3f4;\n  background-color: var(--bg-soft-color);\n}\n.skin-variant-container {\n  text-transform: capitalize;\n}\n\n#delete-pad {\n  background-color: #ff7b72;\n}\n\n#theme-switcher div {\n  position: relative;\n  width: 30px;\n  background-color: white;\n  height: 10px;\n  border-radius: 5px;\n  align-self: center;\n}\n\n#theme-switcher {\n  display: flex;\n  margin-top: 20px;\n  flex-direction: row;\n}\n\n#theme-switcher div span {\n  width: 15px;\n  display: block;\n  height: 15px;\n  border-radius: 20px;\n  position: absolute;\n  top: -2px;\n  background-color: white;\n  transition: background-color 0.25s;\n}\n\nhtml.super-light-editor #theme-switcher div {\n  background-color: #ccc;\n}\n\nhtml.super-light-editor #theme-switcher div span {\n  left: 0;\n  background-color: var(--primary-color);;\n}\n\nhtml.super-dark-editor #theme-switcher div span {\n  right: 0;\n  background-color: var(--primary-color);;\n}\n"
  },
  {
    "path": "src/static/skins/colibris/src/components/scrollbars.css",
    "content": "@media (min-width: 721px) {\n  ::-webkit-scrollbar-track {\n    background-color: white;\n    background-color: var(--scrollbar-track);\n    border-radius: 10px;\n    border: 7px solid #f2f3f4;\n    border: 7px solid var(--scrollbar-bg);\n  }\n\n  ::-webkit-scrollbar {\n    width: 22px;\n  }\n\n  ::-webkit-scrollbar-thumb {\n    min-height: 40px;\n    border-radius: 10px;\n    background-color: #576273;\n    background-color: var(--scrollbar-thumb);\n    border: 7px solid #f2f3f4;\n    border: 7px solid var(--scrollbar-bg);\n  }\n}\n\n.thin-scrollbar::-webkit-scrollbar-track {\n  background-color: #f2f3f4;\n  background-color: var(--bg-soft-color);\n  border-radius: 0px;\n  border: none;\n}\n\n.thin-scrollbar::-webkit-scrollbar {\n  width: 6px;\n}\n\n.thin-scrollbar::-webkit-scrollbar-thumb {\n  border-radius: 0px;\n  min-height: 40px;\n  background-color: #d2d2d2;\n  background-color: var(--middle-color);\n  border: none;\n}"
  },
  {
    "path": "src/static/skins/colibris/src/components/sidediv.css",
    "content": "#sidediv {\n  background-color: transparent;\n  border: none;\n  opacity: .8;\n}\n\n#sidedivinner>div:before {\n  font-family: var(--main-font-family); /* the parent div have font-family monospace (line number) */\n  color: #485365;\n  color: var(--text-color);\n  font-weight: bold;\n}\n\n#sidedivinner>div .line-number {\n  line-height: inherit;\n  font-family: RobotoMono;\n  display: inline-block;\n  color: #576273;\n  color: var(--text-soft-color);\n  height:100%;\n}\n\n#sidedivinner>div .line-number:hover {\n  background-color: var(--bg-soft-color);\n  border-radius: 5px 0 0 5px;  \n  font-weight: bold;\n  color: var(--text-color);\n}\n.plugin-ep_author_neat #sidedivinner>div .line-number:hover {\n  background-color: transparent;\n}\n"
  },
  {
    "path": "src/static/skins/colibris/src/components/table-of-content.css",
    "content": "#toc {\n  padding: 20px 20px 10px 10px !important;\n  min-width: 146px !important;\n  background-color: transparent !important;\n  border: none !important;\n  order: -2;\n}\n\n#tocItems {\n  line-height: 40px !important;\n}\n\n.plugin-ep_resizable_bars #toc {\n  min-width: 186px !important;\n}\n\n@media (max-width: 1200px) {\n  #toc {\n    padding-top: 10px !important\n  }\n}"
  },
  {
    "path": "src/static/skins/colibris/src/components/toolbar.css",
    "content": ".toolbar {\n  border-bottom: none;\n  padding: 0;\n  background-color: #ffffff;\n  background-color: var(--bg-color);\n  color: #576273;\n  color: var(--text-soft-color);\n  border-bottom: none;\n}\n\n#editbar.editor-scrolled {\n  border-bottom: 1px solid #d2d2d2;\n  border-bottom: var(--toolbar-border);\n}\n\n.toolbar ul {\n  align-items: center;\n}\n\n.toolbar ul.menu_left {\n  padding-left: 5px;\n}\n\n.toolbar ul li {\n  margin: 7px 1px;\n}\n\n.toolbar ul li a, .toolbar .buttonicon {\n  color: inherit;\n}\n\n.toolbar .buttonicon {\n  background-color: transparent;\n  font-size: 15px;\n}\n.buttonicon-insertorderedlist:before,\n.buttonicon-insertunorderedlist:before,\n.buttonicon-indent:before,\n.buttonicon-outdent:before {\n  font-size: 16px !important;\n}\n\n.toolbar ul li.separator {\n  visibility: hidden;\n  width: 1px;\n  margin: 0 10px;\n  position: relative;\n}\n\n.toolbar.condensed ul li {\n  margin-left: 0;\n}\n\n.toolbar.condensed ul li.separator {\n  margin: 0 5px;\n}\n\n.toolbar ul li a {\n  background-color: transparent;\n  background: none;\n  border: none;\n  border-radius: 3px !important;\n  transition: background-color .1s;\n}\n\n.toolbar ul li a:hover, .toolbar ul li select:hover  {\n  background-color: #f2f3f4;\n  background-color: var(--bg-soft-color);\n  color: #485365;\n  color: var(--text-color);\n}\n.toolbar ul li a.selected {\n  background-color: #f2f3f4;\n  background-color: var(--bg-soft-color);\n}\n.toolbar ul li a.pressed,\n.toolbar ul li select:active {\n  color: #64d29b;\n  color: var(--primary-color);\n}\n\n.toolbar ul li select:active option {\n  background-color: #ffffff;\n  background-color: var(--bg-color);\n  color: #576273;\n  color: var(--text-soft-color);\n  padding: 5px;\n}\n\n.toolbar .menu_right li a.selected {\n  background-color: #576273;\n  background-color: var(--text-soft-color);\n  color: #ffffff;\n  color: var(--bg-color);\n}\n\n.toolbar ul li[data-key=showusers] {\n  margin: 0;\n  margin-left: 15px;\n  width: 45px;\n  height: 100%;\n}\n.toolbar ul li[data-key=showusers] > a {\n  width: 100%;\n  height: 100%;\n  border-radius: 0 !important;\n}\n\n.toolbar .menu_right .separator {\n  display: none;\n}\n.toolbar .menu_right li {\n  margin-left: 10px;\n}\n\n.toolbar.cropped .menu_left {\n  height: 39px;\n  padding-top: 1px;\n}\n.toolbar .show-more-icon-btn {\n  font-size: 1.8rem;\n  color: #64d29b;\n  color: var(--primary-color);\n}\n\n@media (max-width: 1000px) {\n  .toolbar ul li.separator {\n    margin: 0 5px;\n    background: none;\n    display: block;\n  }\n}\n\n.mobile-layout .toolbar ul li {\n  margin: 5px 2px;\n}\n.mobile-layout .toolbar ul li.separator {\n  margin: 0 5px;\n}\n@media (max-width: 800px) {\n  .mobile-layout .toolbar ul li.separator {\n    display: none;\n  }\n}\n.mobile-layout .toolbar .menu_right {\n  border-top: 1px solid #d2d2d2;\n  border-top: var(--toolbar-border);\n  background-color: #ffffff;\n  background-color: var(--bg-color);\n  padding: 0;\n}\n.mobile-layout .toolbar ul li a:hover {\n  /* background-color: transparent; */\n}\n"
  },
  {
    "path": "src/static/skins/colibris/src/components/users.css",
    "content": "table#otheruserstable {\n  margin-top: 20px;\n}\n\n.popup#users.chatAndUsers > .popup-content {\n  padding: 20px 10px;\n  height: 250px;\n  border-left: none;\n  transition: none;\n  border-bottom-color: #d2d2d2;\n  border-bottom-color: var(--border-color);\n}\n.popup#users.chatAndUsers #mycolorpicker.popup {\n  right: calc(100% + 30px);\n  top: 15px;\n}\n\n#otheruserstable .swatch {\n  border: none !important;\n  border-radius: 50%;\n  width: 18px;\n  height: 18px;\n  margin: 0;\n  margin-left: 1px;\n  margin-right: 15px;\n}\n\n#myusernameform {\n  margin-left: 35px;\n}\n\ninput#myusernameedit {\n  min-width: 110px;\n  border: none !important;\n  background-color: transparent !important;\n  border-bottom: 1px solid #d2d2d2 !important;\n  border-bottom: 1px solid var(--border-color) !important;\n  border-radius: 0;\n  padding-bottom: 5px;\n}\n\n#myswatch {\n  border-radius: 50%;\n}\n\n#colorpicker {\n  margin-bottom: 25px;\n}\n\n#mycolorpickerpreview {\n  border-radius: 50%;\n}"
  },
  {
    "path": "src/static/skins/colibris/src/general.css",
    "content": "body {\n  color: #485365;\n  color: var(--text-color);\n  font-family: Quicksand, Cantarell, \"Open Sans\", \"Helvetica Neue\", sans-serif;\n  font-family: var(--main-font-family);\n}\n\nh1 {\n  color: #64d29b;\n  color: var(--primary-color);\n}"
  },
  {
    "path": "src/static/skins/colibris/src/layout.css",
    "content": "#outerdocbody {\n  margin: 0 auto;\n  padding-top: 20px;\n  width: 100%;\n}\n\n#editorcontainerbox {\n  background-color: #f2f3f4;\n  background-color: var(--bg-color);\n  color: var(--text-color);\n}\n\n#editorcontainerbox .sticky-container {\n  width: 250px;\n}\n\n#outerdocbody iframe, #outerdocbody > #innerdocbody {\n  max-width: 900px;\n  padding: 40px 55px;\n  padding-left: var(--editor-horizontal-padding);\n  padding-right: var(--editor-horizontal-padding);\n  padding-top: var(--editor-vertical-padding);\n  padding-bottom: var(--editor-vertical-padding);\n  box-shadow: none;\n  border: 0;\n  border-radius: 8px 8px 0 0;\n  background-color: #ffffff;\n  background-color: var(--bg-color);\n  color: #485365;\n  color: var(--text-color);\n}\n#sidediv {\n  /* Padding must be the same than editor, otherwise it creates problem */\n  padding-top: 40px; /* = #innerdocbody iframe vertical padding */\n  padding-bottom: 40px;\n  padding-top: calc(var(--editor-vertical-padding) + 15px);\n  padding-bottom: calc(var(--editor-vertical-padding) + 15px);\n}\n\n@media (max-width:1000px) {\n  #outerdocbody {\n    padding-top: 0;\n  }\n  #outerdocbody iframe, #outerdocbody > #innerdocbody {\n    max-width: none;\n    border-radius: 0;\n  }\n}\n"
  },
  {
    "path": "src/static/skins/colibris/src/pad-editor.css",
    "content": "#innerdocbody {\n  background: transparent;\n  color: #485365;\n  color: var(--text-color);\n}\n"
  },
  {
    "path": "src/static/skins/colibris/src/pad-variants.css",
    "content": "/* =========================== */\n/* === Super Light Toolbar === */\n/* =========================== */\n.super-light-toolbar .toolbar, .super-light-toolbar .popup-content, .super-light-toolbar #pad_title  {\n  --text-color: var(--super-dark-color);\n  --text-soft-color: var(--dark-color);\n  --border-color: #e4e6e9;\n  --bg-soft-color: var(--light-color);\n  --bg-color: var(--super-light-color);\n}\n/* ===================== */\n/* === Light Toolbar === */\n/* ===================== */\n.light-toolbar .toolbar, .light-toolbar .popup-content, .light-toolbar #pad_title {\n  --text-color: var(--super-dark-color);\n  --text-soft-color: var(--dark-color);\n  --border-color: var(--middle-color);\n  --bg-soft-color: var(--super-light-color);\n  --bg-color: var(--light-color);\n}\n/* ========================== */\n/* === Super Dark Toolbar === */\n/* ========================== */\n.super-dark-toolbar .toolbar, .super-dark-toolbar .popup-content, .super-dark-toolbar #pad_title {\n  --text-color: var(--super-light-color);\n  --text-soft-color: var(--light-color);\n  --border-color: var(--dark-color);\n  --bg-soft-color: var(--dark-color);\n  --bg-color: var(--super-dark-color);\n}\n.super-dark-toolbar.super-dark-editor .popup-content {\n  border: 1px solid var(--dark-color);\n  box-shadow: none;\n}\n/* ==================== */\n/* === Dark Toolbar === */\n/* ==================== */\n.dark-toolbar .toolbar, .dark-toolbar .popup-content, .dark-toolbar #pad_title  {\n  --text-color: var(--super-light-color);\n  --text-soft-color: var(--light-color);\n  --border-color: var(--super-dark-color);\n  --bg-soft-color: var(--super-dark-color);\n  --bg-color: var(--dark-color);\n}\n\n\n\n\n\n/* ============================ */\n/* == Super Light Background == */\n/* ============================ */\n.super-light-background #editorcontainerbox, .super-light-background #sidediv,\n.super-light-background #chatbox, .super-light-background #outerdocbody, .super-light-background {\n  --text-color: var(--super-dark-color);\n  --text-soft-color: var(--dark-color);\n  --border-color: #e4e6e9;\n  --bg-soft-color: var(--light-color);\n  --bg-color: var(--super-light-color);\n}\n.super-light-background  body, .full-width-editor.super-light-editor body:not(.comments-active) {\n  --scrollbar-bg: var(--super-light-color);\n  --scrollbar-track: var(--light-color);\n  --scrollbar-thumb: var(--dark-color);\n}\n.super-light-background .compact-display-content {\n  background-color: var(--super-light-color);\n}\n/* ====================== */\n/* == Light Background == */\n/* ====================== */\n.light-background #editorcontainerbox, .light-background #sidediv,\n.light-background #chatbox, .light-background #outerdocbody, .light-background {\n  --text-color: var(--super-dark-color);\n  --text-soft-color: var(--dark-color);\n  --border-color: var(--middle-color);\n  --bg-soft-color: var(--super-light-color);\n  --bg-color: var(--light-color);\n}\n.light-background  body, .full-width-editor.light-editor body:not(.comments-active) {\n  --scrollbar-bg: var(--light-color);\n  --scrollbar-track: var(--super-light-color);\n  --scrollbar-thumb: var(--dark-color);\n}\n.light-background .compact-display-content {\n  background-color: var(--light-color);\n}\n/* =========================== */\n/* == Super Dark Background == */\n/* =========================== */\n.super-dark-background #editorcontainerbox, .super-dark-background #sidediv,\n.super-dark-background #chatbox, .super-dark-background #outerdocbody, .super-dark-background {\n  --text-color: var(--super-light-color);\n  --text-soft-color: var(--light-color);\n  --border-color: var(--dark-color);\n  --bg-soft-color: var(--dark-color);\n  --bg-color: var(--super-dark-color);\n}\n.super-dark-background body, .full-width-editor.super-dark-editor body:not(.comments-active) {\n  --scrollbar-bg: var(--super-dark-color);\n  --scrollbar-track: var(--dark-color);\n  --scrollbar-thumb: var(--light-color);\n}\n.super-dark-background .compact-display-content {\n  background-color: var(--super-dark-color);\n}\n/* Special combinaison with toolbar */\n.super-dark-background.super-dark-toolbar .popup-content {\n  border: 1px solid var(--dark-color);\n  box-shadow: none;\n}\n/* ===================== */\n/* == Dark Background == */\n/* ===================== */\n.dark-background #editorcontainerbox, .dark-background #sidediv,\n.dark-background #chatbox, .dark-background #outerdocbody, .dark-background  {\n  --text-color: var(--super-light-color);\n  --text-soft-color: var(--light-color);\n  --border-color: var(--super-dark-color);\n  --bg-soft-color: var(--super-dark-color);\n  --bg-color: var(--dark-color);\n}\n.dark-background  body, .full-width-editor.dark-editor body:not(.comments-active) {\n  --scrollbar-bg: var(--dark-color);\n  --scrollbar-track: var(--super-dark-color);\n  --scrollbar-thumb: var(--light-color);\n}\n.dark-background .compact-display-content {\n  background-color: var(--dark-color);\n}\n/* Special combinaison with toolbar */\n.dark-background.dark-toolbar .popup-content, .dark-editor.dark-toolbar .popup-content {\n  box-shadow: 0 0 14px 0px var(--super-dark-color);\n}\n\n\n\n\n\n/* ======================== */\n/* == Super Light Editor == */\n/* ======================== */\n.super-light-editor #outerdocbody iframe, .super-light-editor #outerdocbody > #innerdocbody {\n  --bg-color: var(--super-light-color);\n}\n.super-light-editor #innerdocbody {\n  --text-color: var(--super-dark-color);\n}\n/* ================== */\n/* == Light Editor == */\n/* ================== */\n.light-editor #outerdocbody iframe, .light-editor #outerdocbody > #innerdocbody {\n  --bg-color: var(--light-color);\n}\n.light-editor #innerdocbody {\n  --text-color: var(--super-dark-color);\n}\n/* ======================= */\n/* == Super Dark Editor == */\n/* ======================= */\n.super-dark-editor #outerdocbody iframe, .super-dark-editor #outerdocbody > #innerdocbody {\n  --bg-color: var(--super-dark-color);\n}\n.super-dark-editor #innerdocbody {\n  --text-color: var(--super-light-color);\n}\n/* ================= */\n/* == Dark Editor == */\n/* ================= */\n.dark-editor #outerdocbody iframe, .dark-editor #outerdocbody > #innerdocbody {\n  --bg-color: var(--dark-color);\n}\n.dark-editor #innerdocbody {\n  --text-color: var(--super-light-color);\n}\n\n\n/* ======================================== */\n/* == Combinaison with background/editor == */\n/* ======================================== */\n.super-light-editor.super-light-background #outerdocbody,\n.light-editor.light-background #outerdocbody,\n.super-dark-editor.super-dark-background #outerdocbody,\n.dark-editor.dark-background #outerdocbody {\n  padding-top: 0;\n}\n@media (min-width: 1001px) {\n  .super-light-editor.super-light-background,\n  .light-editor.light-background,\n  .super-dark-editor.super-dark-background,\n  .dark-editor.dark-background {\n    --editor-horizontal-padding: 20px;\n    --editor-vertical-padding: 5px;\n  }\n}\n\n/* ===================================== */\n/* == Combinaison with toolbar/editor == */\n/* ===================================== */\n.super-light-editor.super-light-toolbar .toolbar,\n.light-editor.light-toolbar .toolbar,\n.super-dark-editor.super-dark-toolbar .toolbar,\n.dark-editor.dark-toolbar .toolbar {\n  --toolbar-border: 1px solid var(--border-color);\n}\n\n\n/* ======================= */\n/* == Full Width Editor == */\n/* ======================= */\n.full-width-editor #outerdocbody iframe, .full-width-editor #outerdocbody > #innerdocbody {\n  max-width: none !important;\n  border-radius: 0;\n}\n.full-width-editor #outerdocbody {\n  padding: 0;\n  margin: 0;\n}\n@media (min-width: 1001px) {\n  .full-width-editor {\n    --editor-horizontal-padding: 20px !important;\n    --editor-vertical-padding: 5px !important;\n  }\n}\n.full-width-editor ::-webkit-scrollbar-track,\n.full-width-editor ::-webkit-scrollbar-thumb {\n  border-radius: 0px;\n}"
  },
  {
    "path": "src/static/skins/colibris/src/plugins/author_hover.css",
    "content": ".authortooltip {\n  opacity: 1!important;\n  border-radius: 2px;\n  padding: 4px 10px 3px!important;\n  text-transform: uppercase;\n  font-size: 13px!important;\n  font-weight: 700;\n  color: #000;\n  background-color: rgba(255, 255, 255, 0.85) !important;\n}"
  },
  {
    "path": "src/static/skins/colibris/src/plugins/brightcolorpicker.css",
    "content": "#colorpicker a.brightColorPicker-cancelButton {\n    background: none;\n    padding: 0;\n    padding-top: 10px;\n    font-weight: bold;\n    border: none;\n}\n\n.brightColorPicker-colorPanel {\n    background-color: white !important;\n    box-shadow: 0 0 0 1px rgba(99, 114, 130, 0.16), 0 8px 16px rgba(27, 39, 51, 0.08) !important;\n    border-radius: 3px !important;\n    padding: 15px !important;\n}"
  },
  {
    "path": "src/static/skins/colibris/src/plugins/comments.css",
    "content": ".sidebar-comment .btn {\n  margin-top: 10px;\n  padding: 3px 8px;\n  font-size: .9rem;\n  margin: 10px 0 5px 0;\n}\n.sidebar-comment .btn.btn-primary:not(#comment-create-btn) {\n  background-color: #576273;\n  background-color: var(--text-soft-color);\n}\n.sidebar-comment .suggestion-create {\n  margin-top: 10px;\n}\n.suggestion-display .from-value, .suggestion-display .to-value {\n  color: #64d29b;\n  color: var(--primary-color);\n  font-weight: bold;\n  opacity: 1;\n}\n.suggestion-display .from-value {\n  margin-right: 5px;\n}\n.comment-actions-wrapper .buttonicon {\n  opacity: .8;\n}\n.comment-actions-wrapper .buttonicon:hover {\n  opacity: 1;\n}\n.comment-actions-wrapper .comment-edit {\n  margin-right: 5px;\n}\n[type=\"checkbox\"] + label.label-suggestion-checkbox {\n  margin-left: 5px;\n  padding-left: 2.4rem;\n}\n.sidebar-comment .full-display-content {\n  margin-left: -10px;\n  box-shadow: none;\n  background-color: #f2f3f4;\n  background-color: var(--bg-soft-color);\n  border: 1px solid #ffffff;\n  border: 1px solid var(--bg-color);\n}\n.comment-reply {\n  border-top: 1px solid #ffffff;\n  border-top: 1px solid var(--bg-color);\n  background-color: inherit;\n}\n.comment-reply textarea, .comment-reply input[type=\"text\"] {\n  background-color: #ffffff;\n  background-color: var(--bg-color);\n}\n.btn.revert-suggestion-btn {\n  padding-left: 0;\n}\n.comment-edit-form {\n  margin-top: 15px;\n}\n\n/* MODAL */\n.comment-modal .full-display-content {\n  box-shadow: none;\n  margin: 0 !important;\n  border: none;\n  background-color: #ffffff;\n  background-color: var(--bg-color);\n}\n.comment-modal .comment-modal-comment {\n  padding: 0;\n}\n.comment-modal .comment-reply textarea, .comment-modal .comment-reply input[type=\"text\"] {\n  background-color: #f2f3f4;\n  background-color: var(--bg-soft-color);\n}\n.comment-modal .comment-reply {\n  border-top: 1px solid #f2f3f4;\n  border-top: 1px solid var(--bg-soft-color);\n}\n.comment-modal .full-display-content .comment-title-wrapper,\n.comment-modal .full-display-content .comment-reply {\n  padding: 15px;\n}\n\n\n/* NEW COMMENT POPUP */\n.new-comment-popup textarea {\n  background-color: #f2f3f4;\n  background-color: var(--bg-soft-color);\n}\n.new-comment-popup .suggestion {\n  margin-bottom: 10px;\n}\n\n\n/* EDITOR COMMENTEED LINE */\n#innerdocbody .ace-line .comment {\n  background-color: #fffacc;\n  color: var(--super-dark-color);\n}\n\n\n@media (min-width: 1200px) {\n  #comments {\n    width: 300px;\n  }\n  .sidebar-comment .full-display-content {\n    margin-left: 10px;\n  }\n  .compact-display-content {\n    padding-left: 20px;\n  }\n}"
  },
  {
    "path": "src/static/skins/colibris/src/plugins/font_color.css",
    "content": ".font-color-icon {\n  display: none !important;\n}\n\n#font-color {\n  display: list-item !important;\n}\n.readonly #font-color {\n  display: none !important;\n}\n\n.color\\:black,\n[data-color=black] {\n  color: #485365;\n  color: var(--text-color);\n}\n\n.color\\:red,\n[data-color=red] {\n  color: #F44336;\n}\n\n.color\\:green,\n[data-color=green] {\n  color: #66d29c;\n}\n\n.color\\:blue,\n[data-color=blue] {\n  color: #2196f3;\n}\n\n.color\\:yellow,\n[data-color=yellow] {\n  color: #ffeb3b;\n}\n\n.color\\:orange,\n[data-color=orange] {\n  color: #FF9800;\n}"
  },
  {
    "path": "src/static/skins/colibris/src/plugins/set_title_on_pad.css",
    "content": "#pad_title {\n  border-bottom: 1px solid var(--border-color) !important;\n  background-color: var(--bg-color) !important;\n}\n#edit_title {\n  color: var(--text-soft-color);\n}"
  },
  {
    "path": "src/static/skins/colibris/src/plugins/tables2.css",
    "content": "/* MENU ICON*/\n#editbar #tbl_menu_list {\n  width: auto !important;\n}\n#tbl-menu {\n  background: none !important;\n  width: 18px !important;\n  padding-left: 2px !important;\n}\n#tbl-menu:before {\n  content: \"\\F0CE\";\n}\n\n#tbl_menu_list > a {\n  font-size: 16px;\n  margin-top: 8px;\n  padding-left: 0;\n  padding-right: 2px;\n  padding-bottom: 4px;\n}\n\n/* DROP DOWN MENU */\n#tbl_context_menu {\n  margin-left: -24px;\n  border: none;\n  margin-top: 9px;\n  box-shadow: 0 0 0 1px rgba(99, 114, 130, 0.16), 0 8px 16px rgba(27, 39, 51, 0.08);\n  border-radius: 3px;\n  background-color: white;\n  font-size: 100%;\n  line-height: 1.7;\n}\n\n#tbl_context_menu > .bd {\n  border: none;\n  background-color: transparent;\n}\n\n#tbl_context_menu > .bd > ul {\n  padding: 6px 0;\n}\n\n/* TABLE SIZE PICKER */\n#tbl_insert  {\n  background-color: white;\n  box-shadow: 0 0 0 1px rgba(99, 114, 130, 0.16), 0 8px 16px rgba(27, 39, 51, 0.08);\n  border-radius: 3px;\n}\n\n#tbl_insert .bd {\n  border: none;\n  text-align: center;\n  background-color: transparent;\n  padding-top: 4px;\n}\n\n#tbl_insert .yuimenuitemlabel { text-align: center; }\n\n#tbl_insert .ft {\n  margin: 0;\n  border: none;\n  background-color: transparent;\n  padding: 6px;\n  padding-top: 0;\n}\n\n#matrix_table tr td {\n  border: 1px solid #d7d7d7;\n  height: 1px;\n  padding: 7px;\n  width: 11px;\n  background-color: #fbfbfb;\n  border-radius: 1px;\n}\n#matrix_table tr td.selected {\n  border: 1px solid #789dce;\n  background-color: #b3d4ff;\n}\n\n/* TABLE SETTINGS POPUP */\n.yui-skin-sam .yui-panel-container {\n  padding: 0;\n  margin: 0;\n  background-color: #fff;\n  box-shadow: 0 0 0 1px rgba(99, 114, 130, 0.16), 0 8px 16px rgba(27, 39, 51, 0.08);\n  border-radius: 5px;\n  padding-bottom: 15px;\n}\n\n.yui-skin-sam .yui-panel-container .yui-panel {\n  border: none !important;\n  background: none;\n  box-shadow: none !important;\n}\n\n.yui-skin-sam .yui-panel-container .yui-panel .hd {\n  cursor: move;\n  padding: 0;\n  border: 0;\n  background: 0;\n  margin: 0;\n  font-size: 14px;\n  line-height: 40px;\n  text-transform: uppercase;\n  padding: 0 15px;\n  padding-top: 5px;\n  font-weight: bold;\n  border-bottom: 1px solid #d2d2d2;\n}\n\n.yui-skin-sam .yui-panel-container .yui-panel .container-close {\n  top: 15px;\n  border: none;\n  background: none;\n  color: white;\n  text-indent: 0;\n}\n.yui-skin-sam .yui-panel-container .yui-panel .container-close::before {\n  content: \"x\";\n  color: #6f757a;\n  font-size: 16px;\n  font-weight: bold;\n}\n\n.yui-skin-sam .yui-panel-container .yui-panel .bd {\n  background: none;\n  border: none;\n  box-shadow: none;\n  padding: 15px;\n  background-color: transparent !important;\n}\n\n.yui-panel .underlay, .yui-skin-sam .yui-panel-container.shadow .underlay {\n  display: none !important;\n}\n\n#div_tbl_btn_close {\n  float: right;\n  position: relative;\n  width: 100%;\n  margin-top: 10px;\n  right: 0;\n  bottom: 0;\n}\n\n#tbl_btn_close {\n  border: none;\n  color: #ffffff;\n  height: 30px;\n  width: 100%;\n  border-radius: 3px;\n  text-transform: uppercase;\n}\n#tbl_btn_close:hover { cursor: pointer; }\n\n.yui-skin-sam .yui-button {\n  background: none;\n  background-color: white;\n  border: none;\n  height: 24px;\n  margin-bottom: -4px;\n  margin-top: 5px;\n}\n\n.yui-skin-sam .yui-button .first-child { margin: 0; border: none; }\n\n.yui-skin-sam .yui-split-button button {\n  padding: 0;\n  background: none !important;\n}\n\n.yui-skin-sam .yui-split-button button em:not(.color-picker-button) {\n  font-style: normal !important;\n  border-bottom: 1px solid #b5b7b7;\n  padding: 0 5px;\n  margin: 0 5px;\n  padding-bottom: 3px;\n}\n\nbutton#yui-gen13-button {\n  margin-left: -5px;\n}\n\nbutton .color-picker-button {\n  border: 1px solid #c1c2c2;\n  border-radius: 50%;\n  width: 16px;\n  height: 16px;\n  margin-top: 2px;\n}\n\n#even-row-bg-color, #single-row-bg-color {\n  margin-right: 5px;\n}\n#single-col-bg-color, #odd-row-bg-color {\n  margin-left: 7px;\n}\n\n#yui-tbl-prop-panel .text-input[type=text] {\n  border: 1px solid #d2d2d2;\n  float: right;\n  height: 10px;\n  border-radius: 3px;\n  padding: 8px 10px;\n}\n\n#text_input_message {\n  background-color: #64d29b;\n  padding: 0 5px;\n  color: white;\n  font-size: 12px;\n  border-radius: 5px;\n  font-weight: bold;\n  display: none;\n}\n\n/* TABLES INSIDE THE PAD */\ntd[name=tData] {\n  /*border: 1px solid grey !important;*/\n}\n\n#yui-picker-panel_c\n{\n  padding-bottom: 40px;\n}\n\ndiv#yui-picker-panel_h {\n  line-height: 1.8em;\n  font-size: 13px;\n  padding: 9px 15px 5px;\n}\n\n#yui-picker-panel .ft {\n  position: relative;\n  border: none;\n  width: 100%;\n  padding: 0;\n  margin-top: 20px;\n}"
  },
  {
    "path": "src/static/skins/colibris/timeslider.css",
    "content": "#timeslider-slider #ui-slider-handle {\n  border-radius: 3px;\n  width: 12px;\n  height: 28px;\n  background-color: #64d29b;\n  background-color: var(--primary-color);\n}\n\n#timeslider-slider #ui-slider-bar {\n  border-radius: 3px;\n  background-color: #d2d2d2;\n  background-color: var(--border-color);\n}\n#slider-btn-container {\n  margin: -18px 15px 0 20px;\n}\n#slider-btn-container #playpause_button_icon {\n  color: #ffffff;\n  color: var(--bg-color);\n  background-color: #64d29b;\n  background-color: var(--primary-color);\n  border: none;\n  margin-right: 5px;\n  padding-top: 3px;\n  width: 45px;\n  height: 45px;\n}\n#slider-btn-container #playpause_button_icon:not(.pause) {\n  padding-left: 4px;\n}\n#slider-btn-container .stepper {\n  border: 2px solid !important;\n  height: 30px;\n  width: 30px;\n  line-height: 28px;\n  margin-left: 5px;\n  font-size: 13px;\n  color: #64d29b;\n  color: var(--primary-color);\n  border-color: #64d29b;\n  border-color: var(--primary-color);\n}\n#slider-btn-container .stepper.disabled {\n  opacity: .5;\n}\n\n.timeslider #editbar .buttontext {\n  background-color: #576273;\n  background-color: var(--text-soft-color);\n  color: #ffffff;\n  color: var(--bg-color);\n  margin: 0;\n}\n\n#editbar {\n  display: block;\n  padding-bottom: 5px;\n}\n\n#editbar li > a {\n  border-radius: 3px;\n}\n\n#timeslider-slider #timer {\n  opacity: .7;\n  top: -12px;\n  font-size: .8em;\n}\n\n.timeslider #authorsList .author {\n  padding: 2px 5px;\n  border-radius: 3px;\n  margin-right: 4px;\n  margin-bottom: 4px;\n}\n\n.timeslider-title {\n  font-size: 1.8rem !important;\n}\n.timeslider-subtitle {\n  margin-top: 6px;\n  font-size: .9em;\n}\n\n@media (max-width: 800px) {\n\n  #slider-btn-container {\n    margin-top: 0;\n    margin-right: 5px;\n  }\n  #slider-btn-container #playpause_button_icon {\n    width: 30px;\n    height: 30px;\n  }\n  #slider-btn-container #playpause_button_icon:before {\n    font-size: 18px;\n  }\n}"
  },
  {
    "path": "src/static/skins/colibris/timeslider.js",
    "content": "'use strict';\n\nwindow.customStart = () => {\n};\n"
  },
  {
    "path": "src/static/skins/no-skin/index.css",
    "content": "/*\n  custom css files are loaded after core css files. Simply use the same selector to override a style.\n  Example:\n      #editbar LI {border:1px solid #000;}\n    overrides\n      #editbar LI {border:1px solid #d5d5d5;}\n    from pad.css\n*/\n#button {\n  font-size: 90%;\n  width: 100%;\n  position: unset;\n}\n\nbutton[type=\"submit\"], input[type=\"text\"] {\n  position: unset;\n}\n\nbutton[type=\"submit\"] {\n  width: auto;\n}\n\n#label {\n  display: none;\n}\n\n\n#padname {\n  max-width: 80%;\n}\n\n\n\n#inner {\n  max-width: 400px;\n}\n\n\nbody {\n  border-top: none;\n}\n\n.body {\n  display: grid;\n  place-items: center;\n  height: 100%;\n}\n\n#wrapper {\n  width: 100%;\n  margin-top: 0;\n  padding: 15px 0 15px 0;\n}\n\n\nform {\n  display: flex;\n  gap: 10px;\n  background-color: transparent;\n  border: none;\n  height: 4rem;\n  flex-direction: row;\n  margin-top: 1rem;\n}\n"
  },
  {
    "path": "src/static/skins/no-skin/index.js",
    "content": "'use strict';\n\nwindow.customStart = () => {\n  // define your javascript here\n  // jquery is available - except index.js\n  // you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/\n  const divHoldingPlaceHolderLabel = document\n      .querySelector('[data-l10n-id=\"index.placeholderPadEnter\"]');\n\n  const observer = new MutationObserver(() => {\n    document.querySelector('#go2Name input')\n        .setAttribute('placeholder', divHoldingPlaceHolderLabel.textContent);\n  });\n\n  observer\n      .observe(divHoldingPlaceHolderLabel, {childList: true, subtree: true, characterData: true});\n};\n"
  },
  {
    "path": "src/static/skins/no-skin/pad.css",
    "content": "/* intentionally empty */\n"
  },
  {
    "path": "src/static/skins/no-skin/pad.js",
    "content": "'use strict';\n\nwindow.customStart = () => {\n  // define your javascript here\n  // jquery is available - except index.js\n  // you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/\n};\n"
  },
  {
    "path": "src/static/skins/no-skin/timeslider.css",
    "content": "/*\n  custom css files are loaded after core css files. Simply use the same selector to override a style.\n  Example:\n      #editbar LI {border:1px solid #000;}\n    overrides\n      #editbar LI {border:1px solid #d5d5d5;}\n    from pad.css\n*/\n"
  },
  {
    "path": "src/static/skins/no-skin/timeslider.js",
    "content": "'use strict';\n\nwindow.customStart = () => {\n  // define your javascript here\n  // jquery is available - except index.js\n  // you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/\n};\n"
  },
  {
    "path": "src/static/tests.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <title>API Test and Examples Page</title>\n  <script type=\"text/javascript\" src=\"js/vendors/jquery.js\"></script>\n  <style type=\"text/css\">\n    body {\n      font-size:9pt;\n      background: rgba(0, 0, 0, .05);\n      color: #333;\n      text-shadow: 0 1px 0 #fff;\n      font: 14px helvetica,sans-serif;\n      background: #ccc;\n      background:    -moz-radial-gradient(circle, #aaa, #eee) no-repeat center center fixed;\n      background: -webkit-radial-gradient(circle, #aaa, #eee) no-repeat center center fixed;\n      background:     -ms-radial-gradient(circle, #aaa, #eee) no-repeat center center fixed;\n      background:      -o-radial-gradient(circle, #aaa, #eee) no-repeat center center fixed;\n      width: 1000px;\n    }\n    .define, #template {\n      display: none;\n    }\n    .test_group {\n      overflow: auto;\n      width: 300px;\n      float:left;\n      color: #555;\n\n      border-top: 1px solid #999;\n      margin: 4px;\n      padding: 4px 10px 4px 10px;\n      background: #eee;\n      background: -webkit-linear-gradient(#fff, #ccc);\n      background:    -moz-linear-gradient(#fff, #ccc);\n      background:     -ms-linear-gradient(#fff, #ccc);\n      background:      -o-linear-gradient(#fff, #ccc);\n      opacity: .9;\n      box-shadow: 0px 1px 8px rgba(0, 0, 0, 0.3);\n    }\n    .test_group h2 {\n      font-size: 10pt;\n    }\n    .test_group table {\n      width: 100%;\n    }\n    \n    #apikeyDIV {\n      width: 100%\n    }\n  </style>\n  <script type=\"text/javascript\">\n    $(document).ready(function() {\n      $('input[type=button]').live('click', function() {\n        var $test_group = $(this).closest('.test_group');\n        var name = parseName($test_group.find('h2').text());\n\n        var results_node = $test_group.find('.results');\n\n        var params = {};\n        $test_group.find('input[type=text]').each(function() {\n          params[$(this).attr('name')] = $(this).val();\n        });\n\n        callFunction(name, results_node, params);\n      });\n      \n      var template = $('#template')\n      $('.define').each(function() {\n        var functionName = parseName($(this).text());\n        var parameters = parseParameters($(this).text());\n\n        var testGroup = template.clone();\n\n        testGroup.find('h2').text(functionName + \"()\");\n\n        var table = testGroup.find('table');\n\n        $(parameters).each(function(index, el) {\n          table.prepend('<tr><td>' + el + ':</td>' +\n                          '<td style=\"width:200px\"><input type=\"text\" size=\"10\" name=\"' + el + '\" /></td></tr>');\n        });\n\n        testGroup.css({display: \"block\"});\n        testGroup.appendTo('body');\n      });\n    });\n\n    function parseName(str)\n    {\n      return str.substring(0, str.indexOf('('));\n    }\n\n    function parseParameters(str)\n    {\n      // parse out the parameters by looking for parens\n      var parens = str.substring(str.indexOf(\"(\"));\n    \n      // return empty array if there are no paremeters\n      if(parens.length < 3)\n      {\n        return [];\n      }\n\n      // remove parens from string\n      parens = parens.substring(1);\n      parens = parens.substring(0, parens.length-1);\n\n      return parens.split(',');\n    }\n\n    function callFunction(memberName, results_node, params) \n    {\n      $('#result').text('Calling ' + memberName + \"()...\");\n      \n      params[\"apikey\"]=$(\"#apikey\").val();\n      $.ajax({\n        type: \"GET\",\n        url: \"/api/1/\" + memberName,\n        data: params,\n        success: function(json,status,xhr) {\n          results_node.text(xhr.responseText);\n        },\n        error: function(jqXHR, textStatus, errorThrown) {\n          results_node.html(\"textStatus: \" + textStatus + \"<br />errorThrown: \" + errorThrown);\n        }\n      });\n    }\n  </script>\n</head>\n<body>\n  <div id=\"apikeyDIV\" class=\"test_group\"><b>APIKEY: </b><input type=\"text\" id=\"apikey\"></div>\n  <div class=\"test_group\" id=\"template\">\n    <h2>createGroup()</h2>\n    <table>\n      <tr>\n        <td class=\"buttonBox\" colspan=\"2\" style=\"text-align:right;\"><input type=\"button\" value=\"Run\" /></td>\n      </tr>\n    </table>\n    <div class=\"results\"></div>\n    \n  </div>\n  <div class=\"define\">createGroup()</div>\n  <div class=\"define\">deleteGroup(groupID)</div>\n  <div class=\"define\">createGroupIfNotExistsFor(groupMapper)</div>\n  <div class=\"define\">listPads(groupID)</div>\n  <div class=\"define\">createPad(padID,text)</div>\n  <div class=\"define\">createGroupPad(groupID,padName,text)</div>\n  <div class=\"define\">createAuthor(name)</div>\n  <div class=\"define\">createAuthorIfNotExistsFor(authorMapper,name)</div>\n  <div class=\"define\">createSession(groupID,authorID,validUntil)</div>\n  <div class=\"define\">deleteSession(sessionID)</div>\n  <div class=\"define\">getSessionInfo(sessionID)</div>\n  <div class=\"define\">listSessionsOfGroup(groupID)</div>\n  <div class=\"define\">listSessionsOfAuthor(authorID)</div>\n  <div class=\"define\">getText(padID,rev)</div>\n  <div class=\"define\">setText(padID,text)</div>\n  <div class=\"define\">getRevisionsCount(padID)</div>\n  <div class=\"define\">getLastEdited(padID)</div>\n  <div class=\"define\">deletePad(padID)</div>\n  <div class=\"define\">getReadOnlyID(padID)</div>\n  <div class=\"define\">setPublicStatus(padID,publicStatus)</div>\n  <div class=\"define\">getPublicStatus(padID)</div>\n</body>\n</html>\n"
  },
  {
    "path": "src/templates/export_html.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n<head>\n<title><%- padId %></title>\n  <link rel=\"manifest\" href=\"/manifest.json\" />\n  <meta name=\"generator\" content=\"Etherpad\"/>\n<meta name=\"author\" content=\"Etherpad\"/>\n<meta name=\"changedby\" content=\"Etherpad\"/>\n<meta charset=\"utf-8\"/>\n<style>\nol {\n  counter-reset: item;\n}\n\nol > li {\n  counter-increment: item;\n}\n\nol ol > li {\n  display: block;\n}\n\nol > li {\n  display: block;\n}\n\nol > li:before {\n  content: counters(item, \".\") \". \";\n}\n\nol ol > li:before {\n  content: counters(item, \".\") \". \";\n  margin-left: -20px;\n}\n\nul.indent {\n  list-style-type: none;\n}\n\n<%- extraCSS %>\n</style>\n</head>\n<body>\n<%- body %>\n</body>\n</html>\n"
  },
  {
    "path": "src/templates/index.html",
    "content": "<!doctype html>\n<html>\n\n        <title><%=settings.title%></title>\n        <meta charset=\"utf-8\">\n        <link rel=\"manifest\" href=\"/manifest.json\" />\n        <meta name=\"referrer\" content=\"no-referrer\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0\">\n        <link rel=\"shortcut icon\" href=\"favicon.ico\">\n        <link rel=\"localizations\" type=\"application/l10n+json\" href=\"locales.json\">\n\n        <style>\n            html, body {\n              height: 100%;\n            }\n            body {\n              margin: 0;\n              color: #333;\n              font: 14px helvetica, sans-serif;\n              background: #ddd;\n              background: -webkit-radial-gradient(circle,#aaa,#eee 60%) center fixed;\n              background: -moz-radial-gradient(circle,#aaa,#eee 60%) center fixed;\n              background: -ms-radial-gradient(circle,#aaa,#eee 60%) center fixed;\n              background: -o-radial-gradient(circle,#aaa,#eee 60%) center fixed;\n              border-top: 8px solid rgba(51,51,51,.8);\n            }\n            #wrapper {\n              border-top: 1px solid #999;\n              margin-top: 160px;\n              padding: 15px;\n              background: #eee;\n              background: -webkit-linear-gradient(#fff,#ccc);\n              background: -moz-linear-gradient(#fff,#ccc);\n              background: -ms-linear-gradient(#fff,#ccc);\n              background: -o-linear-gradient(#fff,#ccc);\n              box-shadow: 0 1px 8px rgba(0,0,0,0.3);\n            }\n            #inner {\n              position:relative;\n              max-width: 300px;\n              margin: 0 auto;\n            }\n            #button {\n              margin: 0 auto;\n              text-align: center;\n              width:300px;\n              border:none;\n              color: white;\n              text-shadow: 0 -1px 0 rgba(0,0,0,.8);\n              height: 70px;\n              line-height: 70px;\n              background: #555;\n              background: -webkit-linear-gradient(#5F5F5F,#565656 50%,#4C4C4C 51%,#373737);\n              background: -moz-linear-gradient(#5F5F5F,#565656 50%,#4C4C4C 51%,#373737);\n              background: -ms-linear-gradient(#5F5F5F,#565656 50%,#4C4C4C 51%,#373737);\n              background: -o-linear-gradient(#5F5F5F,#565656 50%,#4C4C4C 51%,#373737);\n              box-shadow: inset 0 1px 3px rgba(0,0,0,0.9);\n            }\n            #button:hover {\n              cursor: pointer;\n              background: #666;\n              background: -webkit-linear-gradient(#707070,#666666 50%,#5B5B5B 51%,#474747);\n              background: -moz-linear-gradient(#707070,#666666 50%,#5B5B5B 51%,#474747);\n              background: -ms-linear-gradient(#707070,#666666 50%,#5B5B5B 51%,#474747);\n              background: -o-linear-gradient(#707070,#666666 50%,#5B5B5B 51%,#474747);\n            }\n            #button:active {\n              box-shadow: inset 0 1px 12px rgba(0,0,0,0.9);\n              background: #444;\n            }\n            #label {\n              text-align: left;\n              text-shadow: 0 1px 1px #fff;\n              margin: 16px auto 0;\n              display:block;\n            }\n            #padname{\n              max-width:280px;\n            }\n            #go2Name {\n              height: 38px;\n              background: #fff;\n              border: 1px solid #bbb;\n              border-radius: 3px;\n              position: relative;\n            }\n            button, input {\n              font-weight: bold;\n              font-size: 15px;\n            }\n            input[type=\"text\"] {\n              border-radius: 3px;\n              box-sizing: border-box;\n              -moz-box-sizing: border-box;\n              line-height:36px; /* IE8 hack */\n              padding: 0px 45px 0 10px;\n              *padding: 0; /* IE7 hack */\n              width: 100%;\n              height: 100%;\n              outline: none;\n              border: none;\n              position: absolute;\n            }\n            button[type=\"submit\"] {\n              position: absolute;\n              left:253px;\n            }\n            nav, .mission-statement, .pad-datalist {\n              display: none;\n            }\n\n            .settings-button {\n              color: inherit;\n              border: none;\n              padding: 0;\n              font: inherit;\n              cursor: pointer;\n              outline: inherit;\n            }\n\n            #settings-dialog {\n              border: none;\n              border-radius: 8px;\n              box-shadow: 0 4px 8px rgba(0,0,0,0.2);\n              padding: 20px;\n            }\n\n            @media (min-device-width: 320px) and (min-device-width: 800px) {\n              body {\n                background: #bbb;\n                background: -webkit-linear-gradient(#aaa,#eee 60%) center fixed;\n                background: -moz-linear-gradient(#aaa,#eee 60%) center fixed;\n                background: -ms-linear-gradient(#aaa,#eee 60%) center fixed;\n              }\n              #settings-dialog {\n                max-width: 50%;\n              }\n              #wrapper {\n                margin-top: 0;\n              }\n              #inner {\n                width: 95%;\n              }\n              #label {\n                text-align: center;\n              }\n            }\n        </style>\n        <% e.begin_block(\"indexCustomStyles\"); %>\n        <link href=\"static/skins/<%=encodeURI(settings.skinName)%>/index.css?v=<%=settings.randomVersionString%>\" rel=\"stylesheet\">\n        <% e.end_block(); %>\n\n        <nav>\n          <div class=\"logo-box\">\n            <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-file-text w-5 h-5 text-white\"><path d=\"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z\"></path><path d=\"M14 2v4a2 2 0 0 0 2 2h4\"></path><path d=\"M10 9H8\"></path><path d=\"M16 13H8\"></path><path d=\"M16 17H8\"></path></svg>\n          </div>\n          <h1>Etherpad</h1>\n          <div style=\"flex-grow: 1\"></div>\n          <button class=\"settings-button\" aria-label=\"Settings\">\n            <svg width=\"30px\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"settings-icon\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z\" />\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z\" />\n            </svg>\n          </button>\n        </nav>\n\n\n        <!-- Settings menu-->\n        <dialog id=\"settings-dialog\">\n          <div id=\"button-bar\">\n                <button data-l10n-id=\"index.transferSessionTitle\" class=\"active-btn\"></button>\n                <button data-l10n-id=\"index.receiveSessionTitle\"></button>\n          </div>\n          <div>\n\n            <!-- Initial link button -->\n            <h3 data-l10n-id=\"index.transferSession\"></h3>\n            <div data-l10n-id=\"index.transferSessionDescription\"></div>\n            <button type=\"button\" class=\"btn-secondary\" style=\"margin-top: 20px\" data-l10n-id=\"index.transferSessionNow\"></button>\n\n            <!-- Copy link button -->\n            <div style=\"display: none\" id=\"copy-link-section\">\n              <h3 data-l10n-id=\"index.copyLink\"></h3>\n              <div data-l10n-id=\"index.copyLinkDescription\"></div>\n              <button type=\"button\" class=\"btn-secondary\" style=\"margin-top: 20px\" data-l10n-id=\"index.copyLinkButton\"></button>\n            </div>\n          </div>\n          <div id=\"transfer-to-system-section\" style=\"display: none; margin-top: 30px;\">\n            <h3 data-l10n-id=\"index.transferToSystem\"></h3>\n            <div data-l10n-id=\"index.transferToSystemDescription\"></div>\n            <p data-l10n-id=\"index.receiveSessionDescription\"></p>\n            <div>\n              <label for=\"codeInput\" data-l10n-id=\"index.code\"></label>\n              <input type=\"text\" id=\"codeInput\"/>\n            </div>\n\n            <button data-l10n-id=\"index.transferSessionTitle\" id=\"transferSessionButton\" disabled></button>\n          </div>\n\n          <div>\n\n          </div>\n\n        </dialog>\n\n        <div class=\"body\">\n        <div class=\"mission-statement\">\n          <h2 data-l10n-id=\"index.createAndShareDocuments\"></h2>\n          <p data-l10n-id=\"index.createAndShareDocumentsDescription\"></p>\n        </div>\n\n        <div id=\"wrapper\">\n        <% e.begin_block(\"indexWrapper\"); %>\n            <div id=\"inner\">\n                <% if (!settings.requireSession) { %>\n                    <% if (settings.editOnly) { %>\n                        <button data-l10n-id=\"index.openPad\"></button>\n                    <% } else {%>\n                        <button id=\"button\" data-l10n-id=\"index.generateNewPad\"></button>\n                    <% } %>\n                    <form action=\"#\" id=\"go2Name\">\n                      <label id=\"label\" for=\"padname\" data-l10n-id=\"index.labelPad\"></label>\n                      <input type=\"text\" id=\"padname\"  maxlength=\"50\" autofocus placeholder=\"Enter pad name...\">\n                        <button type=\"submit\" data-l10n-id=\"index.createOpenPad\"></button>\n                    </form>\n                <% } %>\n            </div>\n        <% e.end_block(); %>\n        </div>\n        <div style=\"display: none\" data-l10n-id=\"index.placeholderPadEnter\"></div>\n          <% if (settings.showRecentPads) { %>\n        <div class=\"pad-datalist\">\n          <h2 data-l10n-id=\"index.recentPads\"></h2>\n          <ul id=\"recent-pads\">\n          </ul>\n        </div>\n          <% } %>\n        </div>\n        <script src=\"<%=entrypoint%>\"></script>\n\n        <% e.begin_block(\"indexCustomScripts\"); %>\n        <script src=\"static/skins/<%=encodeURI(settings.skinName)%>/index.js?v=<%=settings.randomVersionString%>\"></script>\n        <% e.end_block(); %>\n        <div style=\"display:none\"><a href=\"/javascript\" data-jslicense=\"1\">JavaScript license information</a></div>\n</html>\n"
  },
  {
    "path": "src/templates/indexBootstrap.js",
    "content": "\n(async () => {\n  window.$ = window.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery;\n  require('ep_etherpad-lite/static/js/l10n')\n  require('ep_etherpad-lite/static/js/index')\n  require('ep_etherpad-lite/static/js/welcome')\n})()\n"
  },
  {
    "path": "src/templates/javascript.html",
    "content": "<!doctype html>\n<html>\n    <head>\n      <title>JavaScript license information</title>\n      <link rel=\"manifest\" href=\"/manifest.json\" />\n      <meta charset=\"utf-8\">\n          <meta name=\"robots\" content=\"noindex, nofollow\">\n          <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0\">\n    </head>\n    <body>\n        <table id=\"jslicense-labels1\">\n            <tr>\n                <td><a href=\"/static/js/vendors/jquery-3.0.0.min.js\">jquery-3.0.1.min.js</a></td>\n                <td><a href=\"http://www.jclark.com/xml/copying.txt\">Expat</a></td>\n                <td><a href=\"/static/js/vendors/jquery.js\">jquery.js</a></td>\n            </tr>\n            <tr>\n                <td><a href=\"/static/js/vendors/html10n.js\">html10n.js</a></td>\n                <td><a href=\"http://www.jclark.com/xml/copying.txt\">Expat</a></td>\n                <td><a href=\"/static/js/vendors/html10n.js\">html10n.js</a></td>\n            </tr>\n            <tr>\n                <td><a href=\"/static/js/vendors/l10n.js\">l10n.js</a></td>\n                <td><a href=\"http://www.apache.org/licenses/LICENSE-2.0\">Apache-2.0-only</a></td>\n                <td><a href=\"/static/js/vendors/l10n.js\">l10n.js</a></td>\n            </tr>\n            <tr>\n                <td><a href=\"/static/js/vendors/socket.io.js\">socket.io.js</a></td>\n                <td><a href=\"http://www.jclark.com/xml/copying.txt\">Expat</a></td>\n                <td><a href=\"/static/js/vendors/socket.io.js\">socket.io.js</a></td>\n            </tr>\n            <tr>\n                <td><a href=\"/static/js/require-kernel.js\">require-kernel.js</a></td>\n                <td><a href=\"http://www.jclark.com/xml/copying.txt\">Expat</a></td>\n                <td><a href=\"/static/js/require-kernel.js\">require-kernel.js</a></td>\n            </tr>\n            <tr>\n                <td><a href=\"http://www.apache.org/licenses/LICENSE-2.0\">Apache-2.0-only</a></td>\n            </tr>\n            <tr>\n                <td><a href=\"http://www.jclark.com/xml/copying.txt\">Expat</a></td>\n            </tr>\n            <tr>\n                <td><a href=\"http://www.apache.org/licenses/LICENSE-2.0\">Apache-2.0-only</a></td>\n            </tr>\n            <tr>\n                <td><a href=\"http://www.jclark.com/xml/copying.txt\">Expat</a></td>\n            </tr>\n        </table>\n    </body>\n</html>\n"
  },
  {
    "path": "src/templates/pad.html",
    "content": "<%\n  var langs = require(\"ep_etherpad-lite/node/hooks/i18n\").availableLangs\n    , pluginUtils = require('ep_etherpad-lite/static/js/pluginfw/shared')\n    ;\n%>\n<!doctype html>\n<html translate=\"no\" class=\"pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>\">\n<head>\n  <% e.begin_block(\"htmlHead\"); %>\n  <% e.end_block(); %>\n  <title><%=settings.title%></title>\n  <link rel=\"manifest\" href=\"../../manifest.json\" />\n  <script>\n    /*\n    |@licstart  The following is the entire license notice for the\n    JavaScript code in this page.|\n\n    Copyright 2011 Peter Martischka, Primary Technology.\n\n    Licensed under the Apache License, Version 2.0 (the \"License\");\n    you may not use this file except in compliance with the License.\n    You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n    Unless required by applicable law or agreed to in writing, software\n    distributed under the License is distributed on an \"AS IS\" BASIS,\n    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n    See the License for the specific language governing permissions and\n    limitations under the License.\n\n    |@licend  The above is the entire license notice\n    for the JavaScript code in this page.|\n    */\n  </script>\n\n  <meta charset=\"utf-8\">\n  <meta name=\"robots\" content=\"noindex, nofollow\">\n  <meta name=\"referrer\" content=\"no-referrer\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0\">\n  <link rel=\"shortcut icon\" href=\"../favicon.ico\">\n\n  <% e.begin_block(\"styles\"); %>\n  <link href=\"../static/css/pad.css?v=<%=settings.randomVersionString%>\" rel=\"stylesheet\">\n\n  <% e.begin_block(\"customStyles\"); %>\n  <link href=\"../static/skins/<%=encodeURI(settings.skinName)%>/pad.css?v=<%=settings.randomVersionString%>\" rel=\"stylesheet\">\n  <% e.end_block(); %>\n\n  <style title=\"dynamicsyntax\"></style>\n  <% e.end_block(); %>\n\n  <link rel=\"localizations\" type=\"application/l10n+json\" href=\"../locales.json\" />\n</head>\n<body>\n  <% e.begin_block(\"body\"); %>\n\n  <!----------------------------->\n  <!--------- TOOLBAR ----------->\n  <!----------------------------->\n  <div id=\"editbar\" class=\"toolbar\">\n      <div id=\"toolbar-overlay\"></div>\n\n      <ul class=\"menu_left\" role=\"toolbar\">\n          <% e.begin_block(\"editbarMenuLeft\"); %>\n          <%- toolbar.menu(settings.toolbar.left, isReadOnly, 'left', 'pad') %>\n          <% e.end_block(); %>\n      </ul>\n      <ul class=\"menu_right\" role=\"toolbar\">\n          <% e.begin_block(\"editbarMenuRight\"); %>\n          <%- toolbar.menu(settings.toolbar.right, isReadOnly, 'right', 'pad') %>\n          <% e.end_block(); %>\n      </ul>\n      <span class=\"show-more-icon-btn\"></span> <!-- use on small screen to display hidden toolbar buttons -->\n  </div>\n\n  <% e.begin_block(\"afterEditbar\"); %><% e.end_block(); %>\n\n  <div id=\"editorcontainerbox\" class=\"flex-layout\">\n\n      <% e.begin_block(\"editorContainerBox\"); %>\n\n      <!----------------------------->\n      <!--- PAD EDITOR (in iframe) -->\n      <!----------------------------->\n\n      <div id=\"editorcontainer\" class=\"editorcontainer\"></div>\n\n      <div id=\"editorloadingbox\">\n        <% e.begin_block(\"permissionDenied\"); %>\n        <div id=\"permissionDenied\">\n          <p data-l10n-id=\"pad.permissionDenied\" class=\"editorloadingbox-message\">\n            You do not have permission to access this pad\n          </p>\n        </div>\n        <% e.end_block(); %>\n        <% e.begin_block(\"loading\"); %>\n        <p data-l10n-id=\"pad.loading\" id=\"loading\" class=\"editorloadingbox-message\">\n          <img src='../static/img/brand.svg' class='etherpadBrand'><br/>\n          Loading...\n        </p>\n        <% e.end_block(); %>\n        <noscript>\n          <p class=\"editorloadingbox-message\">\n            <strong>\n              Sorry, you have to enable Javascript in order to use this.\n            </strong>\n          </p>\n        </noscript>\n      </div>\n\n\n      <!------------------------------------------------------------->\n      <!-- SETTINGS POPUP (change font, language, chat parameters) -->\n      <!------------------------------------------------------------->\n\n      <div id=\"settings\" class=\"popup\"><div class=\"popup-content\">\n          <h1 data-l10n-id=\"pad.settings.padSettings\"></h1>\n          <% e.begin_block(\"mySettings\"); %>\n          <h2 data-l10n-id=\"pad.settings.myView\"></h2>\n          <p class=\"hide-for-mobile\">\n              <input type=\"checkbox\" id=\"options-stickychat\">\n              <label for=\"options-stickychat\" data-l10n-id=\"pad.settings.stickychat\"></label>\n          </p>\n          <p class=\"hide-for-mobile\">\n              <input type=\"checkbox\" id=\"options-chatandusers\" onClick=\"chat.chatAndUsers();\">\n              <label for=\"options-chatandusers\" data-l10n-id=\"pad.settings.chatandusers\"></label>\n          </p>\n          <p>\n              <input type=\"checkbox\" id=\"options-colorscheck\">\n              <label for=\"options-colorscheck\" data-l10n-id=\"pad.settings.colorcheck\"></label>\n          </p>\n          <p>\n              <input type=\"checkbox\" id=\"options-linenoscheck\" checked>\n              <label for=\"options-linenoscheck\" data-l10n-id=\"pad.settings.linenocheck\"></label>\n          </p>\n          <p>\n              <input type=\"checkbox\" id=\"options-rtlcheck\">\n              <label for=\"options-rtlcheck\" data-l10n-id=\"pad.settings.rtlcheck\"></label>\n          </p>\n          <% e.end_block(); %>\n\n          <div class=\"dropdowns-container\">\n            <% e.begin_block(\"mySettings.dropdowns\"); %>\n            <p class=\"dropdown-line\">\n              <label for=\"viewfontmenu\" data-l10n-id=\"pad.settings.fontType\">Font type:</label>\n              <select id=\"viewfontmenu\">\n                <option value=\"\" data-l10n-id=\"pad.settings.fontType.normal\">Normal</option>\n                <%= fonts = [\"Quicksand\", \"Roboto\", \"Alegreya\", \"PlayfairDisplay\", \"Montserrat\", \"OpenDyslexic\", \"RobotoMono\"] %>\n                <% for(var i=0; i < fonts.length; i++) { %>\n                  <option value=\"<%=fonts[i]%>\"><%=fonts[i]%></option>\n                <% } %>\n              </select>\n            </p>\n\n            <p class=\"dropdown-line\">\n              <label for=\"languagemenu\" data-l10n-id=\"pad.settings.language\">Language:</label>\n              <select id=\"languagemenu\">\n                  <% for (lang in langs) { %>\n                  <option value=\"<%=lang%>\"><%=langs[lang].nativeName%></option>\n                  <% } %>\n              </select>\n            </p>\n            <% e.end_block(); %>\n          </div>\n          <button data-l10n-id=\"pad.settings.deletePad\" id=\"delete-pad\">Delete pad</button>\n        <div id=\"theme-switcher\" style=\"display: none;\">\n          <svg width=\"2rem\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"size-6\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z\" />\n          </svg>\n          <div>\n            <span aria-label=\"theme-switcher-knob\"></span>\n          </div>\n          <svg width=\"2rem\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"size-6\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z\" />\n          </svg>\n        </div>\n\n\n        <h2 data-l10n-id=\"pad.settings.about\">About</h2>\n          <span data-l10n-id=\"pad.settings.poweredBy\">Powered by</span>\n          <a href=\"https://etherpad.org\" target=\"_blank\" referrerpolicy=\"no-referrer\" rel=\"noopener\">Etherpad</a>\n        <% if (settings.exposeVersion) { %>(commit <%= settings.gitVersion %>)<% } %>      </div></div>\n\n\n      <!------------------------->\n      <!-- IMPORT EXPORT POPUP -->\n      <!------------------------->\n\n      <div id=\"import_export\" class=\"popup\"><div class=\"popup-content\">\n          <h1 data-l10n-id=\"pad.importExport.import_export\"></h1>\n          <div class=\"acl-write\">\n              <% e.begin_block(\"importColumn\"); %>\n              <h2 data-l10n-id=\"pad.importExport.import\"></h2>\n              <div class=\"importmessage\" id=\"importmessageabiword\" data-l10n-id=\"pad.importExport.abiword.innerHTML\"></div><br>\n              <form id=\"importform\" method=\"post\" action=\"\" target=\"importiframe\" enctype=\"multipart/form-data\">\n                  <div class=\"importformdiv\" id=\"importformfilediv\">\n                      <input type=\"file\" name=\"file\" size=\"10\" id=\"importfileinput\">\n                      <div class=\"importmessage\" id=\"importmessagefail\"></div>\n                  </div>\n                  <div id=\"import\"></div>\n                  <div class=\"importmessage\" id=\"importmessagesuccess\" data-l10n-id=\"pad.importExport.importSuccessful\"></div>\n                  <div class=\"importformdiv\" id=\"importformsubmitdiv\">\n                      <span class=\"nowrap\">\n                          <input type=\"submit\" class=\"btn btn-primary\" name=\"submit\" value=\"Import Now\" disabled=\"disabled\" id=\"importsubmitinput\">\n                          <div alt=\"\" id=\"importstatusball\" class=\"loadingAnimation\" align=\"top\"></div>\n                      </span>\n                  </div>\n              </form>\n              <% e.end_block(); %>\n          </div>\n          <div id=\"exportColumn\">\n              <h2 data-l10n-id=\"pad.importExport.export\"></h2>\n              <% e.begin_block(\"exportColumn\"); %>\n              <a id=\"exportetherpada\" target=\"_blank\" class=\"exportlink\">\n                <span class=\"exporttype buttonicon buttonicon-file-powerpoint\" id=\"exportetherpad\" data-l10n-id=\"pad.importExport.exportetherpad\"></span>\n              </a>\n              <a id=\"exporthtmla\" target=\"_blank\" class=\"exportlink\">\n                <span class=\"exporttype buttonicon buttonicon-file-code\" id=\"exporthtml\" data-l10n-id=\"pad.importExport.exporthtml\"></span>\n              </a>\n              <a id=\"exportplaina\" target=\"_blank\" class=\"exportlink\">\n                <span class=\"exporttype buttonicon buttonicon-file\" id=\"exportplain\" data-l10n-id=\"pad.importExport.exportplain\"></span>\n              </a>\n              <a id=\"exportworda\" target=\"_blank\" class=\"exportlink\">\n                <span class=\"exporttype buttonicon buttonicon-file-word\" id=\"exportword\" data-l10n-id=\"pad.importExport.exportword\"></span>\n              </a>\n              <a id=\"exportpdfa\" target=\"_blank\" class=\"exportlink\">\n                <span class=\"exporttype buttonicon buttonicon-file-pdf\" id=\"exportpdf\" data-l10n-id=\"pad.importExport.exportpdf\"></span>\n              </a>\n              <a id=\"exportopena\" target=\"_blank\" class=\"exportlink\">\n                <span class=\"exporttype buttonicon buttonicon-file-alt\" id=\"exportopen\" data-l10n-id=\"pad.importExport.exportopen\"></span>\n              </a>\n              <% e.end_block(); %>\n          </div>\n      </div></div>\n\n\n      <!---------------------------------------------------->\n      <!-- CONNECTIVITY POPUP (when you get disconnected) -->\n      <!---------------------------------------------------->\n\n      <div id=\"connectivity\" class=\"popup\"><div class=\"popup-content\">\n          <% e.begin_block(\"modals\"); %>\n          <div class=\"connected visible\">\n            <h2 data-l10n-id=\"pad.modals.connected\"></h2>\n          </div>\n          <div class=\"reconnecting\">\n            <h1 data-l10n-id=\"pad.modals.reconnecting\"></h1>\n            <i class='buttonicon buttonicon-spin5 icon-spin'>\n              <img src='../static/img/brand.svg' class='etherpadBrand'><br/>\n            </i>\n          </div>\n          <div class=\"userdup\">\n            <h1 data-l10n-id=\"pad.modals.userdup\"></h1>\n            <h2 data-l10n-id=\"pad.modals.userdup.explanation\"></h2>\n            <p id=\"defaulttext\" data-l10n-id=\"pad.modals.userdup.advice\"></p>\n            <button id=\"forcereconnect\" class=\"btn btn-primary\" data-l10n-id=\"pad.modals.forcereconnect\"></button>\n          </div>\n          <div class=\"unauth\">\n            <h1 data-l10n-id=\"pad.modals.unauth\"></h1>\n            <p id=\"defaulttext\" data-l10n-id=\"pad.modals.unauth.explanation\"></p>\n            <button id=\"forcereconnect\" class=\"btn btn-primary\" data-l10n-id=\"pad.modals.forcereconnect\"></button>\n          </div>\n          <div class=\"looping\">\n            <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n            <h2 data-l10n-id=\"pad.modals.looping.explanation\"></h2>\n            <p data-l10n-id=\"pad.modals.looping.cause\"></p>\n          </div>\n          <div class=\"initsocketfail\">\n            <h1 data-l10n-id=\"pad.modals.initsocketfail\"></h1>\n            <h2 data-l10n-id=\"pad.modals.initsocketfail.explanation\"></h2>\n            <p data-l10n-id=\"pad.modals.initsocketfail.cause\"></p>\n          </div>\n          <div class=\"slowcommit with_reconnect_timer\">\n            <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n            <h2 data-l10n-id=\"pad.modals.slowcommit.explanation\"></h2>\n            <p id=\"defaulttext\" data-l10n-id=\"pad.modals.slowcommit.cause\"></p>\n            <button id=\"forcereconnect\" class=\"btn btn-primary\" data-l10n-id=\"pad.modals.forcereconnect\"></button>\n          </div>\n          <div class=\"badChangeset with_reconnect_timer\">\n            <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n            <h2 data-l10n-id=\"pad.modals.badChangeset.explanation\"></h2>\n            <p id=\"defaulttext\" data-l10n-id=\"pad.modals.badChangeset.cause\"></p>\n            <button id=\"forcereconnect\" class=\"btn btn-primary\" data-l10n-id=\"pad.modals.forcereconnect\"></button>\n          </div>\n          <div class=\"corruptPad\">\n            <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n            <h2 data-l10n-id=\"pad.modals.corruptPad.explanation\"></h2>\n            <p data-l10n-id=\"pad.modals.corruptPad.cause\"></p>\n          </div>\n          <div class=\"deleted\">\n            <h1 data-l10n-id=\"pad.modals.deleted\"></h1>\n            <p data-l10n-id=\"pad.modals.deleted.explanation\"></p>\n          </div>\n          <div class=\"rateLimited\">\n            <h1 data-l10n-id=\"pad.modals.rateLimited\"></h1>\n            <p data-l10n-id=\"pad.modals.rateLimited.explanation\"></p>\n          </div>\n          <div class=\"rejected\">\n            <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n            <h2 data-l10n-id=\"pad.modals.rejected.explanation\"></h2>\n            <p data-l10n-id=\"pad.modals.rejected.cause\"></p>\n          </div>\n          <div class=\"disconnected with_reconnect_timer\">\n            <% e.begin_block(\"disconnected\"); %>\n            <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n            <h2 data-l10n-id=\"pad.modals.disconnected.explanation\"></h2>\n            <p id=\"defaulttext\" data-l10n-id=\"pad.modals.disconnected.cause\"></p>\n            <button id=\"forcereconnect\" class=\"btn btn-primary\" data-l10n-id=\"pad.modals.forcereconnect\"></button>\n            <% e.end_block(); %>\n          </div>\n          <form id=\"reconnectform\" method=\"post\" action=\"/ep/pad/reconnect\" accept-charset=\"UTF-8\" style=\"display: none;\">\n              <input type=\"hidden\" class=\"padId\" name=\"padId\">\n              <input type=\"hidden\" class=\"diagnosticInfo\" name=\"diagnosticInfo\">\n              <input type=\"hidden\" class=\"missedChanges\" name=\"missedChanges\">\n          </form>\n          <% e.end_block(); %>\n      </div></div>\n\n\n      <!-------------------------------->\n      <!-- EMBED POPUP (Share, embed) -->\n      <!-------------------------------->\n\n      <div id=\"embed\" class=\"popup\"><div class=\"popup-content\">\n          <% e.begin_block(\"embedPopup\"); %>\n          <h1 data-l10n-id=\"pad.share\"></h1>\n          <div id=\"embedreadonly\" class=\"acl-write\">\n              <input type=\"checkbox\" id=\"readonlyinput\">\n              <label for=\"readonlyinput\" data-l10n-id=\"pad.share.readonly\"></label>\n          </div>\n          <div id=\"linkcode\">\n              <h2 data-l10n-id=\"pad.share.link\"></h2>\n              <input id=\"linkinput\" type=\"text\" value=\"\" onclick=\"this.select()\">\n          </div>\n          <div id=\"embedcode\">\n              <h2 data-l10n-id=\"pad.share.emebdcode\"></h2>\n              <input id=\"embedinput\" type=\"text\" value=\"\" onclick=\"this.select()\">\n          </div>\n          <% e.end_block(); %>\n      </div></div>\n\n      <div class=\"sticky-container\">\n\n        <!---------------------------------------------------------------------->\n        <!-- USERS POPUP (set username, color, see other users names & color) -->\n        <!---------------------------------------------------------------------->\n\n        <div id=\"users\" class=\"popup\"><div class=\"popup-content\">\n            <% e.begin_block(\"userlist\"); %>\n            <div id=\"connectionstatus\"></div>\n            <div id=\"myuser\">\n                <div id=\"mycolorpicker\" class=\"popup\"><div class=\"popup-content\">\n                    <div id=\"colorpicker\"></div>\n                    <div class=\"btn-container\">\n                      <button id=\"mycolorpickersave\" data-l10n-id=\"pad.colorpicker.save\" class=\"btn btn-primary\"></button>\n                      <button id=\"mycolorpickercancel\" data-l10n-id=\"pad.colorpicker.cancel\" class=\"btn btn-default\"></button>\n                      <span id=\"mycolorpickerpreview\" class=\"myswatchboxhoverable\"></span>\n                    </div>\n                </div></div>\n                <div id=\"myswatchbox\"><div id=\"myswatch\"></div></div>\n                <div id=\"myusernameform\">\n                  <input type=\"text\" id=\"myusernameedit\" disabled=\"disabled\" data-l10n-id=\"pad.userlist.entername\">\n                </div>\n            </div>\n            <div id=\"otherusers\" aria-role=\"document\">\n                <table id=\"otheruserstable\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\">\n                    <tr><td></td></tr>\n                </table>\n            </div>\n            <div id=\"userlistbuttonarea\"></div>\n            <% e.end_block(); %>\n        </div></div>\n\n\n        <!----------------------------->\n        <!----------- CHAT ------------>\n        <!----------------------------->\n\n        <div id=\"chaticon\" class=\"visible\" onclick=\"chat.show();return false;\" title=\"Chat (Alt C)\">\n            <span id=\"chatlabel\" data-l10n-id=\"pad.chat\"></span>\n            <span class=\"buttonicon buttonicon-chat\"></span>\n            <span id=\"chatcounter\">0</span>\n        </div>\n\n        <div id=\"chatbox\">\n          <div class=\"chat-content\">\n            <div id=\"titlebar\">\n              <h1 id =\"titlelabel\" data-l10n-id=\"pad.chat\"></h1>\n              <a id=\"titlecross\" class=\"hide-reduce-btn\" onClick=\"chat.hide();return false;\">-&nbsp;</a>\n              <a id=\"titlesticky\" class=\"stick-to-screen-btn\" onClick=\"chat.stickToScreen(true);return false;\" data-l10n-id=\"pad.chat.stick.title\">█&nbsp;&nbsp;</a>\n            </div>\n            <div id=\"chattext\" class=\"thin-scrollbar\" aria-live=\"polite\" aria-relevant=\"additions removals text\" role=\"log\" aria-atomic=\"false\">\n                <div alt=\"loading..\" id=\"chatloadmessagesball\" class=\"chatloadmessages loadingAnimation\" align=\"top\"></div>\n                <button id=\"chatloadmessagesbutton\" class=\"chatloadmessages\" data-l10n-id=\"pad.chat.loadmessages\"></button>\n            </div>\n            <div id=\"chatinputbox\">\n                <form>\n                    <textarea id=\"chatinput\" maxlength=\"999\" data-l10n-id=\"pad.chat.writeMessage.placeholder\"></textarea>\n                </form>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!------------------------------------------------------------------>\n      <!-- SKIN VARIANTS BUILDER (Customize rendering, only for admins) -->\n      <!------------------------------------------------------------------>\n      <% if (settings.skinName == 'colibris') { %>\n      <div id=\"skin-variants\" class=\"popup\"><div class=\"popup-content\">\n        <h1>Skin Builder</h1>\n\n        <div class=\"dropdowns-container\">\n        <% containers = [ \"toolbar\", \"background\", \"editor\" ]; %>\n        <% for(var i=0; i < containers.length; i++) { %>\n          <p class=\"dropdown-line\">\n            <label class=\"skin-variant-container\"><%=containers[i]%></label>\n            <select class=\"skin-variant skin-variant-color\" data-container=\"<%=containers[i]%>\">\n              <option value=\"super-light\">Super Light</option>\n              <option value=\"light\">Light</option>\n              <option value=\"dark\">Dark</option>\n              <option value=\"super-dark\">Super Dark</option>\n            </select>\n          </p>\n        <% } %>\n        </div>\n\n        <p>\n            <input type=\"checkbox\" id=\"skin-variant-full-width\" class=\"skin-variant\"/>\n            <label for=\"skin-variant-full-width\">Full Width Editor</label>\n        </p>\n\n        <p>\n          <label>Result to copy in settings.json</label>\n          <input id=\"skin-variants-result\" type=\"text\" readonly class=\"disabled\" />\n        </p>\n      </div></div>\n      <% } %>\n\n      <% e.end_block(); %>\n\n  </div> <!-- End of #editorcontainerbox -->\n\n  <% e.end_block(); %>\n\n\n  <!----------------------------->\n  <!-------- JAVASCRIPT --------->\n  <!----------------------------->\n\n  <% e.begin_block(\"scripts\"); %>\n\n  <script src=\"<%=entrypoint%>\"></script>\n\n  <% e.begin_block(\"customScripts\"); %>\n  <script type=\"text/javascript\" src=\"../static/skins/<%=encodeURI(settings.skinName)%>/pad.js?v=<%=settings.randomVersionString%>\"></script>\n  <% e.end_block(); %>\n  <div style=\"display:none\"><a href=\"/javascript\" data-jslicense=\"1\">JavaScript license information</a></div>\n  <% e.end_block(); %>\n</body>\n</html>\n"
  },
  {
    "path": "src/templates/padBootstrap.js",
    "content": "\n(async () => {\n\n  require('ep_etherpad-lite/static/js/l10n')\n\n  window.clientVars = {\n    // This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the server\n    // sends the CLIENT_VARS message.\n    randomVersionString: <%-JSON.stringify(settings.randomVersionString)%>,\n  };\n\n  // Allow other frames to access this frame's modules.\n  //window.require.resolveTmp = require.resolve('ep_etherpad-lite/static/js/pad_cookie');\n\n  const basePath = new URL('..', window.location.href).pathname;\n  window.$ = window.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery;\n  window.browser = require('ep_etherpad-lite/static/js/vendors/browser');\n  const pad = require('ep_etherpad-lite/static/js/pad');\n  pad.baseURL = basePath;\n  window.plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins');\n  const hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks');\n\n  // TODO: These globals shouldn't exist.\n  window.pad = pad.pad;\n  window.chat = require('ep_etherpad-lite/static/js/chat').chat;\n  window.padeditbar = require('ep_etherpad-lite/static/js/pad_editbar').padeditbar;\n  window.padimpexp = require('ep_etherpad-lite/static/js/pad_impexp').padimpexp;\n  require('ep_etherpad-lite/static/js/skin_variants');\n  require('ep_etherpad-lite/static/js/basic_error_handler')\n\n  window.plugins.baseURL = basePath;\n  await window.plugins.update(new Map([\n    <% for (const module of pluginModules) { %>\n    [<%- JSON.stringify(module) %>, require(\"../../src/plugin_packages/\"+<%- JSON.stringify(module) %>)],\n    <% } %>\n]));\n  // Mechanism for tests to register hook functions (install fake plugins).\n  window._postPluginUpdateForTestingDone = false;\n  if (window._postPluginUpdateForTesting != null) window._postPluginUpdateForTesting();\n  window._postPluginUpdateForTestingDone = true;\n  window.pluginDefs = require('ep_etherpad-lite/static/js/pluginfw/plugin_defs');\n  pad.init();\n  await new Promise((resolve) => $(resolve));\n  await hooks.aCallAll('documentReady');\n})();\n"
  },
  {
    "path": "src/templates/padViteBootstrap.js",
    "content": "window.$ = window.jQuery = await import('../../src/static/js/rjquery').jQuery;\nawait import('../../src/static/js/l10n')\n\nwindow.clientVars = {\n  // This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the server\n  // sends the CLIENT_VARS message.\n  randomVersionString: \"7a7bdbad\",\n};\n\n(async () => {\n  // Allow other frames to access this frame's modules.\n  //window.require.resolveTmp = require.resolve('ep_etherpad-lite/static/js/pad_cookie');\n\n  const basePath = new URL('..', window.location.href).pathname;\n  window.browser = require('../../src/static/js/vendors/browser');\n  const pad = require('../../src/static/js/pad');\n  pad.baseURL = basePath;\n  window.plugins = require('../../src/static/js/pluginfw/client_plugins');\n  const hooks = require('../../src/static/js/pluginfw/hooks');\n\n  // TODO: These globals shouldn't exist.\n  window.pad = pad.pad;\n  window.chat = require('../../src/static/js/chat').chat;\n  window.padeditbar = require('../../src/static/js/pad_editbar').padeditbar;\n  window.padimpexp = require('../../src/static/js/pad_impexp').padimpexp;\n  require('../../src/static/js/skin_variants');\n  require('../../src/static/js/basic_error_handler')\n\n  window.plugins.baseURL = basePath;\n  await window.plugins.update(new Map([\n\n  ]));\n  // Mechanism for tests to register hook functions (install fake plugins).\n  window._postPluginUpdateForTestingDone = false;\n  if (window._postPluginUpdateForTesting != null) window._postPluginUpdateForTesting();\n  window._postPluginUpdateForTestingDone = true;\n  window.pluginDefs = require('../../src/static/js/pluginfw/plugin_defs');\n  pad.init();\n  await new Promise((resolve) => $(resolve));\n  await hooks.aCallAll('documentReady');\n})();\n"
  },
  {
    "path": "src/templates/timeSliderBootstrap.js",
    "content": "// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt\nwindow.clientVars = {\n  // This is needed to fetch /pluginfw/plugin-definitions.json, which happens before the\n  // server sends the CLIENT_VARS message.\n  randomVersionString: <%-JSON.stringify(settings.randomVersionString)%>,\n};\nlet BroadcastSlider;\n\n\n(function () {\n  const timeSlider = require('ep_etherpad-lite/static/js/timeslider')\n  const pathComponents = location.pathname.split('/');\n\n  // Strip 'p', the padname and 'timeslider' from the pathname and set as baseURL\n  const baseURL = pathComponents.slice(0,pathComponents.length-3).join('/') + '/';\n  require('ep_etherpad-lite/static/js/l10n')\n  window.$ = window.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery; // Expose jQuery #HACK\n  require('ep_etherpad-lite/static/js/vendors/gritter')\n\n  window.browser = require('ep_etherpad-lite/static/js/vendors/browser');\n\n  window.plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins');\n  const socket = timeSlider.socket;\n  BroadcastSlider = timeSlider.BroadcastSlider;\n  plugins.baseURL = baseURL;\n  plugins.update(function () {\n\n\n    /* TODO: These globals shouldn't exist. */\n\n  });\n  const padeditbar = require('ep_etherpad-lite/static/js/pad_editbar').padeditbar;\n  const padimpexp = require('ep_etherpad-lite/static/js/pad_impexp').padimpexp;\n  timeSlider.baseURL = baseURL;\n  timeSlider.init();\n  padeditbar.init()\n})();\n"
  },
  {
    "path": "src/templates/timeslider.html",
    "content": "<%\n  var langs = require(\"ep_etherpad-lite/node/hooks/i18n\").availableLangs\n%>\n<!doctype html>\n<html translate=\"no\" class=\"pad <%=settings.skinVariants%>\">\n<head>\n  <title data-l10n-id=\"timeslider.pageTitle\" data-l10n-args='{ \"appTitle\": \"<%=settings.title%>\" }'><%=settings.title%> Timeslider</title>\n  <script>\n    /*\n    |@licstart  The following is the entire license notice for the\n    JavaScript code in this page.|\n\n    Copyright 2011 Peter Martischka, Primary Technology.\n\n    Licensed under the Apache License, Version 2.0 (the \"License\");\n    you may not use this file except in compliance with the License.\n    You may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\n    Unless required by applicable law or agreed to in writing, software\n    distributed under the License is distributed on an \"AS IS\" BASIS,\n    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n    See the License for the specific language governing permissions and\n    limitations under the License.\n\n    |@licend  The above is the entire license notice\n    for the JavaScript code in this page.|\n    */\n  </script>\n  <meta charset=\"utf-8\">\n  <link rel=\"manifest\" href=\"../../../manifest.json\" />\n  <meta name=\"robots\" content=\"noindex, nofollow\">\n  <meta name=\"referrer\" content=\"no-referrer\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0\">\n  <link rel=\"shortcut icon\" href=\"../../favicon.ico\">\n  <% e.begin_block(\"timesliderStyles\"); %>\n  <link rel=\"stylesheet\" href=\"../../static/css/pad.css?v=<%=settings.randomVersionString%>\">\n  <link rel=\"stylesheet\" href=\"../../static/css/iframe_editor.css?v=<%=settings.randomVersionString%>\">\n  <link rel=\"stylesheet\" href=\"../../static/css/timeslider.css?v=<%=settings.randomVersionString%>\">\n  <link rel=\"stylesheet\" href=\"../../static/skins/<%=encodeURI(settings.skinName)%>/pad.css?v=<%=settings.randomVersionString%>\">\n  <link rel=\"stylesheet\" href=\"../../static/skins/<%=encodeURI(settings.skinName)%>/timeslider.css?v=<%=settings.randomVersionString%>\">\n  <style type=\"text/css\" title=\"dynamicsyntax\"></style>\n  <% e.end_block(); %>\n\n  <link rel=\"localizations\" type=\"application/l10n+json\" href=\"../../locales.json\" />\n  <% e.begin_block(\"timesliderScripts\"); %>\n  <% e.end_block(); %>\n</head>\n\n<% e.begin_block(\"timesliderBody\"); %>\n<body id=\"padbody\" class=\"timeslider limwidth\">\n\n  <!----------------------------->\n  <!--------- TOOLBAR ----------->\n  <!----------------------------->\n  <div id=\"editbar\" class=\"toolbar\">\n    <% e.begin_block(\"timesliderTop\"); %>\n\n    <!-- TITLE & TOOLBAR -->\n    <div class=\"timeslider-bar\">\n\n      <div class=\"timeslider-title-container\">\n        <h1 class=\"timeslider-title\">\n          <span id=\"revision_label\"></span>\n          <span id=\"revision_date\"></span>\n        </h1>\n        <p class=\"timeslider-subtitle\">\n          <span class=\"authors-label\" data-l10n-id=\"timeslider.toolbar.authors\"></span>\n          <span id=\"authorsList\" data-l10n-id=\"timeslider.toolbar.authorsList\"></span>\n        </p>\n      </div>\n\n      <div class=\"editbarright menu_right\">\n        <ul>\n          <% e.begin_block(\"timesliderEditbarRight\"); %>\n            <%- toolbar.menu(settings.toolbar.timeslider, true, 'timeslider-right', 'timeslider') %>\n          <% e.end_block(); %>\n        </ul>\n      </div>\n    </div>\n\n    <!-- SLIDER -->\n    <div id=\"timeslider-wrapper\">\n      <div id=\"timeslider-slider\">\n        <div id=\"ui-slider-handle\"></div>\n        <div id=\"ui-slider-bar\"></div>\n        <div id=\"timer\"></div>\n      </div>\n\n      <div id=\"slider-btn-container\">\n        <button id=\"playpause_button_icon\" class=\"buttonicon buttonicon-play\"></button>\n        <button id=\"leftstep\"  class=\"stepper buttonicon buttonicon-step-backward\"></button>\n        <button id=\"rightstep\" class=\"stepper buttonicon buttonicon-step-forward\"></button>\n        <!-- Left and Right star button are actually not displayed to the screen -->\n        <button id=\"leftstar\"  class=\"stepper buttonicon\" style=\"display:none\"></button>\n        <button id=\"rightstar\" class=\"stepper buttonicon\" style=\"display:none\"></button>\n      </div>\n    </div>\n\n\n  <% e.end_block(); %>\n  </div>\n\n\n  <div id=\"editorcontainerbox\">\n\n    <!----------------------------->\n    <!------- PAD CONTENT --------->\n    <!----------------------------->\n\n    <div id=\"outerdocbody\">\n      <div id=\"innerdocbody\">\n      </div>\n    </div>\n\n\n    <!------------------------->\n    <!-- IMPORT EXPORT POPUP -->\n    <!------------------------->\n\n    <div id=\"import_export\" class=\"popup\" ><div class=\"popup-content\">\n      <div id=\"export\">\n        <h1 data-l10n-id=\"timeslider.exportCurrent\"></h1>\n        <% e.begin_block(\"exportColumn\"); %>\n        <a id=\"exportetherpada\" target=\"_blank\" class=\"exportlink\">\n          <span class=\"exporttype buttonicon buttonicon-file-powerpoint\" id=\"exportetherpad\" data-l10n-id=\"pad.importExport.exportetherpad\"></span>\n        </a>\n        <a id=\"exporthtmla\" target=\"_blank\" class=\"exportlink\">\n          <span class=\"exporttype buttonicon buttonicon-file-code\" id=\"exporthtml\" data-l10n-id=\"pad.importExport.exporthtml\"></span>\n        </a>\n        <a id=\"exportplaina\" target=\"_blank\" class=\"exportlink\">\n          <span class=\"exporttype buttonicon buttonicon-file\" id=\"exportplain\" data-l10n-id=\"pad.importExport.exportplain\"></span>\n        </a>\n        <a id=\"exportworda\" target=\"_blank\" class=\"exportlink\">\n          <span class=\"exporttype buttonicon buttonicon-file-word\" id=\"exportword\" data-l10n-id=\"pad.importExport.exportword\"></span>\n        </a>\n        <a id=\"exportpdfa\" target=\"_blank\" class=\"exportlink\">\n          <span class=\"exporttype buttonicon buttonicon-file-pdf\" id=\"exportpdf\" data-l10n-id=\"pad.importExport.exportpdf\"></span>\n        </a>\n        <a id=\"exportopena\" target=\"_blank\" class=\"exportlink\">\n          <span class=\"exporttype buttonicon buttonicon-file-alt\" id=\"exportopen\" data-l10n-id=\"pad.importExport.exportopen\"></span>\n        </a>\n        <% e.end_block(); %>\n      </div>\n    </div></div>\n\n\n    <!---------------------------------------------------->\n    <!-- CONNECTIVITY POPUP (when you get disconnected) -->\n    <!---------------------------------------------------->\n\n    <div id=\"connectivity\" class=\"popup\"><div class=\"popup-content\">\n    <% e.begin_block(\"modals\"); %>\n      <div class=\"connected visible\">\n        <h2 data-l10n-id=\"pad.modals.connected\"></h2>\n      </div>\n      <div class=\"reconnecting\">\n        <h1 data-l10n-id=\"pad.modals.reconnecting\"></h1>\n        <p class=\"loadingAnimation\"></p>\n      </div>\n      <div class=\"userdup\">\n        <h1 data-l10n-id=\"pad.modals.userdup\"></h1>\n        <h2 data-l10n-id=\"pad.modals.userdup.explanation\"></h2>\n        <p data-l10n-id=\"pad.modals.userdup.advice\"></p>\n        <button id=\"forcereconnect\" class=\"btn btn-primary\" data-l10n-id=\"pad.modals.forcereconnect\"></button>\n      </div>\n      <div class=\"unauth\">\n        <h1 data-l10n-id=\"pad.modals.unauth\"></h1>\n        <p data-l10n-id=\"pad.modals.unauth.explanation\"></p>\n        <button id=\"forcereconnect\" class=\"btn btn-primary\" data-l10n-id=\"pad.modals.forcereconnect\"></button>\n      </div>\n      <div class=\"looping\">\n        <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n        <h2 data-l10n-id=\"pad.modals.looping.explanation\"></h2>\n        <p data-l10n-id=\"pad.modals.looping.cause\"></p>\n      </div>\n      <div class=\"initsocketfail\">\n        <h1 data-l10n-id=\"pad.modals.initsocketfail\"></h1>\n        <h2 data-l10n-id=\"pad.modals.initsocketfail.explanation\"></h2>\n        <p data-l10n-id=\"pad.modals.initsocketfail.cause\"></p>\n      </div>\n      <div class=\"slowcommit\">\n        <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n        <h2 data-l10n-id=\"pad.modals.slowcommit.explanation\"></h2>\n        <p data-l10n-id=\"pad.modals.slowcommit.cause\"></p>\n        <button id=\"forcereconnect\" class=\"btn btn-primary\" data-l10n-id=\"pad.modals.forcereconnect\"></button>\n      </div>\n      <div class=\"badChangeset\">\n        <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n        <h2 data-l10n-id=\"pad.modals.badChangeset.explanation\"></h2>\n        <p data-l10n-id=\"pad.modals.badChangeset.cause\"></p>\n        <button id=\"forcereconnect\" class=\"btn btn-primary\" data-l10n-id=\"pad.modals.forcereconnect\"></button>\n      </div>\n      <div class=\"corruptPad\">\n        <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n        <h2 data-l10n-id=\"pad.modals.corruptPad.explanation\"></h2>\n        <p data-l10n-id=\"pad.modals.corruptPad.cause\"></p>\n      </div>\n      <div class=\"deleted\">\n        <h1 data-l10n-id=\"pad.modals.deleted\"></h1>\n        <p data-l10n-id=\"pad.modals.deleted.explanation\"></p>\n      </div>\n      <div class=\"disconnected\">\n        <% e.begin_block(\"disconnected\"); %>\n        <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n        <h2 data-l10n-id=\"pad.modals.disconnected.explanation\"></h2>\n        <p data-l10n-id=\"pad.modals.disconnected.cause\"></p>\n        <button id=\"forcereconnect\" class=\"btn btn-primary\" data-l10n-id=\"pad.modals.forcereconnect\"></button>\n        <% e.end_block(); %>\n      </div>\n      <form id=\"reconnectform\" method=\"post\" action=\"/ep/pad/reconnect\" accept-charset=\"UTF-8\" style=\"display: none;\">\n          <input type=\"hidden\" class=\"padId\" name=\"padId\">\n          <input type=\"hidden\" class=\"diagnosticInfo\" name=\"diagnosticInfo\">\n          <input type=\"hidden\" class=\"missedChanges\" name=\"missedChanges\">\n      </form>\n    <% e.end_block(); %>\n    </div></div>\n\n\n    <!---------------------------------->\n    <!-- SETTINGS POPUP (change font) -->\n    <!---------------------------------->\n\n    <div id=\"settings\" class=\"popup\"><div class=\"popup-content\">\n      <h1 data-l10n-id=\"pad.settings.padSettings\"></h1>\n      <p>\n        <label for=\"viewfontmenu\" data-l10n-id=\"pad.settings.fontType\">Font type:</label>\n        <select id=\"viewfontmenu\">\n          <option value=\"\" data-l10n-id=\"pad.settings.fontType.normal\">Normal</option>\n          <%= fonts = [\"Quicksand\", \"Roboto\", \"Alegreya\", \"PlayfairDisplay\", \"Montserrat\", \"OpenDyslexic\", \"RobotoMono\"] %>\n          <% for(var i=0; i < fonts.length; i++) { %>\n            <option value=\"<%=fonts[i]%>\"><%=fonts[i]%></option>\n          <% } %>\n        </select>\n      </p>\n      <p>\n        <input type=\"checkbox\" id=\"options-followContents\" checked=\"checked\">\n        <label for=\"options-followContents\" data-l10n-id=\"timeslider.followContents\"></label>\n      </p>\n    </div></div>\n  </div>\n</body>\n\n<!----------------------------->\n<!-------- JAVASCRIPT --------->\n<!----------------------------->\n\n<script type=\"text/javascript\" src=\"../../socket.io/socket.io.js\"></script>\n\n<!-- Include base packages manually (this help with debugging) -->\n\n<script type=\"text/javascript\" src=\"../../static/skins/<%=encodeURI(settings.skinName)%>/timeslider.js?v=<%=settings.randomVersionString%>\"></script>\n\n<!-- Bootstrap -->\n<script src=\"<%=entrypoint%>\"></script>\n<% e.end_block(); %>\n<div style=\"display:none\"><a href=\"/javascript\" data-jslicense=\"1\">JavaScript license information</a></div>\n</html>\n"
  },
  {
    "path": "src/tests/README.md",
    "content": "# About this folder: Tests\n\nBefore running the tests, start an Etherpad instance on your machine.\n\n## Frontend\n\nTo run the frontend tests, point your browser to `<yourdomainhere>/tests/frontend`\n\n## Backend\n\nTo run the backend tests, run `cd src` and then `npm test`\n"
  },
  {
    "path": "src/tests/backend/common.ts",
    "content": "'use strict';\n\nimport {MapArrayType} from \"../../node/types/MapType\";\n\nimport AttributePool from '../../static/js/AttributePool';\nconst assert = require('assert').strict;\nconst io = require('socket.io-client');\nconst log4js = require('log4js');\nimport padutils from '../../static/js/pad_utils';\nconst process = require('process');\nconst server = require('../../node/server');\nconst setCookieParser = require('set-cookie-parser');\nimport settings from '../../node/utils/Settings';\nimport supertest from 'supertest';\nimport TestAgent from \"supertest/lib/agent\";\nimport {Http2Server} from \"node:http2\";\nimport {SignJWT} from \"jose\";\nimport {privateKeyExported} from \"../../node/security/OAuth2Provider\";\nconst webaccess = require('../../node/hooks/express/webaccess');\n\nconst backups:MapArrayType<any> = {};\nlet agentPromise:Promise<any>|null = null;\n\nexport let agent: TestAgent|null = null;\nexport let baseUrl:string|null = null;\nexport let httpServer: Http2Server|null = null;\nexport const logger = log4js.getLogger('test');\n\nconst logLevel = logger.level;\n\n// Mocha doesn't monitor unhandled Promise rejections, so convert them to uncaught exceptions.\n// https://github.com/mochajs/mocha/issues/2640\nprocess.on('unhandledRejection', (reason: string) => { throw reason; });\n\nbefore(async function () {\n  this.timeout(60000);\n  await init();\n});\n\n\nexport const generateJWTToken =  () => {\n  const jwt = new SignJWT({\n    sub: 'admin',\n    jti: '123',\n    exp: Math.floor(Date.now() / 1000) + 60 * 60,\n    aud: 'account',\n    iss: 'http://localhost:9001',\n    admin: true\n  })\n  jwt.setProtectedHeader({alg: 'RS256'})\n  return jwt.sign(privateKeyExported!)\n}\n\n\nexport const generateJWTTokenUser =  () => {\n  const jwt = new SignJWT({\n    sub: 'admin',\n    jti: '123',\n    exp: Math.floor(Date.now() / 1000) + 60 * 60,\n    aud: 'account',\n    iss: 'http://localhost:9001',\n  })\n  jwt.setProtectedHeader({alg: 'RS256'})\n  return jwt.sign(privateKeyExported!)\n}\n\nexport const init = async function () {\n  if (agentPromise != null) return await agentPromise;\n  let agentResolve;\n  agentPromise = new Promise((resolve) => { agentResolve = resolve; });\n\n  if (!logLevel.isLessThanOrEqualTo(log4js.levels.DEBUG)) {\n    logger.warn('Disabling non-test logging for the duration of the test. ' +\n                'To enable non-test logging, change the loglevel setting to DEBUG.');\n  }\n\n  // Note: This is only a shallow backup.\n  backups.settings = Object.assign({}, settings);\n  // Start the Etherpad server on a random unused port.\n  settings.port = 0;\n  settings.ip = 'localhost';\n  settings.importExportRateLimiting = {max: 999999};\n  settings.commitRateLimiting = {duration: 0.001, points: 1e6};\n  httpServer = await server.start();\n  // @ts-ignore\n  baseUrl = `http://localhost:${httpServer!.address()!.port}`;\n  logger.debug(`HTTP server at ${baseUrl}`);\n  // Create a supertest user agent for the HTTP server.\n  agent = supertest(baseUrl)\n      //.set('Authorization', `Bearer ${await generateJWTToken()}`);\n  // Speed up authn tests.\n  backups.authnFailureDelayMs = webaccess.authnFailureDelayMs;\n  webaccess.authnFailureDelayMs = 0;\n\n  after(async function () {\n    webaccess.authnFailureDelayMs = backups.authnFailureDelayMs;\n    // Note: This does not unset settings that were added.\n    Object.assign(settings, backups.settings);\n    await server.exit();\n  });\n\n  agentResolve!(agent);\n  return agent;\n};\n\n/**\n * Waits for the next named socket.io event. Rejects if there is an error event while waiting\n * (unless waiting for that error event).\n *\n * @param {io.Socket} socket - The socket.io Socket object to listen on.\n * @param {string} event - The socket.io Socket event to listen for.\n * @returns The argument(s) passed to the event handler.\n */\nexport const waitForSocketEvent = async (socket: any, event:string) => {\n  const errorEvents = [\n    'error',\n    'connect_error',\n    'connect_timeout',\n    'reconnect_error',\n    'reconnect_failed',\n  ];\n  const handlers = new Map();\n  let cancelTimeout;\n  try {\n    const timeoutP = new Promise<void>((resolve, reject) => {\n      const timeout = setTimeout(() => {\n        reject(new Error(`timed out waiting for ${event} event`));\n        cancelTimeout = () => {};\n      }, 1000);\n      cancelTimeout = () => {\n        clearTimeout(timeout);\n        resolve();\n        cancelTimeout = () => {};\n      };\n    });\n    const errorEventP = Promise.race(errorEvents.map((event) => new Promise((resolve, reject) => {\n      handlers.set(event, (errorString:string) => {\n        logger.debug(`socket.io ${event} event: ${errorString}`);\n        reject(new Error(errorString));\n      });\n    })));\n    const eventP = new Promise<string|string[]>((resolve) => {\n      // This will overwrite one of the above handlers if the user is waiting for an error event.\n      handlers.set(event, (...args:string[]) => {\n        logger.debug(`socket.io ${event} event`);\n        if (args.length > 1) return resolve(args);\n        resolve(args[0]);\n      });\n    });\n    for (const [event, handler] of handlers) socket.on(event, handler);\n    // timeoutP and errorEventP are guaranteed to never resolve here (they can only reject), so the\n    // Promise returned by Promise.race() is guaranteed to resolve to the eventP value (if\n    // the event arrives).\n    return await Promise.race([timeoutP, errorEventP, eventP]);\n  } finally {\n    cancelTimeout!();\n    for (const [event, handler] of handlers) socket.off(event, handler);\n  }\n};\n\n/**\n * Establishes a new socket.io connection.\n *\n * @param {object} [res] - Optional HTTP response object. The cookies from this response's\n *     `set-cookie` header(s) are passed to the server when opening the socket.io connection. If\n *     nullish, no cookies are passed to the server.\n * @returns {io.Socket} A socket.io client Socket object.\n */\nexport const connect = async (res:any = null) => {\n  // Convert the `set-cookie` header(s) into a `cookie` header.\n  const resCookies = (res == null) ? {} : setCookieParser.parse(res, {map: true});\n  const reqCookieHdr = Object.entries(resCookies).map(\n      // @ts-ignore\n      ([name, cookie]) => `${name}=${encodeURIComponent(cookie.value)}`).join('; ');\n\n  logger.debug('socket.io connecting...');\n  let padId = null;\n  if (res) {\n    padId = res.req.path.split('/p/')[1];\n  }\n  const socket = io(`${baseUrl}/`, {\n    forceNew: true, // Different tests will have different query parameters.\n    // socketio.js-client on node.js doesn't support cookies (see https://git.io/JU8u9), so the\n    // express_sid cookie must be passed as a query parameter.\n    query: {cookie: reqCookieHdr, padId},\n  });\n  try {\n    await waitForSocketEvent(socket, 'connect');\n  } catch (e) {\n    socket.close();\n    throw e;\n  }\n  logger.debug('socket.io connected');\n\n  return socket;\n};\n\n/**\n * Helper function to exchange CLIENT_READY+CLIENT_VARS messages for the named pad.\n *\n * @param {io.Socket} socket - Connected socket.io Socket object.\n * @param {string} padId - Which pad to join.\n * @param token\n * @returns The CLIENT_VARS message from the server.\n */\nexport const handshake = async (socket: any, padId:string, token = padutils.generateAuthorToken()) => {\n  logger.debug('sending CLIENT_READY...');\n  socket.emit('message', {\n    component: 'pad',\n    type: 'CLIENT_READY',\n    padId,\n    sessionID: null,\n    token,\n  });\n  logger.debug('waiting for CLIENT_VARS response...');\n  const msg = await waitForSocketEvent(socket, 'message');\n  logger.debug('received CLIENT_VARS message');\n  return msg;\n};\n\n/**\n * Convenience wrapper around `socket.send()` that waits for acknowledgement.\n */\nexport const sendMessage = async (socket: any, message:any) => await new Promise<void>((resolve, reject) => {\n  socket.emit('message', message, (errInfo:{\n    name: string,\n    message: string,\n  }) => {\n    if (errInfo != null) {\n      const {name, message} = errInfo;\n      const err = new Error(message);\n      err.name = name;\n      reject(err);\n      return;\n    }\n    resolve();\n  });\n});\n\n/**\n * Convenience function to send a USER_CHANGES message. Waits for acknowledgement.\n */\nexport const sendUserChanges = async (socket:any, data:any) => await sendMessage(socket, {\n  type: 'COLLABROOM',\n  component: 'pad',\n  data: {\n    type: 'USER_CHANGES',\n    apool: new AttributePool(),\n    ...data,\n  },\n});\n\n\n/*\n  * Convenience function to send a delete pad request.\n */\nexport const sendPadDelete = async (socket:any, data:any) => await sendMessage(socket, {\n  type: 'PAD_DELETE',\n  component: 'pad',\n  data: {\n    padId: data.padId\n  },\n});\n\n\n/**\n * Convenience function that waits for an ACCEPT_COMMIT message. Asserts that the new revision\n * matches the expected revision.\n *\n * Note: To avoid a race condition, this should be called before the USER_CHANGES message is sent.\n * For example:\n *\n *     await Promise.all([\n *       common.waitForAcceptCommit(socket, rev + 1),\n *       common.sendUserChanges(socket, {baseRev: rev, changeset}),\n *     ]);\n */\nexport const waitForAcceptCommit = async (socket:any, wantRev: number) => {\n  const msg = await waitForSocketEvent(socket, 'message');\n  assert.deepEqual(msg, {\n    type: 'COLLABROOM',\n    data: {\n      type: 'ACCEPT_COMMIT',\n      newRev: wantRev,\n    },\n  });\n};\n\nconst alphabet = 'abcdefghijklmnopqrstuvwxyz';\n\n/**\n * Generates a random string.\n *\n * @param {number} [len] - The desired length of the generated string.\n * @param {string} [charset] - Characters to pick from.\n * @returns {string}\n */\nexport const randomString = (len: number = 10, charset: string = `${alphabet}${alphabet.toUpperCase()}0123456789`): string => {\n  let ret = '';\n  while (ret.length < len) ret += charset[Math.floor(Math.random() * charset.length)];\n  return ret;\n};\n"
  },
  {
    "path": "src/tests/backend/fuzzImportTest.ts",
    "content": "/*\n * Fuzz testing the import endpoint\n * Usage: node fuzzImportTest.js\n */\nconst settings = require('../container/loadSettings').loadSettings();\nconst common = require('./common');\nconst host = `http://${settings.ip}:${settings.port}`;\nconst froth = require('mocha-froth');\nconst axios = require('axios');\nconst apiVersion = 1;\nconst testPadId = `TEST_fuzz${makeid()}`;\n\nconst endPoint = function (point: string, version?:number) {\n    version = version || apiVersion;\n    return `/api/${version}/${point}}`;\n};\n\nconsole.log('Testing against padID', testPadId);\nconsole.log(`To watch the test live visit ${host}/p/${testPadId}`);\nconsole.log('Tests will start in 5 seconds, click the URL now!');\n\nsetTimeout(() => {\n    for (let i = 1; i < 1000000; i++) { // 1M runs\n        setTimeout(async () => {\n            await runTest(i);\n        }, i * 100); // 100 ms\n    }\n}, 5000); // wait 5 seconds\n\nasync function runTest(number: number) {\n    await axios\n        .get(`${host + endPoint('createPad')}?padID=${testPadId}`, {\n            headers: {\n                Authorization: await common.generateJWTToken(),\n            }\n        })\n        .then(() => {\n            const req = axios.post(`${host}/p/${testPadId}/import`)\n                .then(() => {\n                    console.log('Success');\n                    let fN = '/test.txt';\n                    let cT = 'text/plain';\n\n                    // To be more aggressive every other test we mess with Etherpad\n                    // We provide a weird file name and also set a weird contentType\n                    if (number % 2 == 0) {\n                        fN = froth().toString();\n                        cT = froth().toString();\n                    }\n\n                    const form = req.form();\n                    form.append('file', froth().toString(), {\n                        filename: fN,\n                        contentType: cT,\n                    });\n                });\n        })\n        .catch((err:any) => {\n        // @ts-ignore\n            throw new Error('FAILURE', err);\n    })\n}\n\nfunction makeid() {\n    let text = '';\n    const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n\n    for (let i = 0; i < 5; i++) {\n        text += possible.charAt(Math.floor(Math.random() * possible.length));\n    }\n    return text;\n}\n"
  },
  {
    "path": "src/tests/backend/specs/ExportEtherpad.ts",
    "content": "'use strict';\n\nconst assert = require('assert').strict;\nconst common = require('../common');\nconst exportEtherpad = require('../../../node/utils/ExportEtherpad');\nconst padManager = require('../../../node/db/PadManager');\nconst plugins = require('../../../static/js/pluginfw/plugin_defs');\nimport readOnlyManager from '../../../node/db/ReadOnlyManager';\n\ndescribe(__filename, function () {\n  let padId:string;\n\n  beforeEach(async function () {\n    padId = common.randomString();\n    assert(!await padManager.doesPadExist(padId));\n  });\n\n  describe('exportEtherpadAdditionalContent', function () {\n    let hookBackup: ()=>void;\n\n    before(async function () {\n      hookBackup = plugins.hooks.exportEtherpadAdditionalContent || [];\n      plugins.hooks.exportEtherpadAdditionalContent = [{hook_fn: () => ['custom']}];\n    });\n\n    after(async function () {\n      plugins.hooks.exportEtherpadAdditionalContent = hookBackup;\n    });\n\n    it('exports custom records', async function () {\n      const pad = await padManager.getPad(padId);\n      await pad.db.set(`custom:${padId}`, 'a');\n      await pad.db.set(`custom:${padId}:`, 'b');\n      await pad.db.set(`custom:${padId}:foo`, 'c');\n      const data = await exportEtherpad.getPadRaw(pad.id, null);\n      assert.equal(data[`custom:${padId}`], 'a');\n      assert.equal(data[`custom:${padId}:`], 'b');\n      assert.equal(data[`custom:${padId}:foo`], 'c');\n    });\n\n    it('export from read-only pad uses read-only ID', async function () {\n      const pad = await padManager.getPad(padId);\n      const readOnlyId = await readOnlyManager.getReadOnlyId(padId);\n      await pad.db.set(`custom:${padId}`, 'a');\n      await pad.db.set(`custom:${padId}:`, 'b');\n      await pad.db.set(`custom:${padId}:foo`, 'c');\n      const data = await exportEtherpad.getPadRaw(padId, readOnlyId);\n      assert.equal(data[`custom:${readOnlyId}`], 'a');\n      assert.equal(data[`custom:${readOnlyId}:`], 'b');\n      assert.equal(data[`custom:${readOnlyId}:foo`], 'c');\n      assert(!(`custom:${padId}` in data));\n      assert(!(`custom:${padId}:` in data));\n      assert(!(`custom:${padId}:foo` in data));\n    });\n\n    it('does not export records from pad with similar ID', async function () {\n      const pad = await padManager.getPad(padId);\n      await pad.db.set(`custom:${padId}x`, 'a');\n      await pad.db.set(`custom:${padId}x:`, 'b');\n      await pad.db.set(`custom:${padId}x:foo`, 'c');\n      const data = await exportEtherpad.getPadRaw(pad.id, null);\n      assert(!(`custom:${padId}x` in data));\n      assert(!(`custom:${padId}x:` in data));\n      assert(!(`custom:${padId}x:foo` in data));\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/ImportEtherpad.ts",
    "content": "'use strict';\n\nimport {MapArrayType} from \"../../../node/types/MapType\";\n\nconst assert = require('assert').strict;\nconst authorManager = require('../../../node/db/AuthorManager');\nconst db = require('../../../node/db/DB');\nconst importEtherpad = require('../../../node/utils/ImportEtherpad');\nconst padManager = require('../../../node/db/PadManager');\nconst plugins = require('../../../static/js/pluginfw/plugin_defs');\nimport {randomString} from '../../../static/js/pad_utils';\n\ndescribe(__filename, function () {\n  let padId: string;\n\n  const makeAuthorId = () => `a.${randomString(16)}`;\n\n  const makeExport = (authorId: string) => ({\n    'pad:testing': {\n      atext: {\n        text: 'foo\\n',\n        attribs: '|1+4',\n      },\n      pool: {\n        numToAttrib: {},\n        nextNum: 0,\n      },\n      head: 0,\n      savedRevisions: [],\n    },\n    [`globalAuthor:${authorId}`]: {\n      colorId: '#000000',\n      name: 'new',\n      timestamp: 1598747784631,\n      padIDs: 'testing',\n    },\n    'pad:testing:revs:0': {\n      changeset: 'Z:1>3+3$foo',\n      meta: {\n        author: '',\n        timestamp: 1597632398288,\n        pool: {\n          numToAttrib: {},\n          nextNum: 0,\n        },\n        atext: {\n          text: 'foo\\n',\n          attribs: '|1+4',\n        },\n      },\n    },\n  });\n\n  beforeEach(async function () {\n    padId = randomString(10);\n    assert(!await padManager.doesPadExist(padId));\n  });\n\n  it('unknown db records are ignored', async function () {\n    const badKey = `maliciousDbKey${randomString(10)}`;\n    await importEtherpad.setPadRaw(padId, JSON.stringify({\n      [badKey]: 'value',\n      ...makeExport(makeAuthorId()),\n    }));\n    assert(await db.get(badKey) == null);\n  });\n\n  it('changes are all or nothing', async function () {\n    const authorId = makeAuthorId();\n    const data:MapArrayType<any> = makeExport(authorId);\n    data['pad:differentPadId:revs:0'] = data['pad:testing:revs:0'];\n    delete data['pad:testing:revs:0'];\n    assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);\n    assert(!await authorManager.doesAuthorExist(authorId));\n    assert(!await padManager.doesPadExist(padId));\n  });\n\n  describe('author pad IDs', function () {\n    let existingAuthorId: string;\n    let newAuthorId:string;\n\n    beforeEach(async function () {\n      existingAuthorId = (await authorManager.createAuthor('existing')).authorID;\n      assert(await authorManager.doesAuthorExist(existingAuthorId));\n      assert.deepEqual((await authorManager.listPadsOfAuthor(existingAuthorId)).padIDs, []);\n      newAuthorId = makeAuthorId();\n      assert.notEqual(newAuthorId, existingAuthorId);\n      assert(!await authorManager.doesAuthorExist(newAuthorId));\n    });\n\n    it('author does not yet exist', async function () {\n      await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));\n      assert(await authorManager.doesAuthorExist(newAuthorId));\n      const author = await authorManager.getAuthor(newAuthorId);\n      assert.equal(author.name, 'new');\n      assert.equal(author.colorId, '#000000');\n      assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs, [padId]);\n    });\n\n    it('author already exists, no pads', async function () {\n      newAuthorId = existingAuthorId;\n      await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));\n      assert(await authorManager.doesAuthorExist(newAuthorId));\n      const author = await authorManager.getAuthor(newAuthorId);\n      assert.equal(author.name, 'existing');\n      assert.notEqual(author.colorId, '#000000');\n      assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs, [padId]);\n    });\n\n    it('author already exists, on different pad', async function () {\n      const otherPadId = randomString(10);\n      await authorManager.addPad(existingAuthorId, otherPadId);\n      newAuthorId = existingAuthorId;\n      await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));\n      assert(await authorManager.doesAuthorExist(newAuthorId));\n      const author = await authorManager.getAuthor(newAuthorId);\n      assert.equal(author.name, 'existing');\n      assert.notEqual(author.colorId, '#000000');\n      assert.deepEqual(\n          (await authorManager.listPadsOfAuthor(newAuthorId)).padIDs.sort(),\n          [otherPadId, padId].sort());\n    });\n\n    it('author already exists, on same pad', async function () {\n      await authorManager.addPad(existingAuthorId, padId);\n      newAuthorId = existingAuthorId;\n      await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));\n      assert(await authorManager.doesAuthorExist(newAuthorId));\n      const author = await authorManager.getAuthor(newAuthorId);\n      assert.equal(author.name, 'existing');\n      assert.notEqual(author.colorId, '#000000');\n      assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs, [padId]);\n    });\n  });\n\n  describe('enforces consistent pad ID', function () {\n    it('pad record has different pad ID', async function () {\n      const data:MapArrayType<any> = makeExport(makeAuthorId());\n      data['pad:differentPadId'] = data['pad:testing'];\n      delete data['pad:testing'];\n      assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);\n    });\n\n    it('globalAuthor record has different pad ID', async function () {\n      const authorId = makeAuthorId();\n      const data = makeExport(authorId);\n      data[`globalAuthor:${authorId}`].padIDs = 'differentPadId';\n      assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);\n    });\n\n    it('pad rev record has different pad ID', async function () {\n      const data:MapArrayType<any> = makeExport(makeAuthorId());\n      data['pad:differentPadId:revs:0'] = data['pad:testing:revs:0'];\n      delete data['pad:testing:revs:0'];\n      assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);\n    });\n  });\n\n  describe('order of records does not matter', function () {\n    for (const perm of [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]]) {\n      it(JSON.stringify(perm), async function () {\n        const authorId = makeAuthorId();\n        const records = Object.entries(makeExport(authorId));\n        assert.equal(records.length, 3);\n        await importEtherpad.setPadRaw(\n            padId, JSON.stringify(Object.fromEntries(perm.map((i) => records[i]))));\n        assert.deepEqual((await authorManager.listPadsOfAuthor(authorId)).padIDs, [padId]);\n        const pad = await padManager.getPad(padId);\n        assert.equal(pad.text(), 'foo\\n');\n      });\n    }\n  });\n\n  describe('exportEtherpadAdditionalContent', function () {\n    let hookBackup: Function;\n\n    before(async function () {\n      hookBackup = plugins.hooks.exportEtherpadAdditionalContent || [];\n      plugins.hooks.exportEtherpadAdditionalContent = [{hook_fn: () => ['custom']}];\n    });\n\n    after(async function () {\n      plugins.hooks.exportEtherpadAdditionalContent = hookBackup;\n    });\n\n    it('imports from custom prefix', async function () {\n      await importEtherpad.setPadRaw(padId, JSON.stringify({\n        ...makeExport(makeAuthorId()),\n        'custom:testing': 'a',\n        'custom:testing:foo': 'b',\n      }));\n      const pad = await padManager.getPad(padId);\n      assert.equal(await pad.db.get(`custom:${padId}`), 'a');\n      assert.equal(await pad.db.get(`custom:${padId}:foo`), 'b');\n    });\n\n    it('rejects records for pad with similar ID', async function () {\n      await assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify({\n        ...makeExport(makeAuthorId()),\n        'custom:testingx': 'x',\n      })), /unexpected pad ID/);\n      assert(await db.get(`custom:${padId}x`) == null);\n      await assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify({\n        ...makeExport(makeAuthorId()),\n        'custom:testingx:foo': 'x',\n      })), /unexpected pad ID/);\n      assert(await db.get(`custom:${padId}x:foo`) == null);\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/Pad.ts",
    "content": "'use strict';\n\nimport {PadType} from \"../../../node/types/PadType\";\n\nconst Pad = require('../../../node/db/Pad');\nimport { strict as assert } from 'assert';\nimport {MapArrayType} from \"../../../node/types/MapType\";\nconst authorManager = require('../../../node/db/AuthorManager');\nconst common = require('../common');\nconst padManager = require('../../../node/db/PadManager');\nconst plugins = require('../../../static/js/pluginfw/plugin_defs');\nimport settings from '../../../node/utils/Settings';\n\ndescribe(__filename, function () {\n  const backups:MapArrayType<any> = {};\n  let pad: PadType|null;\n  let padId: string;\n\n  before(async function () {\n    backups.hooks = {\n      padDefaultContent: plugins.hooks.padDefaultContent,\n    };\n    backups.defaultPadText = settings.defaultPadText;\n  });\n\n  beforeEach(async function () {\n    backups.hooks.padDefaultContent = [];\n    padId = common.randomString();\n    assert(!(await padManager.doesPadExist(padId)));\n  });\n\n  afterEach(async function () {\n    Object.assign(plugins.hooks, backups.hooks);\n    if (pad != null) await pad.remove();\n    pad = null;\n  });\n\n  describe('cleanText', function () {\n    const testCases = [\n      ['', ''],\n      ['\\n', '\\n'],\n      ['x', 'x'],\n      ['x\\n', 'x\\n'],\n      ['x\\ny\\n', 'x\\ny\\n'],\n      ['x\\ry\\n', 'x\\ny\\n'],\n      ['x\\r\\ny\\n', 'x\\ny\\n'],\n      ['x\\r\\r\\ny\\n', 'x\\n\\ny\\n'],\n    ];\n    for (const [input, want] of testCases) {\n      it(`${JSON.stringify(input)} -> ${JSON.stringify(want)}`, async function () {\n        assert.equal(Pad.cleanText(input), want);\n      });\n    }\n  });\n\n  describe('padDefaultContent hook', function () {\n    it('runs when a pad is created without specific text', async function () {\n      const p = new Promise<void>((resolve) => {\n        plugins.hooks.padDefaultContent.push({hook_fn: () => resolve()});\n      });\n      pad = await padManager.getPad(padId);\n      await p;\n    });\n\n    it('not run if pad is created with specific text', async function () {\n      plugins.hooks.padDefaultContent.push(\n          {hook_fn: () => { throw new Error('should not be called'); }});\n      pad = await padManager.getPad(padId, '');\n    });\n\n    it('defaults to settings.defaultPadText', async function () {\n      const p = new Promise<void>((resolve, reject) => {\n        plugins.hooks.padDefaultContent.push({hook_fn: async (hookName:string, ctx:any) => {\n          try {\n            assert.equal(ctx.type, 'text');\n            assert.equal(ctx.content, settings.defaultPadText);\n          } catch (err) {\n            return reject(err);\n          }\n          resolve();\n        }});\n      });\n      pad = await padManager.getPad(padId);\n      await p;\n    });\n\n    it('passes the pad object', async function () {\n      const gotP = new Promise((resolve) => {\n        plugins.hooks.padDefaultContent.push({hook_fn: async (hookName:string, {pad}:{\n            pad: PadType,\n          }) => resolve(pad)});\n      });\n      pad = await padManager.getPad(padId);\n      assert.equal(await gotP, pad);\n    });\n\n    it('passes empty authorId if not provided', async function () {\n      const gotP = new Promise((resolve) => {\n        plugins.hooks.padDefaultContent.push(\n            {hook_fn: async (hookName:string, {authorId}:{\n                authorId: string,\n              }) => resolve(authorId)});\n      });\n      pad = await padManager.getPad(padId);\n      assert.equal(await gotP, '');\n    });\n\n    it('passes provided authorId', async function () {\n      const want = await authorManager.getAuthor4Token(`t.${padId}`);\n      const gotP = new Promise((resolve) => {\n        plugins.hooks.padDefaultContent.push(\n            {hook_fn: async (hookName: string, {authorId}:{\n                authorId: string,\n              }) => resolve(authorId)});\n      });\n      pad = await padManager.getPad(padId, null, want);\n      assert.equal(await gotP, want);\n    });\n\n    it('uses provided content', async function () {\n      const want = 'hello world';\n      assert.notEqual(want, settings.defaultPadText);\n      plugins.hooks.padDefaultContent.push({hook_fn: async (hookName:string, ctx:any) => {\n        ctx.type = 'text';\n        ctx.content = want;\n      }});\n      pad = await padManager.getPad(padId);\n      assert.equal(pad!.text(), `${want}\\n`);\n    });\n\n    it('cleans provided content', async function () {\n      const input = 'foo\\r\\nbar\\r\\tbaz';\n      const want = 'foo\\nbar\\n        baz';\n      assert.notEqual(want, settings.defaultPadText);\n      plugins.hooks.padDefaultContent.push({hook_fn: async (hookName:string, ctx:any) => {\n        ctx.type = 'text';\n        ctx.content = input;\n      }});\n      pad = await padManager.getPad(padId);\n      assert.equal(pad!.text(), `${want}\\n`);\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/SecretRotator.ts",
    "content": "'use strict';\n\nimport {strict} from \"assert\";\nconst common = require('../common');\nconst crypto = require('../../../node/security/crypto');\nconst db = require('../../../node/db/DB');\nconst SecretRotator = require(\"../../../node/security/SecretRotator\").SecretRotator;\n\nconst logger = common.logger;\n\n// Greatest common divisor.\nconst gcd: Function = (...args:number[]) => (\n  args.length === 1 ? args[0]\n  : args.length === 2 ? ((args[1]) ? gcd(args[1], args[0] % args[1]) : Math.abs(args[0]))\n  : gcd(args[0], gcd(...args.slice(1))));\n\n// Least common multiple.\nconst lcm:Function = (...args: number[]) => (\n  args.length === 1 ? args[0]\n  : args.length === 2 ? Math.abs(args[0] * args[1]) / gcd(...args)\n  : lcm(args[0], lcm(...args.slice(1))));\n\nclass FakeClock {\n    _now: number;\n    _nextId: number;\n    _idle: Promise<any>;\n    timeouts: Map<number, any>;\n\n  constructor() {\n    logger.debug('new fake clock');\n    this._now = 0;\n    this._nextId = 1;\n    this._idle = Promise.resolve();\n    this.timeouts = new Map();\n  }\n\n  _next() { return Math.min(...[...this.timeouts.values()].map((x) => x.when)); }\n  async setNow(t: number) {\n    logger.debug(`setting fake time to ${t}`);\n    strict(t >= this._now);\n    strict(t < Infinity);\n    let n;\n    while ((n = this._next()) <= t) {\n      this._now = Math.max(this._now, Math.min(n, t));\n      logger.debug(`fake time set to ${this._now}; firing timeouts...`);\n      await this._fire();\n    }\n    this._now = t;\n    logger.debug(`fake time set to ${this._now}`);\n  }\n  async advance(t: number) { await this.setNow(this._now + t); }\n  async advanceToNext() {\n    const n = this._next();\n    if (n < this._now) await this._fire();\n    else if (n < Infinity) await this.setNow(n);\n  }\n  async _fire() {\n    // This method MUST NOT execute any of the setTimeout callbacks synchronously, otherwise\n    // fc.setTimeout(fn, 0) would execute fn before fc.setTimeout() returns. Fortunately, the\n    // ECMAScript standard guarantees that a function passed to Promise.prototype.then() will run\n    // asynchronously.\n    this._idle = this._idle.then(() => Promise.all(\n        [...this.timeouts.values()]\n            .filter(({when}) => when <= this._now)\n            .sort((a, b) => a.when - b.when)\n            .map(async ({id, fn}) => {\n              this.clearTimeout(id);\n              // With the standard setTimeout(), the callback function's return value is ignored.\n              // Here we await the return value so that test code can block until timeout work is\n              // done.\n              await fn();\n            })));\n    await this._idle;\n  }\n\n  get now() { return this._now; }\n  setTimeout(fn:Function, wait = 0) {\n    const when = this._now + wait;\n    const id = this._nextId++;\n    this.timeouts.set(id, {id, fn, when});\n    this._fire();\n    return id;\n  }\n  clearTimeout(id:number) { this.timeouts.delete(id); }\n}\n\n// In JavaScript, the % operator is remainder, not modulus.\nconst mod = (a: number, n:number) => ((a % n) + n) % n;\n\ndescribe(__filename, function () {\n  let dbPrefix: string;\n  let sr: any;\n  let interval = 1e3;\n  const lifetime = 1e4;\n  const intervalStart = (t: number) => t - mod(t, interval);\n  const hkdf = async (secret: string, salt:string, tN:number) => Buffer.from(\n      await crypto.hkdf('sha256', secret, salt, `${tN}`, 32)).toString('hex');\n\n  const newRotator = (s:string|null = null) => new SecretRotator(dbPrefix, interval, lifetime, s);\n\n  const setFakeClock = (sr: { _t: { now: () => number; setTimeout: (fn: Function, wait?: number) => number; clearTimeout: (id: number) => void; }; }, fc:FakeClock|null = null) => {\n    if (fc == null) fc = new FakeClock();\n    sr._t = {\n      now: () => fc!.now,\n      setTimeout: fc.setTimeout.bind(fc),\n      clearTimeout: fc.clearTimeout.bind(fc),\n    };\n    return fc;\n  };\n\n  before(async function () {\n    await common.init();\n  });\n\n  beforeEach(async function () {\n    dbPrefix = `test-SecretRotator-${common.randomString()}`;\n    interval = 1e3;\n  });\n\n  afterEach(async function () {\n    if (sr != null) sr.stop();\n    sr = null;\n    await Promise.all(\n        (await db.findKeys(`${dbPrefix}:*`, null)).map(async (dbKey: string) => await db.remove(dbKey)));\n  });\n\n  describe('constructor', function () {\n    it('creates empty secrets array', async function () {\n      sr = newRotator();\n      strict.deepEqual(sr.secrets, []);\n    });\n\n    for (const invalidChar of '*:%') {\n      it(`rejects database prefixes containing ${invalidChar}`, async function () {\n        dbPrefix += invalidChar;\n        strict.throws(newRotator, /invalid char/);\n      });\n    }\n  });\n\n  describe('start', function () {\n    it('does not replace secrets array', async function () {\n      sr = newRotator();\n      setFakeClock(sr);\n      const {secrets} = sr;\n      await sr.start();\n      strict.equal(sr.secrets, secrets);\n    });\n\n    it('derives secrets', async function () {\n      sr = newRotator();\n      setFakeClock(sr);\n      await sr.start();\n      strict.equal(sr.secrets.length, 3); // Current (active), previous, and next.\n      for (const s of sr.secrets) {\n        strict.equal(typeof s, 'string');\n        strict(s);\n      }\n      strict.equal(new Set(sr.secrets).size, sr.secrets.length); // The secrets should all differ.\n    });\n\n    it('publishes params', async function () {\n      sr = newRotator();\n      const fc = setFakeClock(sr);\n      await sr.start();\n      const dbKeys = await db.findKeys(`${dbPrefix}:*`, null);\n      strict.equal(dbKeys.length, 1);\n      const [id] = dbKeys;\n      strict(id.startsWith(`${dbPrefix}:`));\n      strict.notEqual(id.slice(dbPrefix.length + 1), '');\n      const p = await db.get(id);\n      const {secret, salt} = p.algParams;\n      strict.deepEqual(p, {\n        algId: 1,\n        algParams: {\n          digest: 'sha256',\n          keyLen: 32,\n          salt,\n          secret,\n        },\n        start: fc.now,\n        end: fc.now + (2 * interval),\n        interval,\n        lifetime,\n      });\n      strict.equal(typeof salt, 'string');\n      strict.match(salt, /^[0-9a-f]{64}$/);\n      strict.equal(typeof secret, 'string');\n      strict.match(secret, /^[0-9a-f]{64}$/);\n      strict.deepEqual(sr.secrets, await Promise.all(\n          [0, -interval, interval].map(async (tN) => await hkdf(secret, salt, tN))));\n    });\n\n    it('reuses matching publication if unexpired', async function () {\n      sr = newRotator();\n      const fc = setFakeClock(sr);\n      await sr.start();\n      const {secrets} = sr;\n      const dbKeys = await db.findKeys(`${dbPrefix}:*`, null);\n      sr.stop();\n      sr = newRotator();\n      setFakeClock(sr, fc);\n      await sr.start();\n      strict.deepEqual(sr.secrets, secrets);\n      strict.deepEqual(await db.findKeys(`${dbPrefix}:*`, null), dbKeys);\n    });\n\n    it('deletes expired publications', async function () {\n      sr = newRotator();\n      const fc = setFakeClock(sr);\n      await sr.start();\n      const [oldId] = await db.findKeys(`${dbPrefix}:*`, null);\n      strict(oldId != null);\n      sr.stop();\n      const p = await db.get(oldId);\n      await fc.setNow(p.end + p.lifetime + p.interval);\n      sr = newRotator();\n      setFakeClock(sr, fc);\n      await sr.start();\n      const ids = await db.findKeys(`${dbPrefix}:*`, null);\n      strict.equal(ids.length, 1);\n      const [newId] = ids;\n      strict.notEqual(newId, oldId);\n    });\n\n    it('keeps expired publications until interval past expiration', async function () {\n      sr = newRotator();\n      const fc = setFakeClock(sr);\n      await sr.start();\n      const [, , future] = sr.secrets;\n      sr.stop();\n      const [origId] = await db.findKeys(`${dbPrefix}:*`, null);\n      const p = await db.get(origId);\n      await fc.advance(p.end + p.lifetime + p.interval - 1);\n      sr = newRotator();\n      setFakeClock(sr, fc);\n      await sr.start();\n      strict(sr.secrets.slice(1).includes(future));\n      // It should have created a new publication, not extended the life of the old publication.\n      strict.equal((await db.findKeys(`${dbPrefix}:*`, null)).length, 2);\n      strict.deepEqual(await db.get(origId), p);\n    });\n\n    it('idempotent', async function () {\n      sr = newRotator();\n      const fc = setFakeClock(sr);\n      await sr.start();\n      strict.equal(fc.timeouts.size, 1);\n      const secrets = [...sr.secrets];\n      const dbKeys = await db.findKeys(`${dbPrefix}:*`, null);\n      await sr.start();\n      strict.equal(fc.timeouts.size, 1);\n      strict.deepEqual(sr.secrets, secrets);\n      strict.deepEqual(await db.findKeys(`${dbPrefix}:*`, null), dbKeys);\n    });\n\n    describe(`schedules update at next interval (= ${interval})`, function () {\n      const testCases = [\n        {now: 0, want: interval},\n        {now: 1, want: interval},\n        {now: interval - 1, want: interval},\n        {now: interval, want: 2 * interval},\n        {now: interval + 1, want: 2 * interval},\n      ];\n      for (const {now, want} of testCases) {\n        it(`${now} -> ${want}`, async function () {\n          sr = newRotator();\n          const fc = setFakeClock(sr);\n          await fc.setNow(now);\n          await sr.start();\n          strict.equal(fc.timeouts.size, 1);\n          const [{when}] = fc.timeouts.values();\n          strict.equal(when, want);\n        });\n      }\n\n      it('multiple active params with different intervals', async function () {\n        const intervals = [400, 600, 1000];\n        const lcmi = lcm(...intervals);\n        const wants:Set<number> = new Set();\n        for (const i of intervals) for (let t = i; t <= lcmi; t += i) wants.add(t);\n        const fcs = new FakeClock();\n        const srs = intervals.map((i) => {\n          interval = i;\n          const sr = newRotator();\n          setFakeClock(sr, fcs);\n          return sr;\n        });\n        try {\n          for (const sr of srs) await sr.start(); // Don't use Promise.all() otherwise they race.\n          interval = intervals[intervals.length - 1];\n          sr = newRotator();\n          const fc = setFakeClock(sr); // Independent clock to test a single instance's behavior.\n          await sr.start();\n          for (const want of [...wants].sort((a, b) => a - b)) {\n            logger.debug(`next timeout should be at ${want}`);\n            await fc.advanceToNext();\n            await fcs.setNow(fc.now); // Keep all of the publications alive.\n            strict.equal(fc.now, want);\n          }\n        } finally {\n          for (const sr of srs) sr.stop();\n        }\n      });\n    });\n  });\n\n  describe('stop', function () {\n    it('clears timeout', async function () {\n      sr = newRotator();\n      const fc = setFakeClock(sr);\n      await sr.start();\n      strict.notEqual(fc.timeouts.size, 0);\n      sr.stop();\n      strict.equal(fc.timeouts.size, 0);\n    });\n\n    it('safe to call multiple times', async function () {\n      sr = newRotator();\n      setFakeClock(sr);\n      await sr.start();\n      sr.stop();\n      sr.stop();\n    });\n  });\n\n  describe('legacy secret', function () {\n    it('ends at now if there are no previously published secrets', async function () {\n      sr = newRotator('legacy');\n      const fc = setFakeClock(sr);\n      // Use a time that isn't a multiple of interval in case there is a modular arithmetic bug that\n      // would otherwise go undetected.\n      await fc.setNow(1);\n      strict(mod(fc.now, interval) !== 0);\n      await sr.start();\n      strict.equal(sr.secrets.length, 4); // 1 for the legacy secret, 3 for past, current, future\n      strict(sr.secrets.slice(1).includes('legacy')); // Should not be the current secret.\n      const ids = await db.findKeys(`${dbPrefix}:*`, null);\n      const params = (await Promise.all(ids.map(async (id:string) => await db.get(id))))\n          .sort((a, b) => a.algId - b.algId);\n      strict.deepEqual(params, [\n        {\n          algId: 0,\n          algParams: 'legacy',\n          // The start time must equal the end time so that legacy secrets do not affect the end\n          // times of legacy secrets published by other instances.\n          start: fc.now,\n          end: fc.now,\n          lifetime,\n          interval: null,\n        },\n        {\n          algId: 1,\n          algParams: params[1].algParams,\n          start: fc.now,\n          end: intervalStart(fc.now) + (2 * interval),\n          interval,\n          lifetime,\n        },\n      ]);\n    });\n\n    it('ends at the start of the oldest previously published secret', async function () {\n      sr = newRotator();\n      const fc = setFakeClock(sr);\n      await fc.setNow(1);\n      strict(mod(fc.now, interval) !== 0);\n      const wantTime = fc.now;\n      await sr.start();\n      strict.equal(sr.secrets.length, 3);\n      const [s1, s0, s2] = sr.secrets; // s1=current, s0=previous, s2=next\n      sr.stop();\n      // Use a time that is not a multiple of interval off of epoch or wantTime just in case there\n      // is a modular arithmetic bug that would otherwise go undetected.\n      await fc.advance(interval + 1);\n      strict(mod(fc.now, interval) !== 0);\n      strict(mod(fc.now - wantTime, interval) !== 0);\n      sr = newRotator('legacy');\n      setFakeClock(sr, fc);\n      await sr.start();\n      strict.equal(sr.secrets.length, 5); // s0 through s3 and the legacy secret.\n      strict.deepEqual(sr.secrets, [s2, s1, s0, sr.secrets[3], 'legacy']);\n      const ids = await db.findKeys(`${dbPrefix}:*`, null);\n      const params = (await Promise.all(ids.map(async (id:string) => await db.get(id))))\n          .sort((a, b) => a.algId - b.algId);\n      strict.deepEqual(params, [\n        {\n          algId: 0,\n          algParams: 'legacy',\n          start: wantTime,\n          end: wantTime,\n          interval: null,\n          lifetime,\n        },\n        {\n          algId: 1,\n          algParams: params[1].algParams,\n          start: wantTime,\n          end: intervalStart(fc.now) + (2 * interval),\n          interval,\n          lifetime,\n        },\n      ]);\n    });\n\n    it('multiple instances with different legacy secrets', async function () {\n      sr = newRotator('legacy1');\n      const fc = setFakeClock(sr);\n      await sr.start();\n      sr.stop();\n      sr = newRotator('legacy2');\n      setFakeClock(sr, fc);\n      await sr.start();\n      strict(sr.secrets.slice(1).includes('legacy1'));\n      strict(sr.secrets.slice(1).includes('legacy2'));\n    });\n\n    it('multiple instances with the same legacy secret', async function () {\n      sr = newRotator('legacy');\n      const fc = setFakeClock(sr);\n      await sr.start();\n      sr.stop();\n      sr = newRotator('legacy');\n      setFakeClock(sr, fc);\n      await sr.start();\n      strict.deepEqual(sr.secrets, [...new Set(sr.secrets)]);\n      // There shouldn't be multiple publications for the same legacy secret.\n      strict.equal((await db.findKeys(`${dbPrefix}:*`, null)).length, 2);\n    });\n\n    it('legacy secret is included for interval after expiration', async function () {\n      sr = newRotator();\n      const fc = setFakeClock(sr);\n      await sr.start();\n      sr.stop();\n      await fc.advance(lifetime + interval - 1);\n      sr = newRotator('legacy');\n      setFakeClock(sr, fc);\n      await sr.start();\n      strict(sr.secrets.slice(1).includes('legacy'));\n    });\n\n    it('legacy secret is not included if the oldest secret is old enough', async function () {\n      sr = newRotator();\n      const fc = setFakeClock(sr);\n      await sr.start();\n      sr.stop();\n      await fc.advance(lifetime + interval);\n      sr = newRotator('legacy');\n      setFakeClock(sr, fc);\n      await sr.start();\n      strict(!sr.secrets.includes('legacy'));\n    });\n\n    it('dead secrets still affect legacy secret end time', async function () {\n      sr = newRotator();\n      const fc = setFakeClock(sr);\n      await sr.start();\n      const secrets = new Set(sr.secrets);\n      sr.stop();\n      await fc.advance(lifetime + (3 * interval));\n      sr = newRotator('legacy');\n      setFakeClock(sr, fc);\n      await sr.start();\n      strict(!sr.secrets.includes('legacy'));\n      strict(!sr.secrets.some((s:string) => secrets.has(s)));\n    });\n  });\n\n  describe('rotation', function () {\n    it('no rotation before start of interval', async function () {\n      sr = newRotator();\n      const fc = setFakeClock(sr);\n      strict.equal(fc.now, 0);\n      await sr.start();\n      const secrets = [...sr.secrets];\n      await fc.advance(interval - 1);\n      strict.deepEqual(sr.secrets, secrets);\n    });\n\n    it('does not replace secrets array', async function () {\n      sr = newRotator();\n      const fc = setFakeClock(sr);\n      await sr.start();\n      const [current] = sr.secrets;\n      const secrets = sr.secrets;\n      await fc.advance(interval);\n      strict.notEqual(sr.secrets[0], current);\n      strict.equal(sr.secrets, secrets);\n    });\n\n    it('future secret becomes current, new future is generated', async function () {\n      sr = newRotator();\n      const fc = setFakeClock(sr);\n      await sr.start();\n      const secrets = new Set(sr.secrets);\n      strict.equal(secrets.size, 3);\n      const [s1, s0, s2] = sr.secrets;\n      await fc.advance(interval);\n      strict.deepEqual(sr.secrets, [s2, s1, s0, sr.secrets[3]]);\n      strict(!secrets.has(sr.secrets[3]));\n    });\n\n    it('expired publications are deleted', async function () {\n      const origInterval = interval;\n      sr = newRotator();\n      const fc = setFakeClock(sr);\n      await sr.start();\n      sr.stop();\n      ++interval; // Force new params so that the old params can expire.\n      sr = newRotator();\n      setFakeClock(sr, fc);\n      await sr.start();\n      strict.equal((await db.findKeys(`${dbPrefix}:*`, null)).length, 2);\n      await fc.advance(lifetime + (3 * origInterval));\n      strict.equal((await db.findKeys(`${dbPrefix}:*`, null)).length, 1);\n    });\n\n    it('old secrets are eventually removed', async function () {\n      sr = newRotator();\n      const fc = setFakeClock(sr);\n      await sr.start();\n      const [, s0] = sr.secrets;\n      await fc.advance(lifetime + interval - 1);\n      strict(sr.secrets.slice(1).includes(s0));\n      await fc.advance(1);\n      strict(!sr.secrets.includes(s0));\n    });\n  });\n\n  describe('clock skew', function () {\n    it('out of sync works if in adjacent interval', async function () {\n      const srs = [newRotator(), newRotator()];\n      const fcs = srs.map((sr) => setFakeClock(sr));\n      for (const sr of srs) await sr.start(); // Don't use Promise.all() otherwise they race.\n      strict.deepEqual(srs[0].secrets, srs[1].secrets);\n      // Advance fcs[0] to the end of the interval after fcs[1].\n      await fcs[0].advance((2 * interval) - 1);\n      strict(srs[0].secrets.includes(srs[1].secrets[0]));\n      strict(srs[1].secrets.includes(srs[0].secrets[0]));\n      // Advance both by an interval.\n      await Promise.all([fcs[1].advance(interval), fcs[0].advance(interval)]);\n      strict(srs[0].secrets.includes(srs[1].secrets[0]));\n      strict(srs[1].secrets.includes(srs[0].secrets[0]));\n      // Advance fcs[1] to the end of the interval after fcs[0].\n      await Promise.all([fcs[1].advance((3 * interval) - 1), fcs[0].advance(1)]);\n      strict(srs[0].secrets.includes(srs[1].secrets[0]));\n      strict(srs[1].secrets.includes(srs[0].secrets[0]));\n    });\n\n    it('start up out of sync', async function () {\n      const srs = [newRotator(), newRotator()];\n      const fcs = srs.map((sr) => setFakeClock(sr));\n      await fcs[0].advance((2 * interval) - 1);\n      await srs[0].start(); // Must start before srs[1] so that srs[1] starts in srs[0]'s past.\n      await srs[1].start();\n      strict(srs[0].secrets.includes(srs[1].secrets[0]));\n      strict(srs[1].secrets.includes(srs[0].secrets[0]));\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/SessionStore.ts",
    "content": "'use strict';\n\nconst SessionStore = require('../../../node/db/SessionStore');\nimport {strict as assert} from 'assert';\nconst common = require('../common');\nconst db = require('../../../node/db/DB');\nimport util from 'util';\n\ntype Session = {\n  set: (sid: string|null,sess:any, sess2:any) => void;\n  get: (sid:string|null) => any;\n  destroy: (sid:string|null) => void;\n  touch: (sid:string|null, sess:any, sess2:any) => void;\n  shutdown: () => void;\n}\n\ndescribe(__filename, function () {\n  let ss: Session|null;\n  let sid: string|null;\n\n  const set = async (sess: string|null) => await util.promisify(ss!.set).call(ss, sid, sess);\n  const get = async () => await util.promisify(ss!.get).call(ss, sid);\n  const destroy = async () => await util.promisify(ss!.destroy).call(ss, sid);\n  const touch = async (sess: Session) => await util.promisify(ss!.touch).call(ss, sid, sess);\n\n  before(async function () {\n    await common.init();\n  });\n\n  beforeEach(async function () {\n    ss = new SessionStore();\n    sid = common.randomString();\n  });\n\n  afterEach(async function () {\n    if (ss != null) {\n      if (sid != null) await destroy();\n      ss.shutdown();\n    }\n    sid = null;\n    ss = null;\n  });\n\n  describe('set', function () {\n    it('set of null is a no-op', async function () {\n      await set(null);\n      assert(await db.get(`sessionstorage:${sid}`) == null);\n    });\n\n    it('set of non-expiring session', async function () {\n      const sess:any = {foo: 'bar', baz: {asdf: 'jkl;'}};\n      await set(sess);\n      assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));\n    });\n\n    it('set of session that expires', async function () {\n      const sess:any  = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};\n      await set(sess);\n      assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));\n      await new Promise((resolve) => setTimeout(resolve, 110));\n      // Writing should start a timeout.\n      assert(await db.get(`sessionstorage:${sid}`) == null);\n    });\n\n    it('set of already expired session', async function () {\n      const sess:any  = {foo: 'bar', cookie: {expires: new Date(1)}};\n      await set(sess);\n      // No record should have been created.\n      assert(await db.get(`sessionstorage:${sid}`) == null);\n    });\n\n    it('switch from non-expiring to expiring', async function () {\n      const sess:any  = {foo: 'bar'};\n      await set(sess);\n      const sess2:any  = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};\n      await set(sess2);\n      await new Promise((resolve) => setTimeout(resolve, 110));\n      assert(await db.get(`sessionstorage:${sid}`) == null);\n    });\n\n    it('switch from expiring to non-expiring', async function () {\n      const sess:any  = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};\n      await set(sess);\n      const sess2:any  = {foo: 'bar'};\n      await set(sess2);\n      await new Promise((resolve) => setTimeout(resolve, 110));\n      assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess2));\n    });\n  });\n\n  describe('get', function () {\n    it('get of non-existent entry', async function () {\n      assert(await get() == null);\n    });\n\n    it('set+get round trip', async function () {\n      const sess:any  = {foo: 'bar', baz: {asdf: 'jkl;'}};\n      await set(sess);\n      assert.equal(JSON.stringify(await get()), JSON.stringify(sess));\n    });\n\n    it('get of record from previous run (no expiration)', async function () {\n      const sess = {foo: 'bar', baz: {asdf: 'jkl;'}};\n      await db.set(`sessionstorage:${sid}`, sess);\n      assert.equal(JSON.stringify(await get()), JSON.stringify(sess));\n    });\n\n    it('get of record from previous run (not yet expired)', async function () {\n      const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};\n      await db.set(`sessionstorage:${sid}`, sess);\n      assert.equal(JSON.stringify(await get()), JSON.stringify(sess));\n      await new Promise((resolve) => setTimeout(resolve, 110));\n      // Reading should start a timeout.\n      assert(await db.get(`sessionstorage:${sid}`) == null);\n    });\n\n    it('get of record from previous run (already expired)', async function () {\n      const sess = {foo: 'bar', cookie: {expires: new Date(1)}};\n      await db.set(`sessionstorage:${sid}`, sess);\n      assert(await get() == null);\n      assert(await db.get(`sessionstorage:${sid}`) == null);\n    });\n\n    it('external expiration update is picked up', async function () {\n      const sess:any  = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};\n      await set(sess);\n      assert.equal(JSON.stringify(await get()), JSON.stringify(sess));\n      const sess2 = {...sess, cookie: {expires: new Date(Date.now() + 200)}};\n      await db.set(`sessionstorage:${sid}`, sess2);\n      assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));\n      await new Promise((resolve) => setTimeout(resolve, 110));\n      // The original timeout should not have fired.\n      assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));\n    });\n  });\n\n  describe('shutdown', function () {\n    it('shutdown cancels timeouts', async function () {\n      const sess:any  = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};\n      await set(sess);\n      assert.equal(JSON.stringify(await get()), JSON.stringify(sess));\n      ss!.shutdown();\n      await new Promise((resolve) => setTimeout(resolve, 110));\n      // The record should not have been automatically purged.\n      assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));\n    });\n  });\n\n  describe('destroy', function () {\n    it('destroy deletes the database record', async function () {\n      const sess:any  = {cookie: {expires: new Date(Date.now() + 100)}};\n      await set(sess);\n      await destroy();\n      assert(await db.get(`sessionstorage:${sid}`) == null);\n    });\n\n    it('destroy cancels the timeout', async function () {\n      const sess:any  = {cookie: {expires: new Date(Date.now() + 100)}};\n      await set(sess);\n      await destroy();\n      await db.set(`sessionstorage:${sid}`, sess);\n      await new Promise((resolve) => setTimeout(resolve, 110));\n      assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));\n    });\n\n    it('destroy session that does not exist', async function () {\n      await destroy();\n    });\n  });\n\n  describe('touch without refresh', function () {\n    it('touch before set is equivalent to set if session expires', async function () {\n      const sess:any  = {cookie: {expires: new Date(Date.now() + 1000)}};\n      await touch(sess);\n      assert.equal(JSON.stringify(await get()), JSON.stringify(sess));\n    });\n\n    it('touch updates observed expiration but not database', async function () {\n      const start = Date.now();\n      const sess:any  = {cookie: {expires: new Date(start + 200)}};\n      await set(sess);\n      const sess2:any  = {cookie: {expires: new Date(start + 12000)}};\n      await touch(sess2);\n      assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));\n      assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));\n    });\n  });\n\n  describe('touch with refresh', function () {\n    beforeEach(async function () {\n      ss = new SessionStore(200);\n    });\n\n    it('touch before set is equivalent to set if session expires', async function () {\n      const sess:any  = {cookie: {expires: new Date(Date.now() + 1000)}};\n      await touch(sess);\n      assert.equal(JSON.stringify(await get()), JSON.stringify(sess));\n    });\n\n    it('touch before eligible for refresh updates expiration but not DB', async function () {\n      const now = Date.now();\n      const sess:any  = {foo: 'bar', cookie: {expires: new Date(now + 1000)}};\n      await set(sess);\n      const sess2:any  = {foo: 'bar', cookie: {expires: new Date(now + 1001)}};\n      await touch(sess2);\n      assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));\n      assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));\n    });\n\n    it('touch before eligible for refresh updates timeout', async function () {\n      const start = Date.now();\n      const sess:any  = {foo: 'bar', cookie: {expires: new Date(start + 200)}};\n      await set(sess);\n      await new Promise((resolve) => setTimeout(resolve, 100));\n      const sess2:any  = {foo: 'bar', cookie: {expires: new Date(start + 399)}};\n      await touch(sess2);\n      await new Promise((resolve) => setTimeout(resolve, 110));\n      assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));\n      assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));\n    });\n\n    it('touch after eligible for refresh updates db', async function () {\n      const start = Date.now();\n      const sess:any  = {foo: 'bar', cookie: {expires: new Date(start + 200)}};\n      await set(sess);\n      await new Promise((resolve) => setTimeout(resolve, 100));\n      const sess2:any  = {foo: 'bar', cookie: {expires: new Date(start + 400)}};\n      await touch(sess2);\n      await new Promise((resolve) => setTimeout(resolve, 110));\n      assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess2));\n      assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));\n    });\n\n    it('refresh=0 updates db every time', async function () {\n      ss = new SessionStore(0);\n      const sess:any  = {foo: 'bar', cookie: {expires: new Date(Date.now() + 1000)}};\n      await set(sess);\n      await db.remove(`sessionstorage:${sid}`);\n      await touch(sess); // No change in expiration time.\n      assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));\n      await db.remove(`sessionstorage:${sid}`);\n      await touch(sess); // No change in expiration time.\n      assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/Stream.ts",
    "content": "'use strict';\n\nconst Stream = require('../../../node/utils/Stream');\nimport {strict} from \"assert\";\n\nclass DemoIterable {\n  private value: number;\n    errs: Error[];\n    rets: any[];\n  constructor() {\n    this.value = 0;\n    this.errs = [];\n    this.rets = [];\n  }\n\n  completed() { return this.errs.length > 0 || this.rets.length > 0; }\n\n  next() {\n    if (this.completed()) return {value: undefined, done: true}; // Mimic standard generators.\n    return {value: this.value++, done: false};\n  }\n\n  throw(err: any) {\n    const alreadyCompleted = this.completed();\n    this.errs.push(err);\n    if (alreadyCompleted) throw err; // Mimic standard generator objects.\n    throw err;\n  }\n\n  return(ret: number) {\n    const alreadyCompleted = this.completed();\n    this.rets.push(ret);\n    if (alreadyCompleted) return {value: ret, done: true}; // Mimic standard generator objects.\n    return {value: ret, done: true};\n  }\n\n  [Symbol.iterator]() { return this; }\n}\n\nconst assertUnhandledRejection = async (action: any, want: any) => {\n  // Temporarily remove unhandled Promise rejection listeners so that the unhandled rejections we\n  // expect to see don't trigger a test failure (or terminate node).\n  const event = 'unhandledRejection';\n  const listenersBackup = process.rawListeners(event);\n  process.removeAllListeners(event);\n  let tempListener: Function;\n  let asyncErr:any;\n  try {\n    const seenErrPromise = new Promise<void>((resolve) => {\n      tempListener = (err:any) => {\n        strict.equal(asyncErr, undefined);\n        asyncErr = err;\n        resolve();\n      };\n    });\n    // @ts-ignore\n    process.on(event, tempListener);\n    await action();\n    await seenErrPromise;\n  } finally {\n    // Restore the original listeners.\n    // @ts-ignore\n    process.off(event, tempListener);\n    for (const listener of listenersBackup) { // @ts-ignore\n      process.on(event, listener);\n    }\n  }\n  await strict.rejects(Promise.reject(asyncErr), want);\n};\n\ndescribe(__filename, function () {\n  describe('basic behavior', function () {\n    it('takes a generator', async function () {\n      strict.deepEqual([...new Stream((function* () { yield 0; yield 1; yield 2; })())], [0, 1, 2]);\n    });\n\n    it('takes an array', async function () {\n      strict.deepEqual([...new Stream([0, 1, 2])], [0, 1, 2]);\n    });\n\n    it('takes an iterator', async function () {\n      strict.deepEqual([...new Stream([0, 1, 2][Symbol.iterator]())], [0, 1, 2]);\n    });\n\n    it('supports empty iterators', async function () {\n      strict.deepEqual([...new Stream([])], []);\n    });\n\n    it('is resumable', async function () {\n      const s = new Stream((function* () { yield 0; yield 1; yield 2; })());\n      let iter = s[Symbol.iterator]();\n      strict.deepEqual(iter.next(), {value: 0, done: false});\n      iter = s[Symbol.iterator]();\n      strict.deepEqual(iter.next(), {value: 1, done: false});\n      strict.deepEqual([...s], [2]);\n    });\n\n    it('supports return value', async function () {\n      const s = new Stream((function* () { yield 0; return 1; })());\n      const iter = s[Symbol.iterator]();\n      strict.deepEqual(iter.next(), {value: 0, done: false});\n      strict.deepEqual(iter.next(), {value: 1, done: true});\n    });\n\n    it('does not start until needed', async function () {\n      let lastYield = null;\n      new Stream((function* () { yield lastYield = 0; })());\n      // Fetching from the underlying iterator should not start until the first value is fetched\n      // from the stream.\n      strict.equal(lastYield, null);\n    });\n\n    it('throw is propagated', async function () {\n      const underlying = new DemoIterable();\n      const s = new Stream(underlying);\n      const iter = s[Symbol.iterator]();\n      strict.deepEqual(iter.next(), {value: 0, done: false});\n      const err = new Error('injected');\n      strict.throws(() => iter.throw(err), err);\n      strict.equal(underlying.errs[0], err);\n    });\n\n    it('return is propagated', async function () {\n      const underlying = new DemoIterable();\n      const s = new Stream(underlying);\n      const iter = s[Symbol.iterator]();\n      strict.deepEqual(iter.next(), {value: 0, done: false});\n      strict.deepEqual(iter.return(42), {value: 42, done: true});\n      strict.equal(underlying.rets[0], 42);\n    });\n  });\n\n  describe('range', function () {\n    it('basic', async function () {\n      strict.deepEqual([...Stream.range(0, 3)], [0, 1, 2]);\n    });\n\n    it('empty', async function () {\n      strict.deepEqual([...Stream.range(0, 0)], []);\n    });\n\n    it('positive start', async function () {\n      strict.deepEqual([...Stream.range(3, 5)], [3, 4]);\n    });\n\n    it('negative start', async function () {\n      strict.deepEqual([...Stream.range(-3, 0)], [-3, -2, -1]);\n    });\n\n    it('end before start', async function () {\n      strict.deepEqual([...Stream.range(3, 0)], []);\n    });\n  });\n\n  describe('batch', function () {\n    it('empty', async function () {\n      strict.deepEqual([...new Stream([]).batch(10)], []);\n    });\n\n    it('does not start until needed', async function () {\n      let lastYield = null;\n      new Stream((function* () { yield lastYield = 0; })()).batch(10);\n      strict.equal(lastYield, null);\n    });\n\n    it('fewer than batch size', async function () {\n      let lastYield = null;\n      const values = (function* () {\n        for (let i = 0; i < 5; i++) yield lastYield = i;\n      })();\n      const s = new Stream(values).batch(10);\n      strict.equal(lastYield, null);\n      strict.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false});\n      strict.equal(lastYield, 4);\n      strict.deepEqual([...s], [1, 2, 3, 4]);\n      strict.equal(lastYield, 4);\n    });\n\n    it('exactly batch size', async function () {\n      let lastYield = null;\n      const values = (function* () {\n        for (let i = 0; i < 5; i++) yield lastYield = i;\n      })();\n      const s = new Stream(values).batch(5);\n      strict.equal(lastYield, null);\n      strict.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false});\n      strict.equal(lastYield, 4);\n      strict.deepEqual([...s], [1, 2, 3, 4]);\n      strict.equal(lastYield, 4);\n    });\n\n    it('multiple batches, last batch is not full', async function () {\n      let lastYield = null;\n      const values = (function* () {\n        for (let i = 0; i < 10; i++) yield lastYield = i;\n      })();\n      const s = new Stream(values).batch(3);\n      strict.equal(lastYield, null);\n      const iter = s[Symbol.iterator]();\n      strict.deepEqual(iter.next(), {value: 0, done: false});\n      strict.equal(lastYield, 2);\n      strict.deepEqual(iter.next(), {value: 1, done: false});\n      strict.deepEqual(iter.next(), {value: 2, done: false});\n      strict.equal(lastYield, 2);\n      strict.deepEqual(iter.next(), {value: 3, done: false});\n      strict.equal(lastYield, 5);\n      strict.deepEqual([...s], [4, 5, 6, 7, 8, 9]);\n      strict.equal(lastYield, 9);\n    });\n\n    it('batched Promise rejections are suppressed while iterating', async function () {\n      let lastYield = null;\n      const err = new Error('injected');\n      const values = (function* () {\n        lastYield = 'promise of 0';\n        yield new Promise((resolve) => setTimeout(() => resolve(0), 100));\n        lastYield = 'rejected Promise';\n        yield Promise.reject(err);\n        lastYield = 'promise of 2';\n        yield Promise.resolve(2);\n      })();\n      const s = new Stream(values).batch(3);\n      const iter = s[Symbol.iterator]();\n      const nextp = iter.next().value;\n      strict.equal(lastYield, 'promise of 2');\n      strict.equal(await nextp, 0);\n      await strict.rejects(iter.next().value, err);\n      iter.return();\n    });\n\n    it('batched Promise rejections are unsuppressed when iteration completes', async function () {\n      let lastYield = null;\n      const err = new Error('injected');\n      const values = (function* () {\n        lastYield = 'promise of 0';\n        yield new Promise((resolve) => setTimeout(() => resolve(0), 100));\n        lastYield = 'rejected Promise';\n        yield Promise.reject(err);\n        lastYield = 'promise of 2';\n        yield Promise.resolve(2);\n      })();\n      const s = new Stream(values).batch(3);\n      const iter = s[Symbol.iterator]();\n      strict.equal(await iter.next().value, 0);\n      strict.equal(lastYield, 'promise of 2');\n      await assertUnhandledRejection(() => iter.return(), err);\n    });\n  });\n\n  describe('buffer', function () {\n    it('empty', async function () {\n      strict.deepEqual([...new Stream([]).buffer(10)], []);\n    });\n\n    it('does not start until needed', async function () {\n      let lastYield = null;\n      new Stream((function* () { yield lastYield = 0; })()).buffer(10);\n      strict.equal(lastYield, null);\n    });\n\n    it('fewer than buffer size', async function () {\n      let lastYield = null;\n      const values = (function* () {\n        for (let i = 0; i < 5; i++) yield lastYield = i;\n      })();\n      const s = new Stream(values).buffer(10);\n      strict.equal(lastYield, null);\n      strict.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false});\n      strict.equal(lastYield, 4);\n      strict.deepEqual([...s], [1, 2, 3, 4]);\n      strict.equal(lastYield, 4);\n    });\n\n    it('exactly buffer size', async function () {\n      let lastYield = null;\n      const values = (function* () {\n        for (let i = 0; i < 5; i++) yield lastYield = i;\n      })();\n      const s = new Stream(values).buffer(5);\n      strict.equal(lastYield, null);\n      strict.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false});\n      strict.equal(lastYield, 4);\n      strict.deepEqual([...s], [1, 2, 3, 4]);\n      strict.equal(lastYield, 4);\n    });\n\n    it('more than buffer size', async function () {\n      let lastYield = null;\n      const values = (function* () {\n        for (let i = 0; i < 10; i++) yield lastYield = i;\n      })();\n      const s = new Stream(values).buffer(3);\n      strict.equal(lastYield, null);\n      const iter = s[Symbol.iterator]();\n      strict.deepEqual(iter.next(), {value: 0, done: false});\n      strict.equal(lastYield, 3);\n      strict.deepEqual(iter.next(), {value: 1, done: false});\n      strict.equal(lastYield, 4);\n      strict.deepEqual(iter.next(), {value: 2, done: false});\n      strict.equal(lastYield, 5);\n      strict.deepEqual([...s], [3, 4, 5, 6, 7, 8, 9]);\n      strict.equal(lastYield, 9);\n    });\n\n    it('buffered Promise rejections are suppressed while iterating', async function () {\n      let lastYield = null;\n      const err = new Error('injected');\n      const values = (function* () {\n        lastYield = 'promise of 0';\n        yield new Promise((resolve) => setTimeout(() => resolve(0), 100));\n        lastYield = 'rejected Promise';\n        yield Promise.reject(err);\n        lastYield = 'promise of 2';\n        yield Promise.resolve(2);\n      })();\n      const s = new Stream(values).buffer(3);\n      const iter = s[Symbol.iterator]();\n      const nextp = iter.next().value;\n      strict.equal(lastYield, 'promise of 2');\n      strict.equal(await nextp, 0);\n      await strict.rejects(iter.next().value, err);\n      iter.return();\n    });\n\n    it('buffered Promise rejections are unsuppressed when iteration completes', async function () {\n      let lastYield = null;\n      const err = new Error('injected');\n      const values = (function* () {\n        lastYield = 'promise of 0';\n        yield new Promise((resolve) => setTimeout(() => resolve(0), 100));\n        lastYield = 'rejected Promise';\n        yield Promise.reject(err);\n        lastYield = 'promise of 2';\n        yield Promise.resolve(2);\n      })();\n      const s = new Stream(values).buffer(3);\n      const iter = s[Symbol.iterator]();\n      strict.equal(await iter.next().value, 0);\n      strict.equal(lastYield, 'promise of 2');\n      await assertUnhandledRejection(() => iter.return(), err);\n    });\n  });\n\n  describe('map', function () {\n    it('empty', async function () {\n      let called = false;\n      strict.deepEqual([...new Stream([]).map(() => called = true)], []);\n      strict.equal(called, false);\n    });\n\n    it('does not start until needed', async function () {\n      let called = false;\n      strict.deepEqual([...new Stream([]).map(() => called = true)], []);\n      new Stream((function* () { yield 0; })()).map(() => called = true);\n      strict.equal(called, false);\n    });\n\n    it('works', async function () {\n      const calls:any[] = [];\n      strict.deepEqual(\n          [...new Stream([0, 1, 2]).map((v:any) => { calls.push(v); return 2 * v; })], [0, 2, 4]);\n      strict.deepEqual(calls, [0, 1, 2]);\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/api/api.ts",
    "content": "'use strict';\n\n/**\n * API specs\n *\n * Tests for generic overarching HTTP API related features not related to any\n * specific part of the data model or domain. For example: tests for versioning\n * and openapi definitions.\n */\n\nconst common = require('../../common');\nconst validateOpenAPI = require('openapi-schema-validation').validate;\n\nlet agent: any;\nlet apiVersion = 1;\n\nconst makeid = () => {\n  let text = '';\n  const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n\n  for (let i = 0; i < 5; i++) {\n    text += possible.charAt(Math.floor(Math.random() * possible.length));\n  }\n  return text;\n};\n\nconst testPadId = makeid();\n\nconst endPoint = (point:string) => `/api/${apiVersion}/${point}`;\n\ndescribe(__filename, function () {\n  before(async function () { agent = await common.init(); });\n\n  it('can obtain API version', async function () {\n    await agent.get('/api/')\n        .expect(200)\n        .expect((res:any) => {\n          apiVersion = res.body.currentVersion;\n          if (!res.body.currentVersion) throw new Error('No version set in API');\n          return;\n        });\n  });\n\n  it('can obtain valid openapi definition document', async function () {\n    this.timeout(15000);\n    await agent.get('/api/openapi.json')\n        .expect(200)\n        .expect((res:any) => {\n          const {valid, errors} = validateOpenAPI(res.body, 3);\n          if (!valid) {\n            const prettyErrors = JSON.stringify(errors, null, 2);\n            throw new Error(`Document is not valid OpenAPI. ${errors.length} ` +\n                            `validation errors:\\n${prettyErrors}`);\n          }\n        });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/api/characterEncoding.ts",
    "content": "'use strict';\n\n/*\n * This file is copied & modified from <basedir>/src/tests/backend/specs/api/pad.js\n *\n * TODO: maybe unify those two files and merge in a single one.\n */\n\nimport {generateJWTToken, generateJWTTokenUser} from \"../../common\";\n\nconst assert = require('assert').strict;\nconst common = require('../../common');\nconst fs = require('fs');\nconst fsp = fs.promises;\n\nlet agent:any;\nlet apiVersion = 1;\nconst testPadId = makeid();\n\nconst endPoint = (point:string, version?:number) => `/api/${version || apiVersion}/${point}`;\n\ndescribe(__filename, function () {\n  before(async function () { agent = await common.init(); });\n\n  describe('Sanity checks', function () {\n    it('can connect', async function () {\n      await agent.get('/api/')\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/);\n    });\n\n    it('finds the version tag', async function () {\n      const res = await agent.get('/api/')\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200);\n      apiVersion = res.body.currentVersion;\n      assert(apiVersion);\n    });\n\n    it('errors with invalid OAuth token', async function () {\n      // This is broken because Etherpad doesn't handle HTTP codes properly see #2343\n      await agent.get(`/api/${apiVersion}/createPad?padID=test`)\n          .set(\"Authorization\", (await generateJWTToken()).substring(0,10))\n          .expect(401);\n    });\n\n    it('errors with unprivileged OAuth token', async function () {\n      // This is broken because Etherpad doesn't handle HTTP codes properly see #2343\n      await agent.get(`/api/${apiVersion}/createPad?padID=test`)\n          .set(\"Authorization\", (await generateJWTTokenUser()).substring(0,10))\n          .expect(401);\n    });\n  });\n\n  describe('Tests', function () {\n    it('creates a new Pad', async function () {\n      const res = await agent.get(`${endPoint('createPad')}?padID=${testPadId}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    it('Sets the HTML of a Pad attempting to weird utf8 encoded content', async function () {\n      const res = await agent.post(endPoint('setHTML'))\n          .set(\"Authorization\", await generateJWTToken())\n          .send({\n            padID: testPadId,\n            html: await fsp.readFile('tests/backend/specs/api/emojis.html', 'utf8'),\n          })\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    it('get the HTML of Pad with emojis', async function () {\n      const res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.match(res.body.data.html, /&#127484/);\n    });\n  });\n});\n\n/*\n\n  End of test\n\n*/\n\nfunction makeid() {\n  let text = '';\n  const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n\n  for (let i = 0; i < 10; i++) {\n    text += possible.charAt(Math.floor(Math.random() * possible.length));\n  }\n  return text;\n}\n"
  },
  {
    "path": "src/tests/backend/specs/api/chat.ts",
    "content": "'use strict';\n\nimport {generateJWTToken} from \"../../common\";\n\nconst common = require('../../common');\n\nimport {strict as assert} from \"assert\";\n\nlet agent:any;\nlet apiVersion = 1;\nlet authorID = '';\nconst padID = makeid();\nconst timestamp = Date.now();\n\nconst endPoint = (point:string) => `/api/${apiVersion}/${point}`;\n\ndescribe(__filename, function () {\n  before(async function () { agent = await common.init(); });\n\n  describe('API Versioning', function () {\n    it('errors if can not connect', async function () {\n      await agent.get('/api/')\n          .expect((res:any) => {\n            apiVersion = res.body.currentVersion;\n            if (!res.body.currentVersion) throw new Error('No version set in API');\n            return;\n          })\n          .expect(200);\n    });\n  });\n\n  // BEGIN GROUP AND AUTHOR TESTS\n  // ///////////////////////////////////\n  // ///////////////////////////////////\n\n  /* Tests performed\n  -> createPad(padID)\n   -> createAuthor([name]) -- should return an authorID\n    -> appendChatMessage(padID, text, authorID, time)\n     -> getChatHead(padID)\n      -> getChatHistory(padID)\n  */\n\n  describe('Chat functionality', function () {\n    it('creates a new Pad', async function () {\n      await agent.get(`${endPoint('createPad')}?padID=${padID}`)\n          .set(\"authorization\", await generateJWTToken())\n          .expect(200)\n          .expect((res:any) => {\n            if (res.body.code !== 0) throw new Error('Unable to create new Pad');\n          })\n          .expect('Content-Type', /json/);\n    });\n\n    it('Creates an author with a name set', async function () {\n      await agent.get(endPoint('createAuthor'))\n          .set(\"authorization\", await generateJWTToken())\n          .expect((res:any) => {\n            if (res.body.code !== 0 || !res.body.data.authorID) {\n              throw new Error('Unable to create author');\n            }\n            authorID = res.body.data.authorID; // we will be this author for the rest of the tests\n          })\n          .expect('Content-Type', /json/)\n          .expect(200);\n    });\n\n    it('Gets the head of chat before the first chat msg', async function () {\n      await agent.get(`${endPoint('getChatHead')}?padID=${padID}`)\n          .set(\"authorization\", await generateJWTToken())\n          .expect((res:any) => {\n            if (res.body.data.chatHead !== -1) throw new Error('Chat Head Length is wrong');\n            if (res.body.code !== 0) throw new Error('Unable to get chat head');\n          })\n          .expect('Content-Type', /json/)\n          .expect(200);\n    });\n\n    it('Adds a chat message to the pad', async function () {\n      await agent.get(`${endPoint('appendChatMessage')}?padID=${padID}&text=blalblalbha` +\n                `&authorID=${authorID}&time=${timestamp}`)\n          .set(\"authorization\", await generateJWTToken())\n          .expect((res:any) => {\n            if (res.body.code !== 0) throw new Error('Unable to create chat message');\n          })\n          .expect('Content-Type', /json/)\n          .expect(200);\n    });\n\n    it('Gets the head of chat', async function () {\n      await agent.get(`${endPoint('getChatHead')}?padID=${padID}`)\n          .set(\"authorization\", await generateJWTToken())\n          .expect((res:any) => {\n            if (res.body.data.chatHead !== 0) throw new Error('Chat Head Length is wrong');\n\n            if (res.body.code !== 0) throw new Error('Unable to get chat head');\n          })\n          .expect('Content-Type', /json/)\n          .expect(200);\n    });\n\n    it('Gets Chat History of a Pad', async function () {\n      await agent.get(`${endPoint('getChatHistory')}?padID=${padID}`)\n          .set(\"authorization\", await generateJWTToken())\n          .expect('Content-Type', /json/)\n          .expect(200)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0, 'Unable to get chat history');\n            assert.equal(res.body.data.messages.length, 1, 'Chat History Length is wrong');\n            assert.equal(res.body.data.messages[0].text, 'blalblalbha', 'Chat text does not match');\n            assert.equal(res.body.data.messages[0].userId, authorID, 'Message author does not match');\n            assert.equal(res.body.data.messages[0].time, timestamp.toString(), 'Message time does not match');\n          });\n    });\n  });\n});\n\nfunction makeid() {\n  let text = '';\n  const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n\n  for (let i = 0; i < 5; i++) {\n    text += possible.charAt(Math.floor(Math.random() * possible.length));\n  }\n  return text;\n}\n"
  },
  {
    "path": "src/tests/backend/specs/api/emojis.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n  <title>foo</title>\n  <meta charset=\"UTF-8\">\n</head>\n<body>\n😀 😁 😂 🤣 😃 😄 😅 😆 😉 😊 😋 😎 😍 😘 🥰 😗 😙 😚 ☺️ 🙂 🤗 🤩 🤔 🤨 😐 😑 😶 🙄 😏 😣 😥 😮 🤐 😯 😪 😫 😴 😌 😛 😜 😝 🤤 😒 😓 😔 😕 🙃 🤑 😲 ☹️ 🙁 😖 😞 😟 😤 😢 😭 😦 😧 😨 😩 🤯 😬 😰 😱 🥵 🥶 😳 🤪 😵 😡 😠 🤬 😷 🤒 🤕 🤢 🤮 🤧 😇 🤠 🤡 🥳 🥴 🥺 🤥 🤫 🤭 🧐 🤓 😈 👿 👹 👺 💀 👻 👽 🤖 💩 😺 😸 😹 😻 😼 😽 🙀 😿 😾 👶 👧 🧒 👦 👩 🧑 👨 👵 🧓 👴 👲 👳‍♀️ 👳‍♂️ 🧕 🧔 👱‍♂️ 👱‍♀️ 👨‍🦰 👩‍🦰 👨‍🦱 👩‍🦱 👨‍🦲 👩‍🦲 👨‍🦳 👩‍🦳 🦸‍♀️ 🦸‍♂️ 🦹‍♀️ 🦹‍♂️ 👮‍♀️ 👮‍♂️ 👷‍♀️ 👷‍♂️ 💂‍♀️ 💂‍♂️ 🕵️‍♀️ 🕵️‍♂️ 👩‍⚕️ 👨‍⚕️ 👩‍🌾 👨‍🌾 👩‍🍳 👨‍🍳 👩‍🎓 👨‍🎓 👩‍🎤 👨‍🎤 👩‍🏫 👨‍🏫 👩‍🏭 👨‍🏭 👩‍💻 👨‍💻 👩‍💼 👨‍💼 👩‍🔧 👨‍🔧 👩‍🔬 👨‍🔬 👩‍🎨 👨‍🎨 👩‍🚒 👨‍🚒 👩‍✈️ 👨‍✈️ 👩‍🚀 👨‍🚀 👩‍⚖️ 👨‍⚖️ 👰 🤵 👸 🤴 🤶 🎅 🧙‍♀️ 🧙‍♂️ 🧝‍♀️ 🧝‍♂️ 🧛‍♀️ 🧛‍♂️ 🧟‍♀️ 🧟‍♂️ 🧞‍♀️ 🧞‍♂️ 🧜‍♀️ 🧜‍♂️ 🧚‍♀️ 🧚‍♂️ 👼 🤰 🤱 🙇‍♀️ 🙇‍♂️ 💁‍♀️ 💁‍♂️ 🙅‍♀️ 🙅‍♂️ 🙆‍♀️ 🙆‍♂️ 🙋‍♀️ 🙋‍♂️ 🤦‍♀️ 🤦‍♂️ 🤷‍♀️ 🤷‍♂️ 🙎‍♀️ 🙎‍♂️ 🙍‍♀️ 🙍‍♂️ 💇‍♀️ 💇‍♂️ 💆‍♀️ 💆‍♂️ 🧖‍♀️ 🧖‍♂️ 💅 🤳 💃 🕺 👯‍♀️ 👯‍♂️ 🕴 🚶‍♀️ 🚶‍♂️ 🏃‍♀️ 🏃‍♂️ 👫 👭 👬 💑 👩‍❤️‍👩 👨‍❤️‍👨 💏 👩‍❤️‍💋‍👩 👨‍❤️‍💋‍👨 👪 👨‍👩‍👧 👨‍👩‍👧‍👦 👨‍👩‍👦‍👦 👨‍👩‍👧‍👧 👩‍👩‍👦 👩‍👩‍👧 👩‍👩‍👧‍👦 👩‍👩‍👦‍👦 👩‍👩‍👧‍👧 👨‍👨‍👦 👨‍👨‍👧 👨‍👨‍👧‍👦 👨‍👨‍👦‍👦 👨‍👨‍👧‍👧 👩‍👦 👩‍👧 👩‍👧‍👦 👩‍👦‍👦 👩‍👧‍👧 👨‍👦 👨‍👧 👨‍👧‍👦 👨‍👦‍👦 👨‍👧‍👧 🤲 👐 🙌 👏 🤝 👍 👎 👊 ✊ 🤛 🤜 🤞 ✌️ 🤟 🤘 👌 👈 👉 👆 👇 ☝️ ✋ 🤚 🖐 🖖 👋 🤙 💪 🦵 🦶 🖕 ✍️ 🙏 💍 💄 💋 👄 👅 👂 👃 👣 👁 👀 🧠 🦴 🦷 🗣 👤 👥🧥 👚 👕 👖 👔 👗 👙 👘 👠 👡 👢 👞 👟 🥾 🥿 🧦 🧤 🧣 🎩 🧢 👒 🎓 ⛑ 👑 👝 👛 👜 💼 🎒 👓 🕶 🥽 🥼 🌂 🧵 🧶👶🏻 👦🏻 👧🏻 👨🏻 👩🏻 👱🏻‍♀️ 👱🏻 👴🏻 👵🏻 👲🏻 👳🏻‍♀️ 👳🏻 👮🏻‍♀️ 👮🏻 👷🏻‍♀️ 👷🏻 💂🏻‍♀️ 💂🏻 🕵🏻‍♀️ 🕵🏻 👩🏻‍⚕️ 👨🏻‍⚕️ 👩🏻‍🌾 👨🏻‍🌾 👩🏻‍🍳 👨🏻‍🍳 👩🏻‍🎓 👨🏻‍🎓 👩🏻‍🎤 👨🏻‍🎤 👩🏻‍🏫 👨🏻‍🏫 👩🏻‍🏭 👨🏻‍🏭 👩🏻‍💻 👨🏻‍💻 👩🏻‍💼 👨🏻‍💼 👩🏻‍🔧 👨🏻‍🔧 👩🏻‍🔬 👨🏻‍🔬 👩🏻‍🎨 👨🏻‍🎨 👩🏻‍🚒 👨🏻‍🚒 👩🏻‍✈️ 👨🏻‍✈️ 👩🏻‍🚀 👨🏻‍🚀 👩🏻‍⚖️ 👨🏻‍⚖️ 🤶🏻 🎅🏻 👸🏻 🤴🏻 👰🏻 🤵🏻 👼🏻 🤰🏻 🙇🏻‍♀️ 🙇🏻 💁🏻 💁🏻‍♂️ 🙅🏻 🙅🏻‍♂️ 🙆🏻 🙆🏻‍♂️ 🙋🏻 🙋🏻‍♂️ 🤦🏻‍♀️ 🤦🏻‍♂️ 🤷🏻‍♀️ 🤷🏻‍♂️ 🙎🏻 🙎🏻‍♂️ 🙍🏻 🙍🏻‍♂️ 💇🏻 💇🏻‍♂️ 💆🏻 💆🏻‍♂️ 🕴🏻 💃🏻 🕺🏻 🚶🏻‍♀️ 🚶🏻 🏃🏻‍♀️ 🏃🏻 🤲🏻 👐🏻 🙌🏻 👏🏻 🙏🏻 👍🏻 👎🏻 👊🏻 ✊🏻 🤛🏻 🤜🏻 🤞🏻 ✌🏻 🤟🏻 🤘🏻 👌🏻 👈🏻 👉🏻 👆🏻 👇🏻 ☝🏻 ✋🏻 🤚🏻 🖐🏻 🖖🏻 👋🏻 🤙🏻 💪🏻 🖕🏻 ✍🏻 🤳🏻 💅🏻 👂🏻 👃🏻👶🏼 👦🏼 👧🏼 👨🏼 👩🏼 👱🏼‍♀️ 👱🏼 👴🏼 👵🏼 👲🏼 👳🏼‍♀️ 👳🏼 👮🏼‍♀️ 👮🏼 👷🏼‍♀️ 👷🏼 💂🏼‍♀️ 💂🏼 🕵🏼‍♀️ 🕵🏼 👩🏼‍⚕️ 👨🏼‍⚕️ 👩🏼‍🌾 👨🏼‍🌾 👩🏼‍🍳 👨🏼‍🍳 👩🏼‍🎓 👨🏼‍🎓 👩🏼‍🎤 👨🏼‍🎤 👩🏼‍🏫 👨🏼‍🏫 👩🏼‍🏭 👨🏼‍🏭 👩🏼‍💻 👨🏼‍💻 👩🏼‍💼 👨🏼‍💼 👩🏼‍🔧 👨🏼‍🔧 👩🏼‍🔬 👨🏼‍🔬 👩🏼‍🎨 👨🏼‍🎨 👩🏼‍🚒 👨🏼‍🚒 👩🏼‍✈️ 👨🏼‍✈️ 👩🏼‍🚀 👨🏼‍🚀 👩🏼‍⚖️ 👨🏼‍⚖️ 🤶🏼 🎅🏼 👸🏼 🤴🏼 👰🏼 🤵🏼 👼🏼 🤰🏼 🙇🏼‍♀️ 🙇🏼 💁🏼 💁🏼‍♂️ 🙅🏼 🙅🏼‍♂️ 🙆🏼 🙆🏼‍♂️ 🙋🏼 🙋🏼‍♂️ 🤦🏼‍♀️ 🤦🏼‍♂️ 🤷🏼‍♀️ 🤷🏼‍♂️ 🙎🏼 🙎🏼‍♂️ 🙍🏼 🙍🏼‍♂️ 💇🏼 💇🏼‍♂️ 💆🏼 💆🏼‍♂️ 🕴🏼 💃🏼 🕺🏼 🚶🏼‍♀️ 🚶🏼 🏃🏼‍♀️ 🏃🏼 🤲🏼 👐🏼 🙌🏼 👏🏼 🙏🏼 👍🏼 👎🏼 👊🏼 ✊🏼 🤛🏼 🤜🏼 🤞🏼 ✌🏼 🤟🏼 🤘🏼 👌🏼 👈🏼 👉🏼 👆🏼 👇🏼 ☝🏼 ✋🏼 🤚🏼 🖐🏼 🖖🏼 👋🏼 🤙🏼 💪🏼 🖕🏼 ✍🏼 🤳🏼 💅🏼 👂🏼 👃🏼👶🏽 👦🏽 👧🏽 👨🏽 👩🏽 👱🏽‍♀️ 👱🏽 👴🏽 👵🏽 👲🏽 👳🏽‍♀️ 👳🏽 👮🏽‍♀️ 👮🏽 👷🏽‍♀️ 👷🏽 💂🏽‍♀️ 💂🏽 🕵🏽‍♀️ 🕵🏽 👩🏽‍⚕️ 👨🏽‍⚕️ 👩🏽‍🌾 👨🏽‍🌾 👩🏽‍🍳 👨🏽‍🍳 👩🏽‍🎓 👨🏽‍🎓 👩🏽‍🎤 👨🏽‍🎤 👩🏽‍🏫 👨🏽‍🏫 👩🏽‍🏭 👨🏽‍🏭 👩🏽‍💻 👨🏽‍💻 👩🏽‍💼 👨🏽‍💼 👩🏽‍🔧 👨🏽‍🔧 👩🏽‍🔬 👨🏽‍🔬 👩🏽‍🎨 👨🏽‍🎨 👩🏽‍🚒 👨🏽‍🚒 👩🏽‍✈️ 👨🏽‍✈️ 👩🏽‍🚀 👨🏽‍🚀 👩🏽‍⚖️ 👨🏽‍⚖️ 🤶🏽 🎅🏽 👸🏽 🤴🏽 👰🏽 🤵🏽 👼🏽 🤰🏽 🙇🏽‍♀️ 🙇🏽 💁🏽 💁🏽‍♂️ 🙅🏽 🙅🏽‍♂️ 🙆🏽 🙆🏽‍♂️ 🙋🏽 🙋🏽‍♂️ 🤦🏽‍♀️ 🤦🏽‍♂️ 🤷🏽‍♀️ 🤷🏽‍♂️ 🙎🏽 🙎🏽‍♂️ 🙍🏽 🙍🏽‍♂️ 💇🏽 💇🏽‍♂️ 💆🏽 💆🏽‍♂️ 🕴🏼 💃🏽 🕺🏽 🚶🏽‍♀️ 🚶🏽 🏃🏽‍♀️ 🏃🏽 🤲🏽 👐🏽 🙌🏽 👏🏽 🙏🏽 👍🏽 👎🏽 👊🏽 ✊🏽 🤛🏽 🤜🏽 🤞🏽 ✌🏽 🤟🏽 🤘🏽 👌🏽 👈🏽 👉🏽 👆🏽 👇🏽 ☝🏽 ✋🏽 🤚🏽 🖐🏽 🖖🏽 👋🏽 🤙🏽 💪🏽 🖕🏽 ✍🏽 🤳🏽 💅🏽 👂🏽 👃🏽👶🏾 👦🏾 👧🏾 👨🏾 👩🏾 👱🏾‍♀️ 👱🏾 👴🏾 👵🏾 👲🏾 👳🏾‍♀️ 👳🏾 👮🏾‍♀️ 👮🏾 👷🏾‍♀️ 👷🏾 💂🏾‍♀️ 💂🏾 🕵🏾‍♀️ 🕵🏾 👩🏾‍⚕️ 👨🏾‍⚕️ 👩🏾‍🌾 👨🏾‍🌾 👩🏾‍🍳 👨🏾‍🍳 👩🏾‍🎓 👨🏾‍🎓 👩🏾‍🎤 👨🏾‍🎤 👩🏾‍🏫 👨🏾‍🏫 👩🏾‍🏭 👨🏾‍🏭 👩🏾‍💻 👨🏾‍💻 👩🏾‍💼 👨🏾‍💼 👩🏾‍🔧 👨🏾‍🔧 👩🏾‍🔬 👨🏾‍🔬 👩🏾‍🎨 👨🏾‍🎨 👩🏾‍🚒 👨🏾‍🚒 👩🏾‍✈️ 👨🏾‍✈️ 👩🏾‍🚀 👨🏾‍🚀 👩🏾‍⚖️ 👨🏾‍⚖️ 🤶🏾 🎅🏾 👸🏾 🤴🏾 👰🏾 🤵🏾 👼🏾 🤰🏾 🙇🏾‍♀️ 🙇🏾 💁🏾 💁🏾‍♂️ 🙅🏾 🙅🏾‍♂️ 🙆🏾 🙆🏾‍♂️ 🙋🏾 🙋🏾‍♂️ 🤦🏾‍♀️ 🤦🏾‍♂️ 🤷🏾‍♀️ 🤷🏾‍♂️ 🙎🏾 🙎🏾‍♂️ 🙍🏾 🙍🏾‍♂️ 💇🏾 💇🏾‍♂️ 💆🏾 💆🏾‍♂️ 🕴🏾 💃🏾 🕺🏾 🚶🏾‍♀️ 🚶🏾 🏃🏾‍♀️ 🏃🏾 🤲🏾 👐🏾 🙌🏾 👏🏾 🙏🏾 👍🏾 👎🏾 👊🏾 ✊🏾 🤛🏾 🤜🏾 🤞🏾 ✌🏾 🤟🏾 🤘🏾 👌🏾 👈🏾 👉🏾 👆🏾 👇🏾 ☝🏾 ✋🏾 🤚🏾 🖐🏾 🖖🏾 👋🏾 🤙🏾 💪🏾 🖕🏾 ✍🏾 🤳🏾 💅🏾 👂🏾 👃🏾👶🏿 👦🏿 👧🏿 👨🏿 👩🏿 👱🏿‍♀️ 👱🏿 👴🏿 👵🏿 👲🏿 👳🏿‍♀️ 👳🏿 👮🏿‍♀️ 👮🏿 👷🏿‍♀️ 👷🏿 💂🏿‍♀️ 💂🏿 🕵🏿‍♀️ 🕵🏿 👩🏿‍⚕️ 👨🏿‍⚕️ 👩🏿‍🌾 👨🏿‍🌾 👩🏿‍🍳 👨🏿‍🍳 👩🏿‍🎓 👨🏿‍🎓 👩🏿‍🎤 👨🏿‍🎤 👩🏿‍🏫 👨🏿‍🏫 👩🏿‍🏭 👨🏿‍🏭 👩🏿‍💻 👨🏿‍💻 👩🏿‍💼 👨🏿‍💼 👩🏿‍🔧 👨🏿‍🔧 👩🏿‍🔬 👨🏿‍🔬 👩🏿‍🎨 👨🏿‍🎨 👩🏿‍🚒 👨🏿‍🚒 👩🏿‍✈️ 👨🏿‍✈️ 👩🏿‍🚀 👨🏿‍🚀 👩🏿‍⚖️ 👨🏿‍⚖️ 🤶🏿 🎅🏿 👸🏿 🤴🏿 👰🏿 🤵🏿 👼🏿 🤰🏿 🙇🏿‍♀️ 🙇🏿 💁🏿 💁🏿‍♂️ 🙅🏿 🙅🏿‍♂️ 🙆🏿 🙆🏿‍♂️ 🙋🏿 🙋🏿‍♂️ 🤦🏿‍♀️ 🤦🏿‍♂️ 🤷🏿‍♀️ 🤷🏿‍♂️ 🙎🏿 🙎🏿‍♂️ 🙍🏿 🙍🏿‍♂️ 💇🏿 💇🏿‍♂️ 💆🏿 💆🏿‍♂️ 🕴🏿 💃🏿 🕺🏿 🚶🏿‍♀️ 🚶🏿 🏃🏿‍♀️ 🏃🏿 🤲🏿 👐🏿 🙌🏿 👏🏿 🙏🏿 👍🏿 👎🏿 👊🏿 ✊🏿 🤛🏿 🤜🏿 🤞🏿 ✌🏿 🤟🏿 🤘🏿 👌🏿 👈🏿 👉🏿 👆🏿 👇🏿 ☝🏿 ✋🏿 🤚🏿 🖐🏿 🖖🏿 👋🏿 🤙🏿 💪🏿 🖕🏿 ✍🏿 🤳🏿 💅🏿 👂🏿 👃🏿🐶 🐱 🐭 🐹 🐰 🦊 🦝 🐻 🐼 🦘 🦡 🐨 🐯 🦁 🐮 🐷 🐽 🐸 🐵 🙈 🙉 🙊 🐒 🐔 🐧 🐦 🐤 🐣 🐥 🦆 🦢 🦅 🦉 🦚 🦜 🦇 🐺 🐗 🐴 🦄 🐝 🐛 🦋 🐌 🐚 🐞 🐜 🦗 🕷 🕸 🦂 🦟 🦠 🐢 🐍 🦎 🦖 🦕 🐙 🦑 🦐 🦀 🐡 🐠 🐟 🐬 🐳 🐋 🦈 🐊 🐅 🐆 🦓 🦍 🐘 🦏 🦛 🐪 🐫 🦙 🦒 🐃 🐂 🐄 🐎 🐖 🐏 🐑 🐐 🦌 🐕 🐩 🐈 🐓 🦃 🕊 🐇 🐁 🐀 🐿 🦔 🐾 🐉 🐲 🌵 🎄 🌲 🌳 🌴 🌱 🌿 ☘️ 🍀 🎍 🎋 🍃 🍂 🍁 🍄 🌾 💐 🌷 🌹 🥀 🌺 🌸 🌼 🌻 🌞 🌝 🌛 🌜 🌚 🌕 🌖 🌗 🌘 🌑 🌒 🌓 🌔 🌙 🌎 🌍 🌏 💫 ⭐️ 🌟 ✨ ⚡️ ☄️ 💥 🔥 🌪 🌈 ☀️ 🌤 ⛅️ 🌥 ☁️ 🌦 🌧 ⛈ 🌩 🌨 ❄️ ☃️ ⛄️ 🌬 💨 💧 💦 ☔️ ☂️ 🌊 🌫🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🍓 🍈 🍒 🍑 🍍 🥭 🥥 🥝 🍅 🍆 🥑 🥦 🥒 🥬 🌶 🌽 🥕 🥔 🍠 🥐 🍞 🥖 🥨 🥯 🧀 🥚 🍳 🥞 🥓 🥩 🍗 🍖 🌭 🍔 🍟 🍕 🥪 🥙 🌮 🌯 🥗 🥘 🥫 🍝 🍜 🍲 🍛 🍣 🍱 🥟 🍤 🍙 🍚 🍘 🍥 🥮 🥠 🍢 🍡 🍧 🍨 🍦 🥧 🍰 🎂 🍮 🍭 🍬 🍫 🍿 🧂 🍩 🍪 🌰 🥜 🍯 🥛 🍼 ☕️ 🍵 🥤 🍶 🍺 🍻 🥂 🍷 🥃 🍸 🍹 🍾 🥄 🍴 🍽 🥣 🥡 🥢⚽️ 🏀 🏈 ⚾️ 🥎 🏐 🏉 🎾 🥏 🎱 🏓 🏸 🥅 🏒 🏑 🥍 🏏 ⛳️ 🏹 🎣 🥊 🥋 🎽 ⛸ 🥌 🛷 🛹 🎿 ⛷ 🏂 🏋️‍♀️ 🏋🏻‍♀️ 🏋🏼‍♀️ 🏋🏽‍♀️ 🏋🏾‍♀️ 🏋🏿‍♀️ 🏋️‍♂️ 🏋🏻‍♂️ 🏋🏼‍♂️ 🏋🏽‍♂️ 🏋🏾‍♂️ 🏋🏿‍♂️ 🤼‍♀️ 🤼‍♂️ 🤸‍♀️ 🤸🏻‍♀️ 🤸🏼‍♀️ 🤸🏽‍♀️ 🤸🏾‍♀️ 🤸🏿‍♀️ 🤸‍♂️ 🤸🏻‍♂️ 🤸🏼‍♂️ 🤸🏽‍♂️ 🤸🏾‍♂️ 🤸🏿‍♂️ ⛹️‍♀️ ⛹🏻‍♀️ ⛹🏼‍♀️ ⛹🏽‍♀️ ⛹🏾‍♀️ ⛹🏿‍♀️ ⛹️‍♂️ ⛹🏻‍♂️ ⛹🏼‍♂️ ⛹🏽‍♂️ ⛹🏾‍♂️ ⛹🏿‍♂️ 🤺 🤾‍♀️ 🤾🏻‍♀️ 🤾🏼‍♀️ 🤾🏾‍♀️ 🤾🏾‍♀️ 🤾🏿‍♀️ 🤾‍♂️ 🤾🏻‍♂️ 🤾🏼‍♂️ 🤾🏽‍♂️ 🤾🏾‍♂️ 🤾🏿‍♂️ 🏌️‍♀️ 🏌🏻‍♀️ 🏌🏼‍♀️ 🏌🏽‍♀️ 🏌🏾‍♀️ 🏌🏿‍♀️ 🏌️‍♂️ 🏌🏻‍♂️ 🏌🏼‍♂️ 🏌🏽‍♂️ 🏌🏾‍♂️ 🏌🏿‍♂️ 🏇 🏇🏻 🏇🏼 🏇🏽 🏇🏾 🏇🏿 🧘‍♀️ 🧘🏻‍♀️ 🧘🏼‍♀️ 🧘🏽‍♀️ 🧘🏾‍♀️ 🧘🏿‍♀️ 🧘‍♂️ 🧘🏻‍♂️ 🧘🏼‍♂️ 🧘🏽‍♂️ 🧘🏾‍♂️ 🧘🏿‍♂️ 🏄‍♀️ 🏄🏻‍♀️ 🏄🏼‍♀️ 🏄🏽‍♀️ 🏄🏾‍♀️ 🏄🏿‍♀️ 🏄‍♂️ 🏄🏻‍♂️ 🏄🏼‍♂️ 🏄🏽‍♂️ 🏄🏾‍♂️ 🏄🏿‍♂️ 🏊‍♀️ 🏊🏻‍♀️ 🏊🏼‍♀️ 🏊🏽‍♀️ 🏊🏾‍♀️ 🏊🏿‍♀️ 🏊‍♂️ 🏊🏻‍♂️ 🏊🏼‍♂️ 🏊🏽‍♂️ 🏊🏾‍♂️ 🏊🏿‍♂️ 🤽‍♀️ 🤽🏻‍♀️ 🤽🏼‍♀️ 🤽🏽‍♀️ 🤽🏾‍♀️ 🤽🏿‍♀️ 🤽‍♂️ 🤽🏻‍♂️ 🤽🏼‍♂️ 🤽🏽‍♂️ 🤽🏾‍♂️ 🤽🏿‍♂️ 🚣‍♀️ 🚣🏻‍♀️ 🚣🏼‍♀️ 🚣🏽‍♀️ 🚣🏾‍♀️ 🚣🏿‍♀️ 🚣‍♂️ 🚣🏻‍♂️ 🚣🏼‍♂️ 🚣🏽‍♂️ 🚣🏾‍♂️ 🚣🏿‍♂️ 🧗‍♀️ 🧗🏻‍♀️ 🧗🏼‍♀️ 🧗🏽‍♀️ 🧗🏾‍♀️ 🧗🏿‍♀️ 🧗‍♂️ 🧗🏻‍♂️ 🧗🏼‍♂️ 🧗🏽‍♂️ 🧗🏾‍♂️ 🧗🏿‍♂️ 🚵‍♀️ 🚵🏻‍♀️ 🚵🏼‍♀️ 🚵🏽‍♀️ 🚵🏾‍♀️ 🚵🏿‍♀️ 🚵‍♂️ 🚵🏻‍♂️ 🚵🏼‍♂️ 🚵🏽‍♂️ 🚵🏾‍♂️ 🚵🏿‍♂️ 🚴‍♀️ 🚴🏻‍♀️ 🚴🏼‍♀️ 🚴🏽‍♀️ 🚴🏾‍♀️ 🚴🏿‍♀️ 🚴‍♂️ 🚴🏻‍♂️ 🚴🏼‍♂️ 🚴🏽‍♂️ 🚴🏾‍♂️ 🚴🏿‍♂️ 🏆 🥇 🥈 🥉 🏅 🎖 🏵 🎗 🎫 🎟 🎪 🤹‍♀️ 🤹🏻‍♀️ 🤹🏼‍♀️ 🤹🏽‍♀️ 🤹🏾‍♀️ 🤹🏿‍♀️ 🤹‍♂️ 🤹🏻‍♂️ 🤹🏼‍♂️ 🤹🏽‍♂️ 🤹🏾‍♂️ 🤹🏿‍♂️ 🎭 🎨 🎬 🎤 🎧 🎼 🎹 🥁 🎷 🎺 🎸 🎻 🎲 🧩 ♟ 🎯 🎳 🎮 🎰🚗 🚕 🚙 🚌 🚎 🏎 🚓 🚑 🚒 🚐 🚚 🚛 🚜 🛴 🚲 🛵 🏍 🚨 🚔 🚍 🚘 🚖 🚡 🚠 🚟 🚃 🚋 🚞 🚝 🚄 🚅 🚈 🚂 🚆 🚇 🚊 🚉 ✈️ 🛫 🛬 🛩 💺 🛰 🚀 🛸 🚁 🛶 ⛵️ 🚤 🛥 🛳 ⛴ 🚢 ⚓️ ⛽️ 🚧 🚦 🚥 🚏 🗺 🗿 🗽 🗼 🏰 🏯 🏟 🎡 🎢 🎠 ⛲️ ⛱ 🏖 🏝 🏜 🌋 ⛰ 🏔 🗻 🏕 ⛺️ 🏠 🏡 🏘 🏚 🏗 🏭 🏢 🏬 🏣 🏤 🏥 🏦 🏨 🏪 🏫 🏩 💒 🏛 ⛪️ 🕌 🕍 🕋 ⛩ 🛤 🛣 🗾 🎑 🏞 🌅 🌄 🌠 🎇 🎆 🌇 🌆 🏙 🌃 🌌 🌉 🌁⌚️ 📱 📲 💻 ⌨️ 🖥 🖨 🖱 🖲 🕹 🗜 💽 💾 💿 📀 📼 📷 📸 📹 🎥 📽 🎞 📞 ☎️ 📟 📠 📺 📻 🎙 🎚 🎛 ⏱ ⏲ ⏰ 🕰 ⌛️ ⏳ 📡 🔋 🔌 💡 🔦 🕯 🗑 🛢 💸 💵 💴 💶 💷 💰 💳 🧾 💎 ⚖️ 🔧 🔨 ⚒ 🛠 ⛏ 🔩 ⚙️ ⛓ 🔫 💣 🔪 🗡 ⚔️ 🛡 🚬 ⚰️ ⚱️ 🏺 🧭 🧱 🔮 🧿 🧸 📿 💈 ⚗️ 🔭 🧰 🧲 🧪 🧫 🧬 🧯 🔬 🕳 💊 💉 🌡 🚽 🚰 🚿 🛁 🛀 🛀🏻 🛀🏼 🛀🏽 🛀🏾 🛀🏿 🧴 🧵 🧶 🧷 🧹 🧺 🧻 🧼 🧽 🛎 🔑 🗝 🚪 🛋 🛏 🛌 🖼 🛍 🧳 🛒 🎁 🎈 🎏 🎀 🎊 🎉 🧨 🎎 🏮 🎐 🧧 ✉️ 📩 📨 📧 💌 📥 📤 📦 🏷 📪 📫 📬 📭 📮 📯 📜 📃 📄 📑 📊 📈 📉 🗒 🗓 📆 📅 📇 🗃 🗳 🗄 📋 📁 📂 🗂 🗞 📰 📓 📔 📒 📕 📗 📘 📙 📚 📖 🔖 🔗 📎 🖇 📐 📏 📌 📍 ✂️ 🖊 🖋 ✒️ 🖌 🖍 📝 ✏️ 🔍 🔎 🔏 🔐 🔒 🔓❤️ 🧡 💛 💚 💙 💜 🖤 💔 ❣️ 💕 💞 💓 💗 💖 💘 💝 💟 ☮️ ✝️ ☪️ 🕉 ☸️ ✡️ 🔯 🕎 ☯️ ☦️ 🛐 ⛎ ♈️ ♉️ ♊️ ♋️ ♌️ ♍️ ♎️ ♏️ ♐️ ♑️ ♒️ ♓️ 🆔 ⚛️ 🉑 ☢️ ☣️ 📴 📳 🈶 🈚️ 🈸 🈺 🈷️ ✴️ 🆚 💮 🉐 ㊙️ ㊗️ 🈴 🈵 🈹 🈲 🅰️ 🅱️ 🆎 🆑 🅾️ 🆘 ❌ ⭕️ 🛑 ⛔️ 📛 🚫 💯 💢 ♨️ 🚷 🚯 🚳 🚱 🔞 📵 🚭 ❗️ ❕ ❓ ❔ ‼️ ⁉️ 🔅 🔆 〽️ ⚠️ 🚸 🔱 ⚜️ 🔰 ♻️ ✅ 🈯️ 💹 ❇️ ✳️ ❎ 🌐 💠 Ⓜ️ 🌀 💤 🏧 🚾 ♿️ 🅿️ 🈳 🈂️ 🛂 🛃 🛄 🛅 🚹 🚺 🚼 🚻 🚮 🎦 📶 🈁 🔣 ℹ️ 🔤 🔡 🔠 🆖 🆗 🆙 🆒 🆕 🆓 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟 🔢 #️⃣ *️⃣ ⏏️ ▶️ ⏸ ⏯ ⏹ ⏺ ⏭ ⏮ ⏩ ⏪ ⏫ ⏬ ◀️ 🔼 🔽 ➡️ ⬅️ ⬆️ ⬇️ ↗️ ↘️ ↙️ ↖️ ↕️ ↔️ ↪️ ↩️ ⤴️ ⤵️ 🔀 🔁 🔂 🔄 🔃 🎵 🎶 ➕ ➖ ➗ ✖️ ♾ 💲 💱 ™️ ©️ ®️ 〰️ ➰ ➿ 🔚 🔙 🔛 🔝 🔜 ✔️ ☑️ 🔘 ⚪️ ⚫️ 🔴 🔵 🔺 🔻 🔸 🔹 🔶 🔷 🔳 🔲 ▪️ ▫️ ◾️ ◽️ ◼️ ◻️ ⬛️ ⬜️ 🔈 🔇 🔉 🔊 🔔 🔕 📣 📢 👁‍🗨 💬 💭 🗯 ♠️ ♣️ ♥️ ♦️ 🃏 🎴 🀄️ 🕐 🕑 🕒 🕓 🕔 🕕 🕖 🕗 🕘 🕙 🕚 🕛 🕜 🕝 🕞 🕟 🕠 🕡 🕢 🕣 🕤 🕥 🕦 🕧🏳️ 🏴 🏁 🚩 🏳️‍🌈 🏴‍☠️ 🇦🇫 🇦🇽 🇦🇱 🇩🇿 🇦🇸 🇦🇩 🇦🇴 🇦🇮 🇦🇶 🇦🇬 🇦🇷 🇦🇲 🇦🇼 🇦🇺 🇦🇹 🇦🇿 🇧🇸 🇧🇭 🇧🇩 🇧🇧 🇧🇾 🇧🇪 🇧🇿 🇧🇯 🇧🇲 🇧🇹 🇧🇴 🇧🇦 🇧🇼 🇧🇷 🇮🇴 🇻🇬 🇧🇳 🇧🇬 🇧🇫 🇧🇮 🇰🇭 🇨🇲 🇨🇦 🇮🇨 🇨🇻 🇧🇶 🇰🇾 🇨🇫 🇹🇩 🇨🇱 🇨🇳 🇨🇽 🇨🇨 🇨🇴 🇰🇲 🇨🇬 🇨🇩 🇨🇰 🇨🇷 🇨🇮 🇭🇷 🇨🇺 🇨🇼 🇨🇾 🇨🇿 🇩🇰 🇩🇯 🇩🇲 🇩🇴 🇪🇨 🇪🇬 🇸🇻 🇬🇶 🇪🇷 🇪🇪 🇪🇹 🇪🇺 🇫🇰 🇫🇴 🇫🇯 🇫🇮 🇫🇷 🇬🇫 🇵🇫 🇹🇫 🇬🇦 🇬🇲 🇬🇪 🇩🇪 🇬🇭 🇬🇮 🇬🇷 🇬🇱 🇬🇩 🇬🇵 🇬🇺 🇬🇹 🇬🇬 🇬🇳 🇬🇼 🇬🇾 🇭🇹 🇭🇳 🇭🇰 🇭🇺 🇮🇸 🇮🇳 🇮🇩 🇮🇷 🇮🇶 🇮🇪 🇮🇲 🇮🇱 🇮🇹 🇯🇲 🇯🇵 🎌 🇯🇪 🇯🇴 🇰🇿 🇰🇪 🇰🇮 🇽🇰 🇰🇼 🇰🇬 🇱🇦 🇱🇻 🇱🇧 🇱🇸 🇱🇷 🇱🇾 🇱🇮 🇱🇹 🇱🇺 🇲🇴 🇲🇰 🇲🇬 🇲🇼 🇲🇾 🇲🇻 🇲🇱 🇲🇹 🇲🇭 🇲🇶 🇲🇷 🇲🇺 🇾🇹 🇲🇽 🇫🇲 🇲🇩 🇲🇨 🇲🇳 🇲🇪 🇲🇸 🇲🇦 🇲🇿 🇲🇲 🇳🇦 🇳🇷 🇳🇵 🇳🇱 🇳🇨 🇳🇿 🇳🇮 🇳🇪 🇳🇬 🇳🇺 🇳🇫 🇰🇵 🇲🇵 🇳🇴 🇴🇲 🇵🇰 🇵🇼 🇵🇸 🇵🇦 🇵🇬 🇵🇾 🇵🇪 🇵🇭 🇵🇳 🇵🇱 🇵🇹 🇵🇷 🇶🇦 🇷🇪 🇷🇴 🇷🇺 🇷🇼 🇼🇸 🇸🇲 🇸🇦 🇸🇳 🇷🇸 🇸🇨 🇸🇱 🇸🇬 🇸🇽 🇸🇰 🇸🇮 🇬🇸 🇸🇧 🇸🇴 🇿🇦 🇰🇷 🇸🇸 🇪🇸 🇱🇰 🇧🇱 🇸🇭 🇰🇳 🇱🇨 🇵🇲 🇻🇨 🇸🇩 🇸🇷 🇸🇿 🇸🇪 🇨🇭 🇸🇾 🇹🇼 🇹🇯 🇹🇿 🇹🇭 🇹🇱 🇹🇬 🇹🇰 🇹🇴 🇹🇹 🇹🇳 🇹🇷 🇹🇲 🇹🇨 🇹🇻 🇻🇮 🇺🇬 🇺🇦 🇦🇪 🇬🇧 🏴󠁧󠁢󠁥󠁮󠁧󠁿 🏴󠁧󠁢󠁳󠁣󠁴󠁿 🏴󠁧󠁢󠁷󠁬󠁳󠁿 🇺🇳 🇺🇸 🇺🇾 🇺🇿 🇻🇺 🇻🇦 🇻🇪 🇻🇳 🇼🇫 🇪🇭 🇾🇪 🇿🇲 🇿🇼🥱 🤏 🦾 🦿 🦻 🧏 🧏‍♂️ 🧏‍♀️ 🧍 🧍‍♂️ 🧍‍♀️ 🧎 🧎‍♂️ 🧎‍♀️ 👨‍🦯 👩‍🦯 👨‍🦼 👩‍🦼 👨‍🦽 👩‍🦽 🦧 🦮 🐕‍🦺 🦥 🦦 🦨 🦩 🧄 🧅 🧇 🧆 🧈 🦪 🧃 🧉 🧊 🛕 🦽 🦼 🛺 🪂 🪐 🤿 🪀 🪁 🦺 🥻 🩱 🩲 🩳 🩰 🪕 🪔 🪓 🦯 🩸 🩹 🩺 🪑 🪒 🤎 🤍 🟠 🟡 🟢 🟣 🟤 🟥 🟧 🟨 🟩 🟦 🟪 🟫\n</body>\n</html>\n"
  },
  {
    "path": "src/tests/backend/specs/api/fuzzImportTest.ts",
    "content": "/*\n * Fuzz testing the import endpoint\n */\n/*\nconst common = require('../../common');\nconst froth = require('mocha-froth');\nconst request = require('request');\nconst settings = require('../../../container/loadSettings.js').loadSettings();\n\nconst host = \"http://\" + settings.ip + \":\" + settings.port;\n\nvar apiVersion = 1;\nvar testPadId = \"TEST_fuzz\" + makeid();\n\nvar endPoint = function(point, version){\n  version = version || apiVersion;\n  return '/api/'+version+'/'+point+'?apikey='+apiKey;\n}\n\n//console.log(\"Testing against padID\", testPadId);\n//console.log(\"To watch the test live visit \" + host + \"/p/\" + testPadId);\n//console.log(\"Tests will start in 5 seconds, click the URL now!\");\n\nsetTimeout(function(){\n  for (let i=1; i<5; i++) { // 5000 runs\n    setTimeout( function timer(){\n      runTest(i);\n    }, i*100 ); // 100 ms\n  }\n  process.exit(0);\n},5000); // wait 5 seconds\n\nfunction runTest(number){\n  request(host + endPoint('createPad') + '&padID=' + testPadId, function(err, res, body){\n    var req = request.post(host + '/p/'+testPadId+'/import', function (err, res, body) {\n      if (err) {\n        throw new Error(\"FAILURE\", err);\n      }else{\n        console.log(\"Success\");\n      }\n    });\n\n    var fN = '/tmp/fuzztest.txt';\n    var cT = 'text/plain';\n\n    if (number % 2 == 0) {\n      fN = froth().toString();\n      cT = froth().toString();\n    }\n\n    let form = req.form();\n\n    form.append('file', froth().toString(), {\n      filename: fN,\n      contentType: cT\n    });\nconsole.log(\"here\");\n  });\n}\n\nfunction makeid() {\n  var text = \"\";\n  var possible = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\";\n\n  for( var i=0; i < 5; i++ ){\n    text += possible.charAt(Math.floor(Math.random() * possible.length));\n  }\n  return text;\n}\n*/\n"
  },
  {
    "path": "src/tests/backend/specs/api/importexport.ts",
    "content": "'use strict';\n/*\n * ACHTUNG: there is a copied & modified version of this file in\n * <basedir>/src/tests/container/spacs/api/pad.js\n *\n * TODO: unify those two files, and merge in a single one.\n */\n\nimport { strict as assert } from 'assert';\nimport {MapArrayType} from \"../../../../node/types/MapType\";\nconst common = require('../../common');\n\nlet agent:any;\nconst apiVersion = 1;\n\nconst endPoint = (point: string, version?:string) => `/api/${version || apiVersion}/${point}`;\n\nconst testImports:MapArrayType<any> = {\n  'malformed': {\n    input: '<html><body><li>wtf</ul></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>wtf<br><br></body></html>',\n    wantText: 'wtf\\n\\n',\n    disabled: true,\n  },\n  'nonelistiteminlist #3620': {\n    input: '<html><body><ul>test<li>FOO</li></ul></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body><ul class=\"bullet\">test<li>FOO</ul><br></body></html>',\n    wantText: '\\ttest\\n\\t* FOO\\n\\n',\n    disabled: true,\n  },\n  'whitespaceinlist #3620': {\n    input: '<html><body><ul> <li>FOO</li></ul></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body><ul class=\"bullet\"><li>FOO</ul><br></body></html>',\n    wantText: '\\t* FOO\\n\\n',\n  },\n  'prefixcorrectlinenumber': {\n    input: '<html><body><ol><li>should be 1</li><li>should be 2</li></ol></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body><ol start=\"1\" class=\"number\"><li>should be 1</li><li>should be 2</ol><br></body></html>',\n    wantText: '\\t1. should be 1\\n\\t2. should be 2\\n\\n',\n  },\n  'prefixcorrectlinenumbernested': {\n    input: '<html><body><ol><li>should be 1</li><ol><li>foo</li></ol><li>should be 2</li></ol></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body><ol start=\"1\" class=\"number\"><li>should be 1<ol start=\"2\" class=\"number\"><li>foo</ol><li>should be 2</ol><br></body></html>',\n    wantText: '\\t1. should be 1\\n\\t\\t1.1. foo\\n\\t2. should be 2\\n\\n',\n  },\n\n  /*\n  \"prefixcorrectlinenumber when introduced none list item - currently not supported see #3450\": {\n    input: '<html><body><ol><li>should be 1</li>test<li>should be 2</li></ol></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body><ol start=\"1\" class=\"number\"><li>should be 1</li>test<li>should be 2</li></ol><br></body></html>',\n    wantText: '\\t1. should be 1\\n\\ttest\\n\\t2. should be 2\\n\\n',\n  }\n  ,\n  \"newlinesshouldntresetlinenumber #2194\": {\n    input: '<html><body><ol><li>should be 1</li>test<li>should be 2</li></ol></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body><ol class=\"number\"><li>should be 1</li>test<li>should be 2</li></ol><br></body></html>',\n    wantText: '\\t1. should be 1\\n\\ttest\\n\\t2. should be 2\\n\\n',\n  }\n  */\n  'ignoreAnyTagsOutsideBody': {\n    description: 'Content outside body should be ignored',\n    input: '<html><head><title>title</title><style></style></head><body>empty<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>empty<br><br></body></html>',\n    wantText: 'empty\\n\\n',\n  },\n  'indentedListsAreNotBullets': {\n    description: 'Indented lists are represented with tabs and without bullets',\n    input: '<html><body><ul class=\"indent\"><li>indent</li><li>indent</ul></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body><ul class=\"indent\"><li>indent</li><li>indent</ul><br></body></html>',\n    wantText: '\\tindent\\n\\tindent\\n\\n',\n  },\n  'lineWithMultipleSpaces': {\n    description: 'Multiple spaces should be collapsed',\n    input: '<html><body>Text with  more   than    one space.<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>Text with more than one space.<br><br></body></html>',\n    wantText: 'Text with more than one space.\\n\\n',\n  },\n  'lineWithMultipleNonBreakingAndNormalSpaces': {\n    // XXX the HTML between \"than\" and \"one\" looks strange\n    description: 'non-breaking space should be preserved, but can be replaced when it',\n    input: '<html><body>Text&nbsp;with&nbsp; more&nbsp;&nbsp;&nbsp;than   &nbsp;one space.<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>Text with&nbsp; more&nbsp;&nbsp; than&nbsp; one space.<br><br></body></html>',\n    wantText: 'Text with  more   than  one space.\\n\\n',\n  },\n  'multiplenbsp': {\n    description: 'Multiple non-breaking space should be preserved',\n    input: '<html><body>&nbsp;&nbsp;<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>&nbsp;&nbsp;<br><br></body></html>',\n    wantText: '  \\n\\n',\n  },\n  'multipleNonBreakingSpaceBetweenWords': {\n    description: 'A normal space is always inserted before a word',\n    input: '<html><body>&nbsp;&nbsp;word1&nbsp;&nbsp;word2&nbsp;&nbsp;&nbsp;word3<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>&nbsp; word1&nbsp; word2&nbsp;&nbsp; word3<br><br></body></html>',\n    wantText: '  word1  word2   word3\\n\\n',\n  },\n  'nonBreakingSpacePreceededBySpaceBetweenWords': {\n    description: 'A non-breaking space preceded by a normal space',\n    input: '<html><body> &nbsp;word1 &nbsp;word2 &nbsp;word3<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>&nbsp;word1&nbsp; word2&nbsp; word3<br><br></body></html>',\n    wantText: ' word1  word2  word3\\n\\n',\n  },\n  'nonBreakingSpaceFollowededBySpaceBetweenWords': {\n    description: 'A non-breaking space followed by a normal space',\n    input: '<html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br><br></body></html>',\n    wantText: '  word1  word2  word3\\n\\n',\n  },\n  'spacesAfterNewline': {\n    description: 'Collapse spaces that follow a newline',\n    input: '<!doctype html><html><body>something<br>             something<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>something<br>something<br><br></body></html>',\n    wantText: 'something\\nsomething\\n\\n',\n  },\n  'spacesAfterNewlineP': {\n    description: 'Collapse spaces that follow a paragraph',\n    input: '<!doctype html><html><body>something<p></p>             something<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>something<br><br></body></html>',\n    wantText: 'something\\n\\nsomething\\n\\n',\n  },\n  'spacesAtEndOfLine': {\n    description: 'Collapse spaces that preceed/follow a newline',\n    input: '<html><body>something            <br>             something<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>something<br>something<br><br></body></html>',\n    wantText: 'something\\nsomething\\n\\n',\n  },\n  'spacesAtEndOfLineP': {\n    description: 'Collapse spaces that preceed/follow a paragraph',\n    input: '<html><body>something            <p></p>             something<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>something<br><br></body></html>',\n    wantText: 'something\\n\\nsomething\\n\\n',\n  },\n  'nonBreakingSpacesAfterNewlines': {\n    description: 'Don\\'t collapse non-breaking spaces that follow a newline',\n    input: '<html><body>something<br>&nbsp;&nbsp;&nbsp;something<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>something<br>&nbsp;&nbsp; something<br><br></body></html>',\n    wantText: 'something\\n   something\\n\\n',\n  },\n  'nonBreakingSpacesAfterNewlinesP': {\n    description: 'Don\\'t collapse non-breaking spaces that follow a paragraph',\n    input: '<html><body>something<p></p>&nbsp;&nbsp;&nbsp;something<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>&nbsp;&nbsp; something<br><br></body></html>',\n    wantText: 'something\\n\\n   something\\n\\n',\n  },\n  'collapseSpacesInsideElements': {\n    description: 'Preserve only one space when multiple are present',\n    input: '<html><body>Need <span> more </span> space<i>  s </i> !<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s </em>!<br><br></body></html>',\n    wantText: 'Need more space s !\\n\\n',\n  },\n  'collapseSpacesAcrossNewlines': {\n    description: 'Newlines and multiple spaces across newlines should be collapsed',\n    input: `\n      <html><body>Need\n          <span> more </span>\n          space\n          <i>  s </i>\n          !<br></body></html>`,\n    wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s </em>!<br><br></body></html>',\n    wantText: 'Need more space s !\\n\\n',\n  },\n  'multipleNewLinesAtBeginning': {\n    description: 'Multiple new lines and paragraphs at the beginning should be preserved',\n    input: '<html><body><br><br><p></p><p></p>first line<br><br>second line<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body><br><br><br><br>first line<br><br>second line<br><br></body></html>',\n    wantText: '\\n\\n\\n\\nfirst line\\n\\nsecond line\\n\\n',\n  },\n  'multiLineParagraph': {\n    description: 'A paragraph with multiple lines should not loose spaces when lines are combined',\n    input: `<html><body>\n    <p>\n      а б в г ґ д е є ж з и і ї й к л м н о\n      п р с т у ф х ц ч ш щ ю я ь\n    </p>\n</body></html>`,\n    wantHTML: '<!DOCTYPE HTML><html><body>&#1072; &#1073; &#1074; &#1075; &#1169; &#1076; &#1077; &#1108; &#1078; &#1079; &#1080; &#1110; &#1111; &#1081; &#1082; &#1083; &#1084; &#1085; &#1086; &#1087; &#1088; &#1089; &#1090; &#1091; &#1092; &#1093; &#1094; &#1095; &#1096; &#1097; &#1102; &#1103; &#1100;<br><br></body></html>',\n    wantText: 'а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь\\n\\n',\n  },\n  'multiLineParagraphWithPre': {\n    // XXX why is there &nbsp; before \"in\"?\n    description: 'lines in preformatted text should be kept intact',\n    input: `<html><body>\n    <p>\n        а б в г ґ д е є ж з и і ї й к л м н о<pre>multiple\n   lines\n in\n      pre\n</pre></p><p>п р с т у ф х ц ч ш щ ю я\nь</p>\n</body></html>`,\n    wantHTML: '<!DOCTYPE HTML><html><body>&#1072; &#1073; &#1074; &#1075; &#1169; &#1076; &#1077; &#1108; &#1078; &#1079; &#1080; &#1110; &#1111; &#1081; &#1082; &#1083; &#1084; &#1085; &#1086;<br>multiple<br>&nbsp;&nbsp; lines<br>&nbsp;in<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; pre<br><br>&#1087; &#1088; &#1089; &#1090; &#1091; &#1092; &#1093; &#1094; &#1095; &#1096; &#1097; &#1102; &#1103; &#1100;<br><br></body></html>',\n    wantText: 'а б в г ґ д е є ж з и і ї й к л м н о\\nmultiple\\n   lines\\n in\\n      pre\\n\\nп р с т у ф х ц ч ш щ ю я ь\\n\\n',\n  },\n  'preIntroducesASpace': {\n    description: 'pre should be on a new line not preceded by a space',\n    input: `<html><body><p>\n    1\n<pre>preline\n</pre></p></body></html>`,\n    wantHTML: '<!DOCTYPE HTML><html><body>1<br>preline<br><br><br></body></html>',\n    wantText: '1\\npreline\\n\\n\\n',\n  },\n  'dontDeleteSpaceInsideElements': {\n    description: 'Preserve spaces inside elements',\n    input: '<html><body>Need<span> more </span>space<i> s </i>!<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s </em>!<br><br></body></html>',\n    wantText: 'Need more space s !\\n\\n',\n  },\n  'dontDeleteSpaceOutsideElements': {\n    description: 'Preserve spaces outside elements',\n    input: '<html><body>Need <span>more</span> space <i>s</i> !<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s</em> !<br><br></body></html>',\n    wantText: 'Need more space s !\\n\\n',\n  },\n  'dontDeleteSpaceAtEndOfElement': {\n    description: 'Preserve spaces at the end of an element',\n    input: '<html><body>Need <span>more </span>space <i>s </i>!<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s </em>!<br><br></body></html>',\n    wantText: 'Need more space s !\\n\\n',\n  },\n  'dontDeleteSpaceAtBeginOfElements': {\n    description: 'Preserve spaces at the start of an element',\n    input: '<html><body>Need<span> more</span> space<i> s</i> !<br></body></html>',\n    wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s</em> !<br><br></body></html>',\n    wantText: 'Need more space s !\\n\\n',\n  },\n};\n\ndescribe(__filename, function () {\n  this.timeout(1000);\n\n  before(async function () { agent = await common.init(); });\n\n  Object.keys(testImports).forEach((testName) => {\n    describe(testName, function () {\n      const testPadId = makeid();\n      const test = testImports[testName];\n      if (test.disabled) {\n        return xit(`DISABLED: ${testName}`, function (done) {\n          done();\n        });\n      }\n\n      it('createPad', async function () {\n        const res = await agent.get(`${endPoint('createPad')}?padID=${testPadId}`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(200)\n            .expect('Content-Type', /json/);\n        assert.equal(res.body.code, 0);\n      });\n\n      it('setHTML', async function () {\n        const res = await agent.get(`${endPoint('setHTML')}?padID=${testPadId}` +\n                        `&html=${encodeURIComponent(test.input)}`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(200)\n            .expect('Content-Type', /json/);\n        assert.equal(res.body.code, 0);\n      });\n\n      it('getHTML', async function () {\n        const res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(200)\n            .expect('Content-Type', /json/);\n        assert.equal(res.body.data.html, test.wantHTML);\n      });\n\n      it('getText', async function () {\n        const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(200)\n            .expect('Content-Type', /json/);\n        assert.equal(res.body.data.text, test.wantText);\n      });\n    });\n  });\n});\n\nfunction makeid() {\n  let text = '';\n  const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n\n  for (let i = 0; i < 5; i++) {\n    text += possible.charAt(Math.floor(Math.random() * possible.length));\n  }\n  return text;\n}\n"
  },
  {
    "path": "src/tests/backend/specs/api/importexportGetPost.ts",
    "content": "'use strict';\n\n/*\n * Import and Export tests for the /p/whateverPadId/import and /p/whateverPadId/export endpoints.\n */\n\nimport {MapArrayType} from \"../../../../node/types/MapType\";\nimport {SuperTestStatic} from \"supertest\";\nimport TestAgent from \"supertest/lib/agent\";\n\nconst assert = require('assert').strict;\nconst common = require('../../common');\nconst fs = require('fs');\nimport settings from '../../../../node/utils/Settings';\nconst superagent = require('superagent');\nconst padManager = require('../../../../node/db/PadManager');\nconst plugins = require('../../../../static/js/pluginfw/plugin_defs');\n\nconst padText = fs.readFileSync(`${__dirname}/test.txt`);\nconst etherpadDoc = fs.readFileSync(`${__dirname}/test.etherpad`);\nconst wordDoc = fs.readFileSync(`${__dirname}/test.doc`);\nconst wordXDoc = fs.readFileSync(`${__dirname}/test.docx`);\nconst odtDoc = fs.readFileSync(`${__dirname}/test.odt`);\nconst pdfDoc = fs.readFileSync(`${__dirname}/test.pdf`);\n\nlet agent: TestAgent;\nconst apiVersion = 1;\nconst testPadId = makeid();\nconst testPadIdEnc = encodeURIComponent(testPadId);\n\nconst deleteTestPad = async () => {\n  if (await padManager.doesPadExist(testPadId)) {\n    const pad = await padManager.getPad(testPadId);\n    await pad.remove();\n  }\n};\n\ndescribe(__filename, function () {\n  this.timeout(45000);\n  before(async function () { agent = await common.init(); });\n\n  describe('Connectivity', function () {\n    it('can connect', async function () {\n      await agent.get('/api/')\n          .set(\"authorization\", await common.generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/);\n    });\n  });\n\n  describe('API Versioning', function () {\n    it('finds the version tag', async function () {\n      await agent.get('/api/')\n          .set(\"authorization\", await common.generateJWTToken())\n          .expect(200)\n          .expect((res:any) => assert(res.body.currentVersion));\n    });\n  });\n\n  /*\n  Tests\n  -----\n\n  Test.\n    / Create a pad\n    / Set pad contents\n    / Try export pad in various formats\n    / Get pad contents and ensure it matches imported contents\n\n  Test.\n    / Try to export a pad that doesn't exist // Expect failure\n\n  Test.\n    / Try to import an unsupported file to a pad that exists\n\n  -- TODO: Test.\n    Try to import to a file and abort it half way through\n\n  Test.\n    Try to import to files of varying size.\n\n  Example Curl command for testing import URI:\n    curl -s -v --form file=@/home/jose/test.txt http://127.0.0.1:9001/p/foo/import\n  */\n\n  describe('Imports and Exports', function () {\n    const backups:MapArrayType<any> = {};\n\n    beforeEach(async function () {\n      backups.hooks = {};\n      for (const hookName of ['preAuthorize', 'authenticate', 'authorize']) {\n        backups.hooks[hookName] = plugins.hooks[hookName];\n        plugins.hooks[hookName] = [];\n      }\n      // Note: This is a shallow copy.\n      backups.settings = Object.assign({}, settings);\n      settings.requireAuthentication = false;\n      settings.requireAuthorization = false;\n      settings.users = {user: {password: 'user-password'}};\n    });\n\n    afterEach(async function () {\n      Object.assign(plugins.hooks, backups.hooks);\n      // Note: This does not unset settings that were added.\n      Object.assign(settings, backups.settings);\n    });\n\n    it('creates a new Pad, imports content to it, checks that content', async function () {\n      await agent.get(`${endPoint('createPad')}?padID=${testPadId}`)\n          .set(\"authorization\", await common.generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => assert.equal(res.body.code, 0));\n      await agent.post(`/p/${testPadId}/import`)\n          .set(\"authorization\", await common.generateJWTToken())\n          .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})\n          .expect(200);\n      await agent.get(`${endPoint('getText')}?padID=${testPadId}`)\n          .set(\"authorization\", await common.generateJWTToken())\n          .expect(200)\n          .expect((res:any) => assert.equal(res.body.data.text, padText.toString()));\n    });\n\n    describe('export from read-only pad ID', function () {\n      let readOnlyId:string;\n\n      // This ought to be before(), but it must run after the top-level beforeEach() above.\n      beforeEach(async function () {\n        if (readOnlyId != null) return;\n        await agent.post(`/p/${testPadId}/import`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})\n            .expect(200);\n        const res = await agent.get(`${endPoint('getReadOnlyID')}?padID=${testPadId}`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(200)\n            .expect('Content-Type', /json/)\n            .expect((res:any) => assert.equal(res.body.code, 0));\n        readOnlyId = res.body.data.readOnlyID;\n      });\n\n      for (const authn of [false, true]) {\n        describe(`requireAuthentication = ${authn}`, function () {\n          // This ought to be before(), but it must run after the top-level beforeEach() above.\n          beforeEach(async function () {\n            settings.requireAuthentication = authn;\n          });\n\n          for (const exportType of ['html', 'txt', 'etherpad']) {\n            describe(`export to ${exportType}`, function () {\n              let text:string;\n\n              // This ought to be before(), but it must run after the top-level beforeEach() above.\n              beforeEach(async function () {\n                if (text != null) return;\n                let req = agent.get(`/p/${readOnlyId}/export/${exportType}`)\n                    .set(\"authorization\", await common.generateJWTToken());\n                if (authn) req = req.auth('user', 'user-password');\n                const res = await req\n                    .expect(200)\n                    .buffer(true).parse(superagent.parse.text);\n                text = res.text;\n              });\n\n              it('export OK', async function () {\n                assert.match(text, /This is the/);\n              });\n\n              it('writable pad ID is not leaked', async function () {\n                assert(!text.includes(testPadId));\n              });\n\n              it('re-import to read-only pad ID gives 403 forbidden', async function () {\n                let req = agent.post(`/p/${readOnlyId}/import`)\n                    .set(\"authorization\", await common.generateJWTToken())\n                    .attach('file', Buffer.from(text), {\n                      filename: `/test.${exportType}`,\n                      contentType: 'text/plain',\n                    });\n                if (authn) req = req.auth('user', 'user-password');\n                await req.expect(403);\n              });\n\n              it('re-import to read-write pad ID gives 200 OK', async function () {\n                // The new pad ID must differ from testPadId because Etherpad refuses to import\n                // .etherpad files on top of a pad that already has edits.\n                let req = agent.post(`/p/${testPadId}_import/import`)\n                    .set(\"authorization\", await common.generateJWTToken())\n                    .attach('file', Buffer.from(text), {\n                      filename: `/test.${exportType}`,\n                      contentType: 'text/plain',\n                    });\n                if (authn) req = req.auth('user', 'user-password');\n                await req.expect(200);\n              });\n            });\n          }\n        });\n      }\n    });\n\n    describe('Import/Export tests requiring AbiWord/LibreOffice', function () {\n      before(async function () {\n        if ((!settings.abiword || settings.abiword.indexOf('/') === -1) &&\n            (!settings.soffice || settings.soffice.indexOf('/') === -1)) {\n          this.skip();\n        }\n      });\n\n      // For some reason word import does not work in testing..\n      // TODO: fix support for .doc files..\n      it('Tries to import .doc that uses soffice or abiword', async function () {\n        await agent.post(`/p/${testPadId}/import`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .attach('file', wordDoc, {filename: '/test.doc', contentType: 'application/msword'})\n            .expect(200)\n            .expect('Content-Type', /json/)\n            .expect((res:any) => assert.deepEqual(res.body, {\n              code: 0,\n              message: 'ok',\n              data: {directDatabaseAccess: false},\n            }));\n      });\n\n      it('exports DOC', async function () {\n        await agent.get(`/p/${testPadId}/export/doc`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .buffer(true).parse(superagent.parse['application/octet-stream'])\n            .expect(200)\n            .expect((res:any) => assert(res.body.length >= 9000));\n      });\n\n      it('Tries to import .docx that uses soffice or abiword', async function () {\n        await agent.post(`/p/${testPadId}/import`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .attach('file', wordXDoc, {\n              filename: '/test.docx',\n              contentType:\n                  'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n            })\n            .expect(200)\n            .expect('Content-Type', /json/)\n            .expect((res:any) => assert.deepEqual(res.body, {\n              code: 0,\n              message: 'ok',\n              data: {directDatabaseAccess: false},\n            }));\n      });\n\n      it('exports DOC from imported DOCX', async function () {\n        await agent.get(`/p/${testPadId}/export/doc`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .buffer(true).parse(superagent.parse['application/octet-stream'])\n            .expect(200)\n            .expect((res:any) => assert(res.body.length >= 9100));\n      });\n\n      it('Tries to import .pdf that uses soffice or abiword', async function () {\n        await agent.post(`/p/${testPadId}/import`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .attach('file', pdfDoc, {filename: '/test.pdf', contentType: 'application/pdf'})\n            .expect(200)\n            .expect('Content-Type', /json/)\n            .expect((res:any) => assert.deepEqual(res.body, {\n              code: 0,\n              message: 'ok',\n              data: {directDatabaseAccess: false},\n            }));\n      });\n\n      it('exports PDF', async function () {\n        await agent.get(`/p/${testPadId}/export/pdf`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .buffer(true).parse(superagent.parse['application/octet-stream'])\n            .expect(200)\n            .expect((res:any) => assert(res.body.length >= 1000));\n      });\n\n      it('Tries to import .odt that uses soffice or abiword', async function () {\n        await agent.post(`/p/${testPadId}/import`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .attach('file', odtDoc, {filename: '/test.odt', contentType: 'application/odt'})\n            .expect(200)\n            .expect('Content-Type', /json/)\n            .expect((res:any) => assert.deepEqual(res.body, {\n              code: 0,\n              message: 'ok',\n              data: {directDatabaseAccess: false},\n            }));\n      });\n\n      it('exports ODT', async function () {\n        await agent.get(`/p/${testPadId}/export/odt`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .buffer(true).parse(superagent.parse['application/octet-stream'])\n            .expect(200)\n            .expect((res:any) => assert(res.body.length >= 7000));\n      });\n    }); // End of AbiWord/LibreOffice tests.\n\n    it('Tries to import .etherpad', async function () {\n      this.timeout(3000);\n      await agent.post(`/p/${testPadId}/import`)\n          .set(\"authorization\", await common.generateJWTToken())\n          .attach('file', etherpadDoc, {\n            filename: '/test.etherpad',\n            contentType: 'application/etherpad',\n          })\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => assert.deepEqual(res.body, {\n            code: 0,\n            message: 'ok',\n            data: {directDatabaseAccess: true},\n          }));\n    });\n\n    it('exports Etherpad', async function () {\n      this.timeout(3000);\n      await agent.get(`/p/${testPadId}/export/etherpad`)\n          .set(\"authorization\", await common.generateJWTToken())\n          .buffer(true).parse(superagent.parse.text)\n          .expect(200)\n          .expect(/hello/);\n    });\n\n    it('exports HTML for this Etherpad file', async function () {\n      this.timeout(3000);\n      await agent.get(`/p/${testPadId}/export/html`)\n          .set(\"authorization\", await common.generateJWTToken())\n          .expect(200)\n          .expect('content-type', 'text/html; charset=utf-8')\n          .expect(/<ul class=\"bullet\"><li><ul class=\"bullet\"><li>hello<\\/ul><\\/li><\\/ul>/);\n    });\n\n    it('Tries to import unsupported file type', async function () {\n      this.timeout(3000);\n      settings.allowUnknownFileEnds = false;\n      await agent.post(`/p/${testPadId}/import`)\n          .set(\"authorization\", await common.generateJWTToken())\n          .attach('file', padText, {filename: '/test.xasdasdxx', contentType: 'weirdness/jobby'})\n          .expect(400)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 1);\n            assert.equal(res.body.message, 'uploadFailed');\n          });\n    });\n\n    describe('malformed .etherpad files are rejected', function () {\n      const makeGoodExport = () => ({\n        'pad:testing': {\n          atext: {\n            text: 'foo\\n',\n            attribs: '|1+4',\n          },\n          pool: {\n            numToAttrib: {\n              0: ['author', 'a.foo'],\n            },\n            nextNum: 1,\n          },\n          chatHead: 0,\n          head: 0,\n          savedRevisions: [],\n        },\n        'globalAuthor:a.foo': {\n          colorId: '#000000',\n          name: 'author foo',\n          timestamp: 1598747784631,\n          padIDs: 'testing',\n        },\n        'pad:testing:revs:0': {\n          changeset: 'Z:1>3+3$foo',\n          meta: {\n            author: 'a.foo',\n            timestamp: 1597632398288,\n            pool: {\n              numToAttrib: {},\n              nextNum: 0,\n            },\n            atext: {\n              text: 'foo\\n',\n              attribs: '|1+4',\n            },\n          },\n        },\n        'pad:testing:chat:0': {\n          text: 'this is a test',\n          authorId: 'a.foo',\n          time: 1637966993265,\n        },\n      });\n\n      const importEtherpad = (records:any) => agent.post(`/p/${testPadId}/import`)\n          .attach('file', Buffer.from(JSON.stringify(records), 'utf8'), {\n            filename: '/test.etherpad',\n            contentType: 'application/etherpad',\n          });\n\n      before(async function () {\n        // makeGoodExport() is assumed to produce good .etherpad records. Verify that assumption so\n        // that a buggy makeGoodExport() doesn't cause checks to accidentally pass.\n        const records = makeGoodExport();\n        await deleteTestPad();\n        const importedPads = await importEtherpad(records)\n        console.log(importedPads)\n        await importEtherpad(records)\n            .expect(200)\n            .expect('Content-Type', /json/)\n            .expect((res:any) => assert.deepEqual(res.body, {\n              code: 0,\n              message: 'ok',\n              data: {directDatabaseAccess: true},\n            }));\n        await agent.get(`/p/${testPadId}/export/txt`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(200)\n            .buffer(true).parse(superagent.parse.text)\n            .expect((res:any) => assert.match(res.text, /foo/));\n      });\n\n      it('missing rev', async function () {\n        const records:MapArrayType<any> = makeGoodExport();\n        delete records['pad:testing:revs:0'];\n        importEtherpad(records).expect(500);\n      });\n\n      it('bad changeset', async function () {\n        const records = makeGoodExport();\n        records['pad:testing:revs:0'].changeset = 'garbage';\n        importEtherpad(records).expect(500);\n      });\n\n      it('missing attrib in pool', async function () {\n        const records = makeGoodExport();\n        records['pad:testing'].pool.nextNum++;\n        (importEtherpad(records)).expect(500);\n      });\n\n      it('extra attrib in pool', async function () {\n        const records = makeGoodExport();\n        const pool = records['pad:testing'].pool;\n        // @ts-ignore\n        pool.numToAttrib[pool.nextNum] = ['key', 'value'];\n        (importEtherpad(records)).expect(500);\n      });\n\n      it('changeset refers to non-existent attrib', async function () {\n        const records:MapArrayType<any> = makeGoodExport();\n        records['pad:testing:revs:1'] = {\n          changeset: 'Z:4>4*1+4$asdf',\n          meta: {\n            author: 'a.foo',\n            timestamp: 1597632398288,\n          },\n        };\n        records['pad:testing'].head = 1;\n        records['pad:testing'].atext = {\n          text: 'asdffoo\\n',\n          attribs: '*1+4|1+4',\n        };\n        (importEtherpad(records)).expect(500);\n      });\n\n      it('pad atext does not match', async function () {\n        const records = makeGoodExport();\n        records['pad:testing'].atext.attribs = `*0${records['pad:testing'].atext.attribs}`;\n        (importEtherpad(records)).expect(500);\n      });\n\n      it('missing chat message', async function () {\n        const records:MapArrayType<any> = makeGoodExport();\n        delete records['pad:testing:chat:0'];\n        importEtherpad(records).expect(500);\n      });\n    });\n\n    describe('revisions are supported in txt and html export', function () {\n      const makeGoodExport = () => ({\n        'pad:testing': {\n          atext: {\n            text: 'oofoo\\n',\n            attribs: '|1+6',\n          },\n          pool: {\n            numToAttrib: {\n              0: ['author', 'a.foo'],\n            },\n            nextNum: 1,\n          },\n          head: 2,\n          savedRevisions: [],\n        },\n        'globalAuthor:a.foo': {\n          colorId: '#000000',\n          name: 'author foo',\n          timestamp: 1598747784631,\n          padIDs: 'testing',\n        },\n        'pad:testing:revs:0': {\n          changeset: 'Z:1>3+3$foo',\n          meta: {\n            author: 'a.foo',\n            timestamp: 1597632398288,\n            pool: {\n              nextNum: 1,\n              numToAttrib: {\n                0: ['author', 'a.foo'],\n              },\n            },\n            atext: {\n              text: 'foo\\n',\n              attribs: '|1+4',\n            },\n          },\n        },\n        'pad:testing:revs:1': {\n          changeset: 'Z:4>1+1$o',\n          meta: {\n            author: 'a.foo',\n            timestamp: 1597632398288,\n            pool: {\n              nextNum: 1,\n              numToAttrib: {\n                0: ['author', 'a.foo'],\n              },\n            },\n            atext: {\n              text: 'fooo\\n',\n              attribs: '*0|1+5',\n            },\n          },\n        },\n        'pad:testing:revs:2': {\n          changeset: 'Z:5>1+1$o',\n          meta: {\n            author: 'a.foo',\n            timestamp: 1597632398288,\n            pool: {\n              numToAttrib: {},\n              nextNum: 0,\n            },\n            atext: {\n              text: 'foooo\\n',\n              attribs: '*0|1+6',\n            },\n          },\n        },\n      });\n\n      const importEtherpad =  (records: MapArrayType<any>) => agent.post(`/p/${testPadId}/import`)\n          .attach('file', Buffer.from(JSON.stringify(records), 'utf8'), {\n            filename: '/test.etherpad',\n            contentType: 'application/etherpad',\n          });\n\n      before(async function () {\n        // makeGoodExport() is assumed to produce good .etherpad records. Verify that assumption so\n        // that a buggy makeGoodExport() doesn't cause checks to accidentally pass.\n        const records = makeGoodExport();\n        await deleteTestPad();\n        await importEtherpad(records)\n            .expect(200)\n            .expect('Content-Type', /json/)\n            .expect((res:any) => assert.deepEqual(res.body, {\n              code: 0,\n              message: 'ok',\n              data: {directDatabaseAccess: true},\n            }));\n        await agent.get(`/p/${testPadId}/export/txt`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(200)\n            .buffer(true).parse(superagent.parse.text)\n            .expect((res:any) => assert.equal(res.text, 'oofoo\\n'));\n      });\n\n      it('txt request rev 1', async function () {\n        await agent.get(`/p/${testPadId}/1/export/txt`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(200)\n            .buffer(true).parse(superagent.parse.text)\n            .expect((res:any) => assert.equal(res.text, 'ofoo\\n'));\n      });\n\n      it('txt request rev 2', async function () {\n        await agent.get(`/p/${testPadId}/2/export/txt`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(200)\n            .buffer(true).parse(superagent.parse.text)\n            .expect((res:any) => assert.equal(res.text, 'oofoo\\n'));\n      });\n\n      it('txt request rev 1test returns rev 1', async function () {\n        await agent.get(`/p/${testPadId}/1test/export/txt`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(200)\n            .buffer(true).parse(superagent.parse.text)\n            .expect((res:any) => assert.equal(res.text, 'ofoo\\n'));\n      });\n\n      it('txt request rev test1 is 403', async function () {\n        await agent.get(`/p/${testPadId}/test1/export/txt`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(500)\n            .buffer(true).parse(superagent.parse.text)\n            .expect((res:any) => assert.match(res.text, /rev is not a number/));\n      });\n\n      it('txt request rev 5 returns head rev', async function () {\n        await agent.get(`/p/${testPadId}/5/export/txt`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(200)\n            .buffer(true).parse(superagent.parse.text)\n            .expect((res:any) => assert.equal(res.text, 'oofoo\\n'));\n      });\n\n      it('html request rev 1', async function () {\n        await agent.get(`/p/${testPadId}/1/export/html`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(200)\n            .buffer(true).parse(superagent.parse.text)\n            .expect((res:any) => assert.match(res.text, /ofoo<br>/));\n      });\n\n      it('html request rev 2', async function () {\n        await agent.get(`/p/${testPadId}/2/export/html`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(200)\n            .buffer(true).parse(superagent.parse.text)\n            .expect((res:any) => assert.match(res.text, /oofoo<br>/));\n      });\n\n      it('html request rev 1test returns rev 1', async function () {\n        await agent.get(`/p/${testPadId}/1test/export/html`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(200)\n            .buffer(true).parse(superagent.parse.text)\n            .expect((res:any) => assert.match(res.text, /ofoo<br>/));\n      });\n\n      it('html request rev test1 results in 500 response', async function () {\n        await agent.get(`/p/${testPadId}/test1/export/html`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(500)\n            .buffer(true).parse(superagent.parse.text)\n            .expect((res:any) => assert.match(res.text, /rev is not a number/));\n      });\n\n      it('html request rev 5 returns head rev', async function () {\n        await agent.get(`/p/${testPadId}/5/export/html`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .expect(200)\n            .buffer(true).parse(superagent.parse.text)\n            .expect((res:any) => assert.match(res.text, /oofoo<br>/));\n      });\n    });\n\n    describe('Import authorization checks', function () {\n      let authorize: (arg0: any) => any;\n\n      const createTestPad = async (text:string) => {\n        const pad = await padManager.getPad(testPadId);\n        if (text) await pad.setText(text);\n        return pad;\n      };\n\n      this.timeout(1000);\n\n      beforeEach(async function () {\n        await deleteTestPad();\n        settings.requireAuthorization = true;\n        authorize = () => true;\n        plugins.hooks.authorize = [{hook_fn: (hookName: string, {req}:any, cb:Function) => cb([authorize(req)])}];\n      });\n\n      afterEach(async function () {\n        await deleteTestPad();\n      });\n\n      it('!authn !exist -> create', async function () {\n        await agent.post(`/p/${testPadIdEnc}/import`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})\n            .expect(200);\n        assert(await padManager.doesPadExist(testPadId));\n        const pad = await padManager.getPad(testPadId);\n        assert.equal(pad.text(), padText.toString());\n      });\n\n      it('!authn exist -> replace', async function () {\n        const pad = await createTestPad('before import');\n        await agent.post(`/p/${testPadIdEnc}/import`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})\n            .expect(200);\n        assert(await padManager.doesPadExist(testPadId));\n        assert.equal(pad.text(), padText.toString());\n      });\n\n      it('authn anonymous !exist -> fail', async function () {\n        settings.requireAuthentication = true;\n        await agent.post(`/p/${testPadIdEnc}/import`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})\n            .expect(401);\n        assert(!(await padManager.doesPadExist(testPadId)));\n      });\n\n      it('authn anonymous exist -> fail', async function () {\n        settings.requireAuthentication = true;\n        const pad = await createTestPad('before import\\n');\n        await agent.post(`/p/${testPadIdEnc}/import`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})\n            .expect(401);\n        assert.equal(pad.text(), 'before import\\n');\n      });\n\n      it('authn user create !exist -> create', async function () {\n        settings.requireAuthentication = true;\n        await agent.post(`/p/${testPadIdEnc}/import`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .auth('user', 'user-password')\n            .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})\n            .expect(200);\n        assert(await padManager.doesPadExist(testPadId));\n        const pad = await padManager.getPad(testPadId);\n        assert.equal(pad.text(), padText.toString());\n      });\n\n      it('authn user modify !exist -> fail', async function () {\n        settings.requireAuthentication = true;\n        authorize = () => 'modify';\n        await agent.post(`/p/${testPadIdEnc}/import`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .auth('user', 'user-password')\n            .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})\n            .expect(403);\n        assert(!(await padManager.doesPadExist(testPadId)));\n      });\n\n      it('authn user readonly !exist -> fail', async function () {\n        settings.requireAuthentication = true;\n        authorize = () => 'readOnly';\n        await agent.post(`/p/${testPadIdEnc}/import`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .auth('user', 'user-password')\n            .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})\n            .expect(403);\n        assert(!(await padManager.doesPadExist(testPadId)));\n      });\n\n      it('authn user create exist -> replace', async function () {\n        settings.requireAuthentication = true;\n        const pad = await createTestPad('before import\\n');\n        await agent.post(`/p/${testPadIdEnc}/import`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .auth('user', 'user-password')\n            .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})\n            .expect(200);\n        assert.equal(pad.text(), padText.toString());\n      });\n\n      it('authn user modify exist -> replace', async function () {\n        settings.requireAuthentication = true;\n        authorize = () => 'modify';\n        const pad = await createTestPad('before import\\n');\n        await agent.post(`/p/${testPadIdEnc}/import`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .auth('user', 'user-password')\n            .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})\n            .expect(200);\n        assert.equal(pad.text(), padText.toString());\n      });\n\n      it('authn user readonly exist -> fail', async function () {\n        const pad = await createTestPad('before import\\n');\n        settings.requireAuthentication = true;\n        authorize = () => 'readOnly';\n        await agent.post(`/p/${testPadIdEnc}/import`)\n            .set(\"authorization\", await common.generateJWTToken())\n            .auth('user', 'user-password')\n            .attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})\n            .expect(403);\n        assert.equal(pad.text(), 'before import\\n');\n      });\n    });\n  });\n}); // End of tests.\n\n\nconst endPoint = (point: string, version?:string) => {\n  return `/api/${version || apiVersion}/${point}`;\n};\n\nfunction makeid() {\n  let text = '';\n  const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n\n  for (let i = 0; i < 5; i++) {\n    text += possible.charAt(Math.floor(Math.random() * possible.length));\n  }\n  return text;\n}\n"
  },
  {
    "path": "src/tests/backend/specs/api/instance.ts",
    "content": "'use strict';\n\n/*\n * Tests for the instance-level APIs\n *\n * Section \"GLOBAL FUNCTIONS\" in src/node/db/API.js\n */\nconst common = require('../../common');\n\nlet agent:any;\nconst apiVersion = '1.2.14';\n\nconst endPoint = (point: string, version?: number) => `/api/${version || apiVersion}/${point}`;\n\ndescribe(__filename, function () {\n  before(async function () { agent = await common.init(); });\n\n  describe('Connectivity for instance-level API tests', function () {\n    it('can connect', async function () {\n      await agent.get('/api/')\n          .expect('Content-Type', /json/)\n          .expect(200);\n    });\n  });\n\n  describe('getStats', function () {\n    it('Gets the stats of a running instance', async function () {\n      await agent.get(endPoint('getStats'))\n          .set(\"Authorization\", await common.generateJWTToken())\n          .expect((res:any) => {\n            if (res.body.code !== 0) throw new Error('getStats() failed');\n\n            if (!('totalPads' in res.body.data && typeof res.body.data.totalPads === 'number')) {\n              throw new Error('Response to getStats() does not contain field totalPads, or ' +\n                              `it's not a number: ${JSON.stringify(res.body.data)}`);\n            }\n\n            if (!('totalSessions' in res.body.data &&\n                  typeof res.body.data.totalSessions === 'number')) {\n              throw new Error('Response to getStats() does not contain field totalSessions, or ' +\n                              `it's not a number: ${JSON.stringify(res.body.data)}`);\n            }\n\n            if (!('totalActivePads' in res.body.data &&\n                  typeof res.body.data.totalActivePads === 'number')) {\n              throw new Error('Response to getStats() does not contain field totalActivePads, or ' +\n                              `it's not a number: ${JSON.stringify(res.body.data)}`);\n            }\n          })\n          .expect('Content-Type', /json/)\n          .expect(200);\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/api/pad.ts",
    "content": "'use strict';\n\n/*\n * ACHTUNG: there is a copied & modified version of this file in\n * <basedir>/src/tests/container/specs/api/pad.js\n *\n * TODO: unify those two files, and merge in a single one.\n */\n\nconst assert = require('assert').strict;\nconst common = require('../../common');\nconst padManager = require('../../../../node/db/PadManager');\n\nlet agent:any;\nlet apiVersion = 1;\nconst testPadId = makeid();\nconst newPadId = makeid();\nconst copiedPadId = makeid();\nconst anotherPadId = makeid();\nlet lastEdited = '';\nconst text = generateLongText();\n\nconst endPoint = (point: string, version?: string) => `/api/${version || apiVersion}/${point}`;\n\n/*\n * Html document with nested lists of different types, to test its import and\n * verify it is exported back correctly\n */\nconst ulHtml = '<!doctype html><html><body><ul class=\"bullet\"><li>one</li><li>two</li><li>0</li><li>1</li><li>2<ul class=\"bullet\"><li>3</li><li>4</li></ul></li></ul><ol class=\"number\"><li>item<ol class=\"number\"><li>item1</li><li>item2</li></ol></li></ol></body></html>';\n\n/*\n * When exported back, Etherpad produces an html which is not exactly the same\n * textually, but at least it remains standard compliant and has an equal DOM\n * structure.\n */\nconst expectedHtml = '<!doctype html><html><body><ul class=\"bullet\"><li>one</li><li>two</li><li>0</li><li>1</li><li>2<ul class=\"bullet\"><li>3</li><li>4</ul></li></ul><ol start=\"1\" class=\"number\"><li>item<ol start=\"2\" class=\"number\"><li>item1</li><li>item2</ol></li></ol></body></html>';\n\n/*\n * Html document with space between list items, to test its import and\n * verify it is exported back correctly\n */\nconst ulSpaceHtml = '<!doctype html><html><body><ul class=\"bullet\"> <li>one</li></ul></body></html>';\n\n/*\n * When exported back, Etherpad produces an html which is not exactly the same\n * textually, but at least it remains standard compliant and has an equal DOM\n * structure.\n */\nconst expectedSpaceHtml = '<!doctype html><html><body><ul class=\"bullet\"><li>one</ul></body></html>';\n\ndescribe(__filename, function () {\n  before(async function () {\n    agent = await common.init();\n    const res = await agent.get('/api/')\n        .expect(200)\n        .expect('Content-Type', /json/);\n    apiVersion = res.body.currentVersion;\n    assert(apiVersion);\n  });\n\n  describe('Sanity checks', function () {\n    it('errors with invalid oauth token', async function () {\n      // This is broken because Etherpad doesn't handle HTTP codes properly see #2343\n      await agent.get(`/api/${apiVersion}/createPad?padID=test`)\n          .set(\"Authorization\", (await common.generateJWTToken()).substring(0, 10))\n          .expect(401);\n    });\n  });\n\n  /* Pad Tests Order of execution\n  -> deletePad -- This gives us a guaranteed clear environment\n   -> createPad\n    -> getRevisions -- Should be 0\n     -> getSavedRevisionsCount(padID) -- Should be 0\n      -> listSavedRevisions(padID) -- Should be an empty array\n       -> getHTML -- Should be the default pad text in HTML format\n        -> deletePad -- Should just delete a pad\n         -> getHTML -- Should return an error\n          -> createPad(withText)\n           -> getText -- Should have the text specified above as the pad text\n            -> setText\n             -> getText -- Should be the text set before\n              -> getRevisions -- Should be 0 still?\n               -> saveRevision\n                -> getSavedRevisionsCount(padID) -- Should be 0 still?\n                 -> listSavedRevisions(padID) -- Should be an empty array still ?\n                  -> padUsersCount -- Should be 0\n                   -> getReadOnlyId -- Should be a value\n                    -> listAuthorsOfPad(padID) -- should be empty array?\n                     -> getLastEdited(padID) -- Should be when pad was made\n                      -> setText(padId)\n                       -> getLastEdited(padID) -- Should be when setText was performed\n                        -> padUsers(padID) -- Should be when setText was performed\n\n                         -> setText(padId, \"hello world\")\n                          -> getLastEdited(padID) -- Should be when pad was made\n                           -> getText(padId) -- Should be \"hello world\"\n                            -> movePad(padID, newPadId) -- Should provide consistent pad data\n                             -> getText(newPadId) -- Should be \"hello world\"\n                              -> movePad(newPadID, originalPadId) -- Should provide consistent pad data\n                               -> getText(originalPadId) -- Should be \"hello world\"\n                                -> getLastEdited(padID) -- Should not be 0\n                                -> appendText(padID, \"hello\")\n                                -> getText(padID) -- Should be \"hello worldhello\"\n                                -> getText(padID, rev=2) - should return \"hello world\"\n                                 -> setHTML(padID) -- Should fail on invalid HTML\n                                  -> setHTML(padID) *3 -- Should fail on invalid HTML\n                                   -> getHTML(padID) -- Should return HTML close to posted HTML\n                                    -> createPad -- Tries to create pads with bad url characters\n\n  */\n\n  describe('Tests', function () {\n    it('deletes a Pad that does not exist', async function () {\n      await agent.get(`${endPoint('deletePad')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200) // @TODO: we shouldn't expect 200 here since the pad may not exist\n          .expect('Content-Type', /json/);\n    });\n\n    it('creates a new Pad', async function () {\n      const res = await agent.get(`${endPoint('createPad')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    it('gets revision count of Pad', async function () {\n      const res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n      assert.equal(res.body.data.revisions, 0);\n    });\n\n    it('gets saved revisions count of Pad', async function () {\n      const res = await agent.get(`${endPoint('getSavedRevisionsCount')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n      assert.equal(res.body.data.savedRevisions, 0);\n    });\n\n    it('gets saved revision list of Pad', async function () {\n      const res = await agent.get(`${endPoint('listSavedRevisions')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n      assert.deepEqual(res.body.data.savedRevisions, []);\n    });\n\n    it('get the HTML of Pad', async function () {\n      const res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert(res.body.data.html.length > 1);\n    });\n\n    it('list all pads', async function () {\n      const res = await agent.get(endPoint('listAllPads'))\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert(res.body.data.padIDs.includes(testPadId));\n    });\n\n    it('deletes the Pad', async function () {\n      const res = await agent.get(`${endPoint('deletePad')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    it('list all pads again', async function () {\n      const res = await agent.get(endPoint('listAllPads'))\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert(!res.body.data.padIDs.includes(testPadId));\n    });\n\n    it('get the HTML of a Pad -- Should return a failure', async function () {\n      const res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 1);\n    });\n\n    it('creates a new Pad with text', async function () {\n      const res = await agent.get(`${endPoint('createPad')}?padID=${testPadId}&text=testText`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    it('gets the Pad text and expect it to be testText with trailing \\\\n', async function () {\n      const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.data.text, 'testText\\n');\n    });\n\n    it('set text', async function () {\n      const res = await agent.post(endPoint('setText'))\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .send({\n            padID: testPadId,\n            text: 'testTextTwo',\n          })\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    it('gets the Pad text', async function () {\n      const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.data.text, 'testTextTwo\\n');\n    });\n\n    it('gets Revision Count of a Pad', async function () {\n      const res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.data.revisions, 1);\n    });\n\n    it('saves Revision', async function () {\n      const res = await agent.get(`${endPoint('saveRevision')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    it('gets saved revisions count of Pad again', async function () {\n      const res = await agent.get(`${endPoint('getSavedRevisionsCount')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n      assert.equal(res.body.data.savedRevisions, 1);\n    });\n\n    it('gets saved revision list of Pad again', async function () {\n      const res = await agent.get(`${endPoint('listSavedRevisions')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n      assert.deepEqual(res.body.data.savedRevisions, [1]);\n    });\n\n    it('gets User Count of a Pad', async function () {\n      const res = await agent.get(`${endPoint('padUsersCount')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.data.padUsersCount, 0);\n    });\n\n    it('Gets the Read Only ID of a Pad', async function () {\n      const res = await agent.get(`${endPoint('getReadOnlyID')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert(res.body.data.readOnlyID);\n    });\n\n    it('Get Authors of the Pad', async function () {\n      const res = await agent.get(`${endPoint('listAuthorsOfPad')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.data.authorIDs.length, 0);\n    });\n\n    it('Get When Pad was left Edited', async function () {\n      const res = await agent.get(`${endPoint('getLastEdited')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert(res.body.data.lastEdited);\n      lastEdited = res.body.data.lastEdited;\n    });\n\n    it('set text again', async function () {\n      const res = await agent.post(endPoint('setText'))\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .send({\n            padID: testPadId,\n            text: 'testTextThree',\n          })\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    it('Get When Pad was left Edited again', async function () {\n      const res = await agent.get(`${endPoint('getLastEdited')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert(res.body.data.lastEdited > lastEdited);\n    });\n\n    it('gets User Count of a Pad again', async function () {\n      const res = await agent.get(`${endPoint('padUsers')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.data.padUsers.length, 0);\n    });\n\n    it('deletes a Pad', async function () {\n      const res = await agent.get(`${endPoint('deletePad')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    it('creates the Pad again', async function () {\n      const res = await agent.get(`${endPoint('createPad')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    it('Sets text on a pad Id', async function () {\n      const res = await agent.post(`${endPoint('setText')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .field({text})\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    it('Gets text on a pad Id', async function () {\n      const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n      assert.equal(res.body.data.text, `${text}\\n`);\n    });\n\n    it('Sets text on a pad Id including an explicit newline', async function () {\n      const res = await agent.post(`${endPoint('setText')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .field({text: `${text}\\n`})\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    it(\"Gets text on a pad Id and doesn't have an excess newline\", async function () {\n      const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n      assert.equal(res.body.data.text, `${text}\\n`);\n    });\n\n    it('Gets when pad was last edited', async function () {\n      const res = await agent.get(`${endPoint('getLastEdited')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.notEqual(res.body.lastEdited, 0);\n    });\n\n    it('Move a Pad to a different Pad ID', async function () {\n      const res = await agent.get(\n          `${endPoint('movePad')}?sourceID=${testPadId}&destinationID=${newPadId}&force=true`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    it('Gets text from new pad', async function () {\n      const res = await agent.get(`${endPoint('getText')}?padID=${newPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.data.text, `${text}\\n`);\n    });\n\n    it('Move pad back to original ID', async function () {\n      const res = await agent.get(\n          `${endPoint('movePad')}?sourceID=${newPadId}&destinationID=${testPadId}&force=false`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    it('Get text using original ID', async function () {\n      const res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.data.text, `${text}\\n`);\n    });\n\n    it('Get last edit of original ID', async function () {\n      const res = await agent.get(`${endPoint('getLastEdited')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.notEqual(res.body.lastEdited, 0);\n    });\n\n    it('Append text to a pad Id', async function () {\n      let res = await agent.get(\n          `${endPoint('appendText', '1.2.13')}?padID=${testPadId}&text=hello`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n      res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n      assert.equal(res.body.data.text, `${text}hello\\n`);\n    });\n\n    it('getText of old revision', async function () {\n      let res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n      const rev = res.body.data.revisions;\n      assert(rev != null);\n      assert(Number.isInteger(rev));\n      assert(rev > 0);\n      res = await agent.get(`${endPoint('getText')}?padID=${testPadId}&rev=${rev - 1}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n      assert.equal(res.body.data.text, `${text}\\n`);\n    });\n\n    it('Sets the HTML of a Pad attempting to pass ugly HTML', async function () {\n      const html = '<div><b>Hello HTML</title></head></div>';\n      const res = await agent.post(endPoint('setHTML'))\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .send({\n            padID: testPadId,\n            html,\n          })\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    it('Pad with complex nested lists of different types', async function () {\n      let res = await agent.post(endPoint('setHTML'))\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .send({\n            padID: testPadId,\n            html: ulHtml,\n          })\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n      res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      const receivedHtml = res.body.data.html.replace('<br></body>', '</body>').toLowerCase();\n      assert.equal(receivedHtml, expectedHtml);\n    });\n\n    it('Pad with white space between list items', async function () {\n      let res = await agent.get(`${endPoint('setHTML')}?padID=${testPadId}&html=${ulSpaceHtml}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n      res = await agent.get(`${endPoint('getHTML')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      const receivedHtml = res.body.data.html.replace('<br></body>', '</body>').toLowerCase();\n      assert.equal(receivedHtml, expectedSpaceHtml);\n    });\n\n    it('errors if pad can be created', async function () {\n      await Promise.all(['/', '%23', '%3F', '%26'].map(async (badUrlChar) => {\n        const res = await agent.get(`${endPoint('createPad')}?padID=${badUrlChar}`)\n            .set(\"Authorization\", (await common.generateJWTToken()))\n            .expect('Content-Type', /json/);\n        assert.equal(res.body.code, 1);\n      }));\n    });\n\n    it('copies the content of a existent pad', async function () {\n      const res = await agent.get(\n          `${endPoint('copyPad')}?sourceID=${testPadId}&destinationID=${copiedPadId}&force=true`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    it('does not add an useless revision', async function () {\n      let res = await agent.post(`${endPoint('setText')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .field({text: 'identical text\\n'})\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n\n      res = await agent.get(`${endPoint('getText')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.data.text, 'identical text\\n');\n\n      res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      const revCount = res.body.data.revisions;\n\n      res = await agent.post(`${endPoint('setText')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .field({text: 'identical text\\n'})\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n\n      res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${testPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.data.revisions, revCount);\n    });\n\n    it('creates a new Pad with empty text', async function () {\n      await agent.get(`${endPoint('createPad')}?padID=${anotherPadId}&text=`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect('Content-Type', /json/)\n          .expect(200)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0, 'Unable to create new Pad');\n          });\n      await agent.get(`${endPoint('getText')}?padID=${anotherPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect('Content-Type', /json/)\n          .expect(200)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0, 'Unable to get pad text');\n            assert.equal(res.body.data.text, '\\n', 'Pad text is not empty');\n          });\n    });\n\n    it('deletes with empty text', async function () {\n      await agent.get(`${endPoint('deletePad')}?padID=${anotherPadId}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect('Content-Type', /json/)\n          .expect(200)\n          .expect((res: any) => {\n            assert.equal(res.body.code, 0, 'Unable to delete empty Pad');\n          });\n    });\n  });\n\n  describe('copyPadWithoutHistory', function () {\n    const sourcePadId = makeid();\n    let newPad:string;\n\n    before(async function () {\n      await createNewPadWithHtml(sourcePadId, ulHtml);\n    });\n\n    beforeEach(async function () {\n      newPad = makeid();\n    });\n\n    it('returns a successful response', async function () {\n      const res = await agent.get(`${endPoint('copyPadWithoutHistory')}?sourceID=${sourcePadId}` +\n                                  `&destinationID=${newPad}&force=false`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/);\n      assert.equal(res.body.code, 0);\n    });\n\n    // this test validates if the source pad's text and attributes are kept\n    it('creates a new pad with the same content as the source pad', async function () {\n      let res = await agent.get(`${endPoint('copyPadWithoutHistory')}?sourceID=${sourcePadId}` +\n                                `&destinationID=${newPad}&force=false`)\n          .set(\"Authorization\", (await common.generateJWTToken()));\n      assert.equal(res.body.code, 0);\n      res = await agent.get(`${endPoint('getHTML')}?padID=${newPad}`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200);\n      const receivedHtml = res.body.data.html.replace('<br><br></body>', '</body>').toLowerCase();\n      assert.equal(receivedHtml, expectedHtml);\n    });\n\n    it('copying to a non-existent group throws an error', async function () {\n      const padWithNonExistentGroup = `notExistentGroup$${newPad}`;\n      const res = await agent.get(`${endPoint('copyPadWithoutHistory')}` +\n                                  `?sourceID=${sourcePadId}` +\n                                  `&destinationID=${padWithNonExistentGroup}&force=true`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200);\n      assert.equal(res.body.code, 1);\n    });\n\n    describe('copying to an existing pad', function () {\n      beforeEach(async function () {\n        await createNewPadWithHtml(newPad, ulHtml);\n      });\n\n      it('force=false fails', async function () {\n        const res = await agent.get(`${endPoint('copyPadWithoutHistory')}` +\n                                    `?sourceID=${sourcePadId}` +\n                                    `&destinationID=${newPad}&force=false`)\n            .set(\"Authorization\", (await common.generateJWTToken()))\n            .expect(200);\n        assert.equal(res.body.code, 1);\n      });\n\n      it('force=true succeeds', async function () {\n        const res = await agent.get(`${endPoint('copyPadWithoutHistory')}` +\n                                    `?sourceID=${sourcePadId}` +\n                                    `&destinationID=${newPad}&force=true`)\n            .set(\"Authorization\", (await common.generateJWTToken()))\n            .expect(200);\n        assert.equal(res.body.code, 0);\n      });\n    });\n\n    // Regression test for https://github.com/ether/etherpad-lite/issues/5296\n    it('source and destination attribute pools are independent', async function () {\n      // Strategy for this test:\n      //   1. Create a new pad without bold or italic text\n      //   2. Use copyPadWithoutHistory to copy the pad.\n      //   3. Add some bold text (but no italic text!) to the source pad. This should add a bold\n      //      attribute to the source pad's pool but not to the destination pad's pool.\n      //   4. Add some italic text (but no bold text!) to the destination pad. This should add an\n      //      italic attribute to the destination pad's pool with the same number as the newly added\n      //      bold attribute in the source pad's pool.\n      //   5. Add some more text (bold or plain) to the source pad. This will save the source pad to\n      //      the database after the destination pad has had an opportunity to corrupt the source\n      //      pad.\n      //   6. Export the source and destination pads. Make sure that <em> doesn't appear in the\n      //      source pad's HTML, and that <strong> doesn't appear int he destination pad's HTML.\n      //   7. Force the server to re-init the pads from the database.\n      //   8. Repeat step 6.\n      // If <em> appears in the source pad, or <strong> appears in the destination pad, then shared\n      // state between the two attribute pools caused corruption.\n\n      const getHtml = async (padId:string) => {\n        const res = await agent.get(`${endPoint('getHTML')}?padID=${padId}`)\n            .set(\"Authorization\", (await common.generateJWTToken()))\n            .expect(200)\n            .expect('Content-Type', /json/);\n        assert.equal(res.body.code, 0);\n        return res.body.data.html;\n      };\n\n      const setBody = async (padId: string, bodyHtml: string) => {\n        await agent.post(endPoint('setHTML'))\n            .set(\"Authorization\", (await common.generateJWTToken()))\n            .send({padID: padId, html: `<!DOCTYPE HTML><html><body>${bodyHtml}</body></html>`})\n            .expect(200)\n            .expect('Content-Type', /json/)\n            .expect((res: any) => assert.equal(res.body.code, 0));\n      };\n\n      const origHtml = await getHtml(sourcePadId);\n      assert.doesNotMatch(origHtml, /<strong>/);\n      assert.doesNotMatch(origHtml, /<em>/);\n      await agent.get(`${endPoint('copyPadWithoutHistory')}?sourceID=${sourcePadId}` +\n                      `&destinationID=${newPad}&force=false`)\n          .set(\"Authorization\", (await common.generateJWTToken()))\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => assert.equal(res.body.code, 0));\n\n      const newBodySrc = '<strong>bold</strong>';\n      const newBodyDst = '<em>italic</em>';\n      await setBody(sourcePadId, newBodySrc);\n      await setBody(newPad, newBodyDst);\n      await setBody(sourcePadId, `${newBodySrc} foo`);\n\n      let [srcHtml, dstHtml] = await Promise.all([getHtml(sourcePadId), getHtml(newPad)]);\n      assert.match(srcHtml, new RegExp(newBodySrc));\n      assert.match(dstHtml, new RegExp(newBodyDst));\n\n      // Force the server to re-read the pads from the database. This rebuilds the attribute pool\n      // objects from scratch, ensuring that an internally inconsistent attribute pool object did\n      // not cause the above tests to accidentally pass.\n      const reInitPad = async (padId:string) => {\n        const pad = await padManager.getPad(padId);\n        await pad.init();\n      };\n      await Promise.all([\n        reInitPad(sourcePadId),\n        reInitPad(newPad),\n      ]);\n\n      [srcHtml, dstHtml] = await Promise.all([getHtml(sourcePadId), getHtml(newPad)]);\n      assert.match(srcHtml, new RegExp(newBodySrc));\n      assert.match(dstHtml, new RegExp(newBodyDst));\n    });\n  });\n});\n\n/*\n                          -> movePadForce Test\n\n*/\n\nconst createNewPadWithHtml = async (padId: string, html: string) => {\n  await agent.get(`${endPoint('createPad')}?padID=${padId}`)\n      .set(\"Authorization\", (await common.generateJWTToken()));\n  await agent.post(endPoint('setHTML'))\n      .set(\"Authorization\", (await common.generateJWTToken()))\n      .send({\n        padID: padId,\n        html,\n      });\n};\n\nfunction makeid() {\n  let text = '';\n  const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n\n  for (let i = 0; i < 5; i++) {\n    text += possible.charAt(Math.floor(Math.random() * possible.length));\n  }\n  return text;\n}\n\nfunction generateLongText() {\n  let text = '';\n  const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n\n  for (let i = 0; i < 80000; i++) {\n    text += possible.charAt(Math.floor(Math.random() * possible.length));\n  }\n  return text;\n}\n"
  },
  {
    "path": "src/tests/backend/specs/api/restoreRevision.ts",
    "content": "'use strict';\n\nimport {PadType} from \"../../../../node/types/PadType\";\n\nconst assert = require('assert').strict;\nconst authorManager = require('../../../../node/db/AuthorManager');\nconst common = require('../../common');\nconst padManager = require('../../../../node/db/PadManager');\n\ndescribe(__filename, function () {\n  let agent:any;\n  let authorId: string;\n  let padId: string;\n  let pad: PadType;\n\n  const restoreRevision = async (v:string, padId: string, rev: number, authorId:string|null = null) => {\n    // @ts-ignore\n    const p = new URLSearchParams(Object.entries({\n      padID: padId,\n      rev,\n      ...(authorId == null ? {} : {authorId}),\n    }));\n    const res = await agent.get(`/api/${v}/restoreRevision?${p}`)\n        .set(\"Authorization\", (await common.generateJWTToken()))\n        .expect(200)\n        .expect('Content-Type', /json/);\n    assert.equal(res.body.code, 0);\n  };\n\n  before(async function () {\n    agent = await common.init();\n    authorId = await authorManager.getAuthor4Token('test-restoreRevision');\n    assert(authorId);\n  });\n\n  beforeEach(async function () {\n    padId = common.randomString();\n    if (await padManager.doesPadExist(padId)) await padManager.removePad(padId);\n    pad = await padManager.getPad(padId);\n    await pad.appendText('\\nfoo');\n    await pad.appendText('\\nbar');\n    assert.equal(pad.head, 2);\n  });\n\n  afterEach(async function () {\n    if (await padManager.doesPadExist(padId)) await padManager.removePad(padId);\n  });\n\n  describe('v1.2.11', function () {\n    // TODO: Enable once the end-of-pad newline bugs are fixed. See:\n    // https://github.com/ether/etherpad-lite/pull/5253\n    xit('content matches', async function () {\n      const oldHead = pad.head;\n      const wantAText = await pad.getInternalRevisionAText(pad.head - 1);\n      assert(wantAText.text.endsWith('\\nfoo\\n'));\n      await restoreRevision('1.2.11', padId, pad.head - 1);\n      assert.equal(pad.head, oldHead + 1);\n      assert.deepEqual(await pad.getInternalRevisionAText(pad.head), wantAText);\n    });\n\n    it('authorId ignored', async function () {\n      const oldHead = pad.head;\n      await restoreRevision('1.2.11', padId, pad.head - 1, authorId);\n      assert.equal(pad.head, oldHead + 1);\n      assert.equal(await pad.getRevisionAuthor(pad.head), '');\n    });\n  });\n\n  describe('v1.3.0', function () {\n    it('change is attributed to given authorId', async function () {\n      const oldHead = pad.head;\n      await restoreRevision('1.3.0', padId, pad.head - 1, authorId);\n      assert.equal(pad.head, oldHead + 1);\n      assert.equal(await pad.getRevisionAuthor(pad.head), authorId);\n    });\n\n    it('authorId can be omitted', async function () {\n      const oldHead = pad.head;\n      await restoreRevision('1.3.0', padId, pad.head - 1);\n      assert.equal(pad.head, oldHead + 1);\n      assert.equal(await pad.getRevisionAuthor(pad.head), '');\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/api/sessionsAndGroups.ts",
    "content": "'use strict';\n\nimport {agent, generateJWTToken, init, logger} from \"../../common\";\n\nimport TestAgent from \"supertest/lib/agent\";\nimport supertest from \"supertest\";\nconst assert = require('assert').strict;\nconst db = require('../../../../node/db/DB');\n\nlet apiVersion = 1;\nlet groupID = '';\nlet authorID = '';\nlet sessionID = '';\nlet padID = makeid();\n\nconst endPoint = (point:string) => {\n   return `/api/${apiVersion}/${point}`;\n}\n\nlet preparedAgent: TestAgent<supertest.Test>\n\ndescribe(__filename, function () {\n  before(async function () {\n      preparedAgent = await init();\n  });\n\n  describe('API Versioning', function () {\n    it('errors if can not connect', async function () {\n      await agent!.get('/api/')\n          .set('Accept', 'application/json')\n          .expect(200)\n          .expect((res:any) => {\n            assert(res.body.currentVersion);\n            apiVersion = res.body.currentVersion;\n          });\n    });\n  });\n\n  // BEGIN GROUP AND AUTHOR TESTS\n  // ///////////////////////////////////\n  // ///////////////////////////////////\n\n  /* Tests performed\n  -> createGroup() -- should return a groupID\n   -> listSessionsOfGroup(groupID) -- should be 0\n    -> deleteGroup(groupID)\n     -> createGroupIfNotExistsFor(groupMapper) -- should return a groupID\n\n      -> createAuthor([name]) -- should return an authorID\n       -> createAuthorIfNotExistsFor(authorMapper [, name]) -- should return an authorID\n        -> getAuthorName(authorID) -- should return a name IE \"john\"\n\n  -> createSession(groupID, authorID, validUntil)\n   -> getSessionInfo(sessionID)\n    -> listSessionsOfGroup(groupID) -- should be 1\n     -> deleteSession(sessionID)\n      -> getSessionInfo(sessionID) -- should have author id etc in\n\n  -> listPads(groupID) -- should be empty array\n   -> createGroupPad(groupID, padName [, text])\n    -> listPads(groupID) -- should be empty array\n     -> getPublicStatus(padId)\n      -> setPublicStatus(padId, status)\n       -> getPublicStatus(padId)\n\n  -> listPadsOfAuthor(authorID)\n  */\n\n  describe('API: Group creation and deletion', function () {\n    it('createGroup', async function () {\n      await agent!.get(endPoint('createGroup'))\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert(res.body.data.groupID);\n            groupID = res.body.data.groupID;\n          });\n    });\n\n    it('listSessionsOfGroup for empty group', async function () {\n      await agent!.get(`${endPoint('listSessionsOfGroup')}?groupID=${groupID}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert.equal(res.body.data, null);\n          });\n    });\n\n    it('deleteGroup', async function () {\n      await agent!\n          .get(`${endPoint('deleteGroup')}?groupID=${groupID}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n          });\n    });\n\n    it('createGroupIfNotExistsFor', async function () {\n      const mapper = makeid();\n      let groupId: string;\n      await preparedAgent.get(`${endPoint('createGroupIfNotExistsFor')}?groupMapper=${mapper}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            groupId = res.body.data.groupID;\n            assert(groupId);\n          });\n      // Passing the same mapper should return the same group ID.\n      await preparedAgent.get(`${endPoint('createGroupIfNotExistsFor')}?groupMapper=${mapper}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert.equal(res.body.data.groupID, groupId);\n          });\n      // Deleting the group should clean up the mapping.\n        assert.equal(await db.get(`mapper2group:${mapper}`), groupId!);\n      await preparedAgent.get(`${endPoint('deleteGroup')}?groupID=${groupId!}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n          });\n      assert(await db.get(`mapper2group:${mapper}`) == null);\n    });\n\n    // Test coverage for https://github.com/ether/etherpad-lite/issues/4227\n    // Creates a group, creates 2 sessions, 2 pads and then deletes the group.\n    it('createGroup', async function () {\n      await preparedAgent.get(endPoint('createGroup'))\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert(res.body.data.groupID);\n            groupID = res.body.data.groupID;\n          });\n    });\n\n    it('createAuthor', async function () {\n      await preparedAgent.get(endPoint('createAuthor'))\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert(res.body.data.authorID);\n            authorID = res.body.data.authorID;\n          });\n    });\n\n    it('createSession', async function () {\n      await preparedAgent.get(`${endPoint('createSession')}?authorID=${authorID}&groupID=${groupID}` +\n                      '&validUntil=999999999999')\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert(res.body.data.sessionID);\n            sessionID = res.body.data.sessionID;\n          });\n    });\n\n    it('createSession', async function () {\n      await preparedAgent.get(`${endPoint('createSession')}?authorID=${authorID}&groupID=${groupID}` +\n                      '&validUntil=999999999999')\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert(res.body.data.sessionID);\n            sessionID = res.body.data.sessionID;\n          });\n    });\n\n    it('createGroupPad', async function () {\n      await preparedAgent.get(`${endPoint('createGroupPad')}?groupID=${groupID}&padName=x1234567`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n          });\n    });\n\n    it('createGroupPad', async function () {\n      await preparedAgent.get(`${endPoint('createGroupPad')}?groupID=${groupID}&padName=x12345678`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n          });\n    });\n\n    it('deleteGroup', async function () {\n      await preparedAgent.get(`${endPoint('deleteGroup')}?groupID=${groupID}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n          });\n    });\n    // End of coverage for https://github.com/ether/etherpad-lite/issues/4227\n  });\n\n  describe('API: Author creation', function () {\n    it('createGroup', async function () {\n      await preparedAgent.get(endPoint('createGroup'))\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert(res.body.data.groupID);\n            groupID = res.body.data.groupID;\n          });\n    });\n\n    it('createAuthor', async function () {\n      await preparedAgent.get(endPoint('createAuthor'))\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert(res.body.data.authorID);\n          });\n    });\n\n    it('createAuthor with name', async function () {\n      await preparedAgent.get(`${endPoint('createAuthor')}?name=john`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert(res.body.data.authorID);\n            authorID = res.body.data.authorID; // we will be this author for the rest of the tests\n          });\n    });\n\n    it('createAuthorIfNotExistsFor', async function () {\n      await preparedAgent.get(`${endPoint('createAuthorIfNotExistsFor')}?authorMapper=chris`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert(res.body.data.authorID);\n          });\n    });\n\n    it('getAuthorName', async function () {\n      await preparedAgent.get(`${endPoint('getAuthorName')}?authorID=${authorID}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert.equal(res.body.data, 'john');\n          });\n    });\n  });\n\n  describe('API: Sessions', function () {\n    it('createSession', async function () {\n      await preparedAgent.get(`${endPoint('createSession')}?authorID=${authorID}&groupID=${groupID}` +\n                      '&validUntil=999999999999')\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert(res.body.data.sessionID);\n            sessionID = res.body.data.sessionID;\n          });\n    });\n\n    it('getSessionInfo', async function () {\n      await preparedAgent.get(`${endPoint('getSessionInfo')}?sessionID=${sessionID}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert(res.body.data.groupID);\n            assert(res.body.data.authorID);\n            assert(res.body.data.validUntil);\n          });\n    });\n\n    it('listSessionsOfGroup', async function () {\n      await preparedAgent.get(`${endPoint('listSessionsOfGroup')}?groupID=${groupID}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert.equal(typeof res.body.data, 'object');\n          });\n    });\n\n    it('deleteSession', async function () {\n      await preparedAgent.get(`${endPoint('deleteSession')}?sessionID=${sessionID}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n          });\n    });\n\n    it('getSessionInfo of deleted session', async function () {\n      await preparedAgent.get(`${endPoint('getSessionInfo')}?sessionID=${sessionID}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 1);\n          });\n    });\n  });\n\n  describe('API: Group pad management', function () {\n    it('listPads', async function () {\n      await preparedAgent.get(`${endPoint('listPads')}?groupID=${groupID}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert.equal(res.body.data.padIDs.length, 0);\n          });\n    });\n\n    it('createGroupPad', async function () {\n      await preparedAgent.get(`${endPoint('createGroupPad')}?groupID=${groupID}&padName=${padID}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            padID = res.body.data.padID;\n          });\n    });\n\n    it('listPads after creating a group pad', async function () {\n      await preparedAgent.get(`${endPoint('listPads')}?groupID=${groupID}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res) => {\n            assert.equal(res.body.code, 0);\n            assert.equal(res.body.data.padIDs.length, 1);\n          });\n    });\n  });\n\n  describe('API: Pad security', function () {\n    it('getPublicStatus', async function () {\n      await preparedAgent.get(`${endPoint('getPublicStatus')}?padID=${padID}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert.equal(res.body.data.publicStatus, false);\n          });\n    });\n\n    it('setPublicStatus', async function () {\n      await preparedAgent.get(`${endPoint('setPublicStatus')}?padID=${padID}&publicStatus=true`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n          });\n    });\n\n    it('getPublicStatus after changing public status', async function () {\n      await preparedAgent.get(`${endPoint('getPublicStatus')}?padID=${padID}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert.equal(res.body.data.publicStatus, true);\n          });\n    });\n  });\n\n  // NOT SURE HOW TO POPULAT THIS /-_-\\\n  // /////////////////////////////////////\n  // /////////////////////////////////////\n\n  describe('API: Misc', function () {\n    it('listPadsOfAuthor', async function () {\n      await preparedAgent.get(`${endPoint('listPadsOfAuthor')}?authorID=${authorID}`)\n          .set(\"Authorization\", await generateJWTToken())\n          .expect(200)\n          .expect('Content-Type', /json/)\n          .expect((res:any) => {\n            assert.equal(res.body.code, 0);\n            assert.equal(res.body.data.padIDs.length, 0);\n          });\n    });\n  });\n});\n\nfunction makeid() {\n  let text = '';\n  const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n\n  for (let i = 0; i < 5; i++) {\n    text += possible.charAt(Math.floor(Math.random() * possible.length));\n  }\n  return text;\n}\n"
  },
  {
    "path": "src/tests/backend/specs/api/test.etherpad",
    "content": "{\"pad:Pd4b1Kgvv9qHZZtj8yzl\":{\"atext\":{\"text\":\"*hello\\n\",\"attribs\":\"*0*1*5*3*4+1*0+5|1+1\"},\"pool\":{\"numToAttrib\":{\"0\":[\"author\",\"a.ElbBWNTxmtRrfFqn\"],\"1\":[\"insertorder\",\"first\"],\"2\":[\"list\",\"bullet1\"],\"3\":[\"lmkr\",\"1\"],\"4\":[\"start\",\"1\"],\"5\":[\"list\",\"bullet2\"]},\"nextNum\":6},\"head\":5,\"chatHead\":-1,\"publicStatus\":false,\"passwordHash\":null,\"savedRevisions\":[]},\"globalAuthor:a.ElbBWNTxmtRrfFqn\":{\"colorId\":50,\"name\":null,\"timestamp\":1595111151414,\"padIDs\":\"Pd4b1Kgvv9qHZZtj8yzl\"},\"pad:Pd4b1Kgvv9qHZZtj8yzl:revs:0\":{\"changeset\":\"Z:1>bj|7+bj$Welcome to Etherpad!\\n\\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\\n\\nGet involved with Etherpad at https://etherpad.org\\n\\nWarning: DirtyDB is used. This is fine for testing but not recommended for production. -- To suppress these warning messages change suppressErrorsInPadText to true in your settings.json\\n\",\"meta\":{\"author\":\"\",\"timestamp\":1595111092400,\"pool\":{\"numToAttrib\":{},\"attribToNum\":{},\"nextNum\":0},\"atext\":{\"text\":\"Welcome to Etherpad!\\n\\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\\n\\nGet involved with Etherpad at https://etherpad.org\\n\\nWarning: DirtyDB is used. This is fine for testing but not recommended for production. -- To suppress these warning messages change suppressErrorsInPadText to true in your settings.json\\n\\n\",\"attribs\":\"|8+bk\"}}},\"pad:Pd4b1Kgvv9qHZZtj8yzl:revs:1\":{\"changeset\":\"Z:bk<bj|7-bj$\",\"meta\":{\"author\":\"a.ElbBWNTxmtRrfFqn\",\"timestamp\":1595111112138}},\"pad:Pd4b1Kgvv9qHZZtj8yzl:revs:2\":{\"changeset\":\"Z:1>1*0*1*2*3*4+1$*\",\"meta\":{\"author\":\"a.ElbBWNTxmtRrfFqn\",\"timestamp\":1595111119434}},\"pad:Pd4b1Kgvv9qHZZtj8yzl:revs:3\":{\"changeset\":\"Z:2>0*5*4=1$\",\"meta\":{\"author\":\"a.ElbBWNTxmtRrfFqn\",\"timestamp\":1595111127471}},\"pad:Pd4b1Kgvv9qHZZtj8yzl:revs:4\":{\"changeset\":\"Z:2>2=1*0+2$he\",\"meta\":{\"author\":\"a.ElbBWNTxmtRrfFqn\",\"timestamp\":1595111128230}},\"pad:Pd4b1Kgvv9qHZZtj8yzl:revs:5\":{\"changeset\":\"Z:4>3=3*0+3$llo\",\"meta\":{\"author\":\"a.ElbBWNTxmtRrfFqn\",\"timestamp\":1595111128727}}}"
  },
  {
    "path": "src/tests/backend/specs/api/test.txt",
    "content": "This is the contents we insert into pads when testing the import functions.  Thanks for using Etherpad!\n"
  },
  {
    "path": "src/tests/backend/specs/chat.ts",
    "content": "'use strict';\n\nimport {MapArrayType} from \"../../../node/types/MapType\";\nimport {PluginDef} from \"../../../node/types/PartType\";\n\nimport ChatMessage from '../../../static/js/ChatMessage';\nconst {Pad} = require('../../../node/db/Pad');\nconst assert = require('assert').strict;\nconst common = require('../common');\nconst padManager = require('../../../node/db/PadManager');\nconst pluginDefs = require('../../../static/js/pluginfw/plugin_defs');\n\nconst logger = common.logger;\n\ntype CheckFN = ({message, pad, padId}:{\n  message?: typeof ChatMessage,\n  pad?: typeof Pad,\n  padId?: string,\n})=>void;\n\nconst checkHook = async (hookName: string, checkFn?:CheckFN) => {\n  if (pluginDefs.hooks[hookName] == null) pluginDefs.hooks[hookName] = [];\n  await new Promise<void>((resolve, reject) => {\n    pluginDefs.hooks[hookName].push({\n      hook_fn: async (hookName: string, context:any) => {\n        if (checkFn == null) return;\n        logger.debug(`hook ${hookName} invoked`);\n        try {\n          // Make sure checkFn is called only once.\n          const _checkFn = checkFn;\n          // @ts-ignore\n          checkFn = null;\n          await _checkFn(context);\n        } catch (err) {\n          reject(err);\n          return;\n        }\n        resolve();\n      },\n    });\n  });\n};\n\nconst sendMessage = (socket: any, data:any) => {\n  socket.emit('message', {\n    type: 'COLLABROOM',\n    component: 'pad',\n    data,\n  });\n};\n\nconst sendChat = (socket:any, message:{\n    text: string,\n\n}) => sendMessage(socket, {type: 'CHAT_MESSAGE', message});\n\ndescribe(__filename, function () {\n  const padId = 'testChatPad';\n  const hooksBackup:MapArrayType<PluginDef[]> = {};\n\n  before(async function () {\n    for (const [name, defs] of Object.entries(pluginDefs.hooks)) {\n      if (defs == null) continue;\n      hooksBackup[name] = defs as PluginDef[];\n    }\n  });\n\n  beforeEach(async function () {\n    for (const [name, defs] of Object.entries(hooksBackup)) pluginDefs.hooks[name] = [...defs];\n    for (const name of Object.keys(pluginDefs.hooks)) {\n      if (hooksBackup[name] == null) delete pluginDefs.hooks[name];\n    }\n    if (await padManager.doesPadExist(padId)) {\n      const pad = await padManager.getPad(padId);\n      await pad.remove();\n    }\n  });\n\n  after(async function () {\n    Object.assign(pluginDefs.hooks, hooksBackup);\n    for (const name of Object.keys(pluginDefs.hooks)) {\n      if (hooksBackup[name] == null) delete pluginDefs.hooks[name];\n    }\n  });\n\n  describe('chatNewMessage hook', function () {\n    let authorId: string;\n    let socket: any;\n\n    beforeEach(async function () {\n      socket = await common.connect();\n      const {data: clientVars} = await common.handshake(socket, padId);\n      authorId = clientVars.userId;\n    });\n\n    afterEach(async function () {\n      socket.close();\n    });\n\n    it('message', async function () {\n      const start = Date.now();\n      await Promise.all([\n        checkHook('chatNewMessage', ({message}) => {\n          assert(message != null);\n          assert(message instanceof ChatMessage);\n          // @ts-ignore\n          assert.equal(message!.authorId, authorId);\n          // @ts-ignore\n          assert.equal(message!.text, this.test!.title);\n          // @ts-ignore\n          assert(message!.time >= start);\n          // @ts-ignore\n          assert(message!.time <= Date.now());\n        }),\n        sendChat(socket, {text: this.test!.title}),\n      ]);\n    });\n\n    it('pad', async function () {\n      await Promise.all([\n        checkHook('chatNewMessage', ({pad}) => {\n          assert(pad != null);\n          assert(pad instanceof Pad);\n          assert.equal(pad.id, padId);\n        }),\n        sendChat(socket, {text: this.test!.title}),\n      ]);\n    });\n\n    it('padId', async function () {\n      await Promise.all([\n        checkHook('chatNewMessage', (context) => {\n          assert.equal(context.padId, padId);\n        }),\n        sendChat(socket, {text: this.test!.title}),\n      ]);\n    });\n\n    it('mutations propagate', async function () {\n\n      type Message = {\n        type: string,\n        data: any,\n      }\n\n      const listen = async (type: string) => await new Promise<any>((resolve) => {\n        const handler = (msg:Message) => {\n          if (msg.type !== 'COLLABROOM') return;\n          if (msg.data == null || msg.data.type !== type) return;\n          resolve(msg.data);\n          socket.off('message', handler);\n        };\n        socket.on('message', handler);\n      });\n\n      const modifiedText = `${this.test!.title} <added changes>`;\n      const customMetadata = {foo: this.test!.title};\n      await Promise.all([\n        checkHook('chatNewMessage', ({message}) => {\n          // @ts-ignore\n          message.text = modifiedText;\n          // @ts-ignore\n          message.customMetadata = customMetadata;\n        }),\n        (async () => {\n          const {message} = await listen('CHAT_MESSAGE');\n          assert(message != null);\n          assert.equal(message.text, modifiedText);\n          assert.deepEqual(message.customMetadata, customMetadata);\n        })(),\n        sendChat(socket, {text: this.test!.title}),\n      ]);\n      // Simulate fetch of historical chat messages when a pad is first loaded.\n      await Promise.all([\n        (async () => {\n          const {messages: [message]} = await listen('CHAT_MESSAGES');\n          assert(message != null);\n          assert.equal(message.text, modifiedText);\n          assert.deepEqual(message.customMetadata, customMetadata);\n        })(),\n        sendMessage(socket, {type: 'GET_CHAT_MESSAGES', start: 0, end: 0}),\n      ]);\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/contentcollector.ts",
    "content": "'use strict';\n\n/*\n * While importexport tests target the `setHTML` API endpoint, which is nearly identical to what\n * happens when a user manually imports a document via the UI, the contentcollector tests here don't\n * use rehype to process the document. Rehype removes spaces and newĺines were applicable, so the\n * expected results here can differ from importexport.js.\n *\n * If you add tests here, please also add them to importexport.js\n */\n\nimport {APool} from \"../../../node/types/PadType\";\n\nimport AttributePool from '../../../static/js/AttributePool';\nconst Changeset = require('../../../static/js/Changeset');\nconst assert = require('assert').strict;\nimport attributes from '../../../static/js/attributes';\nconst contentcollector = require('../../../static/js/contentcollector');\nimport jsdom from 'jsdom';\nimport {Attribute} from \"../../../static/js/types/Attribute\";\n\n// All test case `wantAlines` values must only refer to attributes in this list so that the\n// attribute numbers do not change due to changes in pool insertion order.\nconst knownAttribs: Attribute[] = [\n  ['insertorder', 'first'],\n  ['italic', 'true'],\n  ['list', 'bullet1'],\n  ['list', 'bullet2'],\n  ['list', 'number1'],\n  ['list', 'number2'],\n  ['lmkr', '1'],\n  ['start', '1'],\n  ['start', '2'],\n];\n\nconst testCases = [\n  {\n    description: 'Simple',\n    html: '<html><body><p>foo</p></body></html>',\n    wantAlines: ['+3'],\n    wantText: ['foo'],\n  },\n  {\n    description: 'Line starts with asterisk',\n    html: '<html><body><p>*foo</p></body></html>',\n    wantAlines: ['+4'],\n    wantText: ['*foo'],\n  },\n  {\n    description: 'Complex nested Li',\n    html: '<!doctype html><html><body><ol><li>one</li><li><ol><li>1.1</li></ol></li><li>two</li></ol></body></html>',\n    wantAlines: [\n      '*0*4*6*7+1+3',\n      '*0*5*6*8+1+3',\n      '*0*4*6*8+1+3',\n    ],\n    wantText: [\n      '*one', '*1.1', '*two',\n    ],\n  },\n  {\n    description: 'Complex list of different types',\n    html: '<!doctype html><html><body><ul class=\"bullet\"><li>one</li><li>two</li><li>0</li><li>1</li><li>2<ul class=\"bullet\"><li>3</li><li>4</li></ul></li></ul><ol class=\"number\"><li>item<ol class=\"number\"><li>item1</li><li>item2</li></ol></li></ol></body></html>',\n    wantAlines: [\n      '*0*2*6+1+3',\n      '*0*2*6+1+3',\n      '*0*2*6+1+1',\n      '*0*2*6+1+1',\n      '*0*2*6+1+1',\n      '*0*3*6+1+1',\n      '*0*3*6+1+1',\n      '*0*4*6*7+1+4',\n      '*0*5*6*8+1+5',\n      '*0*5*6*8+1+5',\n    ],\n    wantText: [\n      '*one',\n      '*two',\n      '*0',\n      '*1',\n      '*2',\n      '*3',\n      '*4',\n      '*item',\n      '*item1',\n      '*item2',\n    ],\n  },\n  {\n    description: 'Tests if uls properly get attributes',\n    html: '<html><body><ul><li>a</li><li>b</li></ul><div>div</div><p>foo</p></body></html>',\n    wantAlines: [\n      '*0*2*6+1+1',\n      '*0*2*6+1+1',\n      '+3',\n      '+3',\n    ],\n    wantText: ['*a', '*b', 'div', 'foo'],\n  },\n  {\n    description: 'Tests if indented uls properly get attributes',\n    html: '<html><body><ul><li>a</li><ul><li>b</li></ul><li>a</li></ul><p>foo</p></body></html>',\n    wantAlines: [\n      '*0*2*6+1+1',\n      '*0*3*6+1+1',\n      '*0*2*6+1+1',\n      '+3',\n    ],\n    wantText: ['*a', '*b', '*a', 'foo'],\n  },\n  {\n    description: 'Tests if ols properly get line numbers when in a normal OL',\n    html: '<html><body><ol><li>a</li><li>b</li><li>c</li></ol><p>test</p></body></html>',\n    wantAlines: [\n      '*0*4*6*7+1+1',\n      '*0*4*6*7+1+1',\n      '*0*4*6*7+1+1',\n      '+4',\n    ],\n    wantText: ['*a', '*b', '*c', 'test'],\n    noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?',\n  },\n  {\n    description: 'A single completely empty line break within an ol should reset count if OL is closed off..',\n    html: '<html><body><ol><li>should be 1</li></ol><p>hello</p><ol><li>should be 1</li><li>should be 2</li></ol><p></p></body></html>',\n    wantAlines: [\n      '*0*4*6*7+1+b',\n      '+5',\n      '*0*4*6*8+1+b',\n      '*0*4*6*8+1+b',\n      '',\n    ],\n    wantText: ['*should be 1', 'hello', '*should be 1', '*should be 2', ''],\n    noteToSelf: \"Shouldn't include attribute marker in the <p> line\",\n  },\n  {\n    description: 'A single <p></p> should create a new line',\n    html: '<html><body><p></p><p></p></body></html>',\n    wantAlines: ['', ''],\n    wantText: ['', ''],\n    noteToSelf: '<p></p>should create a line break but not break numbering',\n  },\n  {\n    description: 'Tests if ols properly get line numbers when in a normal OL #2',\n    html: '<html><body>a<ol><li>b<ol><li>c</li></ol></ol>notlist<p>foo</p></body></html>',\n    wantAlines: [\n      '+1',\n      '*0*4*6*7+1+1',\n      '*0*5*6*8+1+1',\n      '+7',\n      '+3',\n    ],\n    wantText: ['a', '*b', '*c', 'notlist', 'foo'],\n    noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?',\n  },\n  {\n    description: 'First item being an UL then subsequent being OL will fail',\n    html: '<html><body><ul><li>a<ol><li>b</li><li>c</li></ol></li></ul></body></html>',\n    wantAlines: ['+1', '*0*1*2*3+1+1', '*0*4*2*5+1+1'],\n    wantText: ['a', '*b', '*c'],\n    noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?',\n    disabled: true,\n  },\n  {\n    description: 'A single completely empty line break within an ol should NOT reset count',\n    html: '<html><body><ol><li>should be 1</li><p></p><li>should be 2</li><li>should be 3</li></ol><p></p></body></html>',\n    wantAlines: [],\n    wantText: ['*should be 1', '*should be 2', '*should be 3'],\n    noteToSelf: \"<p></p>should create a line break but not break numbering -- This is what I can't get working!\",\n    disabled: true,\n  },\n  {\n    description: 'Content outside body should be ignored',\n    html: '<html><head><title>title</title><style></style></head><body>empty<br></body></html>',\n    wantAlines: ['+5'],\n    wantText: ['empty'],\n  },\n  {\n    description: 'Multiple spaces should be preserved',\n    html: '<html><body>Text with  more   than    one space.<br></body></html>',\n    wantAlines: ['+10'],\n    wantText: ['Text with  more   than    one space.'],\n  },\n  {\n    description: 'non-breaking and normal space should be preserved',\n    html: '<html><body>Text&nbsp;with&nbsp; more&nbsp;&nbsp;&nbsp;than   &nbsp;one space.<br></body></html>',\n    wantAlines: ['+10'],\n    wantText: ['Text with  more   than    one space.'],\n  },\n  {\n    description: 'Multiple nbsp should be preserved',\n    html: '<html><body>&nbsp;&nbsp;<br></body></html>',\n    wantAlines: ['+2'],\n    wantText: ['  '],\n  },\n  {\n    description: 'Multiple nbsp between words ',\n    html: '<html><body>&nbsp;&nbsp;word1&nbsp;&nbsp;word2&nbsp;&nbsp;&nbsp;word3<br></body></html>',\n    wantAlines: ['+m'],\n    wantText: ['  word1  word2   word3'],\n  },\n  {\n    description: 'A non-breaking space preceded by a normal space',\n    html: '<html><body> &nbsp;word1 &nbsp;word2 &nbsp;word3<br></body></html>',\n    wantAlines: ['+l'],\n    wantText: ['  word1  word2  word3'],\n  },\n  {\n    description: 'A non-breaking space followed by a normal space',\n    html: '<html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br></body></html>',\n    wantAlines: ['+l'],\n    wantText: ['  word1  word2  word3'],\n  },\n  {\n    description: 'Don\\'t collapse spaces that follow a newline',\n    html: '<!doctype html><html><body>something<br>             something<br></body></html>',\n    wantAlines: ['+9', '+m'],\n    wantText: ['something', '             something'],\n  },\n  {\n    description: 'Don\\'t collapse spaces that follow a empty paragraph',\n    html: '<!doctype html><html><body>something<p></p>             something<br></body></html>',\n    wantAlines: ['+9', '', '+m'],\n    wantText: ['something', '', '             something'],\n  },\n  {\n    description: 'Don\\'t collapse spaces that preceed/follow a newline',\n    html: '<html><body>something            <br>             something<br></body></html>',\n    wantAlines: ['+l', '+m'],\n    wantText: ['something            ', '             something'],\n  },\n  {\n    description: 'Don\\'t collapse spaces that preceed/follow a empty paragraph',\n    html: '<html><body>something            <p></p>             something<br></body></html>',\n    wantAlines: ['+l', '', '+m'],\n    wantText: ['something            ', '', '             something'],\n  },\n  {\n    description: 'Don\\'t collapse non-breaking spaces that follow a newline',\n    html: '<html><body>something<br>&nbsp;&nbsp;&nbsp;something<br></body></html>',\n    wantAlines: ['+9', '+c'],\n    wantText: ['something', '   something'],\n  },\n  {\n    description: 'Don\\'t collapse non-breaking spaces that follow a paragraph',\n    html: '<html><body>something<p></p>&nbsp;&nbsp;&nbsp;something<br></body></html>',\n    wantAlines: ['+9', '', '+c'],\n    wantText: ['something', '', '   something'],\n  },\n  {\n    description: 'Preserve all spaces when multiple are present',\n    html: '<html><body>Need <span> more </span> space<i>  s </i> !<br></body></html>',\n    wantAlines: ['+h*1+4+2'],\n    wantText: ['Need  more  space  s  !'],\n  },\n  {\n    description: 'Newlines and multiple spaces across newlines should be preserved',\n    html: `\n      <html><body>Need\n          <span> more </span>\n          space\n          <i>  s </i>\n          !<br></body></html>`,\n    wantAlines: ['+19*1+4+b'],\n    wantText: ['Need           more           space            s           !'],\n  },\n  {\n    description: 'Multiple new lines at the beginning should be preserved',\n    html: '<html><body><br><br><p></p><p></p>first line<br><br>second line<br></body></html>',\n    wantAlines: ['', '', '', '', '+a', '', '+b'],\n    wantText: ['', '', '', '', 'first line', '', 'second line'],\n  },\n  {\n    description: 'A paragraph with multiple lines should not loose spaces when lines are combined',\n    html: `<html><body><p>\nа б в г ґ д е є ж з и і ї й к л м н о\nп р с т у ф х ц ч ш щ ю я ь</p>\n</body></html>`,\n    wantAlines: ['+1t'],\n    wantText: ['а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь'],\n  },\n  {\n    description: 'lines in preformatted text should be kept intact',\n    html: `<html><body><p>\nа б в г ґ д е є ж з и і ї й к л м н о</p><pre>multiple\nlines\nin\npre\n</pre><p>п р с т у ф х ц ч ш щ ю я\nь</p>\n</body></html>`,\n    wantAlines: ['+11', '+8', '+5', '+2', '+3', '+r'],\n    wantText: [\n      'а б в г ґ д е є ж з и і ї й к л м н о',\n      'multiple',\n      'lines',\n      'in',\n      'pre',\n      'п р с т у ф х ц ч ш щ ю я ь',\n    ],\n  },\n  {\n    description: 'pre should be on a new line not preceded by a space',\n    html: `<html><body><p>\n    1\n</p><pre>preline\n</pre></body></html>`,\n    wantAlines: ['+6', '+7'],\n    wantText: ['    1 ', 'preline'],\n  },\n  {\n    description: 'Preserve spaces on the beginning and end of a element',\n    html: '<html><body>Need<span> more </span>space<i> s </i>!<br></body></html>',\n    wantAlines: ['+f*1+3+1'],\n    wantText: ['Need more space s !'],\n  },\n  {\n    description: 'Preserve spaces outside elements',\n    html: '<html><body>Need <span>more</span> space <i>s</i> !<br></body></html>',\n    wantAlines: ['+g*1+1+2'],\n    wantText: ['Need more space s !'],\n  },\n  {\n    description: 'Preserve spaces at the end of an element',\n    html: '<html><body>Need <span>more </span>space <i>s </i>!<br></body></html>',\n    wantAlines: ['+g*1+2+1'],\n    wantText: ['Need more space s !'],\n  },\n  {\n    description: 'Preserve spaces at the start of an element',\n    html: '<html><body>Need<span> more</span> space<i> s</i> !<br></body></html>',\n    wantAlines: ['+f*1+2+2'],\n    wantText: ['Need more space s !'],\n  },\n];\n\ndescribe(__filename, function () {\n  for (const tc of testCases) {\n    describe(tc.description, function () {\n      let apool: AttributePool;\n      let result: {\n        lines: string[],\n        lineAttribs: string[],\n      };\n\n      before(async function () {\n        if (tc.disabled) return this.skip();\n        const {window: {document}} = new jsdom.JSDOM(tc.html);\n        apool = new AttributePool();\n        // To reduce test fragility, the attribute pool is seeded with `knownAttribs`, and all\n        // attributes in `tc.wantAlines` must be in `knownAttribs`. (This guarantees that attribute\n        // numbers do not change if the attribute processing code changes.)\n        for (const attrib of knownAttribs) apool.putAttrib(attrib);\n        for (const aline of tc.wantAlines) {\n          for (const op of Changeset.deserializeOps(aline)) {\n            for (const n of attributes.decodeAttribString(op.attribs)) {\n              assert(n < knownAttribs.length);\n            }\n          }\n        }\n        const cc = contentcollector.makeContentCollector(true, null, apool);\n        cc.collectContent(document.body);\n        result = cc.finish();\n      });\n\n      it('text matches', async function () {\n        assert.deepEqual(result.lines, tc.wantText);\n      });\n\n      it('alines match', async function () {\n        assert.deepEqual(result.lineAttribs, tc.wantAlines);\n      });\n\n      it('attributes are sorted in canonical order', async function () {\n        const gotAttribs:string[][][] = [];\n        const wantAttribs = [];\n        for (const aline of result.lineAttribs) {\n          const gotAlineAttribs:string[][] = [];\n          gotAttribs.push(gotAlineAttribs);\n          const wantAlineAttribs:Attribute[] = [];\n          wantAttribs.push(wantAlineAttribs);\n          for (const op of Changeset.deserializeOps(aline)) {\n            const gotOpAttribs = [...attributes.attribsFromString(op.attribs, apool)] as unknown as Attribute;\n            gotAlineAttribs.push(gotOpAttribs);\n            // @ts-ignore\n            wantAlineAttribs.push(attributes.sort([...gotOpAttribs]));\n          }\n        }\n        assert.deepEqual(gotAttribs, wantAttribs);\n      });\n    });\n  }\n});\n"
  },
  {
    "path": "src/tests/backend/specs/crypto.ts",
    "content": "'use strict';\n\n\nimport {Buffer} from 'buffer';\nimport nodeCrypto from 'crypto';\nimport util from 'util';\n\nconst nodeHkdf = nodeCrypto.hkdf ? util.promisify(nodeCrypto.hkdf) : null;\n\nconst ab2hex = (ab:string) => Buffer.from(ab).toString('hex');\n"
  },
  {
    "path": "src/tests/backend/specs/export.ts",
    "content": "'use strict';\n\nimport {MapArrayType} from \"../../../node/types/MapType\";\n\nconst common = require('../common');\nconst padManager = require('../../../node/db/PadManager');\nimport settings from '../../../node/utils/Settings';\n\ndescribe(__filename, function () {\n  let agent:any;\n  const settingsBackup:MapArrayType<any> = {};\n\n  before(async function () {\n    agent = await common.init();\n    settingsBackup.soffice = settings.soffice;\n    await padManager.getPad('testExportPad', 'test content');\n  });\n\n  after(async function () {\n    Object.assign(settings, settingsBackup);\n  });\n\n  it('returns 500 on export error', async function () {\n    settings.soffice = 'false'; // '/bin/false' doesn't work on Windows\n    await agent.get('/p/testExportPad/export/doc')\n        .expect(500);\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/favicon.ts",
    "content": "'use strict';\n\nimport {MapArrayType} from \"../../../node/types/MapType\";\n\nconst assert = require('assert').strict;\nconst common = require('../common');\nconst fs = require('fs');\nconst fsp = fs.promises;\nconst path = require('path');\nimport settings from '../../../node/utils/Settings';\nconst superagent = require('superagent');\n\ndescribe(__filename, function () {\n  let agent:any;\n  let backupSettings:MapArrayType<any>;\n  let skinDir: string;\n  let wantCustomIcon: boolean;\n  let wantDefaultIcon: boolean;\n  let wantSkinIcon: boolean;\n\n  before(async function () {\n    agent = await common.init();\n    wantCustomIcon = await fsp.readFile(path.join(__dirname, 'favicon-test-custom.png'));\n    wantDefaultIcon = await fsp.readFile(path.join(settings.root, 'src', 'static', 'favicon.ico'));\n    wantSkinIcon = await fsp.readFile(path.join(__dirname, 'favicon-test-skin.png'));\n  });\n\n  beforeEach(async function () {\n    backupSettings = {...settings};\n    skinDir = await fsp.mkdtemp(path.join(settings.root, 'src', 'static', 'skins', 'test-'));\n    settings.skinName = path.basename(skinDir);\n\n  });\n\n  afterEach(async function () {\n    // @ts-ignore\n    delete settings.favicon;\n    // @ts-ignore\n    delete settings.skinName;\n    Object.assign(settings, backupSettings);\n    try {\n      // TODO: The {recursive: true} option wasn't added to fsp.rmdir() until Node.js v12.10.0 so we\n      // can't rely on it until support for Node.js v10 is dropped.\n      await fsp.unlink(path.join(skinDir, 'favicon.ico'));\n      await fsp.rmdir(skinDir, {recursive: true});\n    } catch (err) { /* intentionally ignored */ }\n  });\n\n  it('uses custom favicon if set (relative pathname)', async function () {\n    settings.favicon =\n        path.relative(settings.root, path.join(__dirname, 'favicon-test-custom.png'));\n    assert(!path.isAbsolute(settings.favicon));\n    const {body: gotIcon} = await agent.get('/favicon.ico')\n        .accept('png').buffer(true).parse(superagent.parse.image)\n        .expect(200);\n    assert(gotIcon.equals(wantCustomIcon));\n  });\n\n  it('uses custom favicon from url', async function () {\n    settings.favicon = 'https://etherpad.org/favicon.ico';\n    await agent.get('/favicon.ico')\n        .expect(302);\n  });\n\n  it('uses custom favicon if set (absolute pathname)', async function () {\n    settings.favicon = path.join(__dirname, 'favicon-test-custom.png');\n    assert(path.isAbsolute(settings.favicon));\n    const {body: gotIcon} = await agent.get('/favicon.ico')\n        .accept('png').buffer(true).parse(superagent.parse.image)\n        .expect(200);\n    assert(gotIcon.equals(wantCustomIcon));\n  });\n\n  it('falls back if custom favicon is missing', async function () {\n    // The previous default for settings.favicon was 'favicon.ico', so many users will continue to\n    // have that in their settings.json for a long time. There is unlikely to be a favicon at\n    // path.resolve(settings.root, 'favicon.ico'), so this test ensures that 'favicon.ico' won't be\n    // a problem for those users.\n    settings.favicon = 'favicon.ico';\n    const {body: gotIcon} = await agent.get('/favicon.ico')\n        .accept('png').buffer(true).parse(superagent.parse.image)\n        .expect(200);\n    assert(gotIcon.equals(wantDefaultIcon));\n  });\n\n  it('uses skin favicon if present', async function () {\n    await fsp.writeFile(path.join(skinDir, 'favicon.ico'), wantSkinIcon);\n    settings.favicon = null;\n    const {body: gotIcon} = await agent.get('/favicon.ico')\n        .accept('png').buffer(true).parse(superagent.parse.image)\n        .expect(200);\n    assert(gotIcon.equals(wantSkinIcon));\n  });\n\n  it('falls back to default favicon', async function () {\n    settings.favicon = null;\n    const {body: gotIcon} = await agent.get('/favicon.ico')\n        .accept('png').buffer(true).parse(superagent.parse.image)\n        .expect(200);\n    assert(gotIcon.equals(wantDefaultIcon));\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/health.ts",
    "content": "'use strict';\n\nimport {MapArrayType} from \"../../../node/types/MapType\";\n\nconst assert = require('assert').strict;\nconst common = require('../common');\nimport settings, {\n  getEpVersion\n} from '../../../node/utils/Settings';\nconst superagent = require('superagent');\n\ndescribe(__filename, function () {\n  let agent:any;\n  const backup:MapArrayType<any> = {};\n\n  const getHealth = () => agent.get('/health')\n      .accept('application/health+json')\n      .buffer(true)\n      .parse(superagent.parse['application/json'])\n      .expect(200)\n      .expect((res:any) => assert.equal(res.type, 'application/health+json'));\n\n  before(async function () {\n    agent = await common.init();\n  });\n\n  beforeEach(async function () {\n    backup.settings = {};\n    for (const setting of ['requireAuthentication', 'requireAuthorization']) {\n      // @ts-ignore\n      backup.settings[setting] = settings[setting];\n    }\n  });\n\n  afterEach(async function () {\n    Object.assign(settings, backup.settings);\n  });\n\n  it('/health works', async function () {\n    const res = await getHealth();\n    assert.equal(res.body.status, 'pass');\n    assert.equal(res.body.releaseId, getEpVersion());\n  });\n\n  it('auth is not required', async function () {\n    settings.requireAuthentication = true;\n    settings.requireAuthorization = true;\n    const res = await getHealth();\n    assert.equal(res.body.status, 'pass');\n  });\n\n  // We actually want to test that no express-session state is created, but that is difficult to do\n  // without intrusive changes or unpleasant ueberdb digging. Instead, we assume that the lack of a\n  // cookie means that no express-session state was created (how would express-session look up the\n  // session state if no ID was returned to the client?).\n  it('no cookie is returned', async function () {\n    const res = await getHealth();\n    const cookie = res.headers['set-cookie'];\n    assert(cookie == null, `unexpected Set-Cookie: ${cookie}`);\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/hooks.ts",
    "content": "'use strict';\n\nimport {strict as assert} from 'assert';\nconst hooks = require('../../../static/js/pluginfw/hooks');\nconst plugins = require('../../../static/js/pluginfw/plugin_defs');\nimport sinon from 'sinon';\nimport {MapArrayType} from \"../../../node/types/MapType\";\n\n\ninterface ExtendedConsole extends Console {\n  warn: {\n    (message?: any, ...optionalParams: any[]): void;\n    callCount: number;\n    getCall: (i: number) => {args: any[]};\n  };\n    error: {\n        (message?: any, ...optionalParams: any[]): void;\n        callCount: number;\n        getCall: (i: number) => {args: any[]};\n        callsFake: (fn: Function) => void;\n        getCalls: () => {args: any[]}[];\n    };\n}\n\ndeclare var console: ExtendedConsole;\n\ndescribe(__filename, function () {\n\n\n\n  const hookName = 'testHook';\n  const hookFnName = 'testPluginFileName:testHookFunctionName';\n  let testHooks; // Convenience shorthand for plugins.hooks[hookName].\n  let hook: any; // Convenience shorthand for plugins.hooks[hookName][0].\n\n  beforeEach(async function () {\n    // Make sure these are not already set so that we don't accidentally step on someone else's\n    // toes:\n    assert(plugins.hooks[hookName] == null);\n    assert(hooks.deprecationNotices[hookName] == null);\n    assert(hooks.exportedForTestingOnly.deprecationWarned[hookFnName] == null);\n\n    // Many of the tests only need a single registered hook function. Set that up here to reduce\n    // boilerplate.\n    hook = makeHook();\n    plugins.hooks[hookName] = [hook];\n    testHooks = plugins.hooks[hookName];\n  });\n\n  afterEach(async function () {\n    sinon.restore();\n    delete plugins.hooks[hookName];\n    delete hooks.deprecationNotices[hookName];\n    delete hooks.exportedForTestingOnly.deprecationWarned[hookFnName];\n  });\n\n  const makeHook = (ret?:any) => ({\n    hook_name: hookName,\n    // Many tests will likely want to change this. Unfortunately, we can't use a convenience\n    // wrapper like `(...args) => hookFn(..args)` because the hooks look at Function.length and\n    // change behavior depending on the number of parameters.\n    hook_fn: (hn:Function, ctx:any, cb:Function) => cb(ret),\n    hook_fn_name: hookFnName,\n    part: {plugin: 'testPluginName'},\n  });\n\n  // Hook functions that should work for both synchronous and asynchronous hooks.\n  const supportedSyncHookFunctions = [\n    {\n      name: 'return non-Promise value, with callback parameter',\n      fn: (hn:Function, ctx:any, cb:Function) => 'val',\n      want: 'val',\n      syncOk: true,\n    },\n    {\n      name: 'return non-Promise value, without callback parameter',\n      fn: (hn:Function, ctx:any) => 'val',\n      want: 'val',\n      syncOk: true,\n    },\n    {\n      name: 'return undefined, without callback parameter',\n      fn: (hn:Function, ctx:any) => {},\n      want: undefined,\n      syncOk: true,\n    },\n    {\n      name: 'pass non-Promise value to callback',\n      fn: (hn:Function, ctx:any, cb:Function) => { cb('val'); },\n      want: 'val',\n      syncOk: true,\n    },\n    {\n      name: 'pass undefined to callback',\n      fn: (hn:Function, ctx:any, cb:Function) => { cb(); },\n      want: undefined,\n      syncOk: true,\n    },\n    {\n      name: 'return the value returned from the callback',\n      fn: (hn:Function, ctx:any, cb:Function) => cb('val'),\n      want: 'val',\n      syncOk: true,\n    },\n    {\n      name: 'throw',\n      fn: (hn:Function, ctx:any, cb:Function) => { throw new Error('test exception'); },\n      wantErr: 'test exception',\n      syncOk: true,\n    },\n  ];\n\n  describe('callHookFnSync', function () {\n    const callHookFnSync = hooks.exportedForTestingOnly.callHookFnSync; // Convenience shorthand.\n\n    describe('basic behavior', function () {\n      it('passes hook name', async function () {\n        hook.hook_fn = (hn: string) => { assert.equal(hn, hookName); };\n        callHookFnSync(hook);\n      });\n\n      it('passes context', async function () {\n        for (const val of ['value', null, undefined]) {\n          hook.hook_fn = (hn: string, ctx:string) => { assert.equal(ctx, val); };\n          callHookFnSync(hook, val);\n        }\n      });\n\n      it('returns the value provided to the callback', async function () {\n        for (const val of ['value', null, undefined]) {\n          hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { cb(ctx); };\n          assert.equal(callHookFnSync(hook, val), val);\n        }\n      });\n\n      it('returns the value returned by the hook function', async function () {\n        for (const val of ['value', null, undefined]) {\n          // Must not have the cb parameter otherwise returning undefined will error.\n          hook.hook_fn = (hn: string, ctx: any) => ctx;\n          assert.equal(callHookFnSync(hook, val), val);\n        }\n      });\n\n      it('does not catch exceptions', async function () {\n        hook.hook_fn = () => { throw new Error('test exception'); };\n        assert.throws(() => callHookFnSync(hook), {message: 'test exception'});\n      });\n\n      it('callback returns undefined', async function () {\n        hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { assert.equal(cb('foo'), undefined); };\n        callHookFnSync(hook);\n      });\n\n      it('checks for deprecation', async function () {\n        sinon.stub(console, 'warn');\n        hooks.deprecationNotices[hookName] = 'test deprecation';\n        callHookFnSync(hook);\n        assert.equal(hooks.exportedForTestingOnly.deprecationWarned[hookFnName], true);\n        // @ts-ignore\n        assert.equal(console.warn.callCount, 1);\n        // @ts-ignore\n        assert.match(console.warn.getCall(0).args[0], /test deprecation/);\n      });\n    });\n\n    describe('supported hook function styles', function () {\n      for (const tc of supportedSyncHookFunctions) {\n        it(tc.name, async function () {\n          sinon.stub(console, 'warn');\n          sinon.stub(console, 'error');\n          hook.hook_fn = tc.fn;\n          const call = () => callHookFnSync(hook);\n          if (tc.wantErr) {\n            assert.throws(call, {message: tc.wantErr});\n          } else {\n            assert.equal(call(), tc.want);\n          }\n          assert.equal(console.warn.callCount, 0);\n          assert.equal(console.error.callCount, 0);\n        });\n      }\n    });\n\n    describe('bad hook function behavior (other than double settle)', function () {\n      const promise1 = Promise.resolve('val1');\n      const promise2 = Promise.resolve('val2');\n\n      const testCases = [\n        {\n          name: 'never settles -> buggy hook detected',\n          // Note that returning undefined without calling the callback is permitted if the function\n          // has 2 or fewer parameters, so this test function must have 3 parameters.\n          fn: (hn:Function, ctx:any, cb:Function) => {},\n          wantVal: undefined,\n          wantError: /UNSETTLED FUNCTION BUG/,\n        },\n        {\n          name: 'returns a Promise -> buggy hook detected',\n          fn: () => promise1,\n          wantVal: promise1,\n          wantError: /PROHIBITED PROMISE BUG/,\n        },\n        {\n          name: 'passes a Promise to cb -> buggy hook detected',\n          fn: (hn:Function, ctx:any, cb:Function) => cb(promise2),\n          wantVal: promise2,\n          wantError: /PROHIBITED PROMISE BUG/,\n        },\n      ];\n\n      for (const tc of testCases) {\n        it(tc.name, async function () {\n          sinon.stub(console, 'error');\n          hook.hook_fn = tc.fn;\n          assert.equal(callHookFnSync(hook), tc.wantVal);\n          assert.equal(console.error.callCount, tc.wantError ? 1 : 0);\n          if (tc.wantError) assert.match(console.error.getCall(0).args[0], tc.wantError);\n        });\n      }\n    });\n\n    // Test various ways a hook might attempt to settle twice. (Examples: call the callback a second\n    // time, or call the callback and then return a value.)\n    describe('bad hook function behavior (double settle)', function () {\n      beforeEach(async function () {\n        sinon.stub(console, 'error');\n      });\n\n      // Each item in this array codifies a way to settle a synchronous hook function. Each of the\n      // test cases below combines two of these behaviors in a single hook function and confirms\n      // that callHookFnSync both (1) returns the result of the first settle attempt, and\n      // (2) detects the second settle attempt.\n      const behaviors = [\n        {\n          name: 'throw',\n          fn: (cb: Function, err:any, val: string) => { throw err; },\n          rejects: true,\n        },\n        {\n          name: 'return value',\n          fn: (cb: Function, err:any, val: string) => val,\n        },\n        {\n          name: 'immediately call cb(value)',\n          fn: (cb: Function, err:any, val: string) => cb(val),\n        },\n        {\n          name: 'defer call to cb(value)',\n          fn: (cb: Function, err:any, val: string) => { process.nextTick(cb, val); },\n          async: true,\n        },\n      ];\n\n      for (const step1 of behaviors) {\n        // There can't be a second step if the first step is to return or throw.\n        if (step1.name.startsWith('return ') || step1.name === 'throw') continue;\n        for (const step2 of behaviors) {\n          // If step1 and step2 are both async then there would be three settle attempts (first an\n          // erroneous unsettled return, then async step 1, then async step 2). Handling triple\n          // settle would complicate the tests, and it is sufficient to test only double settles.\n          if (step1.async && step2.async) continue;\n\n          it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () {\n            hook.hook_fn = (hn:Function, ctx:any, cb:Function) => {\n              step1.fn(cb, new Error(ctx.ret1), ctx.ret1);\n              return step2.fn(cb, new Error(ctx.ret2), ctx.ret2);\n            };\n\n            // Temporarily remove unhandled error listeners so that the errors we expect to see\n            // don't trigger a test failure (or terminate node).\n            const events = ['uncaughtException', 'unhandledRejection'];\n            const listenerBackups:MapArrayType<any> = {};\n            for (const event of events) {\n              listenerBackups[event] = process.rawListeners(event);\n              process.removeAllListeners(event);\n            }\n\n            // We should see an asynchronous error (either an unhandled Promise rejection or an\n            // uncaught exception) if and only if one of the two steps was asynchronous or there was\n            // a throw (in which case the double settle is deferred so that the caller sees the\n            // original error).\n            const wantAsyncErr = step1.async || step2.async || step2.rejects;\n            let tempListener:Function;\n            let asyncErr:Error|undefined;\n            try {\n              const seenErrPromise = new Promise<void>((resolve) => {\n                tempListener = (err:any) => {\n                  assert.equal(asyncErr, undefined);\n                  asyncErr = err;\n                  resolve();\n                };\n                if (!wantAsyncErr) resolve();\n              });\n              // @ts-ignore\n              events.forEach((event) => process.on(event, tempListener));\n              const call = () => callHookFnSync(hook, {ret1: 'val1', ret2: 'val2'});\n              if (step2.rejects) {\n                assert.throws(call, {message: 'val2'});\n              } else if (!step1.async && !step2.async) {\n                assert.throws(call, {message: /DOUBLE SETTLE BUG/});\n              } else {\n                assert.equal(call(), step1.async ? 'val2' : 'val1');\n              }\n              await seenErrPromise;\n            } finally {\n              // Restore the original listeners.\n              for (const event of events) {\n                // @ts-ignore\n                process.off(event, tempListener);\n                for (const listener of listenerBackups[event]) {\n                  process.on(event, listener);\n                }\n              }\n            }\n            assert.equal(console.error.callCount, 1);\n            assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/);\n            if (wantAsyncErr) {\n              assert(asyncErr instanceof Error);\n              assert.match(asyncErr.message, /DOUBLE SETTLE BUG/);\n            }\n          });\n\n          // This next test is the same as the above test, except the second settle attempt is for\n          // the same outcome. The two outcomes can't be the same if one step throws and the other\n          // doesn't, so skip those cases.\n          if (step1.rejects !== step2.rejects) continue;\n\n          it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () {\n            const err = new Error('val');\n            hook.hook_fn = (hn:Function, ctx:any, cb:Function) => {\n              step1.fn(cb, err, 'val');\n              return step2.fn(cb, err, 'val');\n            };\n\n            const errorLogged = new Promise((resolve) => console.error.callsFake(resolve));\n            const call = () => callHookFnSync(hook);\n            if (step2.rejects) {\n              assert.throws(call, {message: 'val'});\n            } else {\n              assert.equal(call(), 'val');\n            }\n            await errorLogged;\n            assert.equal(console.error.callCount, 1);\n            assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/);\n          });\n        }\n      }\n    });\n  });\n\n  describe('hooks.callAll', function () {\n    describe('basic behavior', function () {\n      it('calls all in order', async function () {\n        testHooks.length = 0;\n        testHooks.push(makeHook(1), makeHook(2), makeHook(3));\n        assert.deepEqual(hooks.callAll(hookName), [1, 2, 3]);\n      });\n\n      it('passes hook name', async function () {\n        hook.hook_fn = (hn:string) => { assert.equal(hn, hookName); };\n        hooks.callAll(hookName);\n      });\n\n      it('undefined context -> {}', async function () {\n        hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); };\n        hooks.callAll(hookName);\n      });\n\n      it('null context -> {}', async function () {\n        hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); };\n        hooks.callAll(hookName, null);\n      });\n\n      it('context unmodified', async function () {\n        const wantContext = {};\n        hook.hook_fn = (hn: string, ctx: any) => { assert.equal(ctx, wantContext); };\n        hooks.callAll(hookName, wantContext);\n      });\n    });\n\n    describe('result processing', function () {\n      it('no registered hooks (undefined) -> []', async function () {\n        delete plugins.hooks.testHook;\n        assert.deepEqual(hooks.callAll(hookName), []);\n      });\n\n      it('no registered hooks (empty list) -> []', async function () {\n        testHooks.length = 0;\n        assert.deepEqual(hooks.callAll(hookName), []);\n      });\n\n      it('flattens one level', async function () {\n        testHooks.length = 0;\n        testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]]));\n        assert.deepEqual(hooks.callAll(hookName), [1, 2, [3]]);\n      });\n\n      it('filters out undefined', async function () {\n        testHooks.length = 0;\n        testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]));\n        assert.deepEqual(hooks.callAll(hookName), [2, [3]]);\n      });\n\n      it('preserves null', async function () {\n        testHooks.length = 0;\n        testHooks.push(makeHook(null), makeHook([2]), makeHook([[3]]));\n        assert.deepEqual(hooks.callAll(hookName), [null, 2, [3]]);\n      });\n\n      it('all undefined -> []', async function () {\n        testHooks.length = 0;\n        testHooks.push(makeHook(), makeHook());\n        assert.deepEqual(hooks.callAll(hookName), []);\n      });\n    });\n  });\n\n  describe('hooks.callFirst', function () {\n    it('no registered hooks (undefined) -> []', async function () {\n      delete plugins.hooks.testHook;\n      assert.deepEqual(hooks.callFirst(hookName), []);\n    });\n\n    it('no registered hooks (empty list) -> []', async function () {\n      testHooks.length = 0;\n      assert.deepEqual(hooks.callFirst(hookName), []);\n    });\n\n    it('passes hook name => {}', async function () {\n      hook.hook_fn = (hn: string) => { assert.equal(hn, hookName); };\n      hooks.callFirst(hookName);\n    });\n\n    it('undefined context => {}', async function () {\n      hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); };\n      hooks.callFirst(hookName);\n    });\n\n    it('null context => {}', async function () {\n      hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); };\n      hooks.callFirst(hookName, null);\n    });\n\n    it('context unmodified', async function () {\n      const wantContext = {};\n      hook.hook_fn = (hn: string, ctx: any) => { assert.equal(ctx, wantContext); };\n      hooks.callFirst(hookName, wantContext);\n    });\n\n    it('predicate never satisfied -> calls all in order', async function () {\n      const gotCalls:MapArrayType<any> = [];\n      testHooks.length = 0;\n      for (let i = 0; i < 3; i++) {\n        const hook = makeHook();\n        hook.hook_fn = () => { gotCalls.push(i); };\n        testHooks.push(hook);\n      }\n      assert.deepEqual(hooks.callFirst(hookName), []);\n      assert.deepEqual(gotCalls, [0, 1, 2]);\n    });\n\n    it('stops when predicate is satisfied', async function () {\n      testHooks.length = 0;\n      testHooks.push(makeHook(), makeHook('val1'), makeHook('val2'));\n      assert.deepEqual(hooks.callFirst(hookName), ['val1']);\n    });\n\n    it('skips values that do not satisfy predicate (undefined)', async function () {\n      testHooks.length = 0;\n      testHooks.push(makeHook(), makeHook('val1'));\n      assert.deepEqual(hooks.callFirst(hookName), ['val1']);\n    });\n\n    it('skips values that do not satisfy predicate (empty list)', async function () {\n      testHooks.length = 0;\n      testHooks.push(makeHook([]), makeHook('val1'));\n      assert.deepEqual(hooks.callFirst(hookName), ['val1']);\n    });\n\n    it('null satisifes the predicate', async function () {\n      testHooks.length = 0;\n      testHooks.push(makeHook(null), makeHook('val1'));\n      assert.deepEqual(hooks.callFirst(hookName), [null]);\n    });\n\n    it('non-empty arrays are returned unmodified', async function () {\n      const want = ['val1'];\n      testHooks.length = 0;\n      testHooks.push(makeHook(want), makeHook(['val2']));\n      assert.equal(hooks.callFirst(hookName), want); // Note: *NOT* deepEqual!\n    });\n\n    it('value can be passed via callback', async function () {\n      const want = {};\n      hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { cb(want); };\n      const got = hooks.callFirst(hookName);\n      assert.deepEqual(got, [want]);\n      assert.equal(got[0], want); // Note: *NOT* deepEqual!\n    });\n  });\n\n  describe('callHookFnAsync', function () {\n    const callHookFnAsync = hooks.exportedForTestingOnly.callHookFnAsync; // Convenience shorthand.\n\n    describe('basic behavior', function () {\n      it('passes hook name', async function () {\n        hook.hook_fn = (hn:string) => { assert.equal(hn, hookName); };\n        await callHookFnAsync(hook);\n      });\n\n      it('passes context', async function () {\n        for (const val of ['value', null, undefined]) {\n          hook.hook_fn = (hn: string, ctx: any) => { assert.equal(ctx, val); };\n          await callHookFnAsync(hook, val);\n        }\n      });\n\n      it('returns the value provided to the callback', async function () {\n        for (const val of ['value', null, undefined]) {\n          hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { cb(ctx); };\n          assert.equal(await callHookFnAsync(hook, val), val);\n          assert.equal(await callHookFnAsync(hook, Promise.resolve(val)), val);\n        }\n      });\n\n      it('returns the value returned by the hook function', async function () {\n        for (const val of ['value', null, undefined]) {\n          // Must not have the cb parameter otherwise returning undefined will never resolve.\n          hook.hook_fn = (hn: string, ctx: any) => ctx;\n          assert.equal(await callHookFnAsync(hook, val), val);\n          assert.equal(await callHookFnAsync(hook, Promise.resolve(val)), val);\n        }\n      });\n\n      it('rejects if it throws an exception', async function () {\n        hook.hook_fn = () => { throw new Error('test exception'); };\n        await assert.rejects(callHookFnAsync(hook), {message: 'test exception'});\n      });\n\n      it('rejects if rejected Promise passed to callback', async function () {\n        hook.hook_fn = (hn:Function, ctx:any, cb:Function) => cb(Promise.reject(new Error('test exception')));\n        await assert.rejects(callHookFnAsync(hook), {message: 'test exception'});\n      });\n\n      it('rejects if rejected Promise returned', async function () {\n        hook.hook_fn = (hn:Function, ctx:any, cb:Function) => Promise.reject(new Error('test exception'));\n        await assert.rejects(callHookFnAsync(hook), {message: 'test exception'});\n      });\n\n      it('callback returns undefined', async function () {\n        hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { assert.equal(cb('foo'), undefined); };\n        await callHookFnAsync(hook);\n      });\n\n      it('checks for deprecation', async function () {\n        sinon.stub(console, 'warn');\n        hooks.deprecationNotices[hookName] = 'test deprecation';\n        await callHookFnAsync(hook);\n        assert.equal(hooks.exportedForTestingOnly.deprecationWarned[hookFnName], true);\n        assert.equal(console.warn.callCount, 1);\n        assert.match(console.warn.getCall(0).args[0], /test deprecation/);\n      });\n    });\n\n    describe('supported hook function styles', function () {\n      // @ts-ignore\n      const supportedHookFunctions = supportedSyncHookFunctions.concat([\n        {\n          name: 'legacy async cb',\n          fn: (hn:Function, ctx:any, cb:Function) => { process.nextTick(cb, 'val'); },\n          want: 'val',\n        },\n        // Already resolved Promises:\n        {\n          name: 'return resolved Promise, with callback parameter',\n          fn: (hn:Function, ctx:any, cb:Function) => Promise.resolve('val'),\n          want: 'val',\n        },\n        {\n          name: 'return resolved Promise, without callback parameter',\n          fn: (hn: string, ctx: any) => Promise.resolve('val'),\n          want: 'val',\n        },\n        {\n          name: 'pass resolved Promise to callback',\n          fn: (hn:Function, ctx:any, cb:Function) => { cb(Promise.resolve('val')); },\n          want: 'val',\n        },\n        // Not yet resolved Promises:\n        {\n          name: 'return unresolved Promise, with callback parameter',\n          fn: (hn:Function, ctx:any, cb:Function) => new Promise((resolve) => process.nextTick(resolve, 'val')),\n          want: 'val',\n        },\n        {\n          name: 'return unresolved Promise, without callback parameter',\n          fn: (hn: string, ctx: any) => new Promise((resolve) => process.nextTick(resolve, 'val')),\n          want: 'val',\n        },\n        {\n          name: 'pass unresolved Promise to callback',\n          fn: (hn:Function, ctx:any, cb:Function) => { cb(new Promise((resolve) => process.nextTick(resolve, 'val'))); },\n          want: 'val',\n        },\n        // Already rejected Promises:\n        {\n          name: 'return rejected Promise, with callback parameter',\n          fn: (hn:Function, ctx:any, cb:Function) => Promise.reject(new Error('test rejection')),\n          wantErr: 'test rejection',\n        },\n        {\n          name: 'return rejected Promise, without callback parameter',\n          fn: (hn: string, ctx: any) => Promise.reject(new Error('test rejection')),\n          wantErr: 'test rejection',\n        },\n        {\n          name: 'pass rejected Promise to callback',\n          fn: (hn:Function, ctx:any, cb:Function) => { cb(Promise.reject(new Error('test rejection'))); },\n          wantErr: 'test rejection',\n        },\n        // Not yet rejected Promises:\n        {\n          name: 'return unrejected Promise, with callback parameter',\n          fn: (hn:Function, ctx:any, cb:Function) => new Promise((resolve, reject) => {\n            process.nextTick(reject, new Error('test rejection'));\n          }),\n          wantErr: 'test rejection',\n        },\n        {\n          name: 'return unrejected Promise, without callback parameter',\n          fn: (hn: string, ctx: any) => new Promise((resolve, reject) => {\n            process.nextTick(reject, new Error('test rejection'));\n          }),\n          wantErr: 'test rejection',\n        },\n        {\n          name: 'pass unrejected Promise to callback',\n          fn: (hn:Function, ctx:any, cb:Function) => {\n            cb(new Promise((resolve, reject) => {\n              process.nextTick(reject, new Error('test rejection'));\n            }));\n          },\n          wantErr: 'test rejection',\n        },\n      ]);\n\n      for (const tc of supportedSyncHookFunctions.concat(supportedHookFunctions)) {\n        it(tc.name, async function () {\n          sinon.stub(console, 'warn');\n          sinon.stub(console, 'error');\n          hook.hook_fn = tc.fn;\n          const p = callHookFnAsync(hook);\n          if (tc.wantErr) {\n            await assert.rejects(p, {message: tc.wantErr});\n          } else {\n            assert.equal(await p, tc.want);\n          }\n          assert.equal(console.warn.callCount, 0);\n          assert.equal(console.error.callCount, 0);\n        });\n      }\n    });\n\n    // Test various ways a hook might attempt to settle twice. (Examples: call the callback a second\n    // time, or call the callback and then return a value.)\n    describe('bad hook function behavior (double settle)', function () {\n      beforeEach(async function () {\n        sinon.stub(console, 'error');\n      });\n\n      // Each item in this array codifies a way to settle an asynchronous hook function. Each of the\n      // test cases below combines two of these behaviors in a single hook function and confirms\n      // that callHookFnAsync both (1) resolves to the result of the first settle attempt, and (2)\n      // detects the second settle attempt.\n      //\n      // The 'when' property specifies the relative time that two behaviors will cause the hook\n      // function to settle:\n      //   * If behavior1.when <= behavior2.when and behavior1 is called before behavior2 then\n      //     behavior1 will settle the hook function before behavior2.\n      //   * Otherwise, behavior2 will settle the hook function before behavior1.\n      const behaviors = [\n        {\n          name: 'throw',\n          fn: (cb: Function, err:any, val: string) => { throw err; },\n          rejects: true,\n          when: 0,\n        },\n        {\n          name: 'return value',\n          fn: (cb: Function, err:any, val: string) => val,\n          // This behavior has a later relative settle time vs. the 'throw' behavior because 'throw'\n          // immediately settles the hook function, whereas the 'return value' case is settled by a\n          // .then() function attached to a Promise. EcmaScript guarantees that a .then() function\n          // attached to a Promise is enqueued on the event loop (not executed immediately) when the\n          // Promise settles.\n          when: 1,\n        },\n        {\n          name: 'immediately call cb(value)',\n          fn: (cb: Function, err:any, val: string) => cb(val),\n          // This behavior has the same relative time as the 'return value' case because it too is\n          // settled by a .then() function attached to a Promise.\n          when: 1,\n        },\n        {\n          name: 'return resolvedPromise',\n          fn: (cb: Function, err:any, val: string) => Promise.resolve(val),\n          // This behavior has the same relative time as the 'return value' case because the return\n          // value is wrapped in a Promise via Promise.resolve(). The EcmaScript standard guarantees\n          // that Promise.resolve(Promise.resolve(value)) is equivalent to Promise.resolve(value),\n          // so returning an already resolved Promise vs. returning a non-Promise value are\n          // equivalent.\n          when: 1,\n        },\n        {\n          name: 'immediately call cb(resolvedPromise)',\n          fn: (cb: Function, err:any, val: string) => cb(Promise.resolve(val)),\n          when: 1,\n        },\n        {\n          name: 'return rejectedPromise',\n          fn: (cb: Function, err:any, val: string) => Promise.reject(err),\n          rejects: true,\n          when: 1,\n        },\n        {\n          name: 'immediately call cb(rejectedPromise)',\n          fn: (cb: Function, err:any, val: string) => cb(Promise.reject(err)),\n          rejects: true,\n          when: 1,\n        },\n        {\n          name: 'return unresolvedPromise',\n          fn: (cb: Function, err:any, val: string) => new Promise((resolve) => process.nextTick(resolve, val)),\n          when: 2,\n        },\n        {\n          name: 'immediately call cb(unresolvedPromise)',\n          fn: (cb: Function, err:any, val: string) => cb(new Promise((resolve) => process.nextTick(resolve, val))),\n          when: 2,\n        },\n        {\n          name: 'return unrejectedPromise',\n          fn: (cb: Function, err:any, val: string) => new Promise((resolve, reject) => process.nextTick(reject, err)),\n          rejects: true,\n          when: 2,\n        },\n        {\n          name: 'immediately call cb(unrejectedPromise)',\n          fn: (cb: Function, err:any, val: string) => cb(new Promise((resolve, reject) => process.nextTick(reject, err))),\n          rejects: true,\n          when: 2,\n        },\n        {\n          name: 'defer call to cb(value)',\n          fn: (cb: Function, err:any, val: string) => { process.nextTick(cb, val); },\n          when: 2,\n        },\n        {\n          name: 'defer call to cb(resolvedPromise)',\n          fn: (cb: Function, err:any, val: string) => { process.nextTick(cb, Promise.resolve(val)); },\n          when: 2,\n        },\n        {\n          name: 'defer call to cb(rejectedPromise)',\n          fn: (cb: Function, err:any, val: string) => { process.nextTick(cb, Promise.reject(err)); },\n          rejects: true,\n          when: 2,\n        },\n        {\n          name: 'defer call to cb(unresolvedPromise)',\n          fn: (cb: Function, err:any, val: string) => {\n            process.nextTick(() => {\n              cb(new Promise((resolve) => process.nextTick(resolve, val)));\n            });\n          },\n          when: 3,\n        },\n        {\n          name: 'defer call cb(unrejectedPromise)',\n          fn: (cb: Function, err:any, val: string) => {\n            process.nextTick(() => {\n              cb(new Promise((resolve, reject) => process.nextTick(reject, err)));\n            });\n          },\n          rejects: true,\n          when: 3,\n        },\n      ];\n\n      for (const step1 of behaviors) {\n        // There can't be a second step if the first step is to return or throw.\n        if (step1.name.startsWith('return ') || step1.name === 'throw') continue;\n        for (const step2 of behaviors) {\n          it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () {\n            hook.hook_fn = (hn:Function, ctx:any, cb:Function) => {\n              step1.fn(cb, new Error(ctx.ret1), ctx.ret1);\n              return step2.fn(cb, new Error(ctx.ret2), ctx.ret2);\n            };\n\n            // Temporarily remove unhandled Promise rejection listeners so that the unhandled\n            // rejections we expect to see don't trigger a test failure (or terminate node).\n            const event = 'unhandledRejection';\n            const listenersBackup = process.rawListeners(event);\n            process.removeAllListeners(event);\n\n            let tempListener;\n            let asyncErr: Error;\n            try {\n              const seenErrPromise = new Promise<void>((resolve) => {\n                tempListener = (err:any) => {\n                  assert.equal(asyncErr, undefined);\n                  asyncErr = err;\n                  resolve();\n                };\n              });\n              process.on(event, tempListener!);\n              const step1Wins = step1.when <= step2.when;\n              const winningStep = step1Wins ? step1 : step2;\n              const winningVal = step1Wins ? 'val1' : 'val2';\n              const p = callHookFnAsync(hook, {ret1: 'val1', ret2: 'val2'});\n              if (winningStep.rejects) {\n                await assert.rejects(p, {message: winningVal});\n              } else {\n                assert.equal(await p, winningVal);\n              }\n              await seenErrPromise;\n            } finally {\n              // Restore the original listeners.\n              process.off(event, tempListener!);\n              for (const listener of listenersBackup) {\n                process.on(event, listener as any);\n              }\n            }\n            assert.equal(console.error.callCount, 1,\n                `Got errors:\\n${\n                  console.error.getCalls().map((call) => call.args[0]).join('\\n')}`);\n            assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/);\n            // @ts-ignore\n            assert(asyncErr instanceof Error);\n            assert.match(asyncErr.message, /DOUBLE SETTLE BUG/);\n          });\n\n          // This next test is the same as the above test, except the second settle attempt is for\n          // the same outcome. The two outcomes can't be the same if one step rejects and the other\n          // doesn't, so skip those cases.\n          if (step1.rejects !== step2.rejects) continue;\n\n          it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () {\n            const err = new Error('val');\n            hook.hook_fn = (hn:Function, ctx:any, cb:Function) => {\n              step1.fn(cb, err, 'val');\n              return step2.fn(cb, err, 'val');\n            };\n            const winningStep = (step1.when <= step2.when) ? step1 : step2;\n            const errorLogged = new Promise((resolve) => console.error.callsFake(resolve));\n            const p = callHookFnAsync(hook);\n            if (winningStep.rejects) {\n              await assert.rejects(p, {message: 'val'});\n            } else {\n              assert.equal(await p, 'val');\n            }\n            await errorLogged;\n            assert.equal(console.error.callCount, 1);\n            assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/);\n          });\n        }\n      }\n    });\n  });\n\n  describe('hooks.aCallAll', function () {\n    describe('basic behavior', function () {\n      it('calls all asynchronously, returns values in order', async function () {\n        testHooks.length = 0; // Delete the boilerplate hook -- this test doesn't use it.\n        let nextIndex = 0;\n        const hookPromises: {\n            promise?: Promise<number>,\n            resolve?: Function,\n        } []\n         = [];\n        const hookStarted: boolean[] = [];\n        const hookFinished :boolean[]= [];\n        const makeHook = () => {\n          const i = nextIndex++;\n          const entry:{\n            promise?: Promise<number>,\n            resolve?: Function,\n          } = {};\n          hookStarted[i] = false;\n          hookFinished[i] = false;\n          hookPromises[i] = entry;\n          entry.promise = new Promise((resolve) => {\n            entry.resolve = () => {\n              hookFinished[i] = true;\n              resolve(i);\n            };\n          });\n          return {hook_fn: () => {\n            hookStarted[i] = true;\n            return entry.promise;\n          }};\n        };\n        testHooks.push(makeHook(), makeHook());\n        const p = hooks.aCallAll(hookName);\n        assert.deepEqual(hookStarted, [true, true]);\n        assert.deepEqual(hookFinished, [false, false]);\n        hookPromises[1].resolve!();\n        await hookPromises[1].promise;\n        assert.deepEqual(hookFinished, [false, true]);\n        hookPromises[0].resolve!();\n        assert.deepEqual(await p, [0, 1]);\n      });\n\n      it('passes hook name', async function () {\n        hook.hook_fn = async (hn:string) => { assert.equal(hn, hookName); };\n        await hooks.aCallAll(hookName);\n      });\n\n      it('undefined context -> {}', async function () {\n        hook.hook_fn = async (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); };\n        await hooks.aCallAll(hookName);\n      });\n\n      it('null context -> {}', async function () {\n        hook.hook_fn = async (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); };\n        await hooks.aCallAll(hookName, null);\n      });\n\n      it('context unmodified', async function () {\n        const wantContext = {};\n        hook.hook_fn = async (hn: string, ctx: any) => { assert.equal(ctx, wantContext); };\n        await hooks.aCallAll(hookName, wantContext);\n      });\n    });\n\n    describe('aCallAll callback', function () {\n      it('exception in callback rejects', async function () {\n        const p = hooks.aCallAll(hookName, {}, () => { throw new Error('test exception'); });\n        await assert.rejects(p, {message: 'test exception'});\n      });\n\n      it('propagates error on exception', async function () {\n        hook.hook_fn = () => { throw new Error('test exception'); };\n        await hooks.aCallAll(hookName, {}, (err:any) => {\n          assert(err instanceof Error);\n          assert.equal(err.message, 'test exception');\n        });\n      });\n\n      it('propagages null error on success', async function () {\n        await hooks.aCallAll(hookName, {}, (err:any) => {\n          assert(err == null, `got non-null error: ${err}`);\n        });\n      });\n\n      it('propagages results on success', async function () {\n        hook.hook_fn = () => 'val';\n        await hooks.aCallAll(hookName, {}, (err:any, results:any) => {\n          assert.deepEqual(results, ['val']);\n        });\n      });\n\n      it('returns callback return value', async function () {\n        assert.equal(await hooks.aCallAll(hookName, {}, () => 'val'), 'val');\n      });\n    });\n\n    describe('result processing', function () {\n      it('no registered hooks (undefined) -> []', async function () {\n        delete plugins.hooks[hookName];\n        assert.deepEqual(await hooks.aCallAll(hookName), []);\n      });\n\n      it('no registered hooks (empty list) -> []', async function () {\n        testHooks.length = 0;\n        assert.deepEqual(await hooks.aCallAll(hookName), []);\n      });\n\n      it('flattens one level', async function () {\n        testHooks.length = 0;\n        testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]]));\n        assert.deepEqual(await hooks.aCallAll(hookName), [1, 2, [3]]);\n      });\n\n      it('filters out undefined', async function () {\n        testHooks.length = 0;\n        testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve()));\n        assert.deepEqual(await hooks.aCallAll(hookName), [2, [3]]);\n      });\n\n      it('preserves null', async function () {\n        testHooks.length = 0;\n        testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null)));\n        assert.deepEqual(await hooks.aCallAll(hookName), [null, 2, null]);\n      });\n\n      it('all undefined -> []', async function () {\n        testHooks.length = 0;\n        testHooks.push(makeHook(), makeHook(Promise.resolve()));\n        assert.deepEqual(await hooks.aCallAll(hookName), []);\n      });\n    });\n  });\n\n  describe('hooks.callAllSerial', function () {\n    describe('basic behavior', function () {\n      it('calls all asynchronously, serially, in order', async function () {\n        const gotCalls:number[] = [];\n        testHooks.length = 0;\n        for (let i = 0; i < 3; i++) {\n          const hook = makeHook();\n          hook.hook_fn = async () => {\n            gotCalls.push(i);\n            // Check gotCalls asynchronously to ensure that the next hook function does not start\n            // executing before this hook function has resolved.\n            return await new Promise((resolve) => {\n              setImmediate(() => {\n                assert.deepEqual(gotCalls, [...Array(i + 1).keys()]);\n                resolve(i);\n              });\n            });\n          };\n          testHooks.push(hook);\n        }\n        assert.deepEqual(await hooks.callAllSerial(hookName), [0, 1, 2]);\n        assert.deepEqual(gotCalls, [0, 1, 2]);\n      });\n\n      it('passes hook name', async function () {\n        hook.hook_fn = async (hn:string) => { assert.equal(hn, hookName); };\n        await hooks.callAllSerial(hookName);\n      });\n\n      it('undefined context -> {}', async function () {\n        hook.hook_fn = async (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); };\n        await hooks.callAllSerial(hookName);\n      });\n\n      it('null context -> {}', async function () {\n        hook.hook_fn = async (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); };\n        await hooks.callAllSerial(hookName, null);\n      });\n\n      it('context unmodified', async function () {\n        const wantContext = {};\n        hook.hook_fn = async (hn: string, ctx: any) => { assert.equal(ctx, wantContext); };\n        await hooks.callAllSerial(hookName, wantContext);\n      });\n    });\n\n    describe('result processing', function () {\n      it('no registered hooks (undefined) -> []', async function () {\n        delete plugins.hooks[hookName];\n        assert.deepEqual(await hooks.callAllSerial(hookName), []);\n      });\n\n      it('no registered hooks (empty list) -> []', async function () {\n        testHooks.length = 0;\n        assert.deepEqual(await hooks.callAllSerial(hookName), []);\n      });\n\n      it('flattens one level', async function () {\n        testHooks.length = 0;\n        testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]]));\n        assert.deepEqual(await hooks.callAllSerial(hookName), [1, 2, [3]]);\n      });\n\n      it('filters out undefined', async function () {\n        testHooks.length = 0;\n        testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve()));\n        assert.deepEqual(await hooks.callAllSerial(hookName), [2, [3]]);\n      });\n\n      it('preserves null', async function () {\n        testHooks.length = 0;\n        testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null)));\n        assert.deepEqual(await hooks.callAllSerial(hookName), [null, 2, null]);\n      });\n\n      it('all undefined -> []', async function () {\n        testHooks.length = 0;\n        testHooks.push(makeHook(), makeHook(Promise.resolve()));\n        assert.deepEqual(await hooks.callAllSerial(hookName), []);\n      });\n    });\n  });\n\n  describe('hooks.aCallFirst', function () {\n    it('no registered hooks (undefined) -> []', async function () {\n      delete plugins.hooks.testHook;\n      assert.deepEqual(await hooks.aCallFirst(hookName), []);\n    });\n\n    it('no registered hooks (empty list) -> []', async function () {\n      testHooks.length = 0;\n      assert.deepEqual(await hooks.aCallFirst(hookName), []);\n    });\n\n    it('passes hook name => {}', async function () {\n      hook.hook_fn = (hn:string) => { assert.equal(hn, hookName); };\n      await hooks.aCallFirst(hookName);\n    });\n\n    it('undefined context => {}', async function () {\n      hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); };\n      await hooks.aCallFirst(hookName);\n    });\n\n    it('null context => {}', async function () {\n      hook.hook_fn = (hn: string, ctx: any) => { assert.deepEqual(ctx, {}); };\n      await hooks.aCallFirst(hookName, null);\n    });\n\n    it('context unmodified', async function () {\n      const wantContext = {};\n      hook.hook_fn = (hn: string, ctx: any) => { assert.equal(ctx, wantContext); };\n      await hooks.aCallFirst(hookName, wantContext);\n    });\n\n    it('default predicate: predicate never satisfied -> calls all in order', async function () {\n      const gotCalls:number[] = [];\n      testHooks.length = 0;\n      for (let i = 0; i < 3; i++) {\n        const hook = makeHook();\n        hook.hook_fn = () => { gotCalls.push(i); };\n        testHooks.push(hook);\n      }\n      assert.deepEqual(await hooks.aCallFirst(hookName), []);\n      assert.deepEqual(gotCalls, [0, 1, 2]);\n    });\n\n    it('calls hook functions serially', async function () {\n      const gotCalls: number[] = [];\n      testHooks.length = 0;\n      for (let i = 0; i < 3; i++) {\n        const hook = makeHook();\n        hook.hook_fn = async () => {\n          gotCalls.push(i);\n          // Check gotCalls asynchronously to ensure that the next hook function does not start\n          // executing before this hook function has resolved.\n          return await new Promise<void>((resolve) => {\n            setImmediate(() => {\n              assert.deepEqual(gotCalls, [...Array(i + 1).keys()]);\n              resolve();\n            });\n          });\n        };\n        testHooks.push(hook);\n      }\n      assert.deepEqual(await hooks.aCallFirst(hookName), []);\n      assert.deepEqual(gotCalls, [0, 1, 2]);\n    });\n\n    it('default predicate: stops when satisfied', async function () {\n      testHooks.length = 0;\n      testHooks.push(makeHook(), makeHook('val1'), makeHook('val2'));\n      assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']);\n    });\n\n    it('default predicate: skips values that do not satisfy (undefined)', async function () {\n      testHooks.length = 0;\n      testHooks.push(makeHook(), makeHook('val1'));\n      assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']);\n    });\n\n    it('default predicate: skips values that do not satisfy (empty list)', async function () {\n      testHooks.length = 0;\n      testHooks.push(makeHook([]), makeHook('val1'));\n      assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']);\n    });\n\n    it('default predicate: null satisifes', async function () {\n      testHooks.length = 0;\n      testHooks.push(makeHook(null), makeHook('val1'));\n      assert.deepEqual(await hooks.aCallFirst(hookName), [null]);\n    });\n\n    it('custom predicate: called for each hook function', async function () {\n      testHooks.length = 0;\n      testHooks.push(makeHook(0), makeHook(1), makeHook(2));\n      let got = 0;\n      await hooks.aCallFirst(hookName, null, null, (val:string) => { ++got; return false; });\n      assert.equal(got, 3);\n    });\n\n    it('custom predicate: boolean false/true continues/stops iteration', async function () {\n      testHooks.length = 0;\n      testHooks.push(makeHook(1), makeHook(2), makeHook(3));\n      let nCall = 0;\n      const predicate = (val: number[]) => {\n        assert.deepEqual(val, [++nCall]);\n        return nCall === 2;\n      };\n      assert.deepEqual(await hooks.aCallFirst(hookName, null, null, predicate), [2]);\n      assert.equal(nCall, 2);\n    });\n\n    it('custom predicate: non-boolean falsy/truthy continues/stops iteration', async function () {\n      testHooks.length = 0;\n      testHooks.push(makeHook(1), makeHook(2), makeHook(3));\n      let nCall = 0;\n      const predicate = (val: number[]) => {\n        assert.deepEqual(val, [++nCall]);\n        return nCall === 2 ? {} : null;\n      };\n      assert.deepEqual(await hooks.aCallFirst(hookName, null, null, predicate), [2]);\n      assert.equal(nCall, 2);\n    });\n\n    it('custom predicate: array value passed unmodified to predicate', async function () {\n      const want = [0];\n      hook.hook_fn = () => want;\n      const predicate = (got: []) => { assert.equal(got, want); }; // Note: *NOT* deepEqual!\n      await hooks.aCallFirst(hookName, null, null, predicate);\n    });\n\n    it('custom predicate: normalized value passed to predicate (undefined)', async function () {\n      const predicate = (got: []) => { assert.deepEqual(got, []); };\n      await hooks.aCallFirst(hookName, null, null, predicate);\n    });\n\n    it('custom predicate: normalized value passed to predicate (null)', async function () {\n      hook.hook_fn = () => null;\n      const predicate = (got: []) => { assert.deepEqual(got, [null]); };\n      await hooks.aCallFirst(hookName, null, null, predicate);\n    });\n\n    it('non-empty arrays are returned unmodified', async function () {\n      const want = ['val1'];\n      testHooks.length = 0;\n      testHooks.push(makeHook(want), makeHook(['val2']));\n      assert.equal(await hooks.aCallFirst(hookName), want); // Note: *NOT* deepEqual!\n    });\n\n    it('value can be passed via callback', async function () {\n      const want = {};\n      hook.hook_fn = (hn:Function, ctx:any, cb:Function) => { cb(want); };\n      const got = await hooks.aCallFirst(hookName);\n      assert.deepEqual(got, [want]);\n      assert.equal(got[0], want); // Note: *NOT* deepEqual!\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/lowerCasePadIds.ts",
    "content": "'use strict';\n\nconst assert = require('assert').strict;\nconst common = require('../common');\nconst padManager = require('../../../node/db/PadManager');\nimport settings from '../../../node/utils/Settings';\n\ndescribe(__filename, function () {\n  let agent:any;\n  const cleanUpPads = async () => {\n    const {padIDs} = await padManager.listAllPads();\n    await Promise.all(padIDs.map(async (padId: string) => {\n      if (await padManager.doesPadExist(padId)) {\n        const pad = await padManager.getPad(padId);\n        await pad.remove();\n      }\n    }));\n  };\n  let backup:any;\n\n  before(async function () {\n    backup = settings.lowerCasePadIds;\n    agent = await common.init();\n  });\n  beforeEach(async function () {\n    await cleanUpPads();\n  });\n  afterEach(async function () {\n    await cleanUpPads();\n  });\n  after(async function () {\n    settings.lowerCasePadIds = backup;\n  });\n\n  describe('not activated', function () {\n    beforeEach(async function () {\n      settings.lowerCasePadIds = false;\n    });\n\n\n    it('do nothing', async function () {\n      await agent.get('/p/UPPERCASEpad')\n        .expect(200);\n    });\n  });\n\n  describe('activated', function () {\n    beforeEach(async function () {\n      settings.lowerCasePadIds = true;\n    });\n    it('lowercase pad ids', async function () {\n      await agent.get('/p/UPPERCASEpad')\n        .expect(302)\n        .expect('location', 'uppercasepad');\n    });\n\n    it('keeps old pads accessible', async function () {\n      Object.assign(settings, {\n        lowerCasePadIds: false,\n      });\n      await padManager.getPad('ALREADYexistingPad', 'oldpad');\n      await padManager.getPad('alreadyexistingpad', 'newpad');\n      Object.assign(settings, {\n        lowerCasePadIds: true,\n      });\n\n      const oldPad = await agent.get('/p/ALREADYexistingPad').expect(200);\n      const oldPadSocket = await common.connect(oldPad);\n      const oldPadHandshake = await common.handshake(oldPadSocket, 'ALREADYexistingPad');\n      assert.equal(oldPadHandshake.data.padId, 'ALREADYexistingPad');\n      assert.equal(oldPadHandshake.data.collab_client_vars.initialAttributedText.text, 'oldpad\\n');\n\n      const newPad = await agent.get('/p/alreadyexistingpad').expect(200);\n      const newPadSocket = await common.connect(newPad);\n      const newPadHandshake = await common.handshake(newPadSocket, 'alreadyexistingpad');\n      assert.equal(newPadHandshake.data.padId, 'alreadyexistingpad');\n      assert.equal(newPadHandshake.data.collab_client_vars.initialAttributedText.text, 'newpad\\n');\n    });\n\n    it('disallow creation of different case pad-name via socket connection', async function () {\n      await padManager.getPad('maliciousattempt', 'attempt');\n\n      const newPad = await agent.get('/p/maliciousattempt').expect(200);\n      const newPadSocket = await common.connect(newPad);\n      const newPadHandshake = await common.handshake(newPadSocket, 'MaliciousAttempt');\n\n      assert.equal(newPadHandshake.data.collab_client_vars.initialAttributedText.text, 'attempt\\n');\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/messages.ts",
    "content": "'use strict';\n\nimport {PadType} from \"../../../node/types/PadType\";\nimport {MapArrayType} from \"../../../node/types/MapType\";\n\nconst assert = require('assert').strict;\nconst common = require('../common');\nconst padManager = require('../../../node/db/PadManager');\nconst plugins = require('../../../static/js/pluginfw/plugin_defs');\nimport readOnlyManager from '../../../node/db/ReadOnlyManager';\n\ndescribe(__filename, function () {\n  let agent:any;\n  let pad:PadType|null;\n  let padId: string;\n  let roPadId: string;\n  let rev: number;\n  let socket: any;\n  let roSocket: any;\n  const backups:MapArrayType<any> = {};\n\n  before(async function () {\n    agent = await common.init();\n  });\n\n  beforeEach(async function () {\n    backups.hooks = {handleMessageSecurity: plugins.hooks.handleMessageSecurity};\n    plugins.hooks.handleMessageSecurity = [];\n    padId = common.randomString();\n    assert(!await padManager.doesPadExist(padId));\n    pad = await padManager.getPad(padId, 'dummy text\\n');\n    await pad!.setText('\\n'); // Make sure the pad is created.\n    assert.equal(pad!.text(), '\\n');\n    let res = await agent.get(`/p/${padId}`).expect(200);\n    socket = await common.connect(res);\n    const {type, data: clientVars} = await common.handshake(socket, padId);\n    assert.equal(type, 'CLIENT_VARS');\n    rev = clientVars.collab_client_vars.rev;\n\n    roPadId = await readOnlyManager.getReadOnlyId(padId);\n    res = await agent.get(`/p/${roPadId}`).expect(200);\n    roSocket = await common.connect(res);\n    await common.handshake(roSocket, roPadId);\n    await new Promise(resolve => setTimeout(resolve, 1000));\n  });\n\n  afterEach(async function () {\n    Object.assign(plugins.hooks, backups.hooks);\n    if (socket != null) socket.close();\n    socket = null;\n    if (roSocket != null) roSocket.close();\n    roSocket = null;\n    if (pad != null) await pad.remove();\n    pad = null;\n  });\n\n  describe('CHANGESET_REQ', function () {\n    it('users are unable to read changesets from other pads', async function () {\n      const otherPadId = `${padId}other`;\n      assert(!await padManager.doesPadExist(otherPadId));\n      const otherPad = await padManager.getPad(otherPadId, 'other text\\n');\n      try {\n        await otherPad.setText('other text\\n');\n        const resP = common.waitForSocketEvent(roSocket, 'message');\n        await common.sendMessage(roSocket, {\n          component: 'pad',\n          padId: otherPadId, // The server should ignore this.\n          type: 'CHANGESET_REQ',\n          data: {\n            granularity: 1,\n            start: 0,\n            requestID: 'requestId',\n          },\n        });\n        const res = await resP;\n        assert.equal(res.type, 'CHANGESET_REQ');\n        assert.equal(res.data.requestID, 'requestId');\n        // Should match padId's text, not otherPadId's text.\n        assert.match(res.data.forwardsChangesets[0], /^[^$]*\\$dummy text\\n/);\n      } finally {\n        await otherPad.remove();\n      }\n    });\n\n    it('CHANGESET_REQ: verify revNum is a number (regression)', async function () {\n      const otherPadId = `${padId}other`;\n      assert(!await padManager.doesPadExist(otherPadId));\n      const otherPad = await padManager.getPad(otherPadId, 'other text\\n');\n      let errorCatched = 0;\n      try {\n        await otherPad.setText('other text\\n');\n        await common.sendMessage(roSocket, {\n          component: 'pad',\n          padId: otherPadId, // The server should ignore this.\n          type: 'CHANGESET_REQ',\n          data: {\n            granularity: 1,\n            start: 'test123',\n            requestID: 'requestId',\n          },\n        });\n        assert.equal('This code should never run', 1);\n      }\n      catch(e:any) {\n        assert.match(e.message, /rev is not a number/);\n        errorCatched = 1;\n      }\n      finally {\n        await otherPad.remove();\n        assert.equal(errorCatched, 1);\n      }\n    });\n\n    it('CHANGESET_REQ: revNum is converted to number if possible (regression)', async function () {\n      const otherPadId = `${padId}other`;\n      assert(!await padManager.doesPadExist(otherPadId));\n      const otherPad = await padManager.getPad(otherPadId, 'other text\\n');\n      try {\n        await otherPad.setText('other text\\n');\n        const resP = common.waitForSocketEvent(roSocket, 'message');\n        await common.sendMessage(roSocket, {\n          component: 'pad',\n          padId: otherPadId, // The server should ignore this.\n          type: 'CHANGESET_REQ',\n          data: {\n            granularity: 1,\n            start: '1test123',\n            requestID: 'requestId',\n          },\n        });\n        const res = await resP;\n        assert.equal(res.type, 'CHANGESET_REQ');\n        assert.equal(res.data.requestID, 'requestId');\n        assert.equal(res.data.start, 1);\n      }\n      finally {\n        await otherPad.remove();\n      }\n    });\n\n    it('CHANGESET_REQ: revNum 2 is converted to head rev 1 (regression)', async function () {\n      const otherPadId = `${padId}other`;\n      assert(!await padManager.doesPadExist(otherPadId));\n      const otherPad = await padManager.getPad(otherPadId, 'other text\\n');\n      try {\n        await otherPad.setText('other text\\n');\n        const resP = common.waitForSocketEvent(roSocket, 'message');\n        await common.sendMessage(roSocket, {\n          component: 'pad',\n          padId: otherPadId, // The server should ignore this.\n          type: 'CHANGESET_REQ',\n          data: {\n            granularity: 1,\n            start: '2',\n            requestID: 'requestId',\n          },\n        });\n        const res = await resP;\n        assert.equal(res.type, 'CHANGESET_REQ');\n        assert.equal(res.data.requestID, 'requestId');\n        assert.equal(res.data.start, 1);\n      }\n      finally {\n        await otherPad.remove();\n      }\n    });\n  });\n\n  describe('USER_CHANGES', function () {\n    const sendUserChanges =\n        async (socket:any, cs:any) => await common.sendUserChanges(socket, {baseRev: rev, changeset: cs});\n    const assertAccepted = async (socket:any, wantRev: number) => {\n      await common.waitForAcceptCommit(socket, wantRev);\n      rev = wantRev;\n    };\n    const assertRejected = async (socket:any) => {\n      const msg = await common.waitForSocketEvent(socket, 'message');\n      assert.deepEqual(msg, {disconnect: 'badChangeset'});\n    };\n\n    it('changes are applied', async function () {\n      await Promise.all([\n        assertAccepted(socket, rev + 1),\n        sendUserChanges(socket, 'Z:1>5+5$hello'),\n      ]);\n      assert.equal(pad!.text(), 'hello\\n');\n    });\n\n    it('bad changeset is rejected', async function () {\n      await Promise.all([\n        assertRejected(socket),\n        sendUserChanges(socket, 'this is not a valid changeset'),\n      ]);\n    });\n\n    it('retransmission is accepted, has no effect', async function () {\n      const cs = 'Z:1>5+5$hello';\n      await Promise.all([\n        assertAccepted(socket, rev + 1),\n        sendUserChanges(socket, cs),\n      ]);\n      --rev;\n      await Promise.all([\n        assertAccepted(socket, rev + 1),\n        sendUserChanges(socket, cs),\n      ]);\n      assert.equal(pad!.text(), 'hello\\n');\n    });\n\n    it('identity changeset is accepted, has no effect', async function () {\n      await Promise.all([\n        assertAccepted(socket, rev + 1),\n        sendUserChanges(socket, 'Z:1>5+5$hello'),\n      ]);\n      await Promise.all([\n        assertAccepted(socket, rev),\n        sendUserChanges(socket, 'Z:6>0$'),\n      ]);\n      assert.equal(pad!.text(), 'hello\\n');\n    });\n\n    it('non-identity changeset with no net change is accepted, has no effect', async function () {\n      await Promise.all([\n        assertAccepted(socket, rev + 1),\n        sendUserChanges(socket, 'Z:1>5+5$hello'),\n      ]);\n      await Promise.all([\n        assertAccepted(socket, rev),\n        sendUserChanges(socket, 'Z:6>0-5+5$hello'),\n      ]);\n      assert.equal(pad!.text(), 'hello\\n');\n    });\n\n    it('handleMessageSecurity can grant one-time write access', async function () {\n      const cs = 'Z:1>5+5$hello';\n      const errRegEx = /write attempt on read-only pad/;\n      // First try to send a change and verify that it was dropped.\n      await assert.rejects(sendUserChanges(roSocket, cs), errRegEx);\n      // sendUserChanges() waits for message ack, so if the message was accepted then head should\n      // have already incremented by the time we get here.\n      assert.equal(pad!.head, rev); // Not incremented.\n\n      // Now allow the change.\n      plugins.hooks.handleMessageSecurity.push({hook_fn: () => 'permitOnce'});\n      await Promise.all([\n        assertAccepted(roSocket, rev + 1),\n        sendUserChanges(roSocket, cs),\n      ]);\n      assert.equal(pad!.text(), 'hello\\n');\n\n      // The next change should be dropped.\n      plugins.hooks.handleMessageSecurity = [];\n      await assert.rejects(sendUserChanges(roSocket, 'Z:6>6=5+6$ world'), errRegEx);\n      assert.equal(pad!.head, rev); // Not incremented.\n      assert.equal(pad!.text(), 'hello\\n');\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/pads-with-spaces.ts",
    "content": "'use strict';\n\nconst common = require('../common');\n\nlet agent:any;\n\ndescribe(__filename, function () {\n  before(async function () {\n    agent = await common.init();\n  });\n\n  it('supports pads with spaces, regression test for #4883', async function () {\n    await agent.get('/p/pads with spaces')\n        .expect(302)\n        .expect('location', 'pads_with_spaces');\n  });\n\n  it('supports pads with spaces and query, regression test for #4883', async function () {\n    await agent.get('/p/pads with spaces?showChat=true&noColors=false')\n        .expect(302)\n        .expect('location', 'pads_with_spaces?showChat=true&noColors=false');\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/regression-db.ts",
    "content": "'use strict';\n\nconst AuthorManager = require('../../../node/db/AuthorManager');\nimport {strict as assert} from \"assert\";\nconst common = require('../common');\nconst db = require('../../../node/db/DB');\n\ndescribe(__filename, function () {\n  let setBackup: Function;\n\n  before(async function () {\n    await common.init();\n    setBackup = db.set;\n\n    db.set = async (...args:any) => {\n      // delay db.set\n      await new Promise<void>((resolve) => { setTimeout(() => resolve(), 500); });\n      return await setBackup.call(db, ...args);\n    };\n  });\n\n  after(async function () {\n    db.set = setBackup;\n  });\n\n  it('regression test for missing await in createAuthor (#5000)', async function () {\n    const {authorID} = await AuthorManager.createAuthor(); // Should block until db.set() finishes.\n    assert(await AuthorManager.doesAuthorExist(authorID));\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/settings.json",
    "content": "// line comment\n/*\n * block comment\n */\n{\n  \"trailing commas\": {\n    \"lists\": {\n      \"multiple lines\": [\n        \"\",\n      ]\n    },\n    \"objects\": {\n      \"multiple lines\": {\n        \"key\": \"\",\n      }\n    }\n  },\n  \"environment variable substitution\": {\n    \"set\": {\n      \"true\": \"${SET_VAR_TRUE}\",\n      \"false\": \"${SET_VAR_FALSE}\",\n      \"null\": \"${SET_VAR_NULL}\",\n      \"undefined\": \"${SET_VAR_UNDEFINED}\",\n      \"number\": \"${SET_VAR_NUMBER}\",\n      \"string\": \"${SET_VAR_STRING}\",\n      \"empty string\": \"${SET_VAR_EMPTY_STRING}\"\n    },\n    \"unset\": {\n      \"no default\": \"${UNSET_VAR}\",\n      \"true\": \"${UNSET_VAR:true}\",\n      \"false\": \"${UNSET_VAR:false}\",\n      \"null\": \"${UNSET_VAR:null}\",\n      \"undefined\": \"${UNSET_VAR:undefined}\",\n      \"number\": \"${UNSET_VAR:123}\",\n      \"string\": \"${UNSET_VAR:foo}\",\n      \"empty string\": \"${UNSET_VAR:}\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/tests/backend/specs/settings.ts",
    "content": "'use strict';\n\nconst assert = require('assert').strict;\nimport {exportedForTestingOnly} from '../../../node/utils/Settings'\nimport path from 'path';\nimport process from 'process';\n\ndescribe(__filename, function () {\n  describe('parseSettings', function () {\n    let settings: any;\n    const envVarSubstTestCases = [\n      {name: 'true', val: 'true', var: 'SET_VAR_TRUE', want: true},\n      {name: 'false', val: 'false', var: 'SET_VAR_FALSE', want: false},\n      {name: 'null', val: 'null', var: 'SET_VAR_NULL', want: null},\n      {name: 'undefined', val: 'undefined', var: 'SET_VAR_UNDEFINED', want: undefined},\n      {name: 'number', val: '123', var: 'SET_VAR_NUMBER', want: 123},\n      {name: 'string', val: 'foo', var: 'SET_VAR_STRING', want: 'foo'},\n      {name: 'empty string', val: '', var: 'SET_VAR_EMPTY_STRING', want: ''},\n    ];\n\n    before(async function () {\n      for (const tc of envVarSubstTestCases) process.env[tc.var] = tc.val;\n      delete process.env.UNSET_VAR;\n      settings = exportedForTestingOnly.parseSettings(path.join(__dirname, 'settings.json'), true);\n      assert(settings != null);\n    });\n\n    describe('environment variable substitution', function () {\n      describe('set', function () {\n        for (const tc of envVarSubstTestCases) {\n          it(tc.name, async function () {\n            const obj = settings['environment variable substitution'].set;\n            if (tc.name === 'undefined') {\n              assert(!(tc.name in obj));\n            } else {\n              assert.equal(obj[tc.name], tc.want);\n            }\n          });\n        }\n      });\n\n      describe('unset', function () {\n        it('no default', async function () {\n          const obj = settings['environment variable substitution'].unset;\n          assert.equal(obj['no default'], null);\n        });\n\n        for (const tc of envVarSubstTestCases) {\n          it(tc.name, async function () {\n            const obj = settings['environment variable substitution'].unset;\n            if (tc.name === 'undefined') {\n              assert(!(tc.name in obj));\n            } else {\n              assert.equal(obj[tc.name], tc.want);\n            }\n          });\n        }\n      });\n    });\n  });\n\n\n  describe(\"Parse plugin settings\", function () {\n\n    before(async function () {\n      process.env[\"EP__ADMIN__PASSWORD\"] = \"test\"\n    })\n\n    it('should parse plugin settings', async function () {\n      let settings = exportedForTestingOnly.parseSettings(path.join(__dirname, 'settings.json'), true);\n      assert.equal(settings!.ADMIN.PASSWORD, \"test\");\n    })\n\n    it('should bundle settings with same path', async function () {\n      process.env[\"EP__ADMIN__USERNAME\"] = \"test\"\n      let settings = exportedForTestingOnly.parseSettings(path.join(__dirname, 'settings.json'), true);\n      assert.deepEqual(settings!.ADMIN, {PASSWORD: \"test\", USERNAME: \"test\"});\n    })\n\n    it(\"Can set the ep themes\", async function () {\n      process.env[\"EP__ep_themes__default_theme\"] = \"hacker\"\n      let settings = exportedForTestingOnly.parseSettings(path.join(__dirname, 'settings.json'), true);\n      assert.deepEqual(settings!.ep_themes, {\"default_theme\": \"hacker\"});\n    })\n\n    it(\"can set the ep_webrtc settings\", async function () {\n      process.env[\"EP__ep_webrtc__enabled\"] = \"true\"\n      let settings = exportedForTestingOnly.parseSettings(path.join(__dirname, 'settings.json'), true);\n      assert.deepEqual(settings!.ep_webrtc, {\"enabled\": true});\n    })\n  })\n});\n"
  },
  {
    "path": "src/tests/backend/specs/socketio.ts",
    "content": "'use strict';\n\nimport {MapArrayType} from \"../../../node/types/MapType\";\n\nconst assert = require('assert').strict;\nconst common = require('../common');\nconst padManager = require('../../../node/db/PadManager');\nconst plugins = require('../../../static/js/pluginfw/plugin_defs');\nimport readOnlyManager from '../../../node/db/ReadOnlyManager';\nimport settings from '../../../node/utils/Settings';\nconst socketIoRouter = require('../../../node/handler/SocketIORouter');\n\ndescribe(__filename, function () {\n  this.timeout(30000);\n  let agent: any;\n  let authorize:Function;\n  const backups:MapArrayType<any> = {};\n  const cleanUpPads = async () => {\n    const padIds = ['pad', 'other-pad', 'päd'];\n    await Promise.all(padIds.map(async (padId) => {\n      if (await padManager.doesPadExist(padId)) {\n        const pad = await padManager.getPad(padId);\n        await pad.remove();\n      }\n    }));\n  };\n  let socket:any;\n\n  before(async function () { agent = await common.init(); });\n  beforeEach(async function () {\n    backups.hooks = {};\n    for (const hookName of ['preAuthorize', 'authenticate', 'authorize']) {\n      backups.hooks[hookName] = plugins.hooks[hookName];\n      plugins.hooks[hookName] = [];\n    }\n    backups.settings = {};\n    for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users']) {\n      // @ts-ignore\n      backups.settings[setting] = settings[setting];\n    }\n    settings.editOnly = false;\n    settings.requireAuthentication = false;\n    settings.requireAuthorization = false;\n    settings.users = {\n      admin: {password: 'admin-password', is_admin: true},\n      user: {password: 'user-password'},\n    };\n    assert(socket == null);\n    authorize = () => true;\n    plugins.hooks.authorize = [{hook_fn: (hookName: string, {req}:any, cb:Function) => cb([authorize(req)])}];\n    await cleanUpPads();\n  });\n  afterEach(async function () {\n    if (socket) socket.close();\n    socket = null;\n    await cleanUpPads();\n    Object.assign(plugins.hooks, backups.hooks);\n    Object.assign(settings, backups.settings);\n  });\n\n  describe('Normal accesses', function () {\n    it('!authn anonymous cookie /p/pad -> 200, ok', async function () {\n      const res = await agent.get('/p/pad').expect(200);\n      socket = await common.connect(res);\n      const clientVars = await common.handshake(socket, 'pad');\n      assert.equal(clientVars.type, 'CLIENT_VARS');\n    });\n    it('!authn !cookie -> ok', async function () {\n      socket = await common.connect(null);\n      const clientVars = await common.handshake(socket, 'pad');\n      assert.equal(clientVars.type, 'CLIENT_VARS');\n    });\n    it('!authn user /p/pad -> 200, ok', async function () {\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const clientVars = await common.handshake(socket, 'pad');\n      assert.equal(clientVars.type, 'CLIENT_VARS');\n    });\n    it('authn user /p/pad -> 200, ok', async function () {\n      settings.requireAuthentication = true;\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const clientVars = await common.handshake(socket, 'pad');\n      assert.equal(clientVars.type, 'CLIENT_VARS');\n    });\n\n    for (const authn of [false, true]) {\n      const desc = authn ? 'authn user' : '!authn anonymous';\n      it(`${desc} read-only /p/pad -> 200, ok`, async function () {\n        const get = (ep: string) => {\n          let res = agent.get(ep);\n          if (authn) res = res.auth('user', 'user-password');\n          return res.expect(200);\n        };\n        settings.requireAuthentication = authn;\n        let res = await get('/p/pad');\n        socket = await common.connect(res);\n        let clientVars = await common.handshake(socket, 'pad');\n        assert.equal(clientVars.type, 'CLIENT_VARS');\n        assert.equal(clientVars.data.readonly, false);\n        const readOnlyId = clientVars.data.readOnlyId;\n        assert(readOnlyManager.isReadOnlyId(readOnlyId));\n        socket.close();\n        res = await get(`/p/${readOnlyId}`);\n        socket = await common.connect(res);\n        clientVars = await common.handshake(socket, readOnlyId);\n        assert.equal(clientVars.type, 'CLIENT_VARS');\n        assert.equal(clientVars.data.readonly, true);\n      });\n    }\n\n    it('authz user /p/pad -> 200, ok', async function () {\n      settings.requireAuthentication = true;\n      settings.requireAuthorization = true;\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const clientVars = await common.handshake(socket, 'pad');\n      assert.equal(clientVars.type, 'CLIENT_VARS');\n    });\n    it('supports pad names with characters that must be percent-encoded', async function () {\n      settings.requireAuthentication = true;\n      // requireAuthorization is set to true here to guarantee that the user's padAuthorizations\n      // object is populated. Technically this isn't necessary because the user's padAuthorizations\n      // is currently populated even if requireAuthorization is false, but setting this to true\n      // ensures the test remains useful if the implementation ever changes.\n      settings.requireAuthorization = true;\n      const encodedPadId = encodeURIComponent('päd');\n      const res = await agent.get(`/p/${encodedPadId}`).auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const clientVars = await common.handshake(socket, 'päd');\n      assert.equal(clientVars.type, 'CLIENT_VARS');\n    });\n  });\n\n  describe('Abnormal access attempts', function () {\n    it('authn anonymous /p/pad -> 401, error', async function () {\n      settings.requireAuthentication = true;\n      const res = await agent.get('/p/pad').expect(401);\n      // Despite the 401, try to create the pad via a socket.io connection anyway.\n      socket = await common.connect(res);\n      const message = await common.handshake(socket, 'pad');\n      assert.equal(message.accessStatus, 'deny');\n    });\n\n    it('authn anonymous read-only /p/pad -> 401, error', async function () {\n      settings.requireAuthentication = true;\n      let res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const clientVars = await common.handshake(socket, 'pad');\n      assert.equal(clientVars.type, 'CLIENT_VARS');\n      const readOnlyId = clientVars.data.readOnlyId;\n      assert(readOnlyManager.isReadOnlyId(readOnlyId));\n      socket.close();\n      res = await agent.get(`/p/${readOnlyId}`).expect(401);\n      // Despite the 401, try to read the pad via a socket.io connection anyway.\n      socket = await common.connect(res);\n      const message = await common.handshake(socket, readOnlyId);\n      assert.equal(message.accessStatus, 'deny');\n    });\n\n    it('authn !cookie -> error', async function () {\n      settings.requireAuthentication = true;\n      socket = await common.connect(null);\n      const message = await common.handshake(socket, 'pad');\n      assert.equal(message.accessStatus, 'deny');\n    });\n    it('authorization bypass attempt -> error', async function () {\n      // Only allowed to access /p/pad.\n      authorize = (req:{\n        path: string,\n      }) => req.path === '/p/pad';\n      settings.requireAuthentication = true;\n      settings.requireAuthorization = true;\n      // First authenticate and establish a session.\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      // Accessing /p/other-pad should fail, despite the successful fetch of /p/pad.\n      const message = await common.handshake(socket, 'other-pad');\n      assert.equal(message.accessStatus, 'deny');\n    });\n  });\n\n  describe('Authorization levels via authorize hook', function () {\n    beforeEach(async function () {\n      settings.requireAuthentication = true;\n      settings.requireAuthorization = true;\n    });\n\n    it(\"level='create' -> can create\", async function () {\n      authorize = () => 'create';\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const clientVars = await common.handshake(socket, 'pad');\n      assert.equal(clientVars.type, 'CLIENT_VARS');\n      assert.equal(clientVars.data.readonly, false);\n    });\n    it('level=true -> can create', async function () {\n      authorize = () => true;\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const clientVars = await common.handshake(socket, 'pad');\n      assert.equal(clientVars.type, 'CLIENT_VARS');\n      assert.equal(clientVars.data.readonly, false);\n    });\n    it(\"level='modify' -> can modify\", async function () {\n      await padManager.getPad('pad'); // Create the pad.\n      authorize = () => 'modify';\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const clientVars = await common.handshake(socket, 'pad');\n      assert.equal(clientVars.type, 'CLIENT_VARS');\n      assert.equal(clientVars.data.readonly, false);\n    });\n    it(\"level='create' settings.editOnly=true -> unable to create\", async function () {\n      authorize = () => 'create';\n      settings.editOnly = true;\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const message = await common.handshake(socket, 'pad');\n      assert.equal(message.accessStatus, 'deny');\n    });\n    it(\"level='modify' settings.editOnly=false -> unable to create\", async function () {\n      authorize = () => 'modify';\n      settings.editOnly = false;\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const message = await common.handshake(socket, 'pad');\n      assert.equal(message.accessStatus, 'deny');\n    });\n    it(\"level='readOnly' -> unable to create\", async function () {\n      authorize = () => 'readOnly';\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const message = await common.handshake(socket, 'pad');\n      assert.equal(message.accessStatus, 'deny');\n    });\n    it(\"level='readOnly' -> unable to modify\", async function () {\n      await padManager.getPad('pad'); // Create the pad.\n      authorize = () => 'readOnly';\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const clientVars = await common.handshake(socket, 'pad');\n      assert.equal(clientVars.type, 'CLIENT_VARS');\n      assert.equal(clientVars.data.readonly, true);\n    });\n  });\n\n  describe('Authorization levels via user settings', function () {\n    beforeEach(async function () {\n      settings.requireAuthentication = true;\n    });\n\n    it('user.canCreate = true -> can create and modify', async function () {\n      settings.users.user.canCreate = true;\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const clientVars = await common.handshake(socket, 'pad');\n      assert.equal(clientVars.type, 'CLIENT_VARS');\n      assert.equal(clientVars.data.readonly, false);\n    });\n    it('user.canCreate = false -> unable to create', async function () {\n      settings.users.user.canCreate = false;\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const message = await common.handshake(socket, 'pad');\n      assert.equal(message.accessStatus, 'deny');\n    });\n    it('user.readOnly = true -> unable to create', async function () {\n      settings.users.user.readOnly = true;\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const message = await common.handshake(socket, 'pad');\n      assert.equal(message.accessStatus, 'deny');\n    });\n    it('user.readOnly = true -> unable to modify', async function () {\n      await padManager.getPad('pad'); // Create the pad.\n      settings.users.user.readOnly = true;\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const clientVars = await common.handshake(socket, 'pad');\n      assert.equal(clientVars.type, 'CLIENT_VARS');\n      assert.equal(clientVars.data.readonly, true);\n    });\n    it('user.readOnly = false -> can create and modify', async function () {\n      settings.users.user.readOnly = false;\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const clientVars = await common.handshake(socket, 'pad');\n      assert.equal(clientVars.type, 'CLIENT_VARS');\n      assert.equal(clientVars.data.readonly, false);\n    });\n    it('user.readOnly = true, user.canCreate = true -> unable to create', async function () {\n      settings.users.user.canCreate = true;\n      settings.users.user.readOnly = true;\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const message = await common.handshake(socket, 'pad');\n      assert.equal(message.accessStatus, 'deny');\n    });\n  });\n\n  describe('Authorization level interaction between authorize hook and user settings', function () {\n    beforeEach(async function () {\n      settings.requireAuthentication = true;\n      settings.requireAuthorization = true;\n    });\n\n    it('authorize hook does not elevate level from user settings', async function () {\n      settings.users.user.readOnly = true;\n      authorize = () => 'create';\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const message = await common.handshake(socket, 'pad');\n      assert.equal(message.accessStatus, 'deny');\n    });\n    it('user settings does not elevate level from authorize hook', async function () {\n      settings.users.user.readOnly = false;\n      settings.users.user.canCreate = true;\n      authorize = () => 'readOnly';\n      const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);\n      socket = await common.connect(res);\n      const message = await common.handshake(socket, 'pad');\n      assert.equal(message.accessStatus, 'deny');\n    });\n  });\n\n  describe('SocketIORouter.js', function () {\n    const Module = class {\n      setSocketIO(io:any) {}\n      handleConnect(socket:any) {}\n      handleDisconnect(socket:any) {}\n      handleMessage(socket:any, message:string) {}\n    };\n\n    afterEach(async function () {\n      socketIoRouter.deleteComponent(this.test!.fullTitle());\n      socketIoRouter.deleteComponent(`${this.test!.fullTitle()} #2`);\n    });\n\n    it('setSocketIO', async function () {\n      let ioServer;\n      socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module {\n        setSocketIO(io:any) { ioServer = io; }\n      }());\n      assert(ioServer != null);\n    });\n\n    it('handleConnect', async function () {\n      let serverSocket;\n      socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module {\n        handleConnect(socket:any) { serverSocket = socket; }\n      }());\n      socket = await common.connect();\n      assert(serverSocket != null);\n    });\n\n    it('handleDisconnect', async function () {\n      let resolveConnected:  (value: void | PromiseLike<void>) => void ;\n      const connected = new Promise((resolve) => resolveConnected = resolve);\n      let resolveDisconnected: (value: void | PromiseLike<void>) => void ;\n      const disconnected = new Promise<void>((resolve) => resolveDisconnected = resolve);\n      socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module {\n        private _socket: any;\n        handleConnect(socket:any) {\n          this._socket = socket;\n          resolveConnected();\n        }\n        handleDisconnect(socket:any) {\n          assert(socket != null);\n          // There might be lingering disconnect events from sockets created by other tests.\n          if (this._socket == null || socket.id !== this._socket.id) return;\n          assert.equal(socket, this._socket);\n          resolveDisconnected();\n        }\n      }());\n      socket = await common.connect();\n      await connected;\n      socket.close();\n      socket = null;\n      await disconnected;\n    });\n\n    it('handleMessage (success)', async function () {\n      let serverSocket:any;\n      const want = {\n        component: this.test!.fullTitle(),\n        foo: {bar: 'asdf'},\n      };\n      let rx:Function;\n      const got = new Promise((resolve) => { rx = resolve; });\n      socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module {\n        handleConnect(socket:any) { serverSocket = socket; }\n        handleMessage(socket:any, message:string) { assert.equal(socket, serverSocket); rx(message); }\n      }());\n      socketIoRouter.addComponent(`${this.test!.fullTitle()} #2`, new class extends Module {\n        handleMessage(socket:any, message:any) { assert.fail('wrong handler called'); }\n      }());\n      socket = await common.connect();\n      socket.emit('message', want);\n      assert.deepEqual(await got, want);\n    });\n\n    const tx = async (socket:any, message = {}) => await new Promise((resolve, reject) => {\n      const AckErr = class extends Error {\n        constructor(name: string, ...args:any) { super(...args); this.name = name; }\n      };\n      socket.emit('message', message,\n          (errj: {\n            message: string,\n            name: string,\n          }, val: any) => errj != null ? reject(new AckErr(errj.name, errj.message)) : resolve(val));\n    });\n\n    it('handleMessage with ack (success)', async function () {\n      const want = 'value';\n      socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module {\n        handleMessage(socket:any, msg:any) { return want; }\n      }());\n      socket = await common.connect();\n      const got = await tx(socket, {component: this.test!.fullTitle()});\n      assert.equal(got, want);\n    });\n\n    it('handleMessage with ack (error)', async function () {\n      const InjectedError = class extends Error {\n        constructor() { super('injected test error'); this.name = 'InjectedError'; }\n      };\n      socketIoRouter.addComponent(this.test!.fullTitle(), new class extends Module {\n        handleMessage(socket:any, msg:any) { throw new InjectedError(); }\n      }());\n      socket = await common.connect();\n      await assert.rejects(tx(socket, {component: this.test!.fullTitle()}), new InjectedError());\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/specialpages.ts",
    "content": "'use strict';\n\nimport {MapArrayType} from \"../../../node/types/MapType\";\n\nconst common = require('../common');\nimport settings from '../../../node/utils/Settings';\n\n\n\ndescribe(__filename, function () {\n  this.timeout(30000);\n  let agent:any;\n  const backups:MapArrayType<any> = {};\n  before(async function () { agent = await common.init(); });\n  beforeEach(async function () {\n    backups.settings = {};\n    for (const setting of ['requireAuthentication', 'requireAuthorization']) {\n      // @ts-ignore\n      backups.settings[setting] = settings[setting];\n    }\n    settings.requireAuthentication = false;\n    settings.requireAuthorization = false;\n  });\n  afterEach(async function () {\n    Object.assign(settings, backups.settings);\n  });\n\n  describe('/javascript', function () {\n    it('/javascript -> 200', async function () {\n      await agent.get('/javascript').expect(200);\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend/specs/webaccess.ts",
    "content": "'use strict';\n\nimport {MapArrayType} from \"../../../node/types/MapType\";\nimport {Func} from \"mocha\";\nimport {SettingsUser} from \"../../../node/types/SettingsUser\";\n\nconst assert = require('assert').strict;\nconst common = require('../common');\nconst plugins = require('../../../static/js/pluginfw/plugin_defs');\nimport settings from '../../../node/utils/Settings';\n\ndescribe(__filename, function () {\n  this.timeout(30000);\n  let agent:any;\n  const backups:MapArrayType<any> = {};\n  const authHookNames = ['preAuthorize', 'authenticate', 'authorize'];\n  const failHookNames = ['preAuthzFailure', 'authnFailure', 'authzFailure', 'authFailure'];\n  const makeHook = (hookName: string, hookFn:Function) => ({\n    hook_fn: hookFn,\n    hook_fn_name: `fake_plugin/${hookName}`,\n    hook_name: hookName,\n    part: {plugin: 'fake_plugin'},\n  });\n\n  before(async function () { agent = await common.init(); });\n\n  beforeEach(async function () {\n    backups.hooks = {};\n    for (const hookName of authHookNames.concat(failHookNames)) {\n      backups.hooks[hookName] = plugins.hooks[hookName];\n      plugins.hooks[hookName] = [];\n    }\n    backups.settings = {};\n    for (const setting of ['requireAuthentication', 'requireAuthorization', 'users']) {\n      // @ts-ignore\n      backups.settings[setting] = settings[setting];\n    }\n    settings.requireAuthentication = false;\n    settings.requireAuthorization = false;\n    settings.users = {\n      admin: {password: 'admin-password', is_admin: true},\n      user: {password: 'user-password'},\n    } satisfies SettingsUser;\n  });\n\n  afterEach(async function () {\n    Object.assign(plugins.hooks, backups.hooks);\n    Object.assign(settings, backups.settings);\n  });\n\n  describe('webaccess: without plugins', function () {\n    it('!authn !authz anonymous / -> 200', async function () {\n      settings.requireAuthentication = false;\n      settings.requireAuthorization = false;\n      await agent.get('/').expect(200);\n    });\n\n    it('!authn !authz anonymous /admin-auth// -> 401', async function () {\n      settings.requireAuthentication = false;\n      settings.requireAuthorization = false;\n      await agent.get('/admin-auth/').expect(401);\n    });\n\n    it('authn !authz anonymous / -> 401', async function () {\n      settings.requireAuthentication = true;\n      settings.requireAuthorization = false;\n      await agent.get('/').expect(401);\n    });\n\n    it('authn !authz user / -> 200', async function () {\n      settings.requireAuthentication = true;\n      settings.requireAuthorization = false;\n      await agent.get('/').auth('user', 'user-password').expect(200);\n    });\n\n    it('authn !authz user //admin-auth// -> 403', async function () {\n      settings.requireAuthentication = true;\n      settings.requireAuthorization = false;\n      await agent.get('/admin-auth//').auth('user', 'user-password').expect(403);\n    });\n\n    it('authn !authz admin / -> 200', async function () {\n      settings.requireAuthentication = true;\n      settings.requireAuthorization = false;\n      await agent.get('/').auth('admin', 'admin-password').expect(200);\n    });\n\n    it('authn !authz admin /admin-auth/ -> 200', async function () {\n      settings.requireAuthentication = true;\n      settings.requireAuthorization = false;\n      await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(200);\n    });\n\n    it('authn authz anonymous /robots.txt -> 200', async function () {\n      settings.requireAuthentication = true;\n      settings.requireAuthorization = true;\n      await agent.get('/robots.txt').expect(200);\n    });\n\n    it('authn authz user / -> 403', async function () {\n      settings.requireAuthentication = true;\n      settings.requireAuthorization = true;\n      await agent.get('/').auth('user', 'user-password').expect(403);\n    });\n\n    it('authn authz user //admin-auth// -> 403', async function () {\n      settings.requireAuthentication = true;\n      settings.requireAuthorization = true;\n      await agent.get('/admin-auth//').auth('user', 'user-password').expect(403);\n    });\n\n    it('authn authz admin / -> 200', async function () {\n      settings.requireAuthentication = true;\n      settings.requireAuthorization = true;\n      await agent.get('/').auth('admin', 'admin-password').expect(200);\n    });\n\n    it('authn authz admin /admin-auth/ -> 200', async function () {\n      settings.requireAuthentication = true;\n      settings.requireAuthorization = true;\n      await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(200);\n    });\n\n    describe('login fails if password is nullish', function () {\n      for (const adminPassword of [undefined, null]) {\n        // https://tools.ietf.org/html/rfc7617 says that the username and password are sent as\n        // base64(username + ':' + password), but there's nothing stopping a malicious user from\n        // sending just base64(username) (no colon). The lack of colon could throw off credential\n        // parsing, resulting in successful comparisons against a null or undefined password.\n        for (const creds of ['admin', 'admin:']) {\n          it(`admin password: ${adminPassword} credentials: ${creds}`, async function () {\n            settings.users.admin.password = adminPassword;\n            const encCreds = Buffer.from(creds).toString('base64');\n            await agent.get('/admin-auth/').set('Authorization', `Basic ${encCreds}`).expect(401);\n          });\n        }\n      }\n    });\n  });\n\n  describe('webaccess: preAuthorize, authenticate, and authorize hooks', function () {\n    let callOrder:string[];\n    const Handler = class {\n      private called: boolean;\n        private readonly hookName: string;\n        private readonly innerHandle: Function;\n        private readonly id: string;\n        private readonly checkContext: Function;\n      constructor(hookName:string, suffix: string) {\n        this.called = false;\n        this.hookName = hookName;\n        this.innerHandle = () => [];\n        this.id = hookName + suffix;\n        this.checkContext = () => {};\n      }\n      handle(hookName: string, context: any, cb:Function) {\n        assert.equal(hookName, this.hookName);\n        assert(context != null);\n        assert(context.req != null);\n        assert(context.res != null);\n        assert(context.next != null);\n        this.checkContext(context);\n        assert(!this.called);\n        this.called = true;\n        callOrder.push(this.id);\n        return cb(this.innerHandle(context));\n      }\n    };\n    const handlers:MapArrayType<any> = {};\n\n    beforeEach(async function () {\n      callOrder = [];\n      for (const hookName of authHookNames) {\n        // Create two handlers for each hook to test deferral to the next function.\n        const h0 = new Handler(hookName, '_0');\n        const h1 = new Handler(hookName, '_1');\n        handlers[hookName] = [h0, h1];\n        plugins.hooks[hookName] = [\n          makeHook(hookName, h0.handle.bind(h0)),\n          makeHook(hookName, h1.handle.bind(h1)),\n        ];\n      }\n    });\n\n    describe('preAuthorize', function () {\n      beforeEach(async function () {\n        settings.requireAuthentication = false;\n        settings.requireAuthorization = false;\n      });\n\n      it('defers if it returns []', async function () {\n        await agent.get('/').expect(200);\n        // Note: The preAuthorize hook always runs even if requireAuthorization is false.\n        assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']);\n      });\n\n      it('bypasses authenticate and authorize hooks when true is returned', async function () {\n        settings.requireAuthentication = true;\n        settings.requireAuthorization = true;\n        handlers.preAuthorize[0].innerHandle = () => [true];\n        await agent.get('/').expect(200);\n        assert.deepEqual(callOrder, ['preAuthorize_0']);\n      });\n\n      it('bypasses authenticate and authorize hooks when false is returned', async function () {\n        settings.requireAuthentication = true;\n        settings.requireAuthorization = true;\n        handlers.preAuthorize[0].innerHandle = () => [false];\n        await agent.get('/').expect(403);\n        assert.deepEqual(callOrder, ['preAuthorize_0']);\n      });\n\n      it('bypasses authenticate and authorize hooks when next is called', async function () {\n        settings.requireAuthentication = true;\n        settings.requireAuthorization = true;\n        handlers.preAuthorize[0].innerHandle = ({next}:{\n          next: Function\n        }) => next();\n        await agent.get('/').expect(200);\n        assert.deepEqual(callOrder, ['preAuthorize_0']);\n      });\n\n      it('static content (expressPreSession) bypasses all auth checks', async function () {\n        settings.requireAuthentication = true;\n        settings.requireAuthorization = true;\n        await agent.get('/static/robots.txt').expect(200);\n        assert.deepEqual(callOrder, []);\n      });\n\n      it('cannot grant access to /admin', async function () {\n        handlers.preAuthorize[0].innerHandle = () => [true];\n        await agent.get('/admin-auth/').expect(401);\n        // Notes:\n        //   * preAuthorize[1] is called despite preAuthorize[0] returning a non-empty list because\n        //     'true' entries are ignored for /admin-auth//* requests.\n        //   * The authenticate hook always runs for /admin-auth//* requests even if\n        //     settings.requireAuthentication is false.\n        assert.deepEqual(callOrder, ['preAuthorize_0',\n          'preAuthorize_1',\n          'authenticate_0',\n          'authenticate_1']);\n      });\n\n      it('can deny access to /admin-auth/', async function () {\n        handlers.preAuthorize[0].innerHandle = () => [false];\n        await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(403);\n        assert.deepEqual(callOrder, ['preAuthorize_0']);\n      });\n\n      it('runs preAuthzFailure hook when access is denied', async function () {\n        handlers.preAuthorize[0].innerHandle = () => [false];\n        let called = false;\n        plugins.hooks.preAuthzFailure = [makeHook('preAuthzFailure', (hookName: string, {req, res}:any, cb:Function) => {\n          assert.equal(hookName, 'preAuthzFailure');\n          assert(req != null);\n          assert(res != null);\n          assert(!called);\n          called = true;\n          res.status(200).send('injected');\n          return cb([true]);\n        })];\n        await agent.get('/admin-auth//').auth('admin', 'admin-password').expect(200, 'injected');\n        assert(called);\n      });\n\n      it('returns 500 if an exception is thrown', async function () {\n        handlers.preAuthorize[0].innerHandle = () => { throw new Error('exception test'); };\n        await agent.get('/').expect(500);\n      });\n    });\n\n    describe('authenticate', function () {\n      beforeEach(async function () {\n        settings.requireAuthentication = true;\n        settings.requireAuthorization = false;\n      });\n\n      it('is not called if !requireAuthentication and not /admin-auth/*', async function () {\n        settings.requireAuthentication = false;\n        await agent.get('/').expect(200);\n        assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']);\n      });\n\n      it('is called if !requireAuthentication and /admin-auth//*', async function () {\n        settings.requireAuthentication = false;\n        await agent.get('/admin-auth/').expect(401);\n        assert.deepEqual(callOrder, ['preAuthorize_0',\n          'preAuthorize_1',\n          'authenticate_0',\n          'authenticate_1']);\n      });\n\n      it('defers if empty list returned', async function () {\n        await agent.get('/').expect(401);\n        assert.deepEqual(callOrder, ['preAuthorize_0',\n          'preAuthorize_1',\n          'authenticate_0',\n          'authenticate_1']);\n      });\n\n      it('does not defer if return [true], 200', async function () {\n        handlers.authenticate[0].innerHandle = ({req}:any) => { req.session.user = {}; return [true]; };\n        await agent.get('/').expect(200);\n        // Note: authenticate_1 was not called because authenticate_0 handled it.\n        assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);\n      });\n\n      it('does not defer if return [false], 401', async function () {\n        handlers.authenticate[0].innerHandle = () => [false];\n        await agent.get('/').expect(401);\n        // Note: authenticate_1 was not called because authenticate_0 handled it.\n        assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);\n      });\n\n      it('falls back to HTTP basic auth', async function () {\n        await agent.get('/').auth('user', 'user-password').expect(200);\n        assert.deepEqual(callOrder, ['preAuthorize_0',\n          'preAuthorize_1',\n          'authenticate_0',\n          'authenticate_1']);\n      });\n\n      it('passes settings.users in context', async function () {\n        handlers.authenticate[0].checkContext = ({users}:{\n          users: SettingsUser\n        }) => {\n          assert.equal(users, settings.users);\n        };\n        await agent.get('/').expect(401);\n        assert.deepEqual(callOrder, ['preAuthorize_0',\n          'preAuthorize_1',\n          'authenticate_0',\n          'authenticate_1']);\n      });\n\n      it('passes user, password in context if provided', async function () {\n        handlers.authenticate[0].checkContext = ({username, password}:{\n            username: string,\n            password: string\n\n        }) => {\n          assert.equal(username, 'user');\n          assert.equal(password, 'user-password');\n        };\n        await agent.get('/').auth('user', 'user-password').expect(200);\n        assert.deepEqual(callOrder, ['preAuthorize_0',\n          'preAuthorize_1',\n          'authenticate_0',\n          'authenticate_1']);\n      });\n\n      it('does not pass user, password in context if not provided', async function () {\n        handlers.authenticate[0].checkContext = ({username, password}:{\n            username: string,\n            password: string\n        }) => {\n          assert(username == null);\n          assert(password == null);\n        };\n        await agent.get('/').expect(401);\n        assert.deepEqual(callOrder, ['preAuthorize_0',\n          'preAuthorize_1',\n          'authenticate_0',\n          'authenticate_1']);\n      });\n\n      it('errors if req.session.user is not created', async function () {\n        handlers.authenticate[0].innerHandle = () => [true];\n        await agent.get('/').expect(500);\n        assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);\n      });\n\n      it('returns 500 if an exception is thrown', async function () {\n        handlers.authenticate[0].innerHandle = () => { throw new Error('exception test'); };\n        await agent.get('/').expect(500);\n        assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);\n      });\n    });\n\n    describe('authorize', function () {\n      beforeEach(async function () {\n        settings.requireAuthentication = true;\n        settings.requireAuthorization = true;\n      });\n\n      it('is not called if !requireAuthorization (non-/admin)', async function () {\n        settings.requireAuthorization = false;\n        await agent.get('/').auth('user', 'user-password').expect(200);\n        assert.deepEqual(callOrder, ['preAuthorize_0',\n          'preAuthorize_1',\n          'authenticate_0',\n          'authenticate_1']);\n      });\n\n      it('is not called if !requireAuthorization (/admin)', async function () {\n        settings.requireAuthorization = false;\n        await agent.get('/admin-auth/').auth('admin', 'admin-password').expect(200);\n        assert.deepEqual(callOrder, ['preAuthorize_0',\n          'preAuthorize_1',\n          'authenticate_0',\n          'authenticate_1']);\n      });\n\n      it('defers if empty list returned', async function () {\n        await agent.get('/').auth('user', 'user-password').expect(403);\n        assert.deepEqual(callOrder, ['preAuthorize_0',\n          'preAuthorize_1',\n          'authenticate_0',\n          'authenticate_1',\n          'authorize_0',\n          'authorize_1']);\n      });\n\n      it('does not defer if return [true], 200', async function () {\n        handlers.authorize[0].innerHandle = () => [true];\n        await agent.get('/').auth('user', 'user-password').expect(200);\n        // Note: authorize_1 was not called because authorize_0 handled it.\n        assert.deepEqual(callOrder, ['preAuthorize_0',\n          'preAuthorize_1',\n          'authenticate_0',\n          'authenticate_1',\n          'authorize_0']);\n      });\n\n      it('does not defer if return [false], 403', async function () {\n        handlers.authorize[0].innerHandle = () => [false];\n        await agent.get('/').auth('user', 'user-password').expect(403);\n        // Note: authorize_1 was not called because authorize_0 handled it.\n        assert.deepEqual(callOrder, ['preAuthorize_0',\n          'preAuthorize_1',\n          'authenticate_0',\n          'authenticate_1',\n          'authorize_0']);\n      });\n\n      it('passes req.path in context', async function () {\n        handlers.authorize[0].checkContext = ({resource}:{\n          resource: string\n        }) => {\n          assert.equal(resource, '/');\n        };\n        await agent.get('/').auth('user', 'user-password').expect(403);\n        assert.deepEqual(callOrder, ['preAuthorize_0',\n          'preAuthorize_1',\n          'authenticate_0',\n          'authenticate_1',\n          'authorize_0',\n          'authorize_1']);\n      });\n\n      it('returns 500 if an exception is thrown', async function () {\n        handlers.authorize[0].innerHandle = () => { throw new Error('exception test'); };\n        await agent.get('/').auth('user', 'user-password').expect(500);\n        assert.deepEqual(callOrder, ['preAuthorize_0',\n          'preAuthorize_1',\n          'authenticate_0',\n          'authenticate_1',\n          'authorize_0']);\n      });\n    });\n  });\n\n  describe('webaccess: authnFailure, authzFailure, authFailure hooks', function () {\n    const Handler = class {\n      private hookName: string;\n        private shouldHandle: boolean;\n        private called: boolean;\n      constructor(hookName: string) {\n        this.hookName = hookName;\n        this.shouldHandle = false;\n        this.called = false;\n      }\n      handle(hookName: string, context:any, cb: Function) {\n        assert.equal(hookName, this.hookName);\n        assert(context != null);\n        assert(context.req != null);\n        assert(context.res != null);\n        assert(!this.called);\n        this.called = true;\n        if (this.shouldHandle) {\n          context.res.status(200).send(this.hookName);\n          return cb([true]);\n        }\n        return cb([]);\n      }\n    };\n    const handlers:MapArrayType<any> = {};\n\n    beforeEach(async function () {\n      failHookNames.forEach((hookName) => {\n        const handler = new Handler(hookName);\n        handlers[hookName] = handler;\n        plugins.hooks[hookName] = [makeHook(hookName, handler.handle.bind(handler))];\n      });\n      settings.requireAuthentication = true;\n      settings.requireAuthorization = true;\n    });\n\n    // authn failure tests\n    it('authn fail, no hooks handle -> 401', async function () {\n      await agent.get('/').expect(401);\n      assert(handlers.authnFailure.called);\n      assert(!handlers.authzFailure.called);\n      assert(handlers.authFailure.called);\n    });\n\n    it('authn fail, authnFailure handles', async function () {\n      handlers.authnFailure.shouldHandle = true;\n      await agent.get('/').expect(200, 'authnFailure');\n      assert(handlers.authnFailure.called);\n      assert(!handlers.authzFailure.called);\n      assert(!handlers.authFailure.called);\n    });\n\n    it('authn fail, authFailure handles', async function () {\n      handlers.authFailure.shouldHandle = true;\n      await agent.get('/').expect(200, 'authFailure');\n      assert(handlers.authnFailure.called);\n      assert(!handlers.authzFailure.called);\n      assert(handlers.authFailure.called);\n    });\n\n    it('authnFailure trumps authFailure', async function () {\n      handlers.authnFailure.shouldHandle = true;\n      handlers.authFailure.shouldHandle = true;\n      await agent.get('/').expect(200, 'authnFailure');\n      assert(handlers.authnFailure.called);\n      assert(!handlers.authFailure.called);\n    });\n\n    // authz failure tests\n    it('authz fail, no hooks handle -> 403', async function () {\n      await agent.get('/').auth('user', 'user-password').expect(403);\n      assert(!handlers.authnFailure.called);\n      assert(handlers.authzFailure.called);\n      assert(handlers.authFailure.called);\n    });\n\n    it('authz fail, authzFailure handles', async function () {\n      handlers.authzFailure.shouldHandle = true;\n      await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure');\n      assert(!handlers.authnFailure.called);\n      assert(handlers.authzFailure.called);\n      assert(!handlers.authFailure.called);\n    });\n\n    it('authz fail, authFailure handles', async function () {\n      handlers.authFailure.shouldHandle = true;\n      await agent.get('/').auth('user', 'user-password').expect(200, 'authFailure');\n      assert(!handlers.authnFailure.called);\n      assert(handlers.authzFailure.called);\n      assert(handlers.authFailure.called);\n    });\n\n    it('authzFailure trumps authFailure', async function () {\n      handlers.authzFailure.shouldHandle = true;\n      handlers.authFailure.shouldHandle = true;\n      await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure');\n      assert(handlers.authzFailure.called);\n      assert(!handlers.authFailure.called);\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend-new/easysync-helper.ts",
    "content": "import AttributePool from \"../../static/js/AttributePool\";\nimport { Attribute } from \"../../static/js/types/Attribute\";\nimport {StringAssembler} from \"../../static/js/StringAssembler\";\nimport {SmartOpAssembler} from \"../../static/js/SmartOpAssembler\";\nimport Op from \"../../static/js/Op\";\nimport {numToString} from \"../../static/js/ChangesetUtils\";\nimport {checkRep, pack} from \"../../static/js/Changeset\";\n\nexport const poolOrArray = (attribs: any) => {\n  if (attribs.getAttrib) {\n    return attribs; // it's already an attrib pool\n  } else {\n    // assume it's an array of attrib strings to be split and added\n    const p = new AttributePool();\n    attribs.forEach((kv: { split: (arg0: string) => Attribute; }) => {\n      p.putAttrib(kv.split(','));\n    });\n    return p;\n  }\n};\nconst randInt = (maxValue: number) => Math.floor(Math.random() * maxValue);\nconst randomInlineString = (len: number) => {\n  const assem = new StringAssembler();\n  for (let i = 0; i < len; i++) {\n    assem.append(String.fromCharCode(randInt(26) + 97));\n  }\n  return assem.toString();\n};\nexport const randomMultiline = (approxMaxLines: number, approxMaxCols: number) => {\n  const numParts = randInt(approxMaxLines * 2) + 1;\n  const txt = new StringAssembler();\n  txt.append(randInt(2) ? '\\n' : '');\n  for (let i = 0; i < numParts; i++) {\n    if ((i % 2) === 0) {\n      if (randInt(10)) {\n        txt.append(randomInlineString(randInt(approxMaxCols) + 1));\n      } else {\n        txt.append('\\n');\n      }\n    } else {\n      txt.append('\\n');\n    }\n  }\n  return txt.toString();\n};\n\nconst randomTwoPropAttribs = (opcode: \"\" | \"=\" | \"+\" | \"-\") => {\n  // assumes attrib pool like ['apple,','apple,true','banana,','banana,true']\n  if (opcode === '-' || randInt(3)) {\n    return '';\n  } else if (randInt(3)) { // eslint-disable-line no-dupe-else-if\n    if (opcode === '+' || randInt(2)) {\n      return `*${numToString(randInt(2) * 2 + 1)}`;\n    } else {\n      return `*${numToString(randInt(2) * 2)}`;\n    }\n  } else if (opcode === '+' || randInt(4) === 0) {\n    return '*1*3';\n  } else {\n    return ['*0*2', '*0*3', '*1*2'][randInt(3)];\n  }\n};\n\n\nconst randomStringOperation = (numCharsLeft: number) => {\n  let result;\n  switch (randInt(11)) {\n    case 0:\n    {\n      // insert char\n      result = {\n        insert: randomInlineString(1),\n      };\n      break;\n    }\n    case 1:\n    {\n      // delete char\n      result = {\n        remove: 1,\n      };\n      break;\n    }\n    case 2:\n    {\n      // skip char\n      result = {\n        skip: 1,\n      };\n      break;\n    }\n    case 3:\n    {\n      // insert small\n      result = {\n        insert: randomInlineString(randInt(4) + 1),\n      };\n      break;\n    }\n    case 4:\n    {\n      // delete small\n      result = {\n        remove: randInt(4) + 1,\n      };\n      break;\n    }\n    case 5:\n    {\n      // skip small\n      result = {\n        skip: randInt(4) + 1,\n      };\n      break;\n    }\n    case 6:\n    {\n      // insert multiline;\n      result = {\n        insert: randomMultiline(5, 20),\n      };\n      break;\n    }\n    case 7:\n    {\n      // delete multiline\n      result = {\n        remove: Math.round(numCharsLeft * Math.random() * Math.random()),\n      };\n      break;\n    }\n    case 8:\n    {\n      // skip multiline\n      result = {\n        skip: Math.round(numCharsLeft * Math.random() * Math.random()),\n      };\n      break;\n    }\n    case 9:\n    {\n      // delete to end\n      result = {\n        remove: numCharsLeft,\n      };\n      break;\n    }\n    case 10:\n    {\n      // skip to end\n      result = {\n        skip: numCharsLeft,\n      };\n      break;\n    }\n  }\n  const maxOrig = numCharsLeft - 1;\n  if ('remove' in result!) {\n    result.remove = Math.min(result.remove, maxOrig);\n  } else if ('skip' in result!) {\n    result.skip = Math.min(result.skip, maxOrig);\n  }\n  return result;\n};\n\nexport const randomTestChangeset = (origText: string, withAttribs?: any) => {\n  const charBank = new StringAssembler();\n  let textLeft = origText; // always keep final newline\n  const outTextAssem = new StringAssembler();\n  const opAssem = new SmartOpAssembler();\n  const oldLen = origText.length;\n\n  const nextOp = new Op();\n\n  const appendMultilineOp = (opcode: \"\" | \"=\" | \"+\" | \"-\", txt: string) => {\n    nextOp.opcode = opcode;\n    if (withAttribs) {\n      nextOp.attribs = randomTwoPropAttribs(opcode);\n    }\n    txt.replace(/\\n|[^\\n]+/g, (t) => {\n      if (t === '\\n') {\n        nextOp.chars = 1;\n        nextOp.lines = 1;\n        opAssem.append(nextOp);\n      } else {\n        nextOp.chars = t.length;\n        nextOp.lines = 0;\n        opAssem.append(nextOp);\n      }\n      return '';\n    });\n  };\n\n  const doOp = () => {\n    const o = randomStringOperation(textLeft.length);\n    if (o!.insert) {\n      const txt = o!.insert;\n      charBank.append(txt);\n      outTextAssem.append(txt);\n      appendMultilineOp('+', txt);\n    } else if (o!.skip) {\n      const txt = textLeft.substring(0, o!.skip);\n      textLeft = textLeft.substring(o!.skip);\n      outTextAssem.append(txt);\n      appendMultilineOp('=', txt);\n    } else if (o!.remove) {\n      const txt = textLeft.substring(0, o!.remove);\n      textLeft = textLeft.substring(o!.remove);\n      appendMultilineOp('-', txt);\n    }\n  };\n\n  while (textLeft.length > 1) doOp();\n  for (let i = 0; i < 5; i++) doOp(); // do some more (only insertions will happen)\n  const outText = `${outTextAssem.toString()}\\n`;\n  opAssem.endDocument();\n  const cs = pack(oldLen, outText.length, opAssem.toString(), charBank.toString());\n  checkRep(cs);\n  return [cs, outText];\n};\n"
  },
  {
    "path": "src/tests/backend-new/specs/AttributeMap.ts",
    "content": "'use strict';\n\nimport AttributeMap from '../../../static/js/AttributeMap';\nimport AttributePool from '../../../static/js/AttributePool';\nimport attributes from '../../../static/js/attributes';\nimport {expect, describe, it, beforeEach} from 'vitest'\nimport {Attribute} from \"../../../static/js/types/Attribute\";\n\ndescribe('AttributeMap', function () {\n  const attribs: Attribute[] = [\n    ['foo', 'bar'],\n    ['baz', 'bif'],\n    ['emptyValue', ''],\n  ];\n  let pool: AttributePool;\n\n  const getPoolSize = () => {\n    let n = 0;\n    pool.eachAttrib(() => ++n);\n    return n;\n  };\n\n  beforeEach(async function () {\n    pool = new AttributePool();\n    for (let i = 0; i < attribs.length; ++i) expect(pool.putAttrib(attribs[i])).to.equal(i);\n  });\n\n  it('fromString works', async function () {\n    const got = AttributeMap.fromString('*0*1*2', pool);\n    for (const [k, v] of attribs) expect(got.get(k)).to.equal(v);\n    // Maps iterate in insertion order, so [...got] should be in the same order as attribs.\n    expect(JSON.stringify([...got])).to.equal(JSON.stringify(attribs));\n  });\n\n  describe('set', function () {\n    it('stores the value', async function () {\n      const m = new AttributeMap(pool);\n      expect(m.size).to.equal(0);\n      m.set('k', 'v');\n      expect(m.size).to.equal(1);\n      expect(m.get('k')).to.equal('v');\n    });\n\n    it('reuses attributes in the pool', async function () {\n      expect(getPoolSize()).to.equal(attribs.length);\n      const m = new AttributeMap(pool);\n      const [k0, v0] = attribs[0];\n      m.set(k0, v0);\n      expect(getPoolSize()).to.equal(attribs.length);\n      expect(m.size).to.equal(1);\n      expect(m.toString()).to.equal('*0');\n    });\n\n    it('inserts new attributes into the pool', async function () {\n      const m = new AttributeMap(pool);\n      expect(getPoolSize()).to.equal(attribs.length);\n      m.set('k', 'v');\n      expect(getPoolSize()).to.equal(attribs.length + 1);\n      expect(JSON.stringify(pool.getAttrib(attribs.length))).to.equal(JSON.stringify(['k', 'v']));\n    });\n\n    describe('coerces key and value to string', function () {\n      const testCases = [\n        ['object (with toString)', {toString: () => 'obj'}, 'obj'],\n        ['undefined', undefined, ''],\n        ['null', null, ''],\n        ['boolean', true, 'true'],\n        ['number', 1, '1'],\n      ];\n      for (const [desc, input, want] of testCases) {\n        describe(desc as string, function () {\n          it('key is coerced to string', async function () {\n            const m = new AttributeMap(pool);\n            // @ts-ignore\n            m.set(input, 'value');\n            expect(m.get(want)).to.equal('value');\n          });\n\n          it('value is coerced to string', async function () {\n            const m = new AttributeMap(pool);\n            // @ts-ignore\n            m.set('key', input);\n            expect(m.get('key')).to.equal(want);\n          });\n        });\n      }\n    });\n\n    it('returns the map', async function () {\n      const m = new AttributeMap(pool);\n      expect(m.set('k', 'v')).to.equal(m);\n    });\n  });\n\n  describe('toString', function () {\n    it('sorts attributes', async function () {\n      const m = new AttributeMap(pool).update(attribs);\n      const got = [...attributes.attribsFromString(m.toString(), pool)];\n      const want = attributes.sort([...attribs]);\n      // Verify that attribs is not already sorted so that this test doesn't accidentally pass.\n      expect(JSON.stringify(want)).to.not.equal(JSON.stringify(attribs));\n      expect(JSON.stringify(got)).to.equal(JSON.stringify(want));\n    });\n\n    it('returns all entries', async function () {\n      const m = new AttributeMap(pool);\n      expect(m.toString()).to.equal('');\n      m.set(...attribs[0]);\n      expect(m.toString()).to.equal('*0');\n      m.delete(attribs[0][0]);\n      expect(m.toString()).to.equal('');\n      m.set(...attribs[1]);\n      expect(m.toString()).to.equal('*1');\n      m.set(attribs[1][0], 'new value');\n      expect(m.toString()).to.equal(attributes.encodeAttribString([attribs.length]));\n      m.set(...attribs[2]);\n      expect(m.toString()).to.equal(attributes.attribsToString(\n          attributes.sort([attribs[2], [attribs[1][0], 'new value']]), pool));\n    });\n  });\n\n  for (const funcName of ['update', 'updateFromString']) {\n    const callUpdateFn = (m: any, ...args: (boolean | (string | null | undefined)[][])[]) => {\n      if (funcName === 'updateFromString') {\n        // @ts-ignore\n        args[0] = attributes.attribsToString(attributes.sort([...args[0]]), pool);\n      }\n      // @ts-ignore\n      return AttributeMap.prototype[funcName].call(m, ...args);\n    };\n\n    describe(funcName, function () {\n      it('works', async function () {\n        const m = new AttributeMap(pool);\n        m.set(attribs[2][0], 'value to be overwritten');\n        callUpdateFn(m, attribs);\n        for (const [k, v] of attribs) expect(m.get(k)).to.equal(v);\n        expect(m.size).to.equal(attribs.length);\n        const wantStr = attributes.attribsToString(attributes.sort([...attribs]), pool);\n        expect(m.toString()).to.equal(wantStr);\n        callUpdateFn(m, []);\n        expect(m.toString()).to.equal(wantStr);\n      });\n\n      it('inserts new attributes into the pool', async function () {\n        const m = new AttributeMap(pool);\n        callUpdateFn(m, [['k', 'v']]);\n        expect(m.size).to.equal(1);\n        expect(m.get('k')).to.equal('v');\n        expect(getPoolSize()).to.equal(attribs.length + 1);\n        expect(m.toString()).to.equal(attributes.encodeAttribString([attribs.length]));\n      });\n\n      it('returns the map', async function () {\n        const m = new AttributeMap(pool);\n        expect(callUpdateFn(m, [])).to.equal(m);\n      });\n\n      describe('emptyValueIsDelete=false inserts empty values', function () {\n        for (const emptyVal of ['', null, undefined]) {\n          it(emptyVal == null ? String(emptyVal) : JSON.stringify(emptyVal), async function () {\n            const m = new AttributeMap(pool);\n            m.set('k', 'v');\n            callUpdateFn(m, [['k', emptyVal]]);\n            expect(m.size).to.equal(1);\n            expect(m.toString()).to.equal(attributes.attribsToString([['k', '']], pool));\n          });\n        }\n      });\n\n      describe('emptyValueIsDelete=true deletes entries', function () {\n        for (const emptyVal of ['', null, undefined]) {\n          it(emptyVal == null ? String(emptyVal) : JSON.stringify(emptyVal), async function () {\n            const m = new AttributeMap(pool);\n            m.set('k', 'v');\n            callUpdateFn(m, [['k', emptyVal]], true);\n            expect(m.size).to.equal(0);\n            expect(m.toString()).to.equal('');\n          });\n        }\n      });\n    });\n  }\n});\n"
  },
  {
    "path": "src/tests/backend-new/specs/StringIteratorTest.ts",
    "content": "import {expect, describe, it} from 'vitest'\nimport {StringIterator} from \"../../../static/js/StringIterator\";\n\n\ndescribe('Test string iterator take', function () {\n  it('should iterate over a string', async function () {\n    const str = 'Hello, world!';\n    const iter = new StringIterator(str);\n    let i = 0;\n    while (iter.remaining() > 0) {\n      expect(iter.remaining()).to.equal(str.length - i);\n      console.error(iter.remaining());\n      expect(iter.take(1)).to.equal(str.charAt(i));\n      i++;\n    }\n  });\n})\n\n\ndescribe('Test string iterator peek', function () {\n  it('should peek over a string', async function () {\n    const str = 'Hello, world!';\n    const iter = new StringIterator(str);\n    let i = 0;\n    while (iter.remaining() > 0) {\n      expect(iter.remaining()).to.equal(str.length - i);\n      expect(iter.peek(1)).to.equal(str.charAt(i));\n      i++;\n      iter.skip(1);\n    }\n  });\n})\n\ndescribe('Test string iterator skip', function () {\n  it('should throw error when skip over a string too long', async function () {\n    const str = 'Hello, world!';\n    const iter = new StringIterator(str);\n    expect(()=>iter.skip(1000)).toThrowError();\n  });\n\n  it('should skip over a string', async function () {\n    const str = 'Hello, world!';\n    const iter = new StringIterator(str);\n    iter.skip(7);\n    expect(iter.take(1)).to.equal('w');\n  });\n})\n"
  },
  {
    "path": "src/tests/backend-new/specs/admin_utils.ts",
    "content": "'use strict';\n\n\nimport {strict as assert} from \"assert\";\nimport {cleanComments, minify} from \"admin/src/utils/utils\";\nimport {describe, it, expect, beforeAll} from \"vitest\";\nimport fs from 'fs';\nconst fsp = fs.promises;\nlet template:string;\n\ndescribe(__filename, function () {\n  beforeAll(async function () {\n    template = await fsp.readFile('../settings.json.template', 'utf8')\n  });\n  describe('adminUtils', function () {\n    it('cleanComments function empty', async function () {\n      expect(cleanComments(\"\")).to.equal(\"\");\n    });\n    it('cleanComments function HelloWorld no comment', async function () {\n      expect(cleanComments(\"HelloWorld\")).to.equal(\"HelloWorld\");\n    });\n    it('cleanComments function HelloWorld with comment', async function () {\n      expect(cleanComments(\"Hello/*abc*/World/*def*/\")).to.equal(\"HelloWorld\");\n    });\n    it('cleanComments function HelloWorld with comment and multiline', async function () {\n      expect(cleanComments(\"Hello \\n/*abc\\nxyz*/World/*def*/\")).to.equal(\"Hello\\nWorld\");\n    });\n    it('cleanComments function HelloWorld with multiple line breaks', async function () {\n      expect(cleanComments(\"  \\nHello \\n  \\n  \\nWorld/*def*/\")).to.equal(\"Hello\\nWorld\");\n    });\n    it('cleanComments function same after minified', async function () {\n      expect(minify(cleanComments(template)!)).to.equal(minify(template));\n    });\n    it('minified results are smaller', async function () {\n      expect(minify(template).length < template.length).to.equal(true);\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend-new/specs/attributes.ts",
    "content": "'use strict';\n\nimport {APool} from \"../../../node/types/PadType\";\n\nimport AttributePool from '../../../static/js/AttributePool';\nimport attributes from '../../../static/js/attributes';\n\nimport {expect, describe, it, beforeEach} from 'vitest';\nimport {Attribute} from \"../../../static/js/types/Attribute\";\n\ndescribe('attributes', function () {\n  const attribs: Attribute[] = [['foo', 'bar'], ['baz', 'bif']];\n  let pool: AttributePool;\n\n  beforeEach(async function () {\n    pool = new AttributePool();\n    for (let i = 0; i < attribs.length; ++i) expect(pool.putAttrib(attribs[i])).to.equal(i);\n  });\n\n  describe('decodeAttribString', function () {\n    it('is a generator function', async function () {\n      expect(attributes.decodeAttribString.constructor.name).to.equal('GeneratorFunction');\n    });\n\n    describe('rejects invalid attribute strings', function () {\n      const testCases = ['x', '*0+1', '*A', '*0$', '*', '0', '*-1'];\n      for (const tc of testCases) {\n        it(JSON.stringify(tc), async function () {\n          expect(() => [...attributes.decodeAttribString(tc)])\n              .toThrowError(/invalid character/);\n        });\n      }\n    });\n\n    describe('accepts valid attribute strings', function () {\n      const testCases = [\n        ['', []],\n        ['*0', [0]],\n        ['*a', [10]],\n        ['*z', [35]],\n        ['*10', [36]],\n        [\n          '*0*1*2*3*4*5*6*7*8*9*a*b*c*d*e*f*g*h*i*j*k*l*m*n*o*p*q*r*s*t*u*v*w*x*y*z*10',\n          [...Array(37).keys()],\n        ],\n      ];\n      for (const [input, want] of testCases) {\n        it(`${JSON.stringify(input)} -> ${JSON.stringify(want)}`, async function () {\n          // @ts-ignore\n          const got = [...attributes.decodeAttribString(input)];\n          expect(JSON.stringify(got)).to.equal(JSON.stringify(want));\n        });\n      }\n    });\n  });\n\n  describe('encodeAttribString', function () {\n    describe('accepts any kind of iterable', function () {\n      const testCases = [\n        ['generator', (function* () { yield 0; yield 1; })()],\n        ['list', [0, 1]],\n        ['set', new Set([0, 1])],\n      ];\n      for (const [desc, input] of testCases) {\n        it(desc as string, async function () {\n          // @ts-ignore\n          expect(attributes.encodeAttribString(input)).to.equal('*0*1');\n        });\n      }\n    });\n\n    describe('rejects invalid inputs', function () {\n      const testCases = [\n        [null, /.*/], // Different browsers may have different error messages.\n        [[-1], /is negative/],\n        [['0'], /not a number/],\n        [[null], /not a number/],\n        [[0.5], /not an integer/],\n        [[{}], /not a number/],\n        [[true], /not a number/],\n      ];\n      for (const [input, wantErr] of testCases) {\n        it(JSON.stringify(input), async function () {\n          // @ts-ignore\n          expect(() => attributes.encodeAttribString(input)).toThrowError(wantErr as RegExp);\n        });\n      }\n    });\n\n    describe('accepts valid inputs', function () {\n      const testCases = [\n        [[], ''],\n        [[0], '*0'],\n        [[10], '*a'],\n        [[35], '*z'],\n        [[36], '*10'],\n        [\n          [...Array(37).keys()],\n          '*0*1*2*3*4*5*6*7*8*9*a*b*c*d*e*f*g*h*i*j*k*l*m*n*o*p*q*r*s*t*u*v*w*x*y*z*10',\n        ],\n      ];\n      for (const [input, want] of testCases) {\n        it(`${JSON.stringify(input)} -> ${JSON.stringify(want)}`, async function () {\n          // @ts-ignore\n          expect(attributes.encodeAttribString(input)).to.equal(want);\n        });\n      }\n    });\n  });\n\n  describe('attribsFromNums', function () {\n    it('is a generator function', async function () {\n      expect(attributes.attribsFromNums.constructor.name).to.equal(\"GeneratorFunction\");\n    });\n\n    describe('accepts any kind of iterable', function () {\n      const testCases = [\n        ['generator', (function* () { yield 0; yield 1; })()],\n        ['list', [0, 1]],\n        ['set', new Set([0, 1])],\n      ];\n\n      for (const [desc, input] of testCases) {\n        it(desc as string, async function () {\n          // @ts-ignore\n          const gotAttribs = [...attributes.attribsFromNums(input, pool)];\n          expect(JSON.stringify(gotAttribs)).to.equal(JSON.stringify(attribs));\n        });\n      }\n    });\n\n    describe('rejects invalid inputs', function () {\n      const testCases = [\n        [null, /.*/], // Different browsers may have different error messages.\n        [[-1], /is negative/],\n        [['0'], /not a number/],\n        [[null], /not a number/],\n        [[0.5], /not an integer/],\n        [[{}], /not a number/],\n        [[true], /not a number/],\n        [[9999], /does not exist in pool/],\n      ];\n      for (const [input, wantErr] of testCases) {\n        it(JSON.stringify(input), async function () {\n          // @ts-ignore\n          expect(() => [...attributes.attribsFromNums(input, pool)]).toThrowError(wantErr as RegExp);\n        });\n      }\n    });\n\n    describe('accepts valid inputs', function () {\n      const testCases = [\n        [[], []],\n        [[0], [attribs[0]]],\n        [[1], [attribs[1]]],\n        [[0, 1], [attribs[0], attribs[1]]],\n        [[1, 0], [attribs[1], attribs[0]]],\n      ];\n      for (const [input, want] of testCases) {\n        it(`${JSON.stringify(input)} -> ${JSON.stringify(want)}`, async function () {\n          // @ts-ignore\n          const gotAttribs = [...attributes.attribsFromNums(input, pool)];\n          expect(JSON.stringify(gotAttribs)).to.equal(JSON.stringify(want));\n        });\n      }\n    });\n  });\n\n  describe('attribsToNums', function () {\n    it('is a generator function', async function () {\n      expect(attributes.attribsToNums.constructor.name).to.equal(\"GeneratorFunction\")\n    });\n\n    describe('accepts any kind of iterable', function () {\n      const testCases = [\n        ['generator', (function* () { yield attribs[0]; yield attribs[1]; })()],\n        ['list', [attribs[0], attribs[1]]],\n        ['set', new Set([attribs[0], attribs[1]])],\n      ];\n\n      for (const [desc, input] of testCases) {\n        it(desc as string, async function () {\n          // @ts-ignore\n          const gotNums = [...attributes.attribsToNums(input, pool)];\n          expect(JSON.stringify(gotNums)).to.equal(JSON.stringify([0, 1]));\n        });\n      }\n    });\n\n    describe('rejects invalid inputs', function () {\n      const testCases = [null, [null]];\n      for (const input of testCases) {\n        it(JSON.stringify(input), async function () {\n          // @ts-ignore\n          expect(() => [...attributes.attribsToNums(input, pool)]).toThrowError();\n        });\n      }\n    });\n\n    describe('reuses existing pool entries', function () {\n      const testCases = [\n        [[], []],\n        [[attribs[0]], [0]],\n        [[attribs[1]], [1]],\n        [[attribs[0], attribs[1]], [0, 1]],\n        [[attribs[1], attribs[0]], [1, 0]],\n      ];\n      for (const [input, want] of testCases) {\n        it(`${JSON.stringify(input)} -> ${JSON.stringify(want)}`, async function () {\n          // @ts-ignore\n          const got = [...attributes.attribsToNums(input, pool)];\n          expect(JSON.stringify(got)).to.equal(JSON.stringify(want));\n        });\n      }\n    });\n\n    describe('inserts new attributes into the pool', function () {\n      const testCases = [\n        [[['k', 'v']], [attribs.length]],\n        [[attribs[0], ['k', 'v']], [0, attribs.length]],\n        [[['k', 'v'], attribs[0]], [attribs.length, 0]],\n      ];\n      for (const [input, want] of testCases) {\n        it(`${JSON.stringify(input)} -> ${JSON.stringify(want)}`, async function () {\n          // @ts-ignore\n          const got = [...attributes.attribsToNums(input, pool)];\n          expect(JSON.stringify(got)).to.equal(JSON.stringify(want));\n          expect(JSON.stringify(pool.getAttrib(attribs.length)))\n              .to.equal(JSON.stringify(['k', 'v']));\n        });\n      }\n    });\n\n    describe('coerces key and value to string', function () {\n      const testCases = [\n        ['object (with toString)', {toString: () => 'obj'}, 'obj'],\n        ['undefined', undefined, ''],\n        ['null', null, ''],\n        ['boolean', true, 'true'],\n        ['number', 1, '1'],\n      ];\n      for (const [desc, inputVal, wantVal] of testCases) {\n        describe(desc as string, function () {\n          for (const [desc, inputAttribs, wantAttribs] of [\n            ['key is coerced to string', [[inputVal, 'value']], [[wantVal, 'value']]],\n            ['value is coerced to string', [['key', inputVal]], [['key', wantVal]]],\n          ]) {\n            it(desc as string, async function () {\n              // @ts-ignore\n              const gotNums = [...attributes.attribsToNums(inputAttribs, pool)];\n              // Each attrib in inputAttribs is expected to be new to the pool.\n              const wantNums = [...Array(attribs.length + 1).keys()].slice(attribs.length);\n              expect(JSON.stringify(gotNums)).to.equal(JSON.stringify(wantNums));\n              const gotAttribs = gotNums.map((n) => pool.getAttrib(n));\n              expect(JSON.stringify(gotAttribs)).to.equal(JSON.stringify(wantAttribs));\n            });\n          }\n        });\n      }\n    });\n  });\n\n  describe('attribsFromString', function () {\n    it('is a generator function', async function () {\n      expect(attributes.attribsFromString.constructor.name).to.equal('GeneratorFunction');\n    });\n\n    describe('rejects invalid attribute strings', function () {\n      const testCases = [\n        ['x', /invalid character/],\n        ['*0+1', /invalid character/],\n        ['*A', /invalid character/],\n        ['*0$', /invalid character/],\n        ['*', /invalid character/],\n        ['0', /invalid character/],\n        ['*-1', /invalid character/],\n        ['*9999', /does not exist in pool/],\n      ];\n      for (const [input, wantErr] of testCases) {\n        it(JSON.stringify(input), async function () {\n          // @ts-ignore\n          expect(() => [...attributes.attribsFromString(input, pool)]).toThrowError(wantErr);\n        });\n      }\n    });\n\n    describe('accepts valid inputs', function () {\n      const testCases = [\n        ['', []],\n        ['*0', [attribs[0]]],\n        ['*1', [attribs[1]]],\n        ['*0*1', [attribs[0], attribs[1]]],\n        ['*1*0', [attribs[1], attribs[0]]],\n      ];\n      for (const [input, want] of testCases) {\n        it(`${JSON.stringify(input)} -> ${JSON.stringify(want)}`, async function () {\n          // @ts-ignore\n          const gotAttribs = [...attributes.attribsFromString(input, pool)];\n          expect(JSON.stringify(gotAttribs)).to.equal(JSON.stringify(want));\n        });\n      }\n    });\n  });\n\n  describe('attribsToString', function () {\n    describe('accepts any kind of iterable', function () {\n      const testCases = [\n        ['generator', (function* () { yield attribs[0]; yield attribs[1]; })()],\n        ['list', [attribs[0], attribs[1]]],\n        ['set', new Set([attribs[0], attribs[1]])],\n      ];\n\n      for (const [desc, input] of testCases) {\n        it(desc as string, async function () {\n          // @ts-ignore\n          const got = attributes.attribsToString(input, pool);\n          expect(got).to.equal('*0*1');\n        });\n      }\n    });\n\n    describe('rejects invalid inputs', function () {\n      const testCases = [null, [null]];\n      for (const input of testCases) {\n        it(JSON.stringify(input), async function () {\n          // @ts-ignore\n          expect(() => attributes.attribsToString(input, pool)).toThrowError();\n        });\n      }\n    });\n\n    describe('reuses existing pool entries', function () {\n      const testCases = [\n        [[], ''],\n        [[attribs[0]], '*0'],\n        [[attribs[1]], '*1'],\n        [[attribs[0], attribs[1]], '*0*1'],\n        [[attribs[1], attribs[0]], '*1*0'],\n      ];\n      for (const [input, want] of testCases) {\n        it(`${JSON.stringify(input)} -> ${JSON.stringify(want)}`, async function () {\n          // @ts-ignore\n          const got = attributes.attribsToString(input, pool);\n          expect(got).to.equal(want);\n        });\n      }\n    });\n\n    describe('inserts new attributes into the pool', function () {\n      const testCases = [\n        [[['k', 'v']], `*${attribs.length}`],\n        [[attribs[0], ['k', 'v']], `*0*${attribs.length}`],\n        [[['k', 'v'], attribs[0]], `*${attribs.length}*0`],\n      ];\n      for (const [input, want] of testCases) {\n        it(`${JSON.stringify(input)} -> ${JSON.stringify(want)}`, async function () {\n          // @ts-ignore\n          const got = attributes.attribsToString(input, pool);\n          expect(got).to.equal(want);\n          expect(JSON.stringify(pool.getAttrib(attribs.length)))\n              .to.equal(JSON.stringify(['k', 'v']));\n        });\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend-new/specs/easysync-assembler.ts",
    "content": "'use strict';\n\nimport {deserializeOps, opsFromAText} from '../../../static/js/Changeset';\nimport padutils from '../../../static/js/pad_utils';\nimport {poolOrArray} from '../easysync-helper.js';\n\nimport {describe, it, expect} from 'vitest'\nimport {OpAssembler} from \"../../../static/js/OpAssembler\";\nimport {SmartOpAssembler} from \"../../../static/js/SmartOpAssembler\";\nimport Op from \"../../../static/js/Op\";\n\n\ndescribe('easysync-assembler', function () {\n  it('opAssembler', async function () {\n    const x = '-c*3*4+6|3=az*asdf0*1*2*3+1=1-1+1*0+1=1-1+1|c=c-1';\n    const assem = new OpAssembler();\n    var opLength = 0\n    for (const op of deserializeOps(x)){\n      console.log(op)\n      assem.append(op);\n      opLength++\n    }\n    expect(assem.toString()).to.equal(x);\n  });\n\n  it('smartOpAssembler', async function () {\n    const x = '-c*3*4+6|3=az*asdf0*1*2*3+1=1-1+1*0+1=1-1+1|c=c-1';\n    const assem = new SmartOpAssembler();\n    for (const op of deserializeOps(x)) assem.append(op);\n    assem.endDocument();\n    expect(assem.toString()).to.equal(x);\n  });\n\n  it('smartOpAssembler ignore additional pure keeps (no attributes)', async function () {\n    const x = '-c*3*4+6|1+1=5';\n    const assem = new SmartOpAssembler();\n    for (const op of deserializeOps(x)) assem.append(op);\n    assem.endDocument();\n    expect(assem.toString()).to.equal('-c*3*4+6|1+1');\n  });\n\n  it('smartOpAssembler merge consecutive + ops without multiline', async function () {\n    const x = '-c*3*4+6*3*4+1*3*4+9=5';\n    const assem = new SmartOpAssembler();\n    for (const op of deserializeOps(x)) assem.append(op);\n    assem.endDocument();\n    expect(assem.toString()).to.equal('-c*3*4+g');\n  });\n\n  it('smartOpAssembler merge consecutive + ops with multiline', async function () {\n    const x = '-c*3*4+6*3*4|1+1*3*4|9+f*3*4+k=5';\n    const assem = new SmartOpAssembler();\n    for (const op of deserializeOps(x)) assem.append(op);\n    assem.endDocument();\n    expect(assem.toString()).to.equal('-c*3*4|a+m*3*4+k');\n  });\n\n  it('smartOpAssembler merge consecutive - ops without multiline', async function () {\n    const x = '-c-6-1-9=5';\n    const assem = new SmartOpAssembler();\n    for (const op of deserializeOps(x)) assem.append(op);\n    assem.endDocument();\n    expect(assem.toString()).to.equal('-s');\n  });\n\n  it('smartOpAssembler merge consecutive - ops with multiline', async function () {\n    const x = '-c-6|1-1|9-f-k=5';\n    const assem = new SmartOpAssembler();\n    for (const op of deserializeOps(x)) assem.append(op);\n    assem.endDocument();\n    expect(assem.toString()).to.equal('|a-y-k');\n  });\n\n  it('smartOpAssembler merge consecutive = ops without multiline', async function () {\n    const x = '-c*3*4=6*2*4=1*3*4=f*3*4=2*3*4=a=k=5';\n    const assem = new SmartOpAssembler();\n    for (const op of deserializeOps(x)) assem.append(op);\n    assem.endDocument();\n    expect(assem.toString()).to.equal('-c*3*4=6*2*4=1*3*4=r');\n  });\n\n  it('smartOpAssembler merge consecutive = ops with multiline', async function () {\n    const x = '-c*3*4=6*2*4|1=1*3*4|9=f*3*4|2=2*3*4=a*3*4=1=k=5';\n    const assem = new SmartOpAssembler();\n    for (const op of deserializeOps(x)) assem.append(op);\n    assem.endDocument();\n    expect(assem.toString()).to.equal('-c*3*4=6*2*4|1=1*3*4|b=h*3*4=b');\n  });\n\n  it('smartOpAssembler ignore + ops with ops.chars === 0', async function () {\n    const x = '-c*3*4+6*3*4+0*3*4+1+0*3*4+1';\n    const assem = new SmartOpAssembler();\n    for (const op of deserializeOps(x)) assem.append(op);\n    assem.endDocument();\n    expect(assem.toString()).to.equal('-c*3*4+8');\n  });\n\n  it('smartOpAssembler ignore - ops with ops.chars === 0', async function () {\n    const x = '-c-6-0-1-0-1';\n    const assem = new SmartOpAssembler();\n    for (const op of deserializeOps(x)) assem.append(op);\n    assem.endDocument();\n    expect(assem.toString()).to.equal('-k');\n  });\n\n  it('smartOpAssembler append + op with text', async function () {\n    const assem = new SmartOpAssembler();\n    const pool = poolOrArray([\n      'attr1,1',\n      'attr2,2',\n      'attr3,3',\n      'attr4,4',\n      'attr5,5',\n    ]);\n\n    padutils.warnDeprecatedFlags.disabledForTestingOnly = true;\n    try {\n      assem.appendOpWithText('+', 'test', '*3*4*5', pool);\n      assem.appendOpWithText('+', 'test', '*3*4*5', pool);\n      assem.appendOpWithText('+', 'test', '*1*4*5', pool);\n    } finally {\n      // @ts-ignore\n      delete padutils.warnDeprecatedFlags.disabledForTestingOnly;\n    }\n    assem.endDocument();\n    expect(assem.toString()).to.equal('*3*4*5+8*1*4*5+4');\n  });\n\n  it('smartOpAssembler append + op with multiline text', async function () {\n    const assem = new SmartOpAssembler();\n    const pool = poolOrArray([\n      'attr1,1',\n      'attr2,2',\n      'attr3,3',\n      'attr4,4',\n      'attr5,5',\n    ]);\n\n    padutils.warnDeprecatedFlags.disabledForTestingOnly = true;\n    try {\n      assem.appendOpWithText('+', 'test\\ntest', '*3*4*5', pool);\n      assem.appendOpWithText('+', '\\ntest\\n', '*3*4*5', pool);\n      assem.appendOpWithText('+', '\\ntest', '*1*4*5', pool);\n    } finally {\n      // @ts-ignore\n      delete padutils.warnDeprecatedFlags.disabledForTestingOnly;\n    }\n    assem.endDocument();\n    expect(assem.toString()).to.equal('*3*4*5|3+f*1*4*5|1+1*1*4*5+4');\n  });\n\n  it('smartOpAssembler clear should empty internal assemblers', async function () {\n    const x = '-c*3*4+6|3=az*asdf0*1*2*3+1=1-1+1*0+1=1-1+1|c=c-1';\n    const ops = deserializeOps(x);\n    const iter = {\n      _n: ops.next(),\n      hasNext() { return !this._n.done; },\n      next() { const v = this._n.value; this._n = ops.next(); return v as Op; },\n    };\n    const assem = new SmartOpAssembler();\n    var iter1 = iter.next()\n    assem.append(iter1);\n    var iter2 = iter.next()\n    assem.append(iter2);\n    var iter3 = iter.next()\n    assem.append(iter3);\n    console.log(assem.toString());\n    assem.clear();\n    assem.append(iter.next());\n    assem.append(iter.next());\n    console.log(assem.toString());\n    assem.clear();\n    let counter = 0;\n    while (iter.hasNext()) {\n      console.log(counter++)\n      assem.append(iter.next());\n    }\n    assem.endDocument();\n    expect(assem.toString()).to.equal('-1+1*0+1=1-1+1|c=c-1');\n  });\n\n  describe('append atext to assembler', function () {\n    const testAppendATextToAssembler = (testId: number, atext: { text: string; attribs: string; }, correctOps: string) => {\n      it(`testAppendATextToAssembler#${testId}`, async function () {\n        const assem = new SmartOpAssembler();\n        for (const op of opsFromAText(atext)) assem.append(op);\n        expect(assem.toString()).to.equal(correctOps);\n      });\n    };\n\n    testAppendATextToAssembler(1, {\n      text: '\\n',\n      attribs: '|1+1',\n    }, '');\n    testAppendATextToAssembler(2, {\n      text: '\\n\\n',\n      attribs: '|2+2',\n    }, '|1+1');\n    testAppendATextToAssembler(3, {\n      text: '\\n\\n',\n      attribs: '*x|2+2',\n    }, '*x|1+1');\n    testAppendATextToAssembler(4, {\n      text: '\\n\\n',\n      attribs: '*x|1+1|1+1',\n    }, '*x|1+1');\n    testAppendATextToAssembler(5, {\n      text: 'foo\\n',\n      attribs: '|1+4',\n    }, '+3');\n    testAppendATextToAssembler(6, {\n      text: '\\nfoo\\n',\n      attribs: '|2+5',\n    }, '|1+1+3');\n    testAppendATextToAssembler(7, {\n      text: '\\nfoo\\n',\n      attribs: '*x|2+5',\n    }, '*x|1+1*x+3');\n    testAppendATextToAssembler(8, {\n      text: '\\n\\n\\nfoo\\n',\n      attribs: '|2+2*x|2+5',\n    }, '|2+2*x|1+1*x+3');\n  });\n});\n"
  },
  {
    "path": "src/tests/backend-new/specs/easysync-compose.ts",
    "content": "'use strict';\n\nimport {applyToText, checkRep, compose} from '../../../static/js/Changeset';\nimport AttributePool from '../../../static/js/AttributePool';\nimport {randomMultiline, randomTestChangeset} from '../easysync-helper';\nimport {expect, describe, it} from 'vitest';\n\ndescribe('easysync-compose', function () {\n  describe('compose', function () {\n    const testCompose = (randomSeed: number) => {\n      it(`testCompose#${randomSeed}`, async function () {\n        const p = new AttributePool();\n\n        const startText = `${randomMultiline(10, 20)}\\n`;\n\n        const x1 = randomTestChangeset(startText);\n        const change1 = x1[0];\n        const text1 = x1[1];\n\n        const x2 = randomTestChangeset(text1);\n        const change2 = x2[0];\n        const text2 = x2[1];\n\n        const x3 = randomTestChangeset(text2);\n        const change3 = x3[0];\n        const text3 = x3[1];\n\n        const change12 = checkRep(compose(change1, change2, p));\n        const change23 = checkRep(compose(change2, change3, p));\n        const change123 = checkRep(compose(change12, change3, p));\n        const change123a = checkRep(compose(change1, change23, p));\n        expect(change123a).to.equal(change123);\n\n        expect(applyToText(change12, startText)).to.equal(text2);\n        expect(applyToText(change23, text1)).to.equal(text3);\n        expect(applyToText(change123, startText)).to.equal(text3);\n      });\n    };\n\n    for (let i = 0; i < 30; i++) testCompose(i);\n  });\n\n  describe('compose attributes', function () {\n    it('simpleComposeAttributesTest', async function () {\n      const p = new AttributePool();\n      p.putAttrib(['bold', '']);\n      p.putAttrib(['bold', 'true']);\n      const cs1 = checkRep('Z:2>1*1+1*1=1$x');\n      const cs2 = checkRep('Z:3>0*0|1=3$');\n      const cs12 = checkRep(compose(cs1, cs2, p));\n      expect(cs12).to.equal('Z:2>1+1*0|1=2$x');\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend-new/specs/easysync-inverseRandom.ts",
    "content": "'use strict';\n\nimport AttributePool from '../../../static/js/AttributePool';\nimport {checkRep, inverse, makeAttribution, mutateAttributionLines, mutateTextLines, splitAttributionLines} from '../../../static/js/Changeset';\nimport {randomMultiline, randomTestChangeset, poolOrArray} from '../easysync-helper.js';\nimport {expect, describe, it} from 'vitest'\n\ndescribe('easysync-inverseRandom', function () {\n  describe('inverse random', function () {\n    const testInverseRandom = (randomSeed: number) => {\n      it(`testInverseRandom#${randomSeed}`, async function () {\n        const p = poolOrArray(['apple,', 'apple,true', 'banana,', 'banana,true']);\n\n        const startText = `${randomMultiline(10, 20)}\\n`;\n        const alines =\n          splitAttributionLines(makeAttribution(startText), startText);\n        const lines = startText.slice(0, -1).split('\\n').map((s) => `${s}\\n`);\n\n        const stylifier = randomTestChangeset(startText, true)[0];\n\n        mutateAttributionLines(stylifier, alines, p);\n        mutateTextLines(stylifier, lines);\n\n        const changeset = randomTestChangeset(lines.join(''), true)[0];\n        const inverseChangeset = inverse(changeset, lines, alines, p);\n\n        const origLines = lines.slice();\n        const origALines = alines.slice();\n\n        mutateTextLines(changeset, lines);\n        mutateAttributionLines(changeset, alines, p);\n        mutateTextLines(inverseChangeset, lines);\n        mutateAttributionLines(inverseChangeset, alines, p);\n        expect(lines).to.eql(origLines);\n        expect(alines).to.eql(origALines);\n      });\n    };\n\n    for (let i = 0; i < 30; i++) testInverseRandom(i);\n  });\n\n  describe('inverse', function () {\n    const testInverse = (testId: number, cs: string, lines: string | RegExpMatchArray | null, alines: string[] | { get: (idx: number) => string; }, pool: string[] | AttributePool, correctOutput: string) => {\n      it(`testInverse#${testId}`, async function () {\n        pool = poolOrArray(pool);\n        const str = inverse(checkRep(cs), lines, alines, pool as AttributePool);\n        expect(str).to.equal(correctOutput);\n      });\n    };\n\n    // take \"FFFFTTTTT\" and apply \"-FT--FFTT\", the inverse of which is \"--F--TT--\"\n    testInverse(1, 'Z:9>0=1*0=1*1=1=2*0=2*1|1=2$', null,\n        ['+4*1+5'], ['bold,', 'bold,true'], 'Z:9>0=2*0=1=2*1=2$');\n  });\n});\n"
  },
  {
    "path": "src/tests/backend-new/specs/easysync-mutations.ts",
    "content": "'use strict';\n\nimport {applyToAttribution, applyToText, checkRep, joinAttributionLines, mutateAttributionLines, mutateTextLines, pack} from '../../../static/js/Changeset';\nimport AttributePool from '../../../static/js/AttributePool';\nimport {poolOrArray} from '../easysync-helper';\nimport {expect, describe,it } from \"vitest\";\nimport {SmartOpAssembler} from \"../../../static/js/SmartOpAssembler\";\nimport Op from \"../../../static/js/Op\";\nimport {StringAssembler} from \"../../../static/js/StringAssembler\";\nimport TextLinesMutator from \"../../../static/js/TextLinesMutator\";\nimport {numToString} from \"../../../static/js/ChangesetUtils\";\n\ndescribe('easysync-mutations', function () {\n  const applyMutations = (mu: TextLinesMutator, arrayOfArrays: any[]) => {\n    arrayOfArrays.forEach((a) => {\n      // @ts-ignore\n      const result = mu[a[0]](...a.slice(1));\n      if (a[0] === 'remove' && a[3]) {\n        expect(result).to.equal(a[3]);\n      }\n    });\n  };\n\n  const mutationsToChangeset = (oldLen: number, arrayOfArrays: string[][]) => {\n    const assem = new SmartOpAssembler();\n    const op = new Op();\n    const bank = new StringAssembler();\n    let oldPos = 0;\n    let newLen = 0;\n    arrayOfArrays.forEach((a: any[]) => {\n      if (a[0] === 'skip') {\n        op.opcode = '=';\n        op.chars = a[1];\n        op.lines = (a[2] || 0);\n        assem.append(op);\n        oldPos += op.chars;\n        newLen += op.chars;\n      } else if (a[0] === 'remove') {\n        op.opcode = '-';\n        op.chars = a[1];\n        op.lines = (a[2] || 0);\n        assem.append(op);\n        oldPos += op.chars;\n      } else if (a[0] === 'insert') {\n        op.opcode = '+';\n        bank.append(a[1]);\n        op.chars = a[1].length;\n        op.lines = (a[2] || 0);\n        assem.append(op);\n        newLen += op.chars;\n      }\n    });\n    newLen += oldLen - oldPos;\n    assem.endDocument();\n    return pack(oldLen, newLen, assem.toString(), bank.toString());\n  };\n\n  const runMutationTest = (testId: number, origLines: string[], muts:any, correct: string[]) => {\n    it(`runMutationTest#${testId}`, async function () {\n      let lines = origLines.slice();\n      const mu = new TextLinesMutator(lines);\n      applyMutations(mu, muts);\n      mu.close();\n      expect(lines).to.eql(correct);\n\n      const inText = origLines.join('');\n      const cs = mutationsToChangeset(inText.length, muts);\n      lines = origLines.slice();\n      mutateTextLines(cs, lines);\n      expect(lines).to.eql(correct);\n\n      const correctText = correct.join('');\n      const outText = applyToText(cs, inText);\n      expect(outText).to.equal(correctText);\n    });\n  };\n\n  runMutationTest(1, ['apple\\n', 'banana\\n', 'cabbage\\n', 'duffle\\n', 'eggplant\\n'], [\n    ['remove', 1, 0, 'a'],\n    ['insert', 'tu'],\n    ['remove', 1, 0, 'p'],\n    ['skip', 4, 1],\n    ['skip', 7, 1],\n    ['insert', 'cream\\npie\\n', 2],\n    ['skip', 2],\n    ['insert', 'bot'],\n    ['insert', '\\n', 1],\n    ['insert', 'bu'],\n    ['skip', 3],\n    ['remove', 3, 1, 'ge\\n'],\n    ['remove', 6, 0, 'duffle'],\n  ], ['tuple\\n', 'banana\\n', 'cream\\n', 'pie\\n', 'cabot\\n', 'bubba\\n', 'eggplant\\n']);\n\n  runMutationTest(2, ['apple\\n', 'banana\\n', 'cabbage\\n', 'duffle\\n', 'eggplant\\n'], [\n    ['remove', 1, 0, 'a'],\n    ['remove', 1, 0, 'p'],\n    ['insert', 'tu'],\n    ['skip', 11, 2],\n    ['insert', 'cream\\npie\\n', 2],\n    ['skip', 2],\n    ['insert', 'bot'],\n    ['insert', '\\n', 1],\n    ['insert', 'bu'],\n    ['skip', 3],\n    ['remove', 3, 1, 'ge\\n'],\n    ['remove', 6, 0, 'duffle'],\n  ], ['tuple\\n', 'banana\\n', 'cream\\n', 'pie\\n', 'cabot\\n', 'bubba\\n', 'eggplant\\n']);\n\n  runMutationTest(3, ['apple\\n', 'banana\\n', 'cabbage\\n', 'duffle\\n', 'eggplant\\n'], [\n    ['remove', 6, 1, 'apple\\n'],\n    ['skip', 15, 2],\n    ['skip', 6],\n    ['remove', 1, 1, '\\n'],\n    ['remove', 8, 0, 'eggplant'],\n    ['skip', 1, 1],\n  ], ['banana\\n', 'cabbage\\n', 'duffle\\n']);\n\n  runMutationTest(4, ['15\\n'], [\n    ['skip', 1],\n    ['insert', '\\n2\\n3\\n4\\n', 4],\n    ['skip', 2, 1],\n  ], ['1\\n', '2\\n', '3\\n', '4\\n', '5\\n']);\n\n  runMutationTest(5, ['1\\n', '2\\n', '3\\n', '4\\n', '5\\n'], [\n    ['skip', 1],\n    ['remove', 7, 4, '\\n2\\n3\\n4\\n'],\n    ['skip', 2, 1],\n  ], ['15\\n']);\n\n  runMutationTest(6, ['123\\n', 'abc\\n', 'def\\n', 'ghi\\n', 'xyz\\n'], [\n    ['insert', '0'],\n    ['skip', 4, 1],\n    ['skip', 4, 1],\n    ['remove', 8, 2, 'def\\nghi\\n'],\n    ['skip', 4, 1],\n  ], ['0123\\n', 'abc\\n', 'xyz\\n']);\n\n  runMutationTest(7, ['apple\\n', 'banana\\n', 'cabbage\\n', 'duffle\\n', 'eggplant\\n'], [\n    ['remove', 6, 1, 'apple\\n'],\n    ['skip', 15, 2, true],\n    ['skip', 6, 0, true],\n    ['remove', 1, 1, '\\n'],\n    ['remove', 8, 0, 'eggplant'],\n    ['skip', 1, 1, true],\n  ], ['banana\\n', 'cabbage\\n', 'duffle\\n']);\n\n  it('mutatorHasMore', async function () {\n    const lines = ['1\\n', '2\\n', '3\\n', '4\\n'];\n    let mu;\n\n    mu = new TextLinesMutator(lines);\n    expect(mu.hasMore()).toBeTruthy();\n    mu.skip(8, 4);\n    expect(mu.hasMore()).toBeFalsy();\n    mu.close();\n    expect(mu.hasMore()).toBeFalsy();\n\n    // still 1,2,3,4\n    mu = new TextLinesMutator(lines);\n    expect(mu.hasMore()).toBeTruthy();\n    mu.remove(2, 1);\n    expect(mu.hasMore()).toBeTruthy();\n    mu.skip(2, 1);\n    expect(mu.hasMore()).toBeTruthy();\n    mu.skip(2, 1);\n    expect(mu.hasMore()).toBeTruthy();\n    mu.skip(2, 1);\n    expect(mu.hasMore()).toBeFalsy();\n    mu.insert('5\\n', 1);\n    expect(mu.hasMore()).toBeFalsy();\n    mu.close();\n    expect(mu.hasMore()).toBeFalsy();\n\n    // 2,3,4,5 now\n    mu = new TextLinesMutator(lines);\n    expect(mu.hasMore()).toBeTruthy();\n    mu.remove(6, 3);\n    expect(mu.hasMore()).toBeTruthy();\n    mu.remove(2, 1);\n    expect(mu.hasMore()).toBeFalsy();\n    mu.insert('hello\\n', 1);\n    expect(mu.hasMore()).toBeFalsy();\n    mu.close();\n    expect(mu.hasMore()).toBeFalsy();\n  });\n\n  describe('mutateTextLines', function () {\n    const testMutateTextLines = (testId: number, cs: string, lines: string[], correctLines: string[]) => {\n      it(`testMutateTextLines#${testId}`, async function () {\n        const a = lines.slice();\n        mutateTextLines(cs, a);\n        expect(a).to.eql(correctLines);\n      });\n    };\n\n    testMutateTextLines(1, 'Z:4<1|1-2-1|1+1+1$\\nc', ['a\\n', 'b\\n'], ['\\n', 'c\\n']);\n    testMutateTextLines(2, 'Z:4>0|1-2-1|2+3$\\nc\\n', ['a\\n', 'b\\n'], ['\\n', 'c\\n', '\\n']);\n\n    it('mutate keep only lines', async function () {\n      const lines = ['1\\n', '2\\n', '3\\n', '4\\n'];\n      const result = lines.slice();\n      const cs = 'Z:8>0*0|1=2|2=2';\n\n      mutateTextLines(cs, lines);\n      expect(result).to.eql(lines);\n    });\n  });\n\n  describe('mutate attributions', function () {\n    const testPoolWithChars = (() => {\n      const p = new AttributePool();\n      p.putAttrib(['char', 'newline']);\n      for (let i = 1; i < 36; i++) {\n        p.putAttrib(['char', numToString(i)]);\n      }\n      p.putAttrib(['char', '']);\n      return p;\n    })();\n\n    const runMutateAttributionTest = (testId: number, attribs: string[] | AttributePool, cs: string, alines: string[], outCorrect: string[]) => {\n      it(`runMutateAttributionTest#${testId}`, async function () {\n        const p = poolOrArray(attribs);\n        const alines2 = Array.prototype.slice.call(alines);\n        mutateAttributionLines(checkRep(cs), alines2, p);\n        expect(alines2).to.eql(outCorrect);\n\n        const removeQuestionMarks = (a: string) => a.replace(/\\?/g, '');\n        const inMerged = joinAttributionLines(alines.map(removeQuestionMarks));\n        const correctMerged = joinAttributionLines(outCorrect.map(removeQuestionMarks));\n        const mergedResult = applyToAttribution(cs, inMerged, p);\n        expect(mergedResult).to.equal(correctMerged);\n      });\n    };\n\n    // turn 123\\n 456\\n 789\\n into 123\\n 4<b>5</b>6\\n 789\\n\n    runMutateAttributionTest(1,\n        ['bold,true'], 'Z:c>0|1=4=1*0=1$', ['|1+4', '|1+4', '|1+4'],\n        ['|1+4', '+1*0+1|1+2', '|1+4']);\n\n    // make a document bold\n    runMutateAttributionTest(2,\n        ['bold,true'], 'Z:c>0*0|3=c$', ['|1+4', '|1+4', '|1+4'], ['*0|1+4', '*0|1+4', '*0|1+4']);\n\n    // clear bold on document\n    runMutateAttributionTest(3,\n        ['bold,', 'bold,true'], 'Z:c>0*0|3=c$',\n        ['*1+1+1*1+1|1+1', '+1*1+1|1+2', '*1+1+1*1+1|1+1'], ['|1+4', '|1+4', '|1+4']);\n\n    // add a character on line 3 of a document with 5 blank lines, and make sure\n    // the optimization that skips purely-kept lines is working; if any attribution string\n    // with a '?' is parsed it will cause an error.\n    runMutateAttributionTest(4,\n        ['foo,bar', 'line,1', 'line,2', 'line,3', 'line,4', 'line,5'],\n        'Z:5>1|2=2+1$x', ['?*1|1+1', '?*2|1+1', '*3|1+1', '?*4|1+1', '?*5|1+1'],\n        ['?*1|1+1', '?*2|1+1', '+1*3|1+1', '?*4|1+1', '?*5|1+1']);\n\n    // based on runMutationTest#1\n    runMutateAttributionTest(5, testPoolWithChars,\n        'Z:11>7-2*t+1*u+1|2=b|2+a=2*b+1*o+1*t+1*0|1+1*b+1*u+1=3|1-3-6$tucream\\npie\\nbot\\nbu',\n        [\n          '*a+1*p+2*l+1*e+1*0|1+1',\n          '*b+1*a+1*n+1*a+1*n+1*a+1*0|1+1',\n          '*c+1*a+1*b+2*a+1*g+1*e+1*0|1+1',\n          '*d+1*u+1*f+2*l+1*e+1*0|1+1',\n          '*e+1*g+2*p+1*l+1*a+1*n+1*t+1*0|1+1',\n        ],\n        [\n          '*t+1*u+1*p+1*l+1*e+1*0|1+1',\n          '*b+1*a+1*n+1*a+1*n+1*a+1*0|1+1',\n          '|1+6',\n          '|1+4',\n          '*c+1*a+1*b+1*o+1*t+1*0|1+1',\n          '*b+1*u+1*b+2*a+1*0|1+1',\n          '*e+1*g+2*p+1*l+1*a+1*n+1*t+1*0|1+1',\n        ]);\n\n    // based on runMutationTest#3\n    runMutateAttributionTest(6, testPoolWithChars,\n        'Z:11<f|1-6|2=f=6|1-1-8$', ['*a|1+6', '*b|1+7', '*c|1+8', '*d|1+7', '*e|1+9'],\n        ['*b|1+7', '*c|1+8', '*d+6*e|1+1']);\n\n    // based on runMutationTest#4\n    runMutateAttributionTest(7, testPoolWithChars, 'Z:3>7=1|4+7$\\n2\\n3\\n4\\n',\n        ['*1+1*5|1+2'], ['*1+1|1+1', '|1+2', '|1+2', '|1+2', '*5|1+2']);\n\n    // based on runMutationTest#5\n    runMutateAttributionTest(8, testPoolWithChars, 'Z:a<7=1|4-7$',\n        ['*1|1+2', '*2|1+2', '*3|1+2', '*4|1+2', '*5|1+2'], ['*1+1*5|1+2']);\n\n    // based on runMutationTest#6\n    runMutateAttributionTest(9, testPoolWithChars, 'Z:k<7*0+1*10|2=8|2-8$0',\n        [\n          '*1+1*2+1*3+1|1+1',\n          '*a+1*b+1*c+1|1+1',\n          '*d+1*e+1*f+1|1+1',\n          '*g+1*h+1*i+1|1+1',\n          '?*x+1*y+1*z+1|1+1',\n        ],\n        ['*0+1|1+4', '|1+4', '?*x+1*y+1*z+1|1+1']);\n\n    runMutateAttributionTest(10, testPoolWithChars, 'Z:6>4=1+1=1+1|1=1+1=1*0+1$abcd',\n        ['|1+3', '|1+3'], ['|1+5', '+2*0+1|1+2']);\n\n\n    runMutateAttributionTest(11, testPoolWithChars, 'Z:s>1|1=4=6|1+1$\\n',\n        ['*0|1+4', '*0|1+8', '*0+5|1+1', '*0|1+1', '*0|1+5', '*0|1+1', '*0|1+1', '*0|1+1', '|1+1'],\n        [\n          '*0|1+4',\n          '*0+6|1+1',\n          '*0|1+2',\n          '*0+5|1+1',\n          '*0|1+1',\n          '*0|1+5',\n          '*0|1+1',\n          '*0|1+1',\n          '*0|1+1',\n          '|1+1',\n        ]);\n  });\n});\n"
  },
  {
    "path": "src/tests/backend-new/specs/easysync-other.test.ts",
    "content": "'use strict';\n\nimport {applyToAttribution, applyToText, checkRep, deserializeOps, exportedForTestingOnly, filterAttribNumbers, joinAttributionLines, makeAttribsString, makeSplice, moveOpsToNewPool, opAttributeValue, splitAttributionLines} from '../../../static/js/Changeset';\nimport AttributePool from '../../../static/js/AttributePool';\nimport {randomMultiline, poolOrArray} from '../easysync-helper';\nimport padutils from '../../../static/js/pad_utils';\nimport {describe, it, expect} from 'vitest'\nimport Op from \"../../../static/js/Op\";\nimport {MergingOpAssembler} from \"../../../static/js/MergingOpAssembler\";\nimport {Attribute} from \"../../../static/js/types/Attribute\";\n\n\ndescribe('easysync-other', function () {\n  describe('filter attribute numbers', function () {\n    const testFilterAttribNumbers = (testId: number, cs: string, filter: Function, correctOutput: string) => {\n      it(`testFilterAttribNumbers#${testId}`, async function () {\n        const str = filterAttribNumbers(cs, filter);\n        expect(str).to.equal(correctOutput);\n      });\n    };\n\n    testFilterAttribNumbers(1, '*0*1+1+2+3*1+4*2+5*0*2*1*b*c+6',\n        (n: number) => (n % 2) === 0, '*0+1+2+3+4*2+5*0*2*c+6');\n    testFilterAttribNumbers(2, '*0*1+1+2+3*1+4*2+5*0*2*1*b*c+6',\n        (n: number) => (n % 2) === 1, '*1+1+2+3*1+4+5*1*b+6');\n  });\n\n  describe('make attribs string', function () {\n    const testMakeAttribsString = (testId: number, pool: string[], opcode: string, attribs: string | Attribute[], correctString: string) => {\n      it(`testMakeAttribsString#${testId}`, async function () {\n        const p = poolOrArray(pool);\n        padutils.warnDeprecatedFlags.disabledForTestingOnly = true;\n        try {\n          expect(makeAttribsString(opcode, attribs, p)).to.equal(correctString);\n        } finally {\n          // @ts-ignore\n          delete padutils.warnDeprecatedFlags.disabledForTestingOnly;\n        }\n      });\n    };\n\n    testMakeAttribsString(1, ['bold,'], '+', [\n      ['bold', ''],\n    ], '');\n    testMakeAttribsString(2, ['abc,def', 'bold,'], '=', [\n      ['bold', ''],\n    ], '*1');\n    testMakeAttribsString(3, ['abc,def', 'bold,true'], '+', [\n      ['abc', 'def'],\n      ['bold', 'true'],\n    ], '*0*1');\n    testMakeAttribsString(4, ['abc,def', 'bold,true'], '+', [\n      ['bold', 'true'],\n      ['abc', 'def'],\n    ], '*0*1');\n  });\n\n  describe('other', function () {\n    it('testMoveOpsToNewPool', async function () {\n      const pool1 = new AttributePool();\n      const pool2 = new AttributePool();\n\n      pool1.putAttrib(['baz', 'qux']);\n      pool1.putAttrib(['foo', 'bar']);\n\n      pool2.putAttrib(['foo', 'bar']);\n\n      expect(moveOpsToNewPool('Z:1>2*1+1*0+1$ab', pool1, pool2))\n          .to.equal('Z:1>2*0+1*1+1$ab');\n      expect(moveOpsToNewPool('*1+1*0+1', pool1, pool2)).to.equal('*0+1*1+1');\n    });\n\n    it('testMakeSplice', async function () {\n      const t = 'a\\nb\\nc\\n';\n      let splice = makeSplice(t, 5, 0, 'def')\n      const t2 = applyToText(splice, t);\n      expect(t2).to.equal('a\\nb\\ncdef\\n');\n    });\n\n    it('makeSplice at the end', async function () {\n      const orig = '123';\n      const ins = '456';\n      expect(applyToText(makeSplice(orig, orig.length, 0, ins), orig))\n          .to.equal(`${orig}${ins}`);\n    });\n\n    it('testToSplices', async function () {\n      const cs = checkRep('Z:z>9*0=1=4-3+9=1|1-4-4+1*0+a$123456789abcdefghijk');\n      const correctSplices = [\n        [5, 8, '123456789'],\n        [9, 17, 'abcdefghijk'],\n      ];\n      expect(exportedForTestingOnly.toSplices(cs)).to.eql(correctSplices);\n    });\n\n    it('opAttributeValue', async function () {\n      const p = new AttributePool();\n      p.putAttrib(['name', 'david']);\n      p.putAttrib(['color', 'green']);\n\n      const stringOp = (str: string) => deserializeOps(str).next().value as Op;\n\n      padutils.warnDeprecatedFlags.disabledForTestingOnly = true;\n      try {\n        expect(opAttributeValue(stringOp('*0*1+1'), 'name', p)).to.equal('david');\n        expect(opAttributeValue(stringOp('*0+1'), 'name', p)).to.equal('david');\n        expect(opAttributeValue(stringOp('*1+1'), 'name', p)).to.equal('');\n        expect(opAttributeValue(stringOp('+1'), 'name', p)).to.equal('');\n        expect(opAttributeValue(stringOp('*0*1+1'), 'color', p)).to.equal('green');\n        expect(opAttributeValue(stringOp('*1+1'), 'color', p)).to.equal('green');\n        expect(opAttributeValue(stringOp('*0+1'), 'color', p)).to.equal('');\n        expect(opAttributeValue(stringOp('+1'), 'color', p)).to.equal('');\n      } finally {\n        // @ts-ignore\n        delete padutils.warnDeprecatedFlags.disabledForTestingOnly;\n      }\n    });\n\n    describe('applyToAttribution', function () {\n      const runApplyToAttributionTest = (testId: number, attribs: string[], cs: string, inAttr: string, outCorrect: string) => {\n        it(`applyToAttribution#${testId}`, async function () {\n          const p = poolOrArray(attribs);\n          const result = applyToAttribution(checkRep(cs), inAttr, p);\n          expect(result).to.equal(outCorrect);\n        });\n      };\n\n      // turn c<b>a</b>ctus\\n into a<b>c</b>tusabcd\\n\n      runApplyToAttributionTest(1,\n          ['bold,', 'bold,true'], 'Z:7>3-1*0=1*1=1=3+4$abcd', '+1*1+1|1+5', '+1*1+1|1+8');\n\n      // turn \"david\\ngreenspan\\n\" into \"<b>david\\ngreen</b>\\n\"\n      runApplyToAttributionTest(2,\n          ['bold,', 'bold,true'], 'Z:g<4*1|1=6*1=5-4$', '|2+g', '*1|1+6*1+5|1+1');\n    });\n\n    describe('split/join attribution lines', function () {\n      const testSplitJoinAttributionLines = (randomSeed: number) => {\n        const stringToOps = (str: string) => {\n          const assem = new MergingOpAssembler();\n          const o = new Op('+');\n          o.chars = 1;\n          for (let i = 0; i < str.length; i++) {\n            const c = str.charAt(i);\n            o.lines = (c === '\\n' ? 1 : 0);\n            o.attribs = (c === 'a' || c === 'b' ? `*${c}` : '');\n            assem.append(o);\n          }\n          return assem.toString();\n        };\n\n        it(`testSplitJoinAttributionLines#${randomSeed}`, async function () {\n          const doc = `${randomMultiline(10, 20)}\\n`;\n\n          const theJoined = stringToOps(doc);\n          const theSplit = doc.match(/[^\\n]*\\n/g)!.map(stringToOps);\n\n          expect(splitAttributionLines(theJoined, doc)).to.eql(theSplit);\n          expect(joinAttributionLines(theSplit)).to.equal(theJoined);\n        });\n      };\n\n      for (let i = 0; i < 10; i++) testSplitJoinAttributionLines(i);\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend-new/specs/easysync-subAttribution.ts",
    "content": "'use strict';\n\nimport {subattribution} from '../../../static/js/Changeset';\nimport {expect, describe, it} from 'vitest';\ndescribe('easysync-subAttribution', function () {\n  const testSubattribution = (testId: number, astr: string, start: number, end: number | undefined, correctOutput: string) => {\n    it(`subattribution#${testId}`, async function () {\n      const str = subattribution(astr, start, end);\n      expect(str).to.equal(correctOutput);\n    });\n  };\n\n  testSubattribution(1, '+1', 0, 0, '');\n  testSubattribution(2, '+1', 0, 1, '+1');\n  testSubattribution(3, '+1', 0, undefined, '+1');\n  testSubattribution(4, '|1+1', 0, 0, '');\n  testSubattribution(5, '|1+1', 0, 1, '|1+1');\n  testSubattribution(6, '|1+1', 0, undefined, '|1+1');\n  testSubattribution(7, '*0+1', 0, 0, '');\n  testSubattribution(8, '*0+1', 0, 1, '*0+1');\n  testSubattribution(9, '*0+1', 0, undefined, '*0+1');\n  testSubattribution(10, '*0|1+1', 0, 0, '');\n  testSubattribution(11, '*0|1+1', 0, 1, '*0|1+1');\n  testSubattribution(12, '*0|1+1', 0, undefined, '*0|1+1');\n  testSubattribution(13, '*0+2+1*1+3', 0, 1, '*0+1');\n  testSubattribution(14, '*0+2+1*1+3', 0, 2, '*0+2');\n  testSubattribution(15, '*0+2+1*1+3', 0, 3, '*0+2+1');\n  testSubattribution(16, '*0+2+1*1+3', 0, 4, '*0+2+1*1+1');\n  testSubattribution(17, '*0+2+1*1+3', 0, 5, '*0+2+1*1+2');\n  testSubattribution(18, '*0+2+1*1+3', 0, 6, '*0+2+1*1+3');\n  testSubattribution(19, '*0+2+1*1+3', 0, 7, '*0+2+1*1+3');\n  testSubattribution(20, '*0+2+1*1+3', 0, undefined, '*0+2+1*1+3');\n  testSubattribution(21, '*0+2+1*1+3', 1, undefined, '*0+1+1*1+3');\n  testSubattribution(22, '*0+2+1*1+3', 2, undefined, '+1*1+3');\n  testSubattribution(23, '*0+2+1*1+3', 3, undefined, '*1+3');\n  testSubattribution(24, '*0+2+1*1+3', 4, undefined, '*1+2');\n  testSubattribution(25, '*0+2+1*1+3', 5, undefined, '*1+1');\n  testSubattribution(26, '*0+2+1*1+3', 6, undefined, '');\n  testSubattribution(27, '*0+2+1*1|1+3', 0, 1, '*0+1');\n  testSubattribution(28, '*0+2+1*1|1+3', 0, 2, '*0+2');\n  testSubattribution(29, '*0+2+1*1|1+3', 0, 3, '*0+2+1');\n  testSubattribution(30, '*0+2+1*1|1+3', 0, 4, '*0+2+1*1+1');\n  testSubattribution(31, '*0+2+1*1|1+3', 0, 5, '*0+2+1*1+2');\n  testSubattribution(32, '*0+2+1*1|1+3', 0, 6, '*0+2+1*1|1+3');\n  testSubattribution(33, '*0+2+1*1|1+3', 0, 7, '*0+2+1*1|1+3');\n  testSubattribution(34, '*0+2+1*1|1+3', 0, undefined, '*0+2+1*1|1+3');\n  testSubattribution(35, '*0+2+1*1|1+3', 1, undefined, '*0+1+1*1|1+3');\n  testSubattribution(36, '*0+2+1*1|1+3', 2, undefined, '+1*1|1+3');\n  testSubattribution(37, '*0+2+1*1|1+3', 3, undefined, '*1|1+3');\n  testSubattribution(38, '*0+2+1*1|1+3', 4, undefined, '*1|1+2');\n  testSubattribution(39, '*0+2+1*1|1+3', 5, undefined, '*1|1+1');\n  testSubattribution(40, '*0+2+1*1|1+3', 1, 5, '*0+1+1*1+2');\n  testSubattribution(41, '*0+2+1*1|1+3', 2, 6, '+1*1|1+3');\n  testSubattribution(42, '*0+2+1*1+3', 2, 6, '+1*1+3');\n});\n"
  },
  {
    "path": "src/tests/backend-new/specs/pad_utils.ts",
    "content": "import {MapArrayType} from \"../../../node/types/MapType\";\nimport padutils from '../../../static/js/pad_utils';\nimport {describe, it, expect, afterEach, beforeAll} from \"vitest\";\n\ndescribe(__filename, function () {\n  describe('warnDeprecated', function () {\n    const {warnDeprecatedFlags, warnDeprecated} = padutils;\n    const backups:MapArrayType<any> = {};\n\n    beforeAll(async function () {\n      backups.logger = warnDeprecatedFlags.logger;\n    });\n\n    afterEach(async function () {\n      warnDeprecatedFlags.logger = backups.logger;\n      delete warnDeprecatedFlags._rl; // Reset internal rate limiter state.\n    });\n\n    /*it('includes the stack', async function () {\n      let got;\n      warnDeprecated.logger = {warn: (stack: any) => got = stack};\n      warnDeprecated();\n      assert(got!.includes(__filename));\n    });*/\n\n    it('rate limited', async function () {\n      let got = 0;\n      warnDeprecatedFlags.logger = {warn: () => ++got};\n      warnDeprecated(); // Initialize internal rate limiter state.\n      const {period} = warnDeprecatedFlags._rl!;\n      got = 0;\n      const testCases = [[0, 1], [0, 1], [period - 1, 1], [period, 2]];\n      for (const [now, want] of testCases) { // In a loop so that the stack trace is the same.\n        warnDeprecatedFlags._rl!.now = () => now;\n        warnDeprecated();\n        expect(got).toEqual(want);\n      }\n      warnDeprecated(); // Should have a different stack trace.\n      expect(got).toEqual(testCases[testCases.length - 1][1] + 1);\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend-new/specs/path_exists.ts",
    "content": "import check from \"../../../node/utils/path_exists\";\nimport {expect, describe, it} from \"vitest\";\n\ndescribe('Test path exists', function () {\n  it('should return true if the path exists - directory', function () {\n    const path = './locales';\n    const result = check(path);\n    expect(result).toBeTruthy();\n  });\n\n  it('should return true if the path exists - file', function () {\n    const path = './locales/en.json';\n    const result = check(path);\n    expect(result).toBeTruthy();\n  })\n\n  it('should return false if the path does not exist', function () {\n    const path = './path_not_exists.ts';\n    const result = check(path);\n    expect(result).toEqual(false);\n  });\n})\n"
  },
  {
    "path": "src/tests/backend-new/specs/promises.ts",
    "content": "import {timesLimit} from '../../../node/utils/promises';\nimport {describe, it, expect} from \"vitest\";\n\ndescribe(__filename, function () {\n  describe('promises.timesLimit', function () {\n    let wantIndex = 0;\n\n    type TestPromise = {\n        promise?: Promise<void>,\n        resolve?: () => void,\n    }\n\n    const testPromises: TestPromise[] = [];\n    const makePromise = (index: number) => {\n      // Make sure index increases by one each time.\n      expect(index).toEqual(wantIndex++);\n      // Save the resolve callback (so the test can trigger resolution)\n      // and the promise itself (to wait for resolve to take effect).\n      const p:TestPromise = {};\n      p.promise = new Promise<void>((resolve) => {\n        p.resolve = resolve;\n      });\n      testPromises.push(p);\n      return p.promise;\n    };\n\n    const total = 11;\n    const concurrency = 7;\n    const timesLimitPromise = timesLimit(total, concurrency, makePromise);\n\n    it('honors concurrency', async function () {\n      expect(wantIndex).toEqual(concurrency);\n    });\n\n    it('creates another when one completes', async function () {\n      const {promise, resolve} = testPromises.shift()!;\n      resolve!();\n      await promise;\n      expect(wantIndex).toEqual(concurrency + 1);\n    });\n\n    it('creates the expected total number of promises', async function () {\n      while (testPromises.length > 0) {\n        // Resolve them in random order to ensure that the resolution order doesn't matter.\n        const i = Math.floor(Math.random() * Math.floor(testPromises.length));\n        const {promise, resolve} = testPromises.splice(i, 1)[0];\n        resolve!();\n        await promise;\n      }\n      expect(wantIndex).toEqual(total);\n    });\n\n    it('resolves', async function () {\n      await timesLimitPromise;\n    });\n\n    it('does not create too many promises if total < concurrency', async function () {\n      wantIndex = 0;\n      expect(testPromises.length).toEqual(0);\n      const total = 7;\n      const concurrency = 11;\n      const timesLimitPromise = timesLimit(total, concurrency, makePromise);\n      while (testPromises.length > 0) {\n        const {promise, resolve} = testPromises.pop()!;\n        resolve!();\n        await promise;\n      }\n      await timesLimitPromise;\n      expect(wantIndex).toEqual(total);\n    });\n\n    it('accepts total === 0, concurrency > 0', async function () {\n      wantIndex = 0;\n      expect(testPromises.length).toEqual(0);\n      await timesLimit(0, concurrency, makePromise);\n      expect(wantIndex).toEqual(0);\n    });\n\n    it('accepts total === 0, concurrency === 0', async function () {\n      wantIndex = 0;\n      expect(testPromises.length).toEqual(0);\n      await timesLimit(0, 0, makePromise);\n      expect(wantIndex).toEqual(0);\n    });\n\n    it('rejects total > 0, concurrency === 0', async function () {\n      expect(timesLimit(total, 0, makePromise)).rejects.toThrow(RangeError);\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/backend-new/specs/sanitizePathname.ts",
    "content": "import {strict as assert} from \"assert\";\nimport path from 'path';\nimport sanitizePathname from '../../../node/utils/sanitizePathname';\nimport {describe, it, expect} from 'vitest';\n\ndescribe(__filename, function () {\n  describe('absolute paths rejected', function () {\n    const testCases = [\n      ['posix', '/'],\n      ['posix', '/foo'],\n      ['win32', '/'],\n      ['win32', '\\\\'],\n      ['win32', 'C:/foo'],\n      ['win32', 'C:\\\\foo'],\n      ['win32', 'c:/foo'],\n      ['win32', 'c:\\\\foo'],\n      ['win32', '/foo'],\n      ['win32', '\\\\foo'],\n    ];\n    for (const [platform, p] of testCases) {\n      it(`${platform} ${p}`, async function () {\n        // @ts-ignore\n        expect(() => sanitizePathname(p, path[platform] as any)).toThrowError(/absolute path/);\n      });\n    }\n  });\n  describe('directory traversal rejected', function () {\n    const testCases = [\n      ['posix', '..'],\n      ['posix', '../'],\n      ['posix', '../foo'],\n      ['posix', 'foo/../..'],\n      ['win32', '..'],\n      ['win32', '../'],\n      ['win32', '..\\\\'],\n      ['win32', '../foo'],\n      ['win32', '..\\\\foo'],\n      ['win32', 'foo/../..'],\n      ['win32', 'foo\\\\..\\\\..'],\n    ];\n    for (const [platform, p] of testCases) {\n      it(`${platform} ${p}`, async function () {\n        // @ts-ignore\n        assert.throws(() => sanitizePathname(p, path[platform]), {message: /travers/});\n      });\n    }\n  });\n\n  describe('accepted paths', function () {\n    const testCases = [\n      ['posix', '', '.'],\n      ['posix', '.'],\n      ['posix', './'],\n      ['posix', 'foo'],\n      ['posix', 'foo/'],\n      ['posix', 'foo/bar/..', 'foo'],\n      ['posix', 'foo/bar/../', 'foo/'],\n      ['posix', './foo', 'foo'],\n      ['posix', 'foo/bar'],\n      ['posix', 'foo\\\\bar'],\n      ['posix', '\\\\foo'],\n      ['posix', '..\\\\foo'],\n      ['posix', 'foo/../bar', 'bar'],\n      ['posix', 'C:/foo'],\n      ['posix', 'C:\\\\foo'],\n      ['win32', '', '.'],\n      ['win32', '.'],\n      ['win32', './'],\n      ['win32', '.\\\\', './'],\n      ['win32', 'foo'],\n      ['win32', 'foo/'],\n      ['win32', 'foo\\\\', 'foo/'],\n      ['win32', 'foo/bar/..', 'foo'],\n      ['win32', 'foo\\\\bar\\\\..', 'foo'],\n      ['win32', 'foo/bar/../', 'foo/'],\n      ['win32', 'foo\\\\bar\\\\..\\\\', 'foo/'],\n      ['win32', './foo', 'foo'],\n      ['win32', '.\\\\foo', 'foo'],\n      ['win32', 'foo/bar'],\n      ['win32', 'foo\\\\bar', 'foo/bar'],\n      ['win32', 'foo/../bar', 'bar'],\n      ['win32', 'foo\\\\..\\\\bar', 'bar'],\n      ['win32', 'foo/..\\\\bar', 'bar'],\n      ['win32', 'foo\\\\../bar', 'bar'],\n    ];\n    for (const [platform, p, tcWant] of testCases) {\n      const want = tcWant == null ? p : tcWant;\n      it(`${platform} ${p || '<empty string>'} -> ${want}`, async function () {\n        // @ts-ignore\n        assert.equal(sanitizePathname(p, path[platform]), want);\n      });\n    }\n  });\n\n  it('default path API', async function () {\n    assert.equal(sanitizePathname('foo'), 'foo');\n  });\n});\n"
  },
  {
    "path": "src/tests/backend-new/specs/skiplist.ts",
    "content": "'use strict';\n\nimport SkipList from 'ep_etherpad-lite/static/js/skiplist';\nimport {expect, describe, it} from 'vitest';\n\ndescribe('skiplist.js', function () {\n  it('rejects null keys', async function () {\n    const skiplist = new SkipList();\n    for (const key of [undefined, null]) {\n      // @ts-ignore\n      expect(() => skiplist.push({key})).toThrowError();\n    }\n  });\n\n  it('rejects duplicate keys', async function () {\n    const skiplist = new SkipList();\n    skiplist.push({key: 'foo'});\n    expect(() => skiplist.push({key: 'foo'})).toThrowError();\n  });\n\n  it('atOffset() returns last entry that touches offset', async function () {\n    const skiplist = new SkipList();\n    const entries: { key: string; width: number; }[] = [];\n    let nextId = 0;\n    const makeEntry = (width: number) => {\n      const entry = {key: `id${nextId++}`, width};\n      entries.push(entry);\n      return entry;\n    };\n\n    skiplist.push(makeEntry(5));\n    expect(skiplist.atOffset(4)).toBe(entries[0]);\n    expect(skiplist.atOffset(5)).toBe(entries[0]);\n    expect(() => skiplist.atOffset(6)).toThrowError();\n\n    skiplist.push(makeEntry(0));\n    expect(skiplist.atOffset(4)).toBe(entries[0]);\n    expect(skiplist.atOffset(5)).toBe(entries[1]);\n    expect(() => skiplist.atOffset(6)).toThrowError();\n\n    skiplist.push(makeEntry(0));\n    expect(skiplist.atOffset(4)).toBe(entries[0]);\n    expect(skiplist.atOffset(5)).toBe(entries[2]);\n    expect(() => skiplist.atOffset(6)).toThrowError();\n\n    skiplist.splice(2, 0, [makeEntry(0)]);\n    expect(skiplist.atOffset(4)).toBe(entries[0]);\n    expect(skiplist.atOffset(5)).toBe(entries[2]);\n    expect(() => skiplist.atOffset(6)).toThrowError();\n\n    skiplist.push(makeEntry(3));\n    expect(skiplist.atOffset(4)).toBe(entries[0]);\n    expect(skiplist.atOffset(5)).toBe(entries[4]);\n    expect(skiplist.atOffset(6)).toBe(entries[4]);\n  });\n});\n"
  },
  {
    "path": "src/tests/container/loadSettings.js",
    "content": "/*\n * ACHTUNG: this file is a hack used to load \"settings.json.docker\" instead of\n *          \"settings.json\", since in its present form the Settings module does\n *          not allow it.\n *          This is a remnant of an analogous file that was placed in\n *          <basedir>/tests/backend/loadSettings.js\n *\n * TODO: modify the Settings module:\n *       1) no side effects on module load\n *       2) write a factory method that loads a configuration file (taking the\n *          file name from the command line, a function argument, or falling\n *          back to a default)\n */\n\nconst fs = require('fs');\nconst jsonminify = require('jsonminify');\n\nfunction loadSettings() {\n  let settingsStr = fs.readFileSync(`${__dirname}/../../../settings.json.docker`).toString();\n  // try to parse the settings\n  try {\n    if (settingsStr) {\n      settingsStr = jsonminify(settingsStr).replace(',]', ']').replace(',}', '}');\n      const settings = JSON.parse(settingsStr);\n\n      // custom settings for running in a container\n      settings.ip = 'localhost';\n      settings.port = '9001';\n\n      return settings;\n    }\n  } catch (e) {\n    console.error('whoops something is bad with settings');\n  }\n}\n\nexports.loadSettings = loadSettings;\n"
  },
  {
    "path": "src/tests/container/specs/api/pad.js",
    "content": "/*\n * ACHTUNG: this file was copied & modified from the analogous\n * <basedir>/tests/backend/specs/api/pad.js\n *\n * TODO: unify those two files, and merge in a single one.\n */\n\nconst settings = require('../../loadSettings').loadSettings();\nconst supertest = require('supertest');\n\nconst api = supertest(`http://${settings.ip}:${settings.port}`);\nconst apiVersion = 1;\n\ndescribe('Connectivity', function () {\n  it('can connect', function (done) {\n    api.get('/api/')\n        .expect('Content-Type', /json/)\n        .expect(200, done);\n  });\n});\n\ndescribe('API Versioning', function () {\n  it('finds the version tag', function (done) {\n    api.get('/api/')\n        .expect((res) => {\n          if (!res.body.currentVersion) throw new Error('No version set in API');\n          return;\n        })\n        .expect(200, done);\n  });\n});\n\ndescribe('Permission', function () {\n  it('errors with invalid OAuth token', function (done) {\n    api.get(`/api/${apiVersion}/createPad?padID=test`)\n        .expect(401, done);\n  });\n});\n"
  },
  {
    "path": "src/tests/frontend/cypress/.gitignore",
    "content": "fixtures/*\nplugins/*\nsupport/*\nvideos/*\nscreenshots/*\n"
  },
  {
    "path": "src/tests/frontend/cypress/README.md",
    "content": "# Cypress Etherpad guide\nWe don't install Etherpad as a dev dep or dep within Etherpad because it's not\nour core Frontend testing tool\n\n## Quick start\n```\nnpm i -g cypress\ncd src/tests/frontend/cypress/\ncypress open\n```\n"
  },
  {
    "path": "src/tests/frontend/cypress/cypress.config.js",
    "content": "const { defineConfig } = require('cypress')\n\nmodule.exports = defineConfig({\n  e2e: {\n    baseUrl: \"http://127.0.0.1:9001\",\n    supportFile: false,\n    specPattern: 'tests/frontend/cypress/integration/**/*.js'\n  }\n})\n"
  },
  {
    "path": "src/tests/frontend/cypress/integration/test.js",
    "content": "'use strict';\n\nCypress.Commands.add('iframe', {prevSubject: 'element'},\n    ($iframe) => new Cypress.Promise((resolve) => {\n      $iframe.ready(() => {\n        resolve($iframe.contents().find('body'));\n      });\n    }));\n\ndescribe(__filename, () => {\n  it('Pad content exists', () => {\n    cy.visit('http://127.0.0.1:9001/p/test');\n    cy.wait(10000); // wait for Minified JS to be built...\n    cy.get('iframe[name=\"ace_outer\"]', {timeout: 10000}).iframe()\n        .find('.line-number:first')\n        .should('have.text', '1');\n    cy.get('iframe[name=\"ace_outer\"]').iframe()\n        .find('iframe[name=\"ace_inner\"]').iframe()\n        .find('.ace-line:first')\n        .should('be.visible')\n        .should('have.text', 'Welcome to Etherpad!');\n  });\n});\n"
  },
  {
    "path": "src/tests/frontend/easysync-helper.js",
    "content": "'use strict';\n\nconst Changeset = require('../../static/js/Changeset');\nconst AttributePool = require('../../static/js/AttributePool');\n\nconst randInt = (maxValue) => Math.floor(Math.random() * maxValue);\n\nconst poolOrArray = (attribs) => {\n  if (attribs.getAttrib) {\n    return attribs; // it's already an attrib pool\n  } else {\n    // assume it's an array of attrib strings to be split and added\n    const p = new AttributePool();\n    attribs.forEach((kv) => {\n      p.putAttrib(kv.split(','));\n    });\n    return p;\n  }\n};\nexports.poolOrArray = poolOrArray;\n\nconst randomInlineString = (len) => {\n  const assem = Changeset.stringAssembler();\n  for (let i = 0; i < len; i++) {\n    assem.append(String.fromCharCode(randInt(26) + 97));\n  }\n  return assem.toString();\n};\n\nconst randomMultiline = (approxMaxLines, approxMaxCols) => {\n  const numParts = randInt(approxMaxLines * 2) + 1;\n  const txt = Changeset.stringAssembler();\n  txt.append(randInt(2) ? '\\n' : '');\n  for (let i = 0; i < numParts; i++) {\n    if ((i % 2) === 0) {\n      if (randInt(10)) {\n        txt.append(randomInlineString(randInt(approxMaxCols) + 1));\n      } else {\n        txt.append('\\n');\n      }\n    } else {\n      txt.append('\\n');\n    }\n  }\n  return txt.toString();\n};\nexports.randomMultiline = randomMultiline;\n\nconst randomStringOperation = (numCharsLeft) => {\n  let result;\n  switch (randInt(11)) {\n    case 0:\n    {\n      // insert char\n      result = {\n        insert: randomInlineString(1),\n      };\n      break;\n    }\n    case 1:\n    {\n      // delete char\n      result = {\n        remove: 1,\n      };\n      break;\n    }\n    case 2:\n    {\n      // skip char\n      result = {\n        skip: 1,\n      };\n      break;\n    }\n    case 3:\n    {\n      // insert small\n      result = {\n        insert: randomInlineString(randInt(4) + 1),\n      };\n      break;\n    }\n    case 4:\n    {\n      // delete small\n      result = {\n        remove: randInt(4) + 1,\n      };\n      break;\n    }\n    case 5:\n    {\n      // skip small\n      result = {\n        skip: randInt(4) + 1,\n      };\n      break;\n    }\n    case 6:\n    {\n      // insert multiline;\n      result = {\n        insert: randomMultiline(5, 20),\n      };\n      break;\n    }\n    case 7:\n    {\n      // delete multiline\n      result = {\n        remove: Math.round(numCharsLeft * Math.random() * Math.random()),\n      };\n      break;\n    }\n    case 8:\n    {\n      // skip multiline\n      result = {\n        skip: Math.round(numCharsLeft * Math.random() * Math.random()),\n      };\n      break;\n    }\n    case 9:\n    {\n      // delete to end\n      result = {\n        remove: numCharsLeft,\n      };\n      break;\n    }\n    case 10:\n    {\n      // skip to end\n      result = {\n        skip: numCharsLeft,\n      };\n      break;\n    }\n  }\n  const maxOrig = numCharsLeft - 1;\n  if ('remove' in result) {\n    result.remove = Math.min(result.remove, maxOrig);\n  } else if ('skip' in result) {\n    result.skip = Math.min(result.skip, maxOrig);\n  }\n  return result;\n};\n\nconst randomTwoPropAttribs = (opcode) => {\n  // assumes attrib pool like ['apple,','apple,true','banana,','banana,true']\n  if (opcode === '-' || randInt(3)) {\n    return '';\n  } else if (randInt(3)) { // eslint-disable-line no-dupe-else-if\n    if (opcode === '+' || randInt(2)) {\n      return `*${Changeset.numToString(randInt(2) * 2 + 1)}`;\n    } else {\n      return `*${Changeset.numToString(randInt(2) * 2)}`;\n    }\n  } else if (opcode === '+' || randInt(4) === 0) {\n    return '*1*3';\n  } else {\n    return ['*0*2', '*0*3', '*1*2'][randInt(3)];\n  }\n};\n\nconst randomTestChangeset = (origText, withAttribs) => {\n  const charBank = Changeset.stringAssembler();\n  let textLeft = origText; // always keep final newline\n  const outTextAssem = Changeset.stringAssembler();\n  const opAssem = Changeset.smartOpAssembler();\n  const oldLen = origText.length;\n\n  const nextOp = new Changeset.Op();\n\n  const appendMultilineOp = (opcode, txt) => {\n    nextOp.opcode = opcode;\n    if (withAttribs) {\n      nextOp.attribs = randomTwoPropAttribs(opcode);\n    }\n    txt.replace(/\\n|[^\\n]+/g, (t) => {\n      if (t === '\\n') {\n        nextOp.chars = 1;\n        nextOp.lines = 1;\n        opAssem.append(nextOp);\n      } else {\n        nextOp.chars = t.length;\n        nextOp.lines = 0;\n        opAssem.append(nextOp);\n      }\n      return '';\n    });\n  };\n\n  const doOp = () => {\n    const o = randomStringOperation(textLeft.length);\n    if (o.insert) {\n      const txt = o.insert;\n      charBank.append(txt);\n      outTextAssem.append(txt);\n      appendMultilineOp('+', txt);\n    } else if (o.skip) {\n      const txt = textLeft.substring(0, o.skip);\n      textLeft = textLeft.substring(o.skip);\n      outTextAssem.append(txt);\n      appendMultilineOp('=', txt);\n    } else if (o.remove) {\n      const txt = textLeft.substring(0, o.remove);\n      textLeft = textLeft.substring(o.remove);\n      appendMultilineOp('-', txt);\n    }\n  };\n\n  while (textLeft.length > 1) doOp();\n  for (let i = 0; i < 5; i++) doOp(); // do some more (only insertions will happen)\n  const outText = `${outTextAssem.toString()}\\n`;\n  opAssem.endDocument();\n  const cs = Changeset.pack(oldLen, outText.length, opAssem.toString(), charBank.toString());\n  Changeset.checkRep(cs);\n  return [cs, outText];\n};\nexports.randomTestChangeset = randomTestChangeset;\n"
  },
  {
    "path": "src/tests/frontend/helper/methods.ts",
    "content": "// @ts-nocheck\n\n/**\n * Spys on socket.io messages and saves them into several arrays\n * that are visible in tests\n */\nhelper.spyOnSocketIO = () => {\n  helper.contentWindow().pad.socket.on('message', (msg) => {\n    if (msg.type !== 'COLLABROOM') return;\n    if (msg.data.type === 'ACCEPT_COMMIT') {\n      helper.commits.push(msg);\n    } else if (msg.data.type === 'USER_NEWINFO') {\n      helper.userInfos.push(msg);\n    } else if (msg.data.type === 'CHAT_MESSAGE') {\n      helper.chatMessages.push(msg.data.message);\n    } else if (msg.data.type === 'CHAT_MESSAGES') {\n      helper.chatMessages.push(...msg.data.messages);\n    }\n  });\n};\n\n/**\n * Makes an edit via `sendkeys` to the position of the caret and ensures ACCEPT_COMMIT\n * is returned by the server\n * It does not check if the ACCEPT_COMMIT is the edit sent, though\n * If `line` is not given, the edit goes to line no. 1\n *\n * @param {string} message The edit to make - can be anything supported by `sendkeys`\n * @param {number} [line] the optional line to make the edit on starting from 1\n * @returns {Promise}\n * @todo needs to support writing to a specified caret position\n *\n */\nhelper.edit = async (message, line) => {\n  const editsNum = helper.commits.length;\n  line = line ? line - 1 : 0;\n  await helper.withFastCommit(async (incorp) => {\n    helper.linesDiv()[line].sendkeys(message);\n    incorp();\n    await helper.waitForPromise(() => editsNum + 1 === helper.commits.length, 10000);\n  });\n};\n\n/**\n * The pad text as an array of divs\n *\n * @example\n * helper.linesDiv()[2].sendkeys('abc') // sends abc to the third line\n *\n * @returns {Array.<HTMLElement>} array of divs\n */\nhelper.linesDiv = () => helper.padInner$('.ace-line').map(function () { return $(this); }).get();\n\n/**\n * The pad text as an array of lines\n * For lines in timeslider use `helper.timesliderTextLines()`\n *\n * @returns {Array.<string>} lines of text\n */\nhelper.textLines = () => helper.linesDiv().map((div) => div.text());\n\n/**\n * The default pad text transmitted via `clientVars`\n *\n * @returns {string}\n */\nhelper.defaultText =\n    () => helper.padChrome$.window.clientVars.collab_client_vars.initialAttributedText.text;\n\n/**\n * Sends a chat `message` via `sendKeys`\n * You *must* include `{enter}` at the end of the string or it will\n * just fill the input field but not send the message.\n *\n * @todo Cannot send multiple messages at once\n *\n * @example\n *\n * `helper.sendChatMessage('hi{enter}')`\n *\n * @param {string} message the chat message to be sent\n * @returns {Promise}\n */\nhelper.sendChatMessage = async (message) => {\n  const noOfChatMessages = helper.chatMessages.length;\n  helper.padChrome$('#chatinput').sendkeys(message);\n  await helper.waitForPromise(() => noOfChatMessages + 1 === helper.chatMessages.length);\n};\n\n/**\n * Opens the settings menu if its hidden via button\n *\n * @returns {Promise}\n */\nhelper.showSettings = async () => {\n  if (helper.isSettingsShown()) return;\n  helper.settingsButton().trigger('click');\n  await helper.waitForPromise(() => helper.isSettingsShown(), 2000);\n};\n\n/**\n * Hide the settings menu if its open via button\n *\n * @returns {Promise}\n * @todo untested\n */\nhelper.hideSettings = async () => {\n  if (!helper.isSettingsShown()) return;\n  helper.settingsButton().trigger('click');\n  await helper.waitForPromise(() => !helper.isSettingsShown(), 2000);\n};\n\n/**\n * Makes the chat window sticky via settings menu if the settings menu is\n * open and sticky button is not checked\n *\n * @returns {Promise}\n */\nhelper.enableStickyChatviaSettings = async () => {\n  const stickyChat = helper.padChrome$('#options-stickychat');\n  if (!helper.isSettingsShown() || stickyChat.is(':checked')) return;\n  stickyChat.trigger('click');\n  await helper.waitForPromise(() => helper.isChatboxSticky(), 2000);\n};\n\n/**\n * Unsticks the chat window via settings menu if the settings menu is open\n * and sticky button is checked\n *\n * @returns {Promise}\n */\nhelper.disableStickyChatviaSettings = async () => {\n  const stickyChat = helper.padChrome$('#options-stickychat');\n  if (!helper.isSettingsShown() || !stickyChat.is(':checked')) return;\n  stickyChat.trigger('click');\n  await helper.waitForPromise(() => !helper.isChatboxSticky(), 2000);\n};\n\n/**\n * Makes the chat window sticky via an icon on the top right of the chat\n * window\n *\n * @returns {Promise}\n */\nhelper.enableStickyChatviaIcon = async () => {\n  const stickyChat = helper.padChrome$('#titlesticky');\n  if (!helper.isChatboxShown() || helper.isChatboxSticky()) return;\n  stickyChat.trigger('click');\n  await helper.waitForPromise(() => helper.isChatboxSticky(), 2000);\n};\n\n/**\n * Disables the stickyness of the chat window via an icon on the\n * upper right\n *\n * @returns {Promise}\n */\nhelper.disableStickyChatviaIcon = async () => {\n  if (!helper.isChatboxShown() || !helper.isChatboxSticky()) return;\n  helper.titlecross().trigger('click');\n  await helper.waitForPromise(() => !helper.isChatboxSticky(), 2000);\n};\n\n/**\n * Sets the src-attribute of the main iframe to the timeslider\n * In case a revision is given, sets the timeslider to this specific revision.\n * Defaults to going to the last revision.\n * It waits until the timer is filled with date and time, because it's one of the\n * last things that happen during timeslider load\n *\n * @param {number} [revision] the optional revision\n * @returns {Promise}\n * @todo for some reason this does only work the first time, you cannot\n * goto rev 0 and then via the same method to rev 5. Use buttons instead\n */\nhelper.gotoTimeslider = async (revision) => {\n  revision = Number.isInteger(revision) ? `#${revision}` : '';\n  helper.padChrome$.window.location.href =\n      `${helper.padChrome$.window.location.pathname}/timeslider${revision}`;\n  await helper.waitForPromise(() => helper.timesliderTimerTime() &&\n      !Number.isNaN(new Date(helper.timesliderTimerTime()).getTime()), 10000);\n};\n\n/**\n * Clicks in the timeslider at a specific offset\n * It's used to navigate the timeslider\n *\n * @todo no mousemove test\n * @param {number} X coordinate\n */\nhelper.sliderClick = (X) => {\n  const sliderBar = helper.sliderBar();\n  const edown = new jQuery.Event('mousedown');\n  const eup = new jQuery.Event('mouseup');\n  edown.clientX = eup.clientX = X;\n  edown.clientY = eup.clientY = sliderBar.offset().top;\n\n  sliderBar.trigger(edown);\n  sliderBar.trigger(eup);\n};\n\n/**\n * The timeslider text as an array of lines\n *\n * @returns {Array.<string>} lines of text\n */\nhelper.timesliderTextLines = () => helper.contentWindow().$('.ace-line').map(function () {\n  return $(this).text();\n}).get();\n\nhelper.padIsEmpty = () => (\n  !helper.padInner$.document.getSelection().isCollapsed ||\n  (helper.padInner$('div').length === 1 && helper.padInner$('div').first().html() === '<br>'));\n\nhelper.clearPad = async () => {\n  if (helper.padIsEmpty()) return;\n  const commitsBefore = helper.commits.length;\n  const lines = helper.linesDiv();\n  helper.selectLines(lines[0], lines[lines.length - 1]);\n  await helper.waitForPromise(() => !helper.padInner$.document.getSelection().isCollapsed);\n  const e = new helper.padInner$.Event(helper.evtType);\n  e.keyCode = 8; // delete key\n  await helper.withFastCommit(async (incorp) => {\n    helper.padInner$('#innerdocbody').trigger(e);\n    incorp();\n    await helper.waitForPromise(helper.padIsEmpty);\n    await helper.waitForPromise(() => helper.commits.length > commitsBefore);\n  });\n};\n"
  },
  {
    "path": "src/tests/frontend/helper/multipleUsers.ts",
    "content": "// @ts-nocheck\n\nconst getCookies =\n    () => helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_utils').Cookies;\n\nconst setToken = (token) => getCookies().set('token', token);\n\nconst getToken = () => getCookies().get('token');\n\nconst startActingLike = (user) => {\n  helper.padChrome$ = user.padChrome$;\n  helper.padOuter$ = user.padOuter$;\n  helper.padInner$ = user.padInner$;\n  if (helper.padChrome$) setToken(user.token);\n};\n\nconst clearToken = () => getCookies().remove('token');\n\nhelper.multipleUsers = {\n  _user0: null,\n  _user1: null,\n\n  // open the same pad on different frames (allows concurrent editions to pad)\n  async init() {\n    this._user0 = {\n      $frame: $('#iframe-container iframe'),\n      token: getToken(),\n      // we'll switch between pads, need to store current values of helper.pad*\n      // to be able to restore those values later\n      padChrome$: helper.padChrome$,\n      padOuter$: helper.padOuter$,\n      padInner$: helper.padInner$,\n    };\n    this._user1 = {};\n    // Force generation of a new token.\n    clearToken();\n    // need to perform as the other user, otherwise we'll get the userdup error message\n    await this.performAsOtherUser(this._createUser1Frame.bind(this));\n  },\n\n  async performAsOtherUser(action) {\n    startActingLike(this._user1);\n    await action();\n    startActingLike(this._user0);\n  },\n\n  close() {\n    this._user0.$frame.attr('style', ''); // make the default ocopy the full height\n    this._user1.$frame.remove();\n  },\n\n  async _loadJQueryForUser1Frame() {\n    this._user1.padChrome$ = await helper.getFrameJQuery(this._user1.$frame, true);\n    this._user1.padOuter$ =\n        await helper.getFrameJQuery(this._user1.padChrome$('iframe[name=\"ace_outer\"]'), false);\n    this._user1.padInner$ =\n        await helper.getFrameJQuery(this._user1.padOuter$('iframe[name=\"ace_inner\"]'), true);\n\n    // update helper vars now that they are available\n    helper.padChrome$ = this._user1.padChrome$;\n    helper.padOuter$ = this._user1.padOuter$;\n    helper.padInner$ = this._user1.padInner$;\n  },\n\n  async _createUser1Frame() {\n    this._user0.$frame.css({height: '50%'});\n    this._user1.$frame = $('<iframe>')\n        .attr({id: 'user1_pad', src: this._user0.$frame.attr('src')})\n        .css({height: '50%', top: '50%'})\n        .insertAfter(this._user0.$frame);\n\n    // wait for user1 pad to load\n    await new Promise((resolve) => this._user1.$frame.one('load', resolve));\n\n    const $editorLoadingMessage = this._user1.$frame.contents().find('#editorloadingbox');\n    const $errorMessageModal = this._user0.$frame.contents().find('#connectivity .userdup');\n\n    await helper.waitForPromise(() => {\n      const loaded = !$editorLoadingMessage.is(':visible');\n      // make sure we don't get the userdup by mistake\n      const didNotDetectUserDup = !$errorMessageModal.is(':visible');\n      return loaded && didNotDetectUserDup;\n    }, 50000);\n\n    // need to get values for this._user1.pad* vars\n    await this._loadJQueryForUser1Frame();\n\n    this._user1.token = getToken();\n    if (this._user0.token === this._user1.token) {\n      throw new Error('expected different token for user1');\n    }\n  },\n};\n"
  },
  {
    "path": "src/tests/frontend/helper/ui.ts",
    "content": "// @ts-nocheck\n'use strict';\n\n/**\n * the contentWindow is either the normal pad or timeslider\n *\n * @returns {HTMLElement} contentWindow\n */\nhelper.contentWindow = () => $('#iframe-container iframe')[0].contentWindow;\n\n/**\n * Opens the chat unless it is already open via an\n * icon on the bottom right of the page\n *\n * @returns {Promise}\n */\nhelper.showChat = async () => {\n  const chaticon = helper.chatIcon();\n  if (!chaticon.hasClass('visible')) return;\n  chaticon.trigger('click');\n  await helper.waitForPromise(() => !chaticon.hasClass('visible'), 2000);\n};\n\n/**\n * Closes the chat window if it is shown and not sticky\n *\n * @returns {Promise}\n */\nhelper.hideChat = async () => {\n  if (!helper.isChatboxShown() || helper.isChatboxSticky()) return;\n  helper.titlecross().trigger('click');\n  await helper.waitForPromise(() => !helper.isChatboxShown(), 2000);\n};\n\n/**\n * Gets the chat icon from the bottom right of the page\n *\n * @returns {HTMLElement} the chat icon\n */\nhelper.chatIcon = () => helper.padChrome$('#chaticon');\n\n/**\n * The chat messages from the UI\n *\n * @returns {Array.<HTMLElement>}\n */\nhelper.chatTextParagraphs = () => helper.padChrome$('#chattext').children('p');\n\n/**\n * Returns true if the chat box is sticky\n *\n * @returns {boolean} stickyness of the chat box\n */\nhelper.isChatboxSticky = () => helper.padChrome$('#chatbox').hasClass('stickyChat');\n\n/**\n * Returns true if the chat box is shown\n *\n * @returns {boolean} visibility of the chat box\n */\nhelper.isChatboxShown = () => helper.padChrome$('#chatbox').hasClass('visible');\n\n/**\n * Gets the settings menu\n *\n * @returns {HTMLElement} the settings menu\n */\nhelper.settingsMenu = () => helper.padChrome$('#settings');\n\n/**\n * Gets the settings button\n *\n * @returns {HTMLElement} the settings button\n */\nhelper.settingsButton =\n    () => helper.padChrome$(\"button[data-l10n-id='pad.toolbar.settings.title']\");\n\n/**\n * Toggles user list\n */\nhelper.toggleUserList = async () => {\n  const isVisible = helper.userListShown();\n  const button = helper.padChrome$(\"button[data-l10n-id='pad.toolbar.showusers.title']\");\n  button.trigger('click');\n  await helper.waitForPromise(() => !isVisible);\n};\n\n/**\n * Gets the user name input field\n *\n * @returns {HTMLElement} user name input field\n */\nhelper.usernameField = () => helper.padChrome$(\"input[data-l10n-id='pad.userlist.entername']\");\n\n/**\n * Is the user list popup shown?\n *\n * @returns {boolean}\n */\nhelper.userListShown = () => helper.padChrome$('div#users').hasClass('popup-show');\n\n/**\n * Sets the user name\n *\n */\nhelper.setUserName = async (name) => {\n  const userElement = helper.usernameField();\n  userElement.trigger('click');\n  userElement.val(name);\n  userElement.trigger('blur');\n  await helper.waitForPromise(() => !helper.usernameField().hasClass('editactive'));\n};\n\n/**\n * Gets the titlecross icon\n *\n * @returns {HTMLElement} the titlecross icon\n */\nhelper.titlecross = () => helper.padChrome$('#titlecross');\n\n/**\n * Returns true if the settings menu is visible\n *\n * @returns {boolean} is the settings menu shown?\n */\nhelper.isSettingsShown = () => helper.padChrome$('#settings').hasClass('popup-show');\n\n/**\n * Gets the timer div of a timeslider that has the datetime of the revision\n *\n * @returns {HTMLElement} timer\n */\nhelper.timesliderTimer = () => {\n  if (typeof helper.contentWindow().$ !== 'function') return;\n  return helper.contentWindow().$('#timer');\n};\n\n/**\n * Gets the time of the revision on a timeslider\n *\n * @returns {HTMLElement} timer\n */\nhelper.timesliderTimerTime = () => {\n  if (!helper.timesliderTimer()) return;\n  return helper.timesliderTimer().text();\n};\n\n/**\n * The ui-slidar-bar element in the timeslider\n *\n * @returns {HTMLElement}\n */\nhelper.sliderBar = () => helper.contentWindow().$('#ui-slider-bar');\n\n/**\n * revision_date element\n * like \"Saved October 10, 2020\"\n *\n * @returns {HTMLElement}\n */\nhelper.revisionDateElem = () => helper.contentWindow().$('#revision_date').text();\n\n/**\n * revision_label element\n * like \"Version 1\"\n *\n * @returns {HTMLElement}\n */\nhelper.revisionLabelElem = () => helper.contentWindow().$('#revision_label');\n"
  },
  {
    "path": "src/tests/frontend/helper.js",
    "content": "'use strict';\n\nconst helper = {};\n\n(() => {\n  let $iframe;\n\n  helper.randomString = (len) => {\n    const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';\n    let randomstring = '';\n    for (let i = 0; i < len; i++) {\n      const rnum = Math.floor(Math.random() * chars.length);\n      randomstring += chars.substring(rnum, rnum + 1);\n    }\n    return randomstring;\n  };\n\n  helper.getFrameJQuery = async ($iframe, includeSendkeys = false) => {\n    const win = $iframe[0].contentWindow;\n    const doc = win.document;\n\n    const load = async (url) => {\n      const elem = doc.createElement('script');\n      elem.setAttribute('src', url);\n      const p = new Promise((resolve, reject) => {\n        const handler = (evt) => {\n          elem.removeEventListener('load', handler);\n          elem.removeEventListener('error', handler);\n          if (evt.type === 'error') return reject(new Error(`failed to load ${url}`));\n          resolve();\n        };\n        elem.addEventListener('load', handler);\n        elem.addEventListener('error', handler);\n      });\n      doc.head.appendChild(elem);\n      await p;\n    };\n\n    if (!win.$) await load('../../static/js/vendors/jquery.js');\n    // sendkeys.js depends on jQuery, so it cannot be loaded until jQuery has finished loading. (In\n    // other words, do not load both jQuery and sendkeys inside a Promise.all() call.)\n    if (!win.bililiteRange && includeSendkeys) await load('../tests/frontend/lib/sendkeys.js');\n\n    win.$.window = win;\n    win.$.document = doc;\n\n    return win.$;\n  };\n\n  helper.clearSessionCookies = () => {\n    window.Cookies.remove('token');\n    window.Cookies.remove('language');\n  };\n\n  // Can only happen when the iframe exists, so we're doing it separately from other cookies\n  helper.clearPadPrefCookie = () => {\n    const {padcookie} = helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_cookie');\n    padcookie.clear();\n  };\n\n  // Overwrite all prefs in pad cookie.\n  helper.setPadPrefCookie = (prefs) => {\n    const {padcookie} = helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_cookie');\n    padcookie.clear();\n    for (const [key, value] of Object.entries(prefs)) padcookie.setPref(key, value);\n  };\n\n  // Functionality for knowing what key event type is required for tests\n  let evtType = 'keydown';\n  // if it's IE require keypress\n  if (window.navigator.userAgent.indexOf('MSIE') > -1) {\n    evtType = 'keypress';\n  }\n  // Edge also requires keypress.\n  if (window.navigator.userAgent.indexOf('Edge') > -1) {\n    evtType = 'keypress';\n  }\n  // Opera also requires keypress.\n  if (window.navigator.userAgent.indexOf('OPR') > -1) {\n    evtType = 'keypress';\n  }\n  helper.evtType = evtType;\n\n  // Deprecated; use helper.aNewPad() instead.\n  helper.newPad = (opts, id) => {\n    if (!id) id = `FRONTEND_TEST_${helper.randomString(20)}`;\n    opts = Object.assign({id}, typeof opts === 'function' ? {cb: opts} : opts);\n    const {cb = (err) => { if (err != null) throw err; }} = opts;\n    delete opts.cb;\n    helper.aNewPad(opts).then((id) => cb(null, id), (err) => cb(err || new Error(err)));\n    return id;\n  };\n\n  helper.aNewPad = async (opts = {}) => {\n    opts = Object.assign({\n      _retry: 0,\n      clearCookies: true,\n      id: `FRONTEND_TEST_${helper.randomString(20)}`,\n      hookFns: {},\n    }, opts);\n\n    // Set up socket.io spying as early as possible.\n    /** chat messages received */\n    helper.chatMessages = [];\n    /** changeset commits from the server */\n    helper.commits = [];\n    /** userInfo messages from the server */\n    helper.userInfos = [];\n    if (opts.hookFns._socketCreated == null) opts.hookFns._socketCreated = [];\n    opts.hookFns._socketCreated.unshift(() => helper.spyOnSocketIO());\n\n    // if opts.params is set we manipulate the URL to include URL parameters IE ?foo=Bah.\n    let encodedParams;\n    if (opts.params) {\n      encodedParams = `?${$.param(opts.params)}`;\n    }\n    let hash;\n    if (opts.hash) {\n      hash = `#${opts.hash}`;\n    }\n\n    // clear cookies\n    if (opts.clearCookies) {\n      helper.clearSessionCookies();\n    }\n\n    $iframe = $(`<iframe src='/p/${opts.id}${hash || ''}${encodedParams || ''}'></iframe>`);\n\n    // clean up inner iframe references\n    helper.padChrome$ = helper.padOuter$ = helper.padInner$ = null;\n\n    // remove old iframe\n    $('#iframe-container iframe').remove();\n    // set new iframe\n    $('#iframe-container').append($iframe);\n    await Promise.all([\n      new Promise((resolve) => $iframe.one('load', resolve)),\n      // Install the hook functions as early as possible because some of them fire right away.\n      new Promise((resolve, reject) => {\n        if ($iframe[0].contentWindow._postPluginUpdateForTestingDone) {\n          return reject(new Error(\n              'failed to set _postPluginUpdateForTesting before it would have been called'));\n        }\n        $iframe[0].contentWindow._postPluginUpdateForTesting = () => {\n          const {hooks} =\n                $iframe[0].contentWindow.require('ep_etherpad-lite/static/js/pluginfw/plugin_defs');\n          for (const [hookName, hookFns] of Object.entries(opts.hookFns)) {\n            if (hooks[hookName] == null) hooks[hookName] = [];\n            hooks[hookName].push(\n                ...hookFns.map((hookFn) => ({hook_name: hookName, hook_fn: hookFn})));\n          }\n          resolve();\n        };\n      }),\n    ]);\n    helper.padChrome$ = await helper.getFrameJQuery($('#iframe-container iframe'), true);\n    helper.padChrome$.padeditor =\n        helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_editor').padeditor;\n    if (opts.clearCookies) {\n      helper.clearPadPrefCookie();\n    }\n    if (opts.padPrefs) {\n      helper.setPadPrefCookie(opts.padPrefs);\n    }\n    const $loading = helper.padChrome$('#editorloadingbox');\n    const $container = helper.padChrome$('#editorcontainer');\n    try {\n      await helper.waitForPromise(\n          () => !$loading.is(':visible') && $container.hasClass('initialized'), 10000);\n    } catch (err) {\n      if (opts._retry++ >= 4) throw new Error('Pad never loaded');\n      return await helper.aNewPad(opts);\n    }\n    helper.padOuter$ =\n        await helper.getFrameJQuery(helper.padChrome$('iframe[name=\"ace_outer\"]'), false);\n    helper.padInner$ =\n        await helper.getFrameJQuery(helper.padOuter$('iframe[name=\"ace_inner\"]'), true);\n\n    // disable all animations, this makes tests faster and easier\n    helper.padChrome$.fx.off = true;\n    helper.padOuter$.fx.off = true;\n    helper.padInner$.fx.off = true;\n\n    // Don't return opts.id -- the server might have redirected the browser to a transformed version\n    // of the requested pad ID.\n    return helper.padChrome$.window.clientVars.padId;\n  };\n\n  helper.newAdmin = async (page) => {\n    // define the iframe\n    $iframe = $(`<iframe src='/admin/${page}'></iframe>`);\n\n    // clean up inner iframe references\n    helper.admin$ = null;\n\n    // remove old iframe\n    $('#iframe-container iframe').remove();\n    // set new iframe\n    $('#iframe-container').append($iframe);\n    $iframe.one('load', async () => {\n      helper.admin$ = await helper.getFrameJQuery($('#iframe-container iframe'), false);\n    });\n  };\n\n  helper.waitFor = (conditionFunc, timeoutTime = 1900, intervalTime = 10) => {\n    // Create an Error object to use if the condition is never satisfied. This is created here so\n    // that the Error has a useful stack trace associated with it.\n    const timeoutError =\n        new Error(`waitFor condition never became true ${conditionFunc.toString()}`);\n    const deferred = new $.Deferred();\n\n    const _fail = deferred.fail.bind(deferred);\n    let listenForFail = false;\n    deferred.fail = (...args) => {\n      listenForFail = true;\n      return _fail(...args);\n    };\n\n    const check = async () => {\n      try {\n        if (!await conditionFunc()) return;\n        deferred.resolve();\n      } catch (err) {\n        deferred.reject(err);\n      }\n      clearInterval(intervalCheck);\n      clearTimeout(timeout);\n    };\n\n    const intervalCheck = setInterval(check, intervalTime);\n\n    const timeout = setTimeout(() => {\n      clearInterval(intervalCheck);\n      deferred.reject(timeoutError);\n\n      if (!listenForFail) {\n        throw timeoutError;\n      }\n    }, timeoutTime);\n\n    // Check right away to avoid an unnecessary sleep if the condition is already true.\n    check();\n\n    return deferred;\n  };\n\n  /**\n   * Same as `waitFor` but using Promises\n   *\n   * @returns {Promise}\n   *\n   */\n  // Note: waitFor() has a strange API: On timeout it rejects, but it also throws an uncatchable\n  // exception unless .fail() has been called. That uncatchable exception is disabled here by\n  // passing a no-op function to .fail().\n  helper.waitForPromise = async (...args) => await helper.waitFor(...args).fail(() => {});\n\n  helper.selectLines = ($startLine, $endLine, startOffset, endOffset) => {\n    // if no offset is provided, use beginning of start line and end of end line\n    startOffset = startOffset || 0;\n    endOffset = endOffset === undefined ? $endLine.text().length : endOffset;\n\n    const inner$ = helper.padInner$;\n    const selection = inner$.document.getSelection();\n    const range = selection.getRangeAt(0);\n\n    const start = getTextNodeAndOffsetOf($startLine, startOffset);\n    const end = getTextNodeAndOffsetOf($endLine, endOffset);\n\n    range.setStart(start.node, start.offset);\n    range.setEnd(end.node, end.offset);\n\n    selection.removeAllRanges();\n    selection.addRange(range);\n  };\n\n  // Temporarily reduces minimum time between commits and calls the provided function with a single\n  // argument: a function that immediately incorporates all pad edits (as opposed to waiting for the\n  // idle timer to fire).\n  helper.withFastCommit = async (fn) => {\n    const incorp = () => helper.padChrome$.padeditor.ace.callWithAce(\n        (ace) => ace.ace_inCallStackIfNecessary('helper.edit', () => ace.ace_fastIncorp()));\n    const cc = helper.padChrome$.window.pad.collabClient;\n    const {commitDelay} = cc;\n    cc.commitDelay = 0;\n    try {\n      return await fn(incorp);\n    } finally {\n      cc.commitDelay = commitDelay;\n    }\n  };\n\n  const getTextNodeAndOffsetOf = ($targetLine, targetOffsetAtLine) => {\n    const $textNodes = $targetLine.find('*').contents().filter(function () {\n      return this.nodeType === Node.TEXT_NODE;\n    });\n\n    // search node where targetOffsetAtLine is reached, and its 'inner offset'\n    let textNodeWhereOffsetIs = null;\n    let offsetBeforeTextNode = 0;\n    let offsetInsideTextNode = 0;\n    $textNodes.each((index, element) => {\n      const elementTotalOffset = element.textContent.length;\n      textNodeWhereOffsetIs = element;\n      offsetInsideTextNode = targetOffsetAtLine - offsetBeforeTextNode;\n\n      const foundTextNode = offsetBeforeTextNode + elementTotalOffset >= targetOffsetAtLine;\n      if (foundTextNode) {\n        return false; // stop .each by returning false\n      }\n\n      offsetBeforeTextNode += elementTotalOffset;\n    });\n\n    // edge cases\n    if (textNodeWhereOffsetIs == null) {\n      // there was no text node inside $targetLine, so it is an empty line (<br>).\n      // Use beginning of line\n      textNodeWhereOffsetIs = $targetLine.get(0);\n      offsetInsideTextNode = 0;\n    }\n    // avoid errors if provided targetOffsetAtLine is higher than line offset (maxOffset).\n    // Use max allowed instead\n    const maxOffset = textNodeWhereOffsetIs.textContent.length;\n    offsetInsideTextNode = Math.min(offsetInsideTextNode, maxOffset);\n\n    return {\n      node: textNodeWhereOffsetIs,\n      offset: offsetInsideTextNode,\n    };\n  };\n\n  /* Ensure console.log doesn't blow up in IE, ugly but ok for a test framework imho*/\n  window.console = window.console || {};\n  window.console.log = window.console.log || (() => {});\n})();\n"
  },
  {
    "path": "src/tests/frontend/index.html",
    "content": "<!doctype html>\n<html>\n  <head>\n    <title>Frontend tests</title>\n    <meta charset=\"utf-8\">\n\n    <link rel=\"stylesheet\" href=\"runner.css\" />\n  </head>\n  <body>\n    <div id=\"console\"></div>\n    <div id=\"split-view\">\n      <div id=\"mocha\"></div>\n      <div id=\"separator\"></div>\n      <div id=\"iframe-container\"></div>\n    </div>\n\n    <script src=\"../../static/js/vendors/jquery.js\"></script>\n    <script src=\"lib/sendkeys.js\"></script>\n    <script src=\"../../static/js/vendors/browser.js\"></script>\n    <script src=\"../../static/plugins/js-cookie/dist/js.cookie.js\"></script>\n    <script src=\"lib/underscore.js\"></script>\n\n    <script src=\"lib/mocha.js\"></script>\n    <script> mocha.setup({ui: 'bdd', checkLeaks: true, globals: 'ret_nodes', timeout: 60000}) </script>\n    <script src=\"lib/expect.js\"></script>\n\n    <script src=\"helper.js\"></script>\n    <script src=\"helper/methods.js\"></script>\n    <script src=\"helper/ui.js\"></script>\n    <script src=\"helper/multipleUsers.js\"></script>\n    <script src=\"runner.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "src/tests/frontend/runner.css",
    "content": "html {\n  height: 100%;\n}\n\nbody {\n  padding: 0px;\n  margin: 0px;\n  height: 100%;\n  overflow: hidden;\n}\n\n#console {\n  display: none;\n}\n\n#split-view {\n  width: 100%;\n  height: 100%;\n  display: grid;\n  grid-template-columns: 20% 10px 1fr;\n}\n\n#separator {\n  grid-column: 2;\n  grid-row: 1/-1;\n  cursor: col-resize;\n  background-color: #999;\n}\n\n#iframe-container {\n  height: 100%;\n  overflow: auto hidden;\n}\n\n#iframe-container iframe {\n  border: 0;\n  height: 100%;\n  width: 100%;\n  min-width: 820px;\n}\n\n#mocha {\n  font: 20px/1.5 \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n  height: 100%;\n  min-height: 100%; /* https://css-tricks.com/preventing-a-grid-blowout/ */\n  font-size:80%;\n  display: flex;\n  flex-direction: column;\n}\n\n#mocha ul {\n  list-style: none;\n}\n\n#mocha h1, #mocha h2 {\n  margin: 0;\n}\n\n#mocha h1 {\n  padding-top: 15px; /* margin-top breaks autoscrolling */\n  font-size: 1em;\n  font-weight: 200;\n}\n\n#mocha h1 a:visited\n{\n  color: #00E;\n}\n\n#mocha .suite .suite h1 {\n  padding-top: 0;\n  font-size: .8em;\n}\n\n#mocha h2 {\n  font-size: 12px;\n  font-weight: normal;\n  cursor: pointer;\n}\n\n#mocha .suite {\n  margin-left: 0px;\n}\n\n#mocha .test {\n  margin-left: 5px;\n}\n\n#mocha .test:hover h2::after {\n  position: relative;\n  top: 0;\n  right: -10px;\n  content: '(view source)';\n  font-size: 12px;\n  font-family: arial;\n  color: #888;\n}\n\n#mocha .test.pending:hover h2::after {\n  content: '(pending)';\n  font-family: arial;\n}\n\n#mocha .test.pass.medium .duration {\n  background: #ffd285;\n}\n\n#mocha .test.pass.slow .duration {\n  background: #ffc2c0;\n}\n\n#mocha .test.pass::before {\n  content: '✓';\n  font-size: 12px;\n  display: block;\n  float: left;\n  margin-right: 5px;\n  color: #00d6b2;\n}\n\n#mocha .test.pass .duration {\n  font-size: 9px;\n  margin-left: 5px;\n  padding: 2px 5px;\n  border-radius: 5px;\n}\n\n#mocha .test.pass.fast .duration {\n  background: #d3ffe9;\n}\n\n#mocha .test.pending {\n  color: #0b97c4;\n}\n\n#mocha .test.pending::before {\n  content: '◦';\n  color: #0b97c4;\n}\n\n#mocha .test.fail {\n  color: #c00;\n}\n\n#mocha .test.fail pre {\n  color: black;\n}\n\n#mocha .test.fail::before {\n  content: '✖';\n  font-size: 12px;\n  display: block;\n  float: left;\n  margin-right: 5px;\n  color: #c00;\n}\n\n#mocha .test pre.error {\n  color: #c00;\n}\n\n#mocha .test pre {\n  display: inline-block;\n  font: 12px/1.5 monaco, monospace;\n  margin: 5px;\n  padding: 15px;\n  border: 1px solid #eee;\n  border-bottom-color: #ddd;\n  -webkit-border-radius: 3px;\n  -webkit-box-shadow: 0 1px 3px #eee;\n}\n\n#mocha-report {\n  flex: 1 1 auto;\n  overflow: auto;\n  margin: 0;\n}\n\n#mocha-report ul {\n  padding: 0;\n}\n\n#mocha-report.pass .test.fail {\n  display: none;\n}\n\n#mocha-report.fail .test.pass {\n  display: none;\n}\n\n#mocha-error {\n  color: #c00;\n  font-size: 1.5  em;\n  font-weight: 100;\n  letter-spacing: 1px;\n}\n\n#mocha-stats {\n  flex: 0 0 auto;\n  padding: 10px;\n  font-size: 12px;\n  margin: 0;\n  color: #888;\n  text-align: right;\n}\n\n#mocha-stats .progress {\n  float: right;\n  padding-top: 0;\n  margin-right:5px;\n}\n\n#mocha-stats em {\n  color: black;\n}\n\n#mocha-stats a {\n  text-decoration: none;\n  color: inherit;\n}\n\n#mocha-stats a:hover {\n  border-bottom: 1px solid #eee;\n}\n\n#mocha-stats li {\n  display: inline-block;\n  margin: 0 5px;\n  list-style: none;\n  padding-top: 11px;\n}\n\ncode .comment { color: #ddd }\ncode .init { color: #2F6FAD }\ncode .string { color: #5890AD }\ncode .keyword { color: #8A6343 }\ncode .number { color: #2F6FAD }\n\nul{\n  padding-left:5px;\n}\n"
  },
  {
    "path": "src/tests/frontend/runner.js",
    "content": "'use strict';\n\n// $(handler), $().ready(handler), $.wait($.ready).then(handler), etc. don't work if handler is an\n// async function for some bizarre reason, so the async function is wrapped in a non-async function.\n$(() => (async () => {\n  const stringifyException = (exception) => {\n    let err = exception.stack || exception.toString();\n\n    // FF / Opera do not add the message\n    if (!~err.indexOf(exception.message)) {\n      err = `${exception.message}\\n${err}`;\n    }\n\n    // <=IE7 stringifies to [Object Error]. Since it can be overloaded, we\n    // check for the result of the stringifying.\n    if (err === '[object Error]') err = exception.message;\n\n    // Safari doesn't give you a stack. Let's at least provide a source line.\n    if (!exception.stack && exception.sourceURL && exception.line !== undefined) {\n      err += `\\n(${exception.sourceURL}:${exception.line})`;\n    }\n\n    return err;\n  };\n\n  const customRunner = (runner) => {\n    const stats = {suites: 0, tests: 0, passes: 0, pending: 0, failures: 0};\n    let level = 0;\n\n    if (!runner) return;\n\n    // AUTO-SCROLLING:\n\n    // Mocha can start multiple suites before the first 'suite' event is emitted. This can break the\n    // logic used to determine if the div is already scrolled to the bottom. If this is false,\n    // auto-scrolling unconditionally scrolls to the bottom no matter how far up the div is\n    // currently scrolled. If true, auto-scrolling only happens if the div is scrolled close to the\n    // bottom.\n    let manuallyScrolled = false;\n    // The 'scroll' event is fired for manual scrolling as well as JavaScript-initiated scrolling.\n    // This is incremented while auto-scrolling and decremented when done auto-scrolling. This is\n    // used to ensure that auto-scrolling never sets manuallyScrolled to true.\n    let autoScrolling = 0;\n\n    // Auto-scroll the #mocha-report div to show the newly added test entry if it was previously\n    // scrolled to the bottom.\n    const autoscroll = (newElement) => {\n      const mr = $('#mocha-report')[0];\n      const scroll = !manuallyScrolled || (() => {\n        const offsetTopAbs = newElement.getBoundingClientRect().top;\n        const mrOffsetTopAbs = mr.getBoundingClientRect().top - mr.scrollTop;\n        const offsetTop = offsetTopAbs - mrOffsetTopAbs;\n        // Add some margin to cover rounding error and to make it easier to engage the auto-scroll.\n        return offsetTop <= mr.clientHeight + mr.scrollTop + 5;\n      })();\n      if (!scroll) return;\n      ++autoScrolling;\n      mr.scrollTop = mr.scrollHeight;\n      manuallyScrolled = false;\n    };\n\n    $('#mocha-report').on('scroll', () => {\n      if (!autoScrolling) manuallyScrolled = true;\n      else --autoScrolling;\n    });\n\n    runner.on('start', () => {\n      stats.start = new Date();\n    });\n\n    runner.on('suite', (suite) => {\n      if (suite.root) return;\n      autoscroll($('#mocha-report .suite').last()[0]);\n      stats.suites++;\n      append(suite.title);\n      level++;\n    });\n\n    runner.on('suite end', (suite) => {\n      if (suite.root) return;\n      level--;\n\n      if (level === 0) {\n        append('');\n      }\n    });\n\n    // max time a test is allowed to run\n    // TODO this should be lowered once timeslider_revision.js is faster\n    let killTimeout;\n    runner.on('test end', () => {\n      autoscroll($('#mocha-report .test').last()[0]);\n      stats.tests++;\n    });\n\n    runner.on('pass', (test) => {\n      if (killTimeout) clearTimeout(killTimeout);\n      killTimeout = setTimeout(() => {\n        append('FINISHED - [red]no test started since 5 minutes, tests stopped[clear]');\n      }, 60000 * 5);\n\n      const medium = test.slow() / 2;\n      test.speed = test.duration > test.slow()\n        ? 'slow'\n        : test.duration > medium\n          ? 'medium'\n          : 'fast';\n\n      stats.passes++;\n      append(`-> [green]PASSED[clear] : ${test.title}   ${test.duration} ms`);\n    });\n\n    runner.on('fail', (test, err) => {\n      if (killTimeout) clearTimeout(killTimeout);\n      killTimeout = setTimeout(() => {\n        append('FINISHED - [red]no test started since 5 minutes, tests stopped[clear]');\n      }, 60000 * 5);\n\n      stats.failures++;\n      test.err = err;\n      append(`-> [red]FAILED[clear] : ${test.title} ${stringifyException(test.err)}`);\n    });\n\n    runner.on('pending', (test) => {\n      if (killTimeout) clearTimeout(killTimeout);\n      killTimeout = setTimeout(() => {\n        append('FINISHED - [red]no test started since 5 minutes, tests stopped[clear]');\n      }, 60000 * 5);\n\n      stats.pending++;\n      append(`-> [yellow]PENDING[clear]: ${test.title}`);\n    });\n\n    const $console = $('#console');\n    const append = (text) => {\n      // Indent each line.\n      const lines = text.split('\\n').map((line) => ' '.repeat(level * 2) + line);\n      $console.append(document.createTextNode(`${lines.join('\\n')}\\n`));\n    };\n\n    const total = runner.total;\n    runner.on('end', () => {\n      stats.end = new Date();\n      stats.duration = stats.end - stats.start;\n      const minutes = Math.floor(stats.duration / 1000 / 60);\n      // chrome < 57 does not like this .toString().padStart('2', '0');\n      const seconds = Math.round((stats.duration / 1000) % 60);\n      if (stats.tests === total) {\n        append(`FINISHED - ${stats.passes} tests passed, ${stats.failures} tests failed, ` +\n               `${stats.pending} pending, duration: ${minutes}:${seconds}`);\n      } else if (stats.tests > total) {\n        append(`FINISHED - but more tests than planned returned ${stats.passes} tests passed, ` +\n               `${stats.failures} tests failed, ${stats.pending} pending, ` +\n               `duration: ${minutes}:${seconds}`);\n        append(`${total} tests, but ${stats.tests} returned. ` +\n               'There is probably a problem with your async code or error handling, ' +\n               'see https://github.com/mochajs/mocha/issues/1327');\n      } else {\n        append(`FINISHED - but not all tests returned ${stats.passes} tests passed, ` +\n               `${stats.failures} tests failed, ${stats.pending} tests pending, ` +\n               `duration: ${minutes}:${seconds}`);\n        append(`${total} tests, but only ${stats.tests} returned. ` +\n               'Check for failed before/beforeEach-hooks (no `test end` is called for them ' +\n               'and subsequent tests of the same suite are skipped), ' +\n               'see https://github.com/mochajs/mocha/pull/1043');\n      }\n    });\n  };\n\n  const getURLParameter = (name) => (new URLSearchParams(location.search)).get(name);\n\n  const absUrl = (url) => new URL(url, window.location.href).href;\n  require.setRootURI(absUrl('../../javascripts/src'));\n  require.setLibraryURI(absUrl('../../javascripts/lib'));\n  require.setGlobalKeyPath('require');\n\n  const Split = require('split-grid/dist/split-grid.min');\n  new Split({\n    columnGutters: [{\n      track: 1,\n      element: document.getElementById('separator'),\n    }],\n  });\n\n  // Speed up tests by loading test definitions in parallel. Approach: Define a new global object\n  // that has a define() method, which is a wrapper around window.require.define(). The wrapper\n  // mutates the module definition function to temporarily replace Mocha's functions with\n  // placeholders. The placeholders make it possible to defer the actual Mocha function calls until\n  // after the modules are all loaded in parallel. require.setGlobalKeyPath() is used to coax\n\n  // Per-module log of attempted Mocha function calls. Key is module path, value is an array of\n  // [functionName, argsArray] arrays.\n  const mochaCalls = new Map();\n  const mochaFns = [\n    'after',\n    'afterEach',\n    'before',\n    'beforeEach',\n    'context',\n    'describe',\n    'it',\n    'run',\n    'specify',\n    'xcontext', // Undocumented as of Mocha 7.1.2.\n    'xdescribe', // Undocumented as of Mocha 7.1.2.\n    'xit', // Undocumented as of Mocha 7.1.2.\n    'xspecify', // Undocumented as of Mocha 7.1.2.\n  ];\n  window.testRunnerRequire = {\n    define(...args) {\n      if (args.length === 2) args = [{[args[0]]: args[1]}];\n      if (args.length !== 1) throw new Error('unexpected args passed to testRunnerRequire.define');\n      const [origDefs] = args;\n      const defs = {};\n      for (const [path, origDef] of Object.entries(origDefs)) {\n        defs[path] = origDef == null ? origDef : function (require, exports, module) {\n          const calls = [];\n          mochaCalls.set(module.id.replace(/\\.js$/, ''), calls);\n          // Backup Mocha functions. Note that because modules can require other modules, these\n          // backups might be placeholders, not the actual Mocha functions.\n          const backups = {};\n          for (const fn of mochaFns) {\n            if (typeof window[fn] !== 'function') continue;\n            // Note: Test specs can require other modules, so window[fn] might be a placeholder\n            // function, not the actual Mocha function.\n            backups[fn] = window[fn];\n            window[fn] = (...args) => calls.push([fn, args]);\n          }\n          try {\n            return origDef.call(this, require, exports, module);\n          } finally {\n            Object.assign(window, backups);\n          }\n        };\n      }\n      return require.define(defs);\n    },\n  };\n  require.setGlobalKeyPath('testRunnerRequire');\n  // Increase fetch parallelism to speed up test spec loading. (Note: The browser might limit to a\n  // lower value -- this is just an upper limit.)\n  require.setRequestMaximum(20);\n\n  const $log = $('<div>');\n  const appendToLog = (msg) => {\n    if (typeof msg === 'string') msg = document.createTextNode(msg);\n    // Add some margin to cover rounding error and to make it easier to engage the auto-scroll.\n    const scrolledToBottom = $log[0].scrollHeight <= $log[0].scrollTop + $log[0].clientHeight + 5;\n    const $msg = $('<div>').css('white-space', 'nowrap').append(msg).appendTo($log);\n    if (scrolledToBottom) $log[0].scrollTop = $log[0].scrollHeight;\n    return $msg;\n  };\n  const $bar = $('<progress>');\n  let barLastUpdate = Date.now();\n  const incrementBar = async (amount = 1) => {\n    $bar.attr('value', Number.parseInt($bar.attr('value')) + 1);\n    // Give the browser an opportunity to draw the progress bar's new length. `await\n    // Promise.resolve()` isn't enough, so a timeout is used. Sleeping every increment (even 0ms)\n    // unnecessarily slows down spec loading so the sleep is occasional.\n    if (Date.now() - barLastUpdate > 100) {\n      await new Promise((resolve) => setTimeout(resolve, 0));\n      barLastUpdate = Date.now();\n    }\n  };\n  const $progressArea = $('<div>')\n      .css({'display': 'flex', 'flex-direction': 'column', 'height': '100%'})\n      .append($('<div>').css({flex: '1 0 0'}))\n      .append($('<div>')\n          .css({'flex': '0 0 auto', 'font-weight': 'bold'})\n          .text('Loading frontend test specs...'))\n      .append($log.css({flex: '0 1 auto', overflow: 'auto'}))\n      .append($bar.css({flex: '0 0 auto', width: '100%'}))\n      .appendTo('#mocha');\n  const specs = await $.getJSON('frontendTestSpecs.json');\n  if (specs.length > 0) {\n    $bar.attr({value: 0, max: specs.length * 2});\n    await incrementBar(0);\n  }\n  const makeDesc = (spec) => `${spec\n      .replace(/^ep_etherpad-lite\\/tests\\/frontend\\/specs\\//, '<core> ')\n      .replace(/^([^/ ]*)\\/static\\/tests\\/frontend\\/specs\\//, '<$1> ')}.js`;\n  await Promise.all(specs.map(async (spec) => {\n    const $msg = appendToLog(`Fetching ${makeDesc(spec)}...`);\n    try {\n      await new Promise((resolve, reject) => require(spec, (module) => {\n        if (module == null) return reject(new Error(`failed to load module ${spec}`));\n        resolve();\n      }));\n    } catch (err) {\n      $msg.append($('<b>').css('color', 'red').text(' FAILED'));\n      appendToLog($('<pre>').text(`${err.stack || err}`));\n      throw err;\n    }\n    $msg.append(' done');\n    await incrementBar();\n  }));\n  require.setGlobalKeyPath('require');\n  delete window.testRunnerRequire;\n  for (const spec of specs) {\n    const desc = makeDesc(spec);\n    const $msg = appendToLog(`Executing ${desc}...`);\n    try {\n      describe(desc, function () {\n        for (const [fn, args] of mochaCalls.get(spec)) window[fn](...args);\n      });\n    } catch (err) {\n      $msg.append($('<b>').css('color', 'red').text(' FAILED'));\n      appendToLog($('<pre>').text(`${err.stack || err}`));\n      throw err;\n    }\n    $msg.append(' done');\n    await incrementBar();\n  }\n  $progressArea.remove();\n\n  const grep = getURLParameter('grep');\n  if (grep != null) {\n    mocha.grep(grep);\n  }\n  const runner = mocha.run();\n  customRunner(runner);\n})());\n"
  },
  {
    "path": "src/tests/frontend/specs/authorship_of_editions.js",
    "content": "'use strict';\n\ndescribe('author of pad edition', function () {\n  const REGULAR_LINE = 0;\n  const LINE_WITH_ORDERED_LIST = 1;\n  const LINE_WITH_UNORDERED_LIST = 2;\n\n  // author 1 creates a new pad with some content (regular lines and lists)\n  before(async function () {\n    const padId = await helper.aNewPad();\n\n    // make sure pad has at least 3 lines\n    const $firstLine = helper.padInner$('div').first();\n    const threeLines = ['regular line', 'line with ordered list', 'line with unordered list']\n        .join('<br>');\n    $firstLine.html(threeLines);\n\n    // wait for lines to be processed by Etherpad\n    await helper.waitForPromise(() => (\n      getLine(LINE_WITH_UNORDERED_LIST).text() === 'line with unordered list' &&\n      helper.commits.length === 1));\n\n    // create the unordered list\n    const $lineWithUnorderedList = getLine(LINE_WITH_UNORDERED_LIST);\n    $lineWithUnorderedList.sendkeys('{selectall}');\n\n    const $insertUnorderedListButton = helper.padChrome$('.buttonicon-insertunorderedlist');\n    $insertUnorderedListButton.trigger('click');\n\n    await helper.waitForPromise(() => (\n      getLine(LINE_WITH_UNORDERED_LIST).find('ul li').length === 1 &&\n      helper.commits.length === 2));\n\n    // create the ordered list\n    const $lineWithOrderedList = getLine(LINE_WITH_ORDERED_LIST);\n    $lineWithOrderedList.sendkeys('{selectall}');\n\n    const $insertOrderedListButton = helper.padChrome$('.buttonicon-insertorderedlist');\n    $insertOrderedListButton.trigger('click');\n\n    await helper.waitForPromise(() => (\n      getLine(LINE_WITH_ORDERED_LIST).find('ol li').length === 1 &&\n      helper.commits.length === 3));\n\n    // Expire cookie, so author is changed after reloading the pad.\n    const {Cookies} = helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_utils');\n    Cookies.remove('token');\n\n    // Reload pad, to make changes as a second user.\n    await helper.aNewPad({id: padId});\n  });\n\n  // author 2 makes some changes on the pad\n  it('regular line', async function () {\n    await changeLineAndCheckOnlyThatChangeIsFromThisAuthor(REGULAR_LINE, 'x');\n  });\n\n  it('line with ordered list', async function () {\n    await changeLineAndCheckOnlyThatChangeIsFromThisAuthor(LINE_WITH_ORDERED_LIST, 'y');\n  });\n\n  it('line with unordered list', async function () {\n    await changeLineAndCheckOnlyThatChangeIsFromThisAuthor(LINE_WITH_UNORDERED_LIST, 'z');\n  });\n\n  /* ********************** Helper functions ************************ */\n  const getLine = (lineNumber) => helper.padInner$('div').eq(lineNumber);\n\n  const getAuthorFromClassList = (classes) => classes.find((cls) => cls.startsWith('author'));\n\n  const changeLineAndCheckOnlyThatChangeIsFromThisAuthor = async (lineNumber, textChange) => {\n    // get original author class\n    const classes = getLine(lineNumber).find('span').first().attr('class').split(' ');\n    const originalAuthor = getAuthorFromClassList(classes);\n\n    // make change on target line\n    const $regularLine = getLine(lineNumber);\n    helper.selectLines($regularLine, $regularLine, 2, 2); // place caret after 2nd char of line\n    $regularLine.sendkeys(textChange);\n\n    // wait for change to be processed by Etherpad\n    let otherAuthorsOfLine;\n    await helper.waitForPromise(() => {\n      const authorsOfLine = getLine(lineNumber).find('span').map(function () {\n        return getAuthorFromClassList($(this).attr('class').split(' '));\n      }).get();\n      otherAuthorsOfLine = authorsOfLine.filter((author) => author !== originalAuthor);\n      const lineHasChangeOfThisAuthor = otherAuthorsOfLine.length > 0;\n      return lineHasChangeOfThisAuthor;\n    });\n    const thisAuthor = otherAuthorsOfLine[0];\n    const $changeOfThisAuthor = getLine(lineNumber).find(`span.${thisAuthor}`);\n    expect($changeOfThisAuthor.text()).to.be(textChange);\n  };\n});\n"
  },
  {
    "path": "src/tests/frontend/specs/chat_hooks.js",
    "content": "'use strict';\n\ndescribe('chat hooks', function () {\n  let ChatMessage;\n  let hooks;\n  const hooksBackup = {};\n  let padId;\n\n  const loadPad = async (opts = {}) => {\n    padId = await helper.aNewPad(opts);\n    ChatMessage = helper.padChrome$.window.require('ep_etherpad-lite/static/js/ChatMessage');\n    ({hooks} = helper.padChrome$.window.require('ep_etherpad-lite/static/js/pluginfw/plugin_defs'));\n    for (const [name, defs] of Object.entries(hooks)) {\n      hooksBackup[name] = defs;\n      hooks[name] = [...defs];\n    }\n    await helper.showChat();\n  };\n\n  before(async function () {\n    await loadPad();\n  });\n\n  afterEach(async function () {\n    for (const [name, defs] of Object.entries(hooksBackup)) hooks[name] = [...defs];\n    for (const name of Object.keys(hooks)) {\n      if (hooksBackup[name] == null) delete hooks[name];\n    }\n  });\n\n  const checkHook = async (hookName, checkFn) => {\n    if (hooks[hookName] == null) hooks[hookName] = [];\n    await new Promise((resolve, reject) => {\n      hooks[hookName].push({\n        hook_fn: async (hookName, context) => {\n          if (checkFn == null) return;\n          try {\n            // Make sure checkFn is called only once.\n            const _checkFn = checkFn;\n            checkFn = null;\n            await _checkFn(context);\n          } catch (err) {\n            reject(err);\n            return;\n          }\n          resolve();\n        },\n      });\n    });\n  };\n\n  describe('chatNewMessage', function () {\n    for (const [desc, msg, wantRegEx] of [\n      ['HTML is escaped', '<script>alert(\"foo\");</script>', /^[^<]*$/],\n      ['URL becomes a link', 'https://etherpad.org', /<a [^>]*href/],\n    ]) {\n      it(`text processing: ${desc}`, async function () {\n        await Promise.all([\n          checkHook('chatNewMessage', ({text}) => {\n            expect(text).to.match(wantRegEx);\n          }),\n          helper.sendChatMessage(`${msg}{enter}`),\n        ]);\n      });\n    }\n\n    it('message is a ChatMessage object', async function () {\n      await Promise.all([\n        checkHook('chatNewMessage', ({message}) => {\n          expect(message).to.be.a(ChatMessage);\n        }),\n        helper.sendChatMessage(`${this.test.title}{enter}`),\n      ]);\n    });\n\n    it('message.text is not processed', async function () {\n      const msg = '<script>alert(\"foo\");</script> https://etherpad.org';\n      await Promise.all([\n        checkHook('chatNewMessage', ({message: {text}}) => {\n          expect(text).to.equal(`${msg}\\n`);\n        }),\n        helper.sendChatMessage(`${msg}{enter}`),\n      ]);\n    });\n\n    it('`rendered` overrides default rendering', async function () {\n      let rendered;\n      await Promise.all([\n        checkHook('chatNewMessage', (context) => {\n          expect(context.rendered == null).to.be.ok();\n          rendered = context.rendered = helper.padChrome$.document.createElement('p');\n          rendered.append('message rendering overridden');\n        }),\n        helper.sendChatMessage(`${this.test.title}{enter}`),\n      ]);\n      expect(helper.chatTextParagraphs().last()[0]).to.be(rendered);\n    });\n  });\n\n  describe('chatSendMessage', function () {\n    it('message is a ChatMessage object', async function () {\n      await Promise.all([\n        checkHook('chatSendMessage', ({message}) => {\n          expect(message).to.be.a(ChatMessage);\n        }),\n        helper.sendChatMessage(`${this.test.title}{enter}`),\n      ]);\n    });\n\n    it('message metadata propagates end-to-end', async function () {\n      const metadata = {foo: this.test.title};\n      await Promise.all([\n        checkHook('chatSendMessage', ({message}) => {\n          message.customMetadata = metadata;\n        }),\n        checkHook('chatNewMessage', ({message: {customMetadata}}) => {\n          expect(JSON.stringify(customMetadata)).to.equal(JSON.stringify(metadata));\n        }),\n        helper.sendChatMessage(`${this.test.title}{enter}`),\n      ]);\n    });\n\n    it('message metadata is saved in the database', async function () {\n      const msg = this.test.title;\n      const metadata = {foo: this.test.title};\n      await Promise.all([\n        checkHook('chatSendMessage', ({message}) => {\n          message.customMetadata = metadata;\n        }),\n        helper.sendChatMessage(`${msg}{enter}`),\n      ]);\n      let gotMessage;\n      const messageP = new Promise((resolve) => gotMessage = resolve);\n      await loadPad({\n        id: padId,\n        hookFns: {\n          chatNewMessage: [\n            (hookName, {message}) => {\n              if (message.text === `${msg}\\n`) gotMessage(message);\n            },\n          ],\n        },\n      });\n      const message = await messageP;\n      expect(message).to.be.a(ChatMessage);\n      expect(JSON.stringify(message.customMetadata)).to.equal(JSON.stringify(metadata));\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/frontend/specs/chat_load_messages.js",
    "content": "'use strict';\n\ndescribe('chat-load-messages', function () {\n  let padName;\n\n  it('creates a pad', async function () {\n    padName = await helper.aNewPad();\n  });\n\n  it('adds a lot of messages', async function () {\n    const chrome$ = helper.padChrome$;\n    const chatButton = chrome$('#chaticon');\n    chatButton.trigger('click');\n    const chatInput = chrome$('#chatinput');\n    const chatText = chrome$('#chattext');\n\n    const messages = 140;\n    for (let i = 1; i <= messages; i++) {\n      let num = `${i}`;\n      if (num.length === 1) num = `00${num}`;\n      if (num.length === 2) num = `0${num}`;\n      chatInput.sendkeys(`msg${num}`);\n      chatInput.sendkeys('{enter}');\n      await helper.waitForPromise(() => chatText.children('p').length === i);\n    }\n    await helper.aNewPad({id: padName});\n  });\n\n  it('checks initial message count', function (done) {\n    this.timeout(1000);\n    let chatText;\n    const expectedCount = 101;\n    const chrome$ = helper.padChrome$;\n    helper.waitFor(() => {\n      const chatButton = chrome$('#chaticon');\n      chatButton.trigger('click');\n      chatText = chrome$('#chattext');\n      return chatText.children('p').length === expectedCount;\n    }).always(() => {\n      expect(chatText.children('p').length).to.be(expectedCount);\n      done();\n    });\n  });\n\n  it('loads more messages', function (done) {\n    this.timeout(3000);\n    const expectedCount = 122;\n    const chrome$ = helper.padChrome$;\n    const chatButton = chrome$('#chaticon');\n    chatButton.trigger('click');\n    const chatText = chrome$('#chattext');\n    const loadMsgBtn = chrome$('#chatloadmessagesbutton');\n\n    loadMsgBtn.trigger('click');\n    helper.waitFor(() => chatText.children('p').length === expectedCount).always(() => {\n      expect(chatText.children('p').length).to.be(expectedCount);\n      done();\n    });\n  });\n\n  it('checks for button vanishing', function (done) {\n    this.timeout(2000);\n    const expectedDisplay = 'none';\n    const chrome$ = helper.padChrome$;\n    const chatButton = chrome$('#chaticon');\n    chatButton.trigger('click');\n    const loadMsgBtn = chrome$('#chatloadmessagesbutton');\n    const loadMsgBall = chrome$('#chatloadmessagesball');\n\n    loadMsgBtn.trigger('click');\n    helper.waitFor(() => loadMsgBtn.css('display') === expectedDisplay &&\n             loadMsgBall.css('display') === expectedDisplay).always(() => {\n      expect(loadMsgBtn.css('display')).to.be(expectedDisplay);\n      expect(loadMsgBall.css('display')).to.be(expectedDisplay);\n      done();\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/frontend/specs/drag_and_drop.js",
    "content": "'use strict';\n\n// WARNING: drag and drop is only simulated on these tests, manual testing might also be necessary\ndescribe('drag and drop', function () {\n  before(async function () {\n    await helper.aNewPad();\n    await createScriptWithSeveralLines();\n  });\n\n  context('when user drags part of one line and drops it far form its original place', function () {\n    before(async function () {\n      selectPartOfSourceLine();\n      dragSelectedTextAndDropItIntoMiddleOfLine(TARGET_LINE);\n\n      // make sure DnD was correctly simulated\n      await helper.waitForPromise(() => {\n        const $targetLine = getLine(TARGET_LINE);\n        const sourceWasMovedToTarget = $targetLine.text() === 'Target line [line 1]';\n        return sourceWasMovedToTarget;\n      });\n    });\n\n    context('and user triggers UNDO', function () {\n      before(async function () {\n        const originalHTML = helper.padInner$('body').html();\n        const $undoButton = helper.padChrome$('.buttonicon-undo');\n        $undoButton.trigger('click');\n        await helper.waitForPromise(() => helper.padInner$('body').html() !== originalHTML);\n      });\n\n      it('moves text back to its original place', async function () {\n        // test text was removed from drop target\n        const $targetLine = getLine(TARGET_LINE);\n        expect($targetLine.text()).to.be('Target line []');\n\n        // test text was added back to original place\n        const $firstSourceLine = getLine(FIRST_SOURCE_LINE);\n        const $lastSourceLine = getLine(FIRST_SOURCE_LINE + 1);\n        expect($firstSourceLine.text()).to.be('Source line 1.');\n        expect($lastSourceLine.text()).to.be('Source line 2.');\n      });\n    });\n  });\n\n  context('when user drags some lines far form its original place', function () {\n    before(async function () {\n      selectMultipleSourceLines();\n      dragSelectedTextAndDropItIntoMiddleOfLine(TARGET_LINE);\n\n      // make sure DnD was correctly simulated\n      await helper.waitForPromise(() => {\n        const $lineAfterTarget = getLine(TARGET_LINE + 1);\n        const sourceWasMovedToTarget = $lineAfterTarget.text() !== '...';\n        return sourceWasMovedToTarget;\n      });\n    });\n\n    context('and user triggers UNDO', function () {\n      before(async function () {\n        const originalHTML = helper.padInner$('body').html();\n        const $undoButton = helper.padChrome$('.buttonicon-undo');\n        $undoButton.trigger('click');\n        await helper.waitForPromise(() => helper.padInner$('body').html() !== originalHTML);\n      });\n\n      it('moves text back to its original place', async function () {\n        // test text was removed from drop target\n        const $targetLine = getLine(TARGET_LINE);\n        expect($targetLine.text()).to.be('Target line []');\n\n        // test text was added back to original place\n        const $firstSourceLine = getLine(FIRST_SOURCE_LINE);\n        const $lastSourceLine = getLine(FIRST_SOURCE_LINE + 1);\n        expect($firstSourceLine.text()).to.be('Source line 1.');\n        expect($lastSourceLine.text()).to.be('Source line 2.');\n      });\n    });\n  });\n\n  /* ********************* Helper functions/constants ********************* */\n  const TARGET_LINE = 2;\n  const FIRST_SOURCE_LINE = 5;\n\n  const getLine = (lineNumber) => {\n    const $lines = helper.padInner$('div');\n    return $lines.slice(lineNumber, lineNumber + 1);\n  };\n\n  const createScriptWithSeveralLines = async () => {\n    // create some lines to be used on the tests\n    const $firstLine = helper.padInner$('div').first();\n    $firstLine.html('...<br>...<br>Target line []<br>...<br>...<br>' +\n        'Source line 1.<br>Source line 2.<br>');\n\n    // wait for lines to be split\n    await helper.waitForPromise(() => {\n      const $lastSourceLine = getLine(FIRST_SOURCE_LINE + 1);\n      return $lastSourceLine.text() === 'Source line 2.';\n    });\n  };\n\n  const selectPartOfSourceLine = () => {\n    const $sourceLine = getLine(FIRST_SOURCE_LINE);\n\n    // select 'line 1' from 'Source line 1.'\n    const start = 'Source '.length;\n    const end = start + 'line 1'.length;\n    helper.selectLines($sourceLine, $sourceLine, start, end);\n  };\n  const selectMultipleSourceLines = () => {\n    const $firstSourceLine = getLine(FIRST_SOURCE_LINE);\n    const $lastSourceLine = getLine(FIRST_SOURCE_LINE + 1);\n\n    helper.selectLines($firstSourceLine, $lastSourceLine);\n  };\n\n  const dragSelectedTextAndDropItIntoMiddleOfLine = (targetLineNumber) => {\n    // dragstart: start dragging content\n    triggerEvent('dragstart');\n\n    // drop: get HTML data from selected text\n    const draggedHtml = getHtmlFromSelectedText();\n    triggerEvent('drop');\n\n    // dragend: remove original content + insert HTML data into target\n    moveSelectionIntoTarget(draggedHtml, targetLineNumber);\n    triggerEvent('dragend');\n  };\n\n  const getHtmlFromSelectedText = () => {\n    const innerDocument = helper.padInner$.document;\n\n    const range = innerDocument.getSelection().getRangeAt(0);\n    const clonedSelection = range.cloneContents();\n    const span = innerDocument.createElement('span');\n    span.id = 'buffer';\n    span.appendChild(clonedSelection);\n    const draggedHtml = span.outerHTML;\n\n    return draggedHtml;\n  };\n\n  const triggerEvent = (eventName) => {\n    const event = new helper.padInner$.Event(eventName);\n    helper.padInner$('#innerdocbody').trigger(event);\n  };\n\n  const moveSelectionIntoTarget = (draggedHtml, targetLineNumber) => {\n    const innerDocument = helper.padInner$.document;\n\n    // delete original content\n    innerDocument.execCommand('delete');\n\n    // set position to insert content on target line\n    const $target = getLine(targetLineNumber);\n    $target.sendkeys('{selectall}{rightarrow}{leftarrow}');\n\n    // Insert content.\n    // Based on http://stackoverflow.com/a/6691294, to be IE-compatible\n    const range = innerDocument.getSelection().getRangeAt(0);\n    const frag = innerDocument.createDocumentFragment();\n    const el = innerDocument.createElement('div');\n    el.innerHTML = draggedHtml;\n    while (el.firstChild) {\n      frag.appendChild(el.firstChild);\n    }\n    range.insertNode(frag);\n  };\n});\n"
  },
  {
    "path": "src/tests/frontend/specs/easysync-follow.js",
    "content": "'use strict';\n\nconst Changeset = require('../../../static/js/Changeset');\nconst AttributePool = require('../../../static/js/AttributePool');\nconst {randomMultiline, randomTestChangeset} = require('../easysync-helper.js');\n\ndescribe('easysync-follow', function () {\n  describe('follow & compose', function () {\n    const testFollow = (randomSeed) => {\n      it(`testFollow#${randomSeed}`, async function () {\n        const p = new AttributePool();\n\n        const startText = `${randomMultiline(10, 20)}\\n`;\n\n        const cs1 = randomTestChangeset(startText)[0];\n        const cs2 = randomTestChangeset(startText)[0];\n\n        const afb = Changeset.checkRep(Changeset.follow(cs1, cs2, false, p));\n        const bfa = Changeset.checkRep(Changeset.follow(cs2, cs1, true, p));\n\n        const merge1 = Changeset.checkRep(Changeset.compose(cs1, afb));\n        const merge2 = Changeset.checkRep(Changeset.compose(cs2, bfa));\n\n        expect(merge2).to.equal(merge1);\n      });\n    };\n\n    for (let i = 0; i < 30; i++) testFollow(i);\n  });\n\n  describe('followAttributes & composeAttributes', function () {\n    const p = new AttributePool();\n    p.putAttrib(['x', '']);\n    p.putAttrib(['x', 'abc']);\n    p.putAttrib(['x', 'def']);\n    p.putAttrib(['y', '']);\n    p.putAttrib(['y', 'abc']);\n    p.putAttrib(['y', 'def']);\n    let n = 0;\n\n    const testFollow = (a, b, afb, bfa, merge) => {\n      it(`manual #${++n}`, async function () {\n        expect(Changeset.exportedForTestingOnly.followAttributes(a, b, p)).to.equal(afb);\n        expect(Changeset.exportedForTestingOnly.followAttributes(b, a, p)).to.equal(bfa);\n        expect(Changeset.composeAttributes(a, afb, true, p)).to.equal(merge);\n        expect(Changeset.composeAttributes(b, bfa, true, p)).to.equal(merge);\n      });\n    };\n\n    testFollow('', '', '', '', '');\n    testFollow('*0', '', '', '*0', '*0');\n    testFollow('*0', '*0', '', '', '*0');\n    testFollow('*0', '*1', '', '*0', '*0');\n    testFollow('*1', '*2', '', '*1', '*1');\n    testFollow('*0*1', '', '', '*0*1', '*0*1');\n    testFollow('*0*4', '*2*3', '*3', '*0', '*0*3');\n    testFollow('*0*4', '*2', '', '*0*4', '*0*4');\n  });\n\n  describe('chracterRangeFollow', function () {\n    const testCharacterRangeFollow = (testId, cs, oldRange, insertionsAfter, correctNewRange) => {\n      it(`testCharacterRangeFollow#${testId}`, async function () {\n        cs = Changeset.checkRep(cs);\n        expect(Changeset.characterRangeFollow(cs, oldRange[0], oldRange[1], insertionsAfter))\n            .to.eql(correctNewRange);\n      });\n    };\n\n    testCharacterRangeFollow(1, 'Z:z>9*0=1=4-3+9=1|1-4-4+1*0+a$123456789abcdefghijk',\n        [7, 10], false, [14, 15]);\n    testCharacterRangeFollow(2, 'Z:bc<6|x=b4|2-6$', [400, 407], false, [400, 401]);\n    testCharacterRangeFollow(3, 'Z:4>0-3+3$abc', [0, 3], false, [3, 3]);\n    testCharacterRangeFollow(4, 'Z:4>0-3+3$abc', [0, 3], true, [0, 0]);\n    testCharacterRangeFollow(5, 'Z:5>1+1=1-3+3$abcd', [1, 4], false, [5, 5]);\n    testCharacterRangeFollow(6, 'Z:5>1+1=1-3+3$abcd', [1, 4], true, [2, 2]);\n    testCharacterRangeFollow(7, 'Z:5>1+1=1-3+3$abcd', [0, 6], false, [1, 7]);\n    testCharacterRangeFollow(8, 'Z:5>1+1=1-3+3$abcd', [0, 3], false, [1, 2]);\n    testCharacterRangeFollow(9, 'Z:5>1+1=1-3+3$abcd', [2, 5], false, [5, 6]);\n    testCharacterRangeFollow(10, 'Z:2>1+1$a', [0, 0], false, [1, 1]);\n    testCharacterRangeFollow(11, 'Z:2>1+1$a', [0, 0], true, [0, 0]);\n  });\n});\n"
  },
  {
    "path": "src/tests/frontend/specs/helper.js",
    "content": "'use strict';\n\ndescribe('the test helper', function () {\n  describe('the newPad method', function () {\n    xit(\"doesn't leak memory if you creates iframes over and over again\", async function () {\n      this.timeout(100000);\n      for (let i = 0; i < 10; ++i) await helper.aNewPad();\n    });\n\n    xit('gives me 3 jquery instances of chrome, outer and inner', async function () {\n      this.timeout(10000);\n      await helper.aNewPad();\n      // check if the jquery selectors have the desired elements\n      expect(helper.padChrome$('#editbar').length).to.be(1);\n      expect(helper.padOuter$('#outerdocbody').length).to.be(1);\n      expect(helper.padInner$('#innerdocbody').length).to.be(1);\n      // check if the document object was set correctly\n      expect(helper.padChrome$.window.document).to.be(helper.padChrome$.document);\n      expect(helper.padOuter$.window.document).to.be(helper.padOuter$.document);\n      expect(helper.padInner$.window.document).to.be(helper.padInner$.document);\n    });\n\n    // Make sure the cookies are cleared, and make sure that the cookie\n    // clearing has taken effect at this point in the code. It has been\n    // observed that the former can happen without the latter if there\n    // isn't a timeout (within `newPad`) after clearing the cookies.\n    // However this doesn't seem to always be easily replicated, so this\n    // timeout may or may end up in the code. None the less, we test here\n    // to catch it if the bug comes up again.\n    xit('clears cookies', async function () {\n      // set cookies far into the future to make sure they're not expired yet\n      window.Cookies.set('token', 'foo', {expires: 7 /* days */});\n      window.Cookies.set('language', 'bar', {expires: 7 /* days */});\n\n      expect(window.document.cookie).to.contain('token=foo');\n      expect(window.document.cookie).to.contain('language=bar');\n\n      await helper.aNewPad();\n\n      // helper function seems to have cleared cookies\n      // NOTE: this doesn't yet mean it's proven to have taken effect by this point in execution\n      const firstCookie = window.document.cookie;\n      expect(window.Cookies.get('token')).to.not.be('foo');\n      expect(window.Cookies.get('language') == null).to.be(true);\n\n      let chrome$ = helper.padChrome$;\n\n      // click on the settings button to make settings visible\n      let $userButton = chrome$('.buttonicon-showusers');\n      $userButton.trigger('click');\n\n      let $usernameInput = chrome$('#myusernameedit');\n      $usernameInput.trigger('click');\n\n      $usernameInput.val('John McLear');\n      $usernameInput.trigger('blur');\n\n      // Before refreshing, make sure the name is there\n      expect($usernameInput.val()).to.be('John McLear');\n\n      // Now that we have a chrome, we can set a pad cookie\n      // so we can confirm it gets wiped as well\n      const getPadcookie =\n          () => helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_cookie').padcookie;\n      let padcookie = getPadcookie();\n      padcookie.clear();\n      padcookie.setPref('foo', 'bar');\n      expect(padcookie.getPref('foo')).to.be('bar');\n\n      // give it a second to save the username on the server side\n      await new Promise((resolve) => setTimeout(resolve, 1000));\n\n      await helper.aNewPad(); // get a new pad, let it clear the cookies\n      chrome$ = helper.padChrome$;\n      padcookie = getPadcookie();\n\n      // helper function seems to have cleared cookies\n      // NOTE: this doesn't yet mean cookies were cleared effectively.\n      // We still need to test below that we're in a new session\n      expect(window.Cookies.get('token')).to.not.be('foo');\n      expect(window.Cookies.get('language') == null).to.be(true);\n      expect(padcookie.getPref('foo') == null).to.be(true);\n\n      expect(window.document.cookie).to.not.be(firstCookie);\n\n      // click on the settings button to make settings visible\n      $userButton = chrome$('.buttonicon-showusers');\n      $userButton.trigger('click');\n\n      // confirm that the session was actually cleared\n      $usernameInput = chrome$('#myusernameedit');\n      expect($usernameInput.val()).to.be('');\n    });\n\n    it('sets pad prefs cookie', async function () {\n      await helper.aNewPad({padPrefs: {foo: 'padPrefs test'}});\n      const {padcookie} = helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_cookie');\n      expect(padcookie.getPref('foo')).to.be('padPrefs test');\n    });\n  });\n\n  describe('the waitFor method', function () {\n    it('takes a timeout and waits long enough', function (done) {\n      this.timeout(2000);\n      const startTime = Date.now();\n\n      helper.waitFor(() => false, 1500).fail(() => {\n        const duration = Date.now() - startTime;\n        expect(duration).to.be.greaterThan(1490);\n        done();\n      });\n    });\n\n    it('takes an interval and checks on every interval', function (done) {\n      this.timeout(4000);\n      let checks = 0;\n\n      helper.waitFor(() => {\n        checks++;\n        return false;\n      }, 2000, 100).fail(() => {\n        // One at the beginning, and 19-20 more depending on whether it's the timeout or the final\n        // poll that wins at 2000ms.\n        expect(checks).to.be.greaterThan(15);\n        expect(checks).to.be.lessThan(24);\n        done();\n      });\n    });\n\n    it('rejects if the predicate throws', async function () {\n      let err;\n      await helper.waitFor(() => { throw new Error('test exception'); })\n          .fail(() => {}) // Suppress the redundant uncatchable exception.\n          .catch((e) => { err = e; });\n      expect(err).to.be.an(Error);\n      expect(err.message).to.be('test exception');\n    });\n\n    describe('returns a deferred object', function () {\n      it('it calls done after success', function (done) {\n        helper.waitFor(() => true).done(() => {\n          done();\n        });\n      });\n\n      it('calls fail after failure', function (done) {\n        helper.waitFor(() => false, 0).fail(() => {\n          done();\n        });\n      });\n\n      xit(\"throws if you don't listen for fails\", function (done) {\n        const onerror = window.onerror;\n        window.onerror = function () {\n          window.onerror = onerror;\n          done();\n        };\n\n        helper.waitFor(() => false, 100);\n      });\n    });\n\n    describe('checks first then sleeps', function () {\n      it('resolves quickly if the predicate is immediately true', async function () {\n        const before = Date.now();\n        await helper.waitFor(() => true, 1000, 900);\n        expect(Date.now() - before).to.be.lessThan(800);\n      });\n\n      xit('polls exactly once if timeout < interval', async function () {\n        let calls = 0;\n        await helper.waitFor(() => { calls++; }, 1, 1000)\n            .fail(() => {}) // Suppress the redundant uncatchable exception.\n            .catch(() => {}); // Don't throw an exception -- we know it rejects.\n        expect(calls).to.be(1);\n      });\n\n      it('resolves if condition is immediately true even if timeout is 0', async function () {\n        await helper.waitFor(() => true, 0);\n      });\n    });\n\n    it('accepts async functions', async function () {\n      await helper.waitFor(async () => true).fail(() => {});\n      // Make sure it checks the truthiness of the Promise's resolved value, not the truthiness of\n      // the Promise itself (a Promise is always truthy).\n      let ok = false;\n      try {\n        await helper.waitFor(async () => false, 0).fail(() => {});\n      } catch (err) {\n        ok = true;\n      }\n      expect(ok).to.be(true);\n    });\n  });\n\n  describe('the waitForPromise method', function () {\n    it('returns a Promise', async function () {\n      expect(helper.waitForPromise(() => true)).to.be.a(Promise);\n    });\n\n    it('takes a timeout and waits long enough', async function () {\n      this.timeout(2000);\n      const startTime = Date.now();\n      let rejected;\n      await helper.waitForPromise(() => false, 1500)\n          .catch(() => { rejected = true; });\n      expect(rejected).to.be(true);\n      const duration = Date.now() - startTime;\n      expect(duration).to.be.greaterThan(1490);\n    });\n\n    it('takes an interval and checks on every interval', async function () {\n      this.timeout(4000);\n      let checks = 0;\n      let rejected;\n      await helper.waitForPromise(() => { checks++; return false; }, 2000, 100)\n          .catch(() => { rejected = true; });\n      expect(rejected).to.be(true);\n      // `checks` is expected to be 20 or 21: one at the beginning, plus 19 or 20 more depending on\n      // whether it's the timeout or the final poll that wins at 2000ms. Margin is added to reduce\n      // flakiness on slow test machines.\n      expect(checks).to.be.greaterThan(15);\n      expect(checks).to.be.lessThan(24);\n    });\n  });\n\n  describe('the selectLines method', function () {\n    // function to support tests, use a single way to represent whitespaces\n    const cleanText = function (text) {\n      return text\n      // IE replaces line breaks with a whitespace, so we need to unify its behavior\n      // for other browsers, to have all tests running for all browsers\n          .replace(/\\n/gi, '')\n          .replace(/\\s/gi, ' ');\n    };\n\n    before(async function () {\n      await helper.aNewPad();\n\n      // create some lines to be used on the tests\n      const $firstLine = helper.padInner$('div').first();\n      $firstLine.sendkeys('{selectall}some{enter}short{enter}lines{enter}to test{enter}{enter}');\n\n      // wait for lines to be split\n      await helper.waitForPromise(() => {\n        const $fourthLine = helper.padInner$('div').eq(3);\n        return $fourthLine.text() === 'to test';\n      });\n    });\n\n    xit('changes editor selection to be between startOffset of $startLine ' +\n        'and endOffset of $endLine', function (done) {\n      const inner$ = helper.padInner$;\n\n      const startOffset = 2;\n      const endOffset = 4;\n\n      const $lines = inner$('div');\n      const $startLine = $lines.eq(1);\n      const $endLine = $lines.eq(3);\n\n      helper.selectLines($startLine, $endLine, startOffset, endOffset);\n\n      const selection = inner$.document.getSelection();\n\n      /*\n       * replace() is required here because Firefox keeps the line breaks.\n       *\n       * I'm not sure this is ideal behavior of getSelection() where the text\n       * is not consistent between browsers but that's the situation so that's\n       * how I'm covering it in this test.\n       */\n      expect(cleanText(selection.toString().replace(/(\\r\\n|\\n|\\r)/gm, ''))).to.be('ort lines to t');\n\n      done();\n    });\n\n    it('ends selection at beginning of $endLine when it is an empty line', function (done) {\n      const inner$ = helper.padInner$;\n\n      const startOffset = 2;\n      const endOffset = 1;\n\n      const $lines = inner$('div');\n      const $startLine = $lines.eq(1);\n      const $endLine = $lines.eq(4);\n\n      helper.selectLines($startLine, $endLine, startOffset, endOffset);\n\n      const selection = inner$.document.getSelection();\n\n      /*\n       * replace() is required here because Firefox keeps the line breaks.\n       *\n       * I'm not sure this is ideal behavior of getSelection() where the text\n       * is not consistent between browsers but that's the situation so that's\n       * how I'm covering it in this test.\n       */\n      expect(cleanText(\n          selection.toString().replace(/(\\r\\n|\\n|\\r)/gm, ''))).to.be('ort lines to test');\n\n      done();\n    });\n\n    it('ends selection at beginning of $endLine when its offset is zero', async function () {\n      const inner$ = helper.padInner$;\n\n      const startOffset = 2;\n      const endOffset = 0;\n\n      const $lines = inner$('div');\n      const $startLine = $lines.eq(1);\n      const $endLine = $lines.eq(3);\n\n      helper.selectLines($startLine, $endLine, startOffset, endOffset);\n\n      const selection = inner$.document.getSelection();\n\n      /*\n       * replace() is required here because Firefox keeps the line breaks.\n       *\n       * I'm not sure this is ideal behavior of getSelection() where the text\n       * is not consistent between browsers but that's the situation so that's\n       * how I'm covering it in this test.\n       */\n      expect(cleanText(selection.toString().replace(/(\\r\\n|\\n|\\r)/gm, ''))).to.be('ort lines ');\n    });\n\n    it('selects full line when offset is longer than line content', function (done) {\n      const inner$ = helper.padInner$;\n\n      const startOffset = 2;\n      const endOffset = 50;\n\n      const $lines = inner$('div');\n      const $startLine = $lines.eq(1);\n      const $endLine = $lines.eq(3);\n\n      helper.selectLines($startLine, $endLine, startOffset, endOffset);\n\n      const selection = inner$.document.getSelection();\n\n      /*\n       * replace() is required here because Firefox keeps the line breaks.\n       *\n       * I'm not sure this is ideal behavior of getSelection() where the text\n       * is not consistent between browsers but that's the situation so that's\n       * how I'm covering it in this test.\n       */\n      expect(cleanText(\n          selection.toString().replace(/(\\r\\n|\\n|\\r)/gm, ''))).to.be('ort lines to test');\n\n      done();\n    });\n\n    it('selects all text between beginning of $startLine and end of $endLine ' +\n        'when no offset is provided', async function () {\n      const inner$ = helper.padInner$;\n\n      const $lines = inner$('div');\n      const $startLine = $lines.eq(1);\n      const $endLine = $lines.eq(3);\n\n      helper.selectLines($startLine, $endLine);\n\n      const selection = inner$.document.getSelection();\n\n      /*\n       * replace() is required here because Firefox keeps the line breaks.\n       *\n       * I'm not sure this is ideal behavior of getSelection() where the text\n       * is not consistent between browsers but that's the situation so that's\n       * how I'm covering it in this test.\n       */\n      expect(cleanText(\n          selection.toString().replace(/(\\r\\n|\\n|\\r)/gm, ''))).to.be('short lines to test');\n    });\n  });\n\n  describe('helper', function () {\n    before(async function () {\n      await helper.aNewPad();\n    });\n\n    it('.textLines() returns the text of the pad as strings', async function () {\n      const lines = helper.textLines();\n      const defaultText = helper.defaultText();\n      expect(Array.isArray(lines)).to.be(true);\n      expect(lines[0]).to.be.an('string');\n      // @todo\n      // final \"\\n\" is added automatically, but my understanding is this should happen\n      // only when the default text does not end with \"\\n\" already\n      expect(`${lines.join('\\n')}\\n`).to.equal(defaultText);\n    });\n\n    it('.linesDiv() returns the text of the pad as div elements', async function () {\n      const lines = helper.linesDiv();\n      const defaultText = helper.defaultText();\n      expect(Array.isArray(lines)).to.be(true);\n      expect(lines[0]).to.be.an('object');\n      expect(lines[0].text()).to.be.an('string');\n      _.each(defaultText.split('\\n'), (line, index) => {\n        // last line of default text\n        if (index === lines.length) {\n          expect(line).to.equal('');\n        } else {\n          expect(lines[index].text()).to.equal(line);\n        }\n      });\n    });\n\n    xit('.edit() defaults to send an edit to the first line', async function () {\n      const firstLine = helper.textLines()[0];\n      await helper.edit('line');\n      expect(helper.textLines()[0]).to.be(`line${firstLine}`);\n    });\n\n    xit('.edit() to the line specified with parameter lineNo', async function () {\n      const firstLine = helper.textLines()[0];\n      await helper.edit('second line', 2);\n\n      const text = helper.textLines();\n      expect(text[0]).to.equal(firstLine);\n      expect(text[1]).to.equal('second line');\n    });\n\n    xit('.edit() supports sendkeys syntax ({selectall},{del},{enter})', async function () {\n      expect(helper.textLines()[0]).to.not.equal('');\n\n      // select first line\n      helper.linesDiv()[0].sendkeys('{selectall}');\n      // delete first line\n      await helper.edit('{del}');\n\n      expect(helper.textLines()[0]).to.be('');\n      const noOfLines = helper.textLines().length;\n      await helper.edit('{enter}');\n      expect(helper.textLines().length).to.be(noOfLines + 1);\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/frontend/specs/importexport.js",
    "content": "'use strict';\n\ndescribe('importexport.js', function () {\n  const testCases = [\n    {\n      name: 'text with newlines',\n      inputText: [\n        'imported text\\n',\n        'newline',\n      ].join(''),\n      wantPadLines: [\n        '<span class=\"\">imported text</span>',\n        '<span class=\"\">newline</span>',\n      ],\n      wantExportHtmlBody: [\n        'imported text<br>',\n        'newline<br>',\n      ].join(''),\n      wantExportText: [\n        'imported text\\n',\n        'newline\\n',\n      ].join(''),\n    },\n    {\n      name: 'HTML with newlines',\n      inputHtmlBody: [\n        'htmltext<br>',\n        'newline',\n      ].join(''),\n      wantPadLines: [\n        '<span class=\"\">htmltext</span>',\n        '<span class=\"\">newline</span>',\n        '<br>',\n      ],\n      wantExportHtmlBody: [\n        'htmltext<br>',\n        'newline<br>',\n        '<br>',\n      ].join(''),\n      wantExportText: [\n        'htmltext\\n',\n        'newline\\n',\n        '\\n',\n      ].join(''),\n    },\n    {\n      name: 'HTML with attributes',\n      inputHtmlBody: [\n        'htmltext<br>',\n        '<span class=\"b s i u\"><b><i><s><u>newline</u></s></i></b>',\n      ].join(''),\n      wantPadLines: [\n        '<span class=\"\">htmltext</span>',\n        '<span class=\"b i s u\"><b><i><s><u>newline</u></s></i></b></span>',\n        '<br>',\n      ],\n      wantExportHtmlBody: [\n        'htmltext<br>',\n        '<strong><em><s><u>newline</u></s></em></strong><br>',\n        '<br>',\n      ].join(''),\n      wantExportText: [\n        'htmltext\\n',\n        'newline\\n',\n        '\\n',\n      ].join(''),\n    },\n    {\n      name: 'HTML with bullets',\n      inputHtmlBody: [\n        '<ul class=\"list-bullet1\">',\n        ' <li>bullet line 1</li>',\n        ' <li>bullet line 2',\n        '  <ul class=\"list-bullet2\">',\n        '   <li>bullet2 line 1</li>',\n        '   <li>bullet2 line 2</li>',\n        '  </ul>',\n        ' </li>',\n        '</ul>',\n      ].join(''),\n      wantPadLines: [\n        '<ul class=\"list-bullet1\"><li><span class=\"\">bullet line 1</span></li></ul>',\n        '<ul class=\"list-bullet1\"><li><span class=\"\">bullet line 2</span></li></ul>',\n        '<ul class=\"list-bullet2\"><li><span class=\"\">bullet2 line 1</span></li></ul>',\n        '<ul class=\"list-bullet2\"><li><span class=\"\">bullet2 line 2</span></li></ul>',\n        '<br>',\n      ],\n      wantExportHtmlBody: [\n        '<ul class=bullet>',\n        ' <li>bullet line 1</li>',\n        ' <li>bullet line 2',\n        '  <ul class=bullet>',\n        '   <li>bullet2 line 1</li>',\n        '   <li>bullet2 line 2</li>',\n        '  </ul>',\n        ' </li>',\n        '</ul>',\n        '<br>',\n      ].map((l) => l.replace(/^\\s+/, '')).join(''),\n      wantExportText: [\n        '\\t* bullet line 1\\n',\n        '\\t* bullet line 2\\n',\n        '\\t\\t* bullet2 line 1\\n',\n        '\\t\\t* bullet2 line 2\\n',\n        '\\n',\n      ].join(''),\n    },\n    {\n      name: 'HTML with bullets and newlines',\n      inputHtmlBody: [\n        '<ul class=\"list-bullet1\">',\n        ' <li>bullet line 1</li>',\n        '</ul>',\n        '<br>',\n        '<ul class=\"list-bullet1\">',\n        ' <li>bullet line 2',\n        '  <ul class=\"list-bullet2\">',\n        '   <li>bullet2 line 1</li>',\n        '  </ul>',\n        ' </li>',\n        '</ul>',\n        '<br>',\n        '<ul class=\"list-bullet1\">',\n        ' <li>',\n        '  <ul class=\"list-bullet2\">',\n        '   <li>bullet2 line 2</li>',\n        '  </ul>',\n        ' </li>',\n        '</ul>',\n      ].join(''),\n      wantPadLines: [\n        '<ul class=\"list-bullet1\"><li><span class=\"\">bullet line 1</span></li></ul>',\n        '<br>',\n        '<ul class=\"list-bullet1\"><li><span class=\"\">bullet line 2</span></li></ul>',\n        '<ul class=\"list-bullet2\"><li><span class=\"\">bullet2 line 1</span></li></ul>',\n        '<br>',\n        '<ul class=\"list-bullet2\"><li><span class=\"\">bullet2 line 2</span></li></ul>',\n        '<br>',\n      ],\n      wantExportHtmlBody: [\n        '<ul class=bullet>',\n        ' <li>bullet line 1</li>',\n        '</ul>',\n        '<br>',\n        '<ul class=bullet>',\n        ' <li>bullet line 2',\n        '  <ul class=bullet>',\n        '   <li>bullet2 line 1</li>',\n        '  </ul>',\n        ' </li>',\n        '</ul>',\n        '<br>',\n        '<ul class=bullet>',\n        ' <li>',\n        '  <ul class=bullet>',\n        '   <li>bullet2 line 2</li>',\n        '  </ul>',\n        ' </li>',\n        '</ul>',\n        '<br>',\n      ].map((l) => l.replace(/^\\s+/, '')).join(''),\n      wantExportText: [\n        '\\t* bullet line 1\\n',\n        '\\n',\n        '\\t* bullet line 2\\n',\n        '\\t\\t* bullet2 line 1\\n',\n        '\\n',\n        '\\t\\t* bullet2 line 2\\n',\n        '\\n',\n      ].join(''),\n    },\n    {\n      name: 'HTML with bullets, newlines, and attributes',\n      inputHtmlBody: [\n        '<ul class=\"list-bullet1\">',\n        ' <li>bullet line 1</li>',\n        '</ul>',\n        '<br>',\n        '<ul class=\"list-bullet1\">',\n        ' <li>bullet line 2',\n        '  <ul class=\"list-bullet2\">',\n        '   <li>bullet2 line 1</li>',\n        '  </ul>',\n        ' </li>',\n        '</ul>',\n        '<br>',\n        '<ul class=\"list-bullet1\">',\n        ' <li>',\n        '  <ul class=\"list-bullet2\">',\n        '   <li>',\n        '    <ul class=\"list-bullet3\">',\n        '     <li>',\n        '      <ul class=\"list-bullet4\">',\n        '       <li><span class=\"b s i u\"><b><i><s><u>bullet4 line 2 bisu</u>' +\n                   '</s></i></b></span></li>',\n        '       <li><span class=\"b s \"><b><s>bullet4 line 2 bs</s></b></span></li>',\n        '       <li><span class=\"u\"><u>bullet4 line 2 u</u></span>' +\n                   '<span class=\"u i s\"><i><s><u>uis</u></s></i></span></li>',\n        '      </ul>',\n        '     </li>',\n        '    </ul>',\n        '   </li>',\n        '  </ul>',\n        ' </li>',\n        '</ul>',\n      ].join(''),\n      wantPadLines: [\n        '<ul class=\"list-bullet1\"><li><span class=\"\">bullet line 1</span></li></ul>',\n        '<br>',\n        '<ul class=\"list-bullet1\"><li><span class=\"\">bullet line 2</span></li></ul>',\n        '<ul class=\"list-bullet2\"><li><span class=\"\">bullet2 line 1</span></li></ul>',\n        '<br>',\n        '<ul class=\"list-bullet4\"><li><span class=\"b i s u\"><b><i><s><u>' +\n            'bullet4 line 2 bisu</u></s></i></b></span></li></ul>',\n        '<ul class=\"list-bullet4\"><li><span class=\"b s\"><b><s>bullet4 line 2 bs</s>' +\n            '</b></span></li></ul>',\n        '<ul class=\"list-bullet4\"><li><span class=\"u\"><u>bullet4 line 2 u</u></span>' +\n            '<span class=\"i s u\"><i><s><u>uis</u></s></i></span></li></ul>',\n        '<br>',\n      ],\n      wantExportHtmlBody: [\n        '<ul class=bullet><li>bullet line 1</li></ul>',\n        '<br>',\n        '<ul class=bullet>',\n        ' <li>bullet line 2',\n        '  <ul class=bullet><li>bullet2 line 1</li></ul>',\n        ' </li>',\n        '</ul>',\n        '<br>',\n        '<ul class=bullet>',\n        ' <li>',\n        '  <ul class=bullet>',\n        '   <li>',\n        '    <ul class=bullet>',\n        '     <li>',\n        '      <ul class=bullet>',\n        '       <li><strong><em><s><u>bullet4 line 2 bisu</u></s></em></strong></li>',\n        '       <li><strong><s>bullet4 line 2 bs</s></strong></li>',\n        '       <li><u>bullet4 line 2 u<em><s>uis</s></em></u></li>',\n        '      </ul>',\n        '     </li>',\n        '    </ul>',\n        '   </li>',\n        '  </ul>',\n        ' </li>',\n        '</ul>',\n        '<br>',\n      ].map((l) => l.replace(/^\\s+/, '')).join(''),\n      wantExportText: [\n        '\\t* bullet line 1\\n',\n        '\\n',\n        '\\t* bullet line 2\\n',\n        '\\t\\t* bullet2 line 1\\n',\n        '\\n',\n        '\\t\\t\\t\\t* bullet4 line 2 bisu\\n',\n        '\\t\\t\\t\\t* bullet4 line 2 bs\\n',\n        '\\t\\t\\t\\t* bullet4 line 2 uuis\\n',\n        '\\n',\n      ].join(''),\n    },\n    {\n      name: 'HTML with nested bullets',\n      inputHtmlBody: [\n        '<ul class=\"list-bullet1\"><li>bullet line 1</li></ul>',\n        '<ul class=\"list-bullet1\">',\n        ' <li>bullet line 2',\n        '  <ul class=\"list-bullet2\">',\n        '   <li>bullet2 line 1</li>',\n        '  </ul>',\n        ' </li>',\n        '</ul>',\n        '<ul class=\"list-bullet1\">',\n        ' <li>',\n        '  <ul class=\"list-bullet2\">',\n        '   <li>',\n        '    <ul class=\"list-bullet3\">',\n        '     <li>',\n        '      <ul class=\"list-bullet4\">',\n        '       <li>bullet4 line 2</li>',\n        '       <li>bullet4 line 2</li>',\n        '       <li>bullet4 line 2</li>',\n        '      </ul>',\n        '     </li>',\n        '     <li>bullet3 line 1</li>',\n        '    </ul>',\n        '   </li>',\n        '  </ul>',\n        ' </li>',\n        '</ul>',\n      ].join(''),\n      wantPadLines: [\n        '<ul class=\"list-bullet1\"><li><span class=\"\">bullet line 1</span></li></ul>',\n        '<ul class=\"list-bullet1\"><li><span class=\"\">bullet line 2</span></li></ul>',\n        '<ul class=\"list-bullet2\"><li><span class=\"\">bullet2 line 1</span></li></ul>',\n        '<ul class=\"list-bullet4\"><li><span class=\"\">bullet4 line 2</span></li></ul>',\n        '<ul class=\"list-bullet4\"><li><span class=\"\">bullet4 line 2</span></li></ul>',\n        '<ul class=\"list-bullet4\"><li><span class=\"\">bullet4 line 2</span></li></ul>',\n        '<ul class=\"list-bullet3\"><li><span class=\"\">bullet3 line 1</span></li></ul>',\n        '<br>',\n      ],\n      wantExportHtmlBody: [\n        '<ul class=bullet>',\n        ' <li>bullet line 1</li>',\n        ' <li>bullet line 2',\n        '  <ul class=bullet>',\n        '   <li>bullet2 line 1',\n        '    <ul class=bullet>',\n        '     <li>',\n        '      <ul class=bullet>',\n        '       <li>bullet4 line 2</li>',\n        '       <li>bullet4 line 2</li>',\n        '       <li>bullet4 line 2</li>',\n        '      </ul>',\n        '     </li>',\n        '     <li>bullet3 line 1</li>',\n        '    </ul>',\n        '   </li>',\n        '  </ul>',\n        ' </li>',\n        '</ul>',\n        '<br>',\n      ].map((l) => l.replace(/^\\s+/, '')).join(''),\n      wantExportText: [\n        '\\t* bullet line 1\\n',\n        '\\t* bullet line 2\\n',\n        '\\t\\t* bullet2 line 1\\n',\n        '\\t\\t\\t\\t* bullet4 line 2\\n',\n        '\\t\\t\\t\\t* bullet4 line 2\\n',\n        '\\t\\t\\t\\t* bullet4 line 2\\n',\n        '\\t\\t\\t* bullet3 line 1\\n',\n        '\\n',\n      ].join(''),\n    },\n    {\n      name: 'HTML with 8 levels of bullets, newlines, and attributes',\n      inputHtmlBody: [\n        '<ul class=\"list-bullet1\">',\n        ' <li>bullet line 1</li>',\n        '</ul>',\n        '<br>',\n        '<ul class=\"list-bullet1\">',\n        ' <li>bullet line 2',\n        '  <ul class=\"list-bullet2\">',\n        '   <li>bullet2 line 1</li>',\n        '  </ul>',\n        ' </li>',\n        '</ul>',\n        '<br>',\n        '<ul class=\"list-bullet1\">',\n        ' <li>',\n        '  <ul class=\"list-bullet2\">',\n        '   <li>',\n        '    <ul class=\"list-bullet3\">',\n        '     <li>',\n        '      <ul class=\"list-bullet4\">',\n        '       <li><span class=\"b s i u\"><b><i><s><u>bullet4 line 2 bisu' +\n                   '</u></s></i></b></span></li>',\n        '       <li><span class=\"b s \"><b><s>bullet4 line 2 bs</s></b></span></li>',\n        '       <li><span class=\"u\"><u>bullet4 line 2 u</u></span>' +\n                   '<span class=\"u i s\"><i><s><u>uis</u></s></i></span></li>',\n        '       <li>',\n        '        <ul class=\"list-bullet5\">',\n        '         <li>',\n        '          <ul class=\"list-bullet6\">',\n        '           <li>',\n        '            <ul class=\"list-bullet7\">',\n        '             <li>',\n        '              <ul class=\"list-bullet8\">',\n        '               <li><span class=\"\">foo</span></li>',\n        '               <li><span class=\"b s\"><b><s>foobar bs</b></s></span></li>',\n        '              </ul>',\n        '             </li>',\n        '            </ul>',\n        '           </li>',\n        '          </ul>',\n        '         </li>',\n        '        </ul>',\n        '       </li>',\n        '       <li>',\n        '        <ul class=\"list-bullet5\">',\n        '         <li>foobar</li>',\n        '        </ul>',\n        '       </li>',\n        '      </ul>',\n        '     </li>',\n        '    </ul>',\n        '   </li>',\n        '  </ul>',\n        ' </li>',\n        '</ul>',\n      ].join(''),\n      wantPadLines: [\n        '<ul class=\"list-bullet1\"><li><span class=\"\">bullet line 1</span></li></ul>',\n        '<br>',\n        '<ul class=\"list-bullet1\"><li><span class=\"\">bullet line 2</span></li></ul>',\n        '<ul class=\"list-bullet2\"><li><span class=\"\">bullet2 line 1</span></li></ul>',\n        '<br>',\n        '<ul class=\"list-bullet4\"><li><span class=\"b i s u\"><b><i><s><u>' +\n            'bullet4 line 2 bisu</u></s></i></b></span></li></ul>',\n        '<ul class=\"list-bullet4\"><li><span class=\"b s\"><b><s>bullet4 line 2 bs</s>' +\n            '</b></span></li></ul>',\n        '<ul class=\"list-bullet4\"><li><span class=\"u\"><u>bullet4 line 2 u</u></span>' +\n            '<span class=\"i s u\"><i><s><u>uis</u></s></i></span></li></ul>',\n        '<ul class=\"list-bullet8\"><li><span class=\"\">foo</span></li></ul>',\n        '<ul class=\"list-bullet8\"><li><span class=\"b s\"><b><s>foobar bs</s></b></span></li></ul>',\n        '<ul class=\"list-bullet5\"><li><span class=\"\">foobar</span></li></ul>',\n        '<br>',\n      ],\n      wantExportHtmlBody: [\n        '<ul class=bullet>',\n        ' <li>bullet line 1</li>',\n        '</ul>',\n        '<br>',\n        '<ul class=bullet>',\n        ' <li>bullet line 2',\n        '  <ul class=bullet>',\n        '   <li>bullet2 line 1</li>',\n        '  </ul>',\n        ' </li>',\n        '</ul>',\n        '<br>',\n        '<ul class=bullet>',\n        ' <li>',\n        '  <ul class=bullet>',\n        '   <li>',\n        '    <ul class=bullet>',\n        '     <li>',\n        '      <ul class=bullet>',\n        '       <li><strong><em><s><u>bullet4 line 2 bisu</u></s></em></strong></li>',\n        '       <li><strong><s>bullet4 line 2 bs</s></strong></li>',\n        '       <li><u>bullet4 line 2 u<em><s>uis</s></em></u>',\n        '        <ul class=bullet>',\n        '         <li>',\n        '          <ul class=bullet>',\n        '           <li>',\n        '            <ul class=bullet>',\n        '             <li>',\n        '              <ul class=bullet>',\n        '               <li>foo</li>',\n        '               <li><strong><s>foobar bs</s></strong></li>',\n        '              </ul>',\n        '             </li>',\n        '            </ul>',\n        '           </li>',\n        '          </ul>',\n        '         </li>',\n        '         <li>foobar</li>',\n        '        </ul>',\n        '       </li>',\n        '      </ul>',\n        '     </li>',\n        '    </ul>',\n        '   </li>',\n        '  </ul>',\n        ' </li>',\n        '</ul>',\n        '<br>',\n      ].map((l) => l.replace(/^\\s+/, '')).join(''),\n      wantExportText: [\n        '\\t* bullet line 1\\n',\n        '\\n',\n        '\\t* bullet line 2\\n',\n        '\\t\\t* bullet2 line 1\\n',\n        '\\n',\n        '\\t\\t\\t\\t* bullet4 line 2 bisu\\n',\n        '\\t\\t\\t\\t* bullet4 line 2 bs\\n',\n        '\\t\\t\\t\\t* bullet4 line 2 uuis\\n',\n        '\\t\\t\\t\\t\\t\\t\\t\\t* foo\\n',\n        '\\t\\t\\t\\t\\t\\t\\t\\t* foobar bs\\n',\n        '\\t\\t\\t\\t\\t* foobar\\n',\n        '\\n',\n      ].join(''),\n    },\n    {\n      name: 'HTML with ordered lists',\n      inputHtmlBody: [\n        '<ol class=\"list-number1\" start=\"1\"><li>number 1 line 1</li></ol>',\n        '<ol class=\"list-number1\" start=\"2\"><li>number 2 line 2</li></ol>',\n      ].join(''),\n      wantPadLines: [\n        '<ol start=\"1\" class=\"list-number1\"><li><span class=\"\">number 1 line 1</span></li></ol>',\n        '<ol start=\"2\" class=\"list-number1\"><li><span class=\"\">number 2 line 2</span></li></ol>',\n        '<br>',\n      ],\n      wantExportHtmlBody: [\n        '<ol start=1 class=number>',\n        ' <li>number 1 line 1</li>',\n        ' <li>number 2 line 2</li>',\n        '</ol>',\n        '<br>',\n      ].map((l) => l.replace(/^\\s+/, '')).join(''),\n      wantExportText: [\n        '\\t1. number 1 line 1\\n',\n        '\\t2. number 2 line 2\\n',\n        '\\n',\n      ].join(''),\n    },\n  ];\n\n  let confirm;\n  before(async function () {\n    await helper.aNewPad();\n    confirm = helper.padChrome$.window.confirm;\n    helper.padChrome$.window.confirm = () => true;\n    // As of 2021-02-22 a mutable FileList cannot be directly created so DataTransfer is used as a\n    // hack to access a mutable FileList for testing the '<input type=\"file\">' element. DataTransfer\n    // itself is quite new so support for it is tested here. See:\n    //   * https://github.com/whatwg/html/issues/3269\n    //   * https://stackoverflow.com/q/47119426\n    try {\n      const dt = new DataTransfer();\n      dt.items.add(new File(['testing'], 'file.txt', {type: 'text/plain'}));\n      // Supposedly all modern browsers support a settable HTMLInputElement.files property, but\n      // Firefox 52 complains.\n      helper.padChrome$('#importform input[type=file]')[0].files = dt.files;\n    } catch (err) {\n      return this.skip();\n    }\n  });\n\n  after(async function () {\n    helper.padChrome$.window.confirm = confirm;\n  });\n\n  beforeEach(async function () {\n    const popup = helper.padChrome$('#import_export');\n    const isVisible = () => popup.hasClass('popup-show');\n    if (isVisible()) return;\n    const button = helper.padChrome$('button[data-l10n-id=\"pad.toolbar.import_export.title\"]');\n    button.trigger('click');\n    await helper.waitForPromise(isVisible);\n  });\n\n  const docToHtml = (() => {\n    const s = new XMLSerializer();\n    return (doc) => s.serializeToString(doc);\n  })();\n\n  const htmlToDoc = (() => {\n    const p = new DOMParser();\n    return (html) => p.parseFromString(html, 'text/html');\n  })();\n\n  const htmlBodyToDoc = (htmlBody) => {\n    const doc = document.implementation.createHTMLDocument();\n    $('body', doc).html(htmlBody);\n    return doc;\n  };\n\n  for (const tc of testCases) {\n    describe(tc.name, function () {\n      it('import', async function () {\n        const ext = tc.inputHtmlBody ? 'html' : 'txt';\n        const contents = ext === 'html' ? docToHtml(htmlBodyToDoc(tc.inputHtmlBody)) : tc.inputText;\n        // DataTransfer is used as a hacky way to get a mutable FileList. For details, see:\n        // https://stackoverflow.com/q/47119426\n        const dt = new DataTransfer();\n        dt.items.add(new File([contents], `file.${ext}`, {type: 'text/plain'}));\n        const form = helper.padChrome$('#importform');\n        form.find('input[type=file]')[0].files = dt.files;\n        form.find('#importsubmitinput').trigger('submit');\n        try {\n          await helper.waitForPromise(() => {\n            const got = helper.linesDiv();\n            if (got.length !== tc.wantPadLines.length) return false;\n            for (let i = 0; i < got.length; i++) {\n              const gotDiv = $('<div>').html(got[i].html());\n              const wantDiv = $('<div>').html(tc.wantPadLines[i]);\n              if (!gotDiv[0].isEqualNode(wantDiv[0])) return false;\n            }\n            return true;\n          });\n        } catch (err) {\n          const formatLine = (l) => `  ${JSON.stringify(l)}`;\n          const g = helper.linesDiv().map((div) => formatLine(div.html())).join('\\n');\n          const w = tc.wantPadLines.map(formatLine).join('\\n');\n          throw new Error(`Import failed. Got pad lines:\\n${g}\\nWant pad lines:\\n${w}`);\n        }\n      });\n\n      it('export to HTML', async function () {\n        const link = helper.padChrome$('#exporthtmla').attr('href');\n        const url = new URL(link, helper.padChrome$.window.location.href).href;\n        const gotHtml = await $.ajax({url, dataType: 'html'});\n        const gotBody = $('body', htmlToDoc(gotHtml));\n        gotBody.html(gotBody.html().replace(/^\\s+|\\s+$/g, ''));\n        const wantBody = $('body', htmlBodyToDoc(tc.wantExportHtmlBody));\n        if (!gotBody[0].isEqualNode(wantBody[0])) {\n          throw new Error(`Got exported HTML body:\\n  ${JSON.stringify(gotBody.html())}\\n` +\n                          `Want HTML body:\\n  ${JSON.stringify(wantBody.html())}`);\n        }\n      });\n\n      it('export to text', async function () {\n        const link = helper.padChrome$('#exportplaina').attr('href');\n        const url = new URL(link, helper.padChrome$.window.location.href).href;\n        const got = await $.ajax({url, dataType: 'text'});\n        expect(got).to.be(tc.wantExportText);\n      });\n    });\n  }\n});\n"
  },
  {
    "path": "src/tests/frontend/specs/importindents.js",
    "content": "'use strict';\n\ndescribe('import indents functionality', function () {\n  beforeEach(async function () {\n    await helper.aNewPad();\n  });\n\n  const getinnertext = () => {\n    const inner = helper.padInner$;\n    let newtext = '';\n    inner('div').each((line, el) => {\n      newtext += `${el.innerHTML}\\n`;\n    });\n    return newtext;\n  };\n\n  const importrequest = (data, importurl, type) => {\n    let error;\n    const result = $.ajax({\n      url: importurl,\n      type: 'post',\n      processData: false,\n      async: false,\n      contentType: 'multipart/form-data; boundary=boundary',\n      accepts: {\n        text: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',\n      },\n      data: [\n        'Content-Type: multipart/form-data; boundary=--boundary',\n        '',\n        '--boundary',\n        `Content-Disposition: form-data; name=\"file\"; filename=\"import.${type}\"`,\n        'Content-Type: text/plain',\n        '',\n        data,\n        '',\n        '--boundary',\n      ].join('\\r\\n'),\n      error(res) {\n        error = res;\n      },\n    });\n    expect(error).to.be(undefined);\n    return result;\n  };\n\n  const exportfunc = (link) => {\n    const exportresults = [];\n    $.ajaxSetup({\n      async: false,\n    });\n    $.get(`${link}/export/html`, (data) => {\n      const start = data.indexOf('<body>');\n      const end = data.indexOf('</body>');\n      const html = data.substr(start + 6, end - start - 6);\n      exportresults.push(['html', html]);\n    });\n    $.get(`${link}/export/txt`, (data) => {\n      exportresults.push(['txt', data]);\n    });\n    return exportresults;\n  };\n\n  xit('import a pad with indents from html', async function () {\n    const importurl = `${helper.padChrome$.window.location.href}/import`;\n    const htmlWithIndents =\n        '<html><body><ul class=\"list-indent1\"><li>indent line 1</li><li>indent line 2</li>' +\n        '<ul class=\"list-indent2\"><li>indent2 line 1</li><li>indent2 line 2</li></ul></ul>' +\n        '</body></html>';\n    importrequest(htmlWithIndents, importurl, 'html');\n    await helper.waitForPromise(() => getinnertext() ===\n        '<ul class=\"list-indent1\"><li><span class=\"\">indent line 1</span></li></ul>\\n' +\n        '<ul class=\"list-indent1\"><li><span class=\"\">indent line 2</span></li></ul>\\n' +\n        '<ul class=\"list-indent2\"><li><span class=\"\">indent2 line 1</span></li></ul>\\n' +\n        '<ul class=\"list-indent2\"><li><span class=\"\">indent2 line 2</span></li></ul>\\n' +\n        '<br>\\n');\n    const results = exportfunc(helper.padChrome$.window.location.href);\n    expect(results[0][1]).to.be(\n        '<ul class=\"indent\"><li>indent line 1</li><li>indent line 2</li>' +\n        '<ul class=\"indent\"><li>indent2 line 1</li><li>indent2 line 2</li></ul></ul><br>');\n    expect(results[1][1])\n        .to.be('\\tindent line 1\\n\\tindent line 2\\n\\t\\tindent2 line 1\\n\\t\\tindent2 line 2\\n\\n');\n  });\n\n  xit('import a pad with indented lists and newlines from html', async function () {\n    const importurl = `${helper.padChrome$.window.location.href}/import`;\n    const htmlWithIndents =\n        '<html><body><ul class=\"list-indent1\"><li>indent line 1</li></ul><br/>' +\n        '<ul class=\"list-indent1\"><li>indent 1 line 2</li>' +\n        '<ul class=\"list-indent2\"><li>indent 2 times line 1</li></ul></ul><br/>' +\n        '<ul class=\"list-indent1\"><ul class=\"list-indent2\"><li>indent 2 times line 2</li>' +\n        '</ul></ul></body></html>';\n    importrequest(htmlWithIndents, importurl, 'html');\n    await helper.waitForPromise(() => getinnertext() ===\n        '<ul class=\"list-indent1\"><li><span class=\"\">indent line 1</span></li></ul>\\n' +\n        '<br>\\n' +\n        '<ul class=\"list-indent1\"><li><span class=\"\">indent 1 line 2</span></li></ul>\\n' +\n        '<ul class=\"list-indent2\"><li><span class=\"\">indent 2 times line 1</span></li></ul>\\n' +\n        '<br>\\n' +\n        '<ul class=\"list-indent2\"><li><span class=\"\">indent 2 times line 2</span></li></ul>\\n' +\n        '<br>\\n');\n    const results = exportfunc(helper.padChrome$.window.location.href);\n    expect(results[0][1]).to.be(\n        '<ul class=\"indent\"><li>indent line 1</li></ul><br>' +\n        '<ul class=\"indent\"><li>indent 1 line 2</li>' +\n        '<ul class=\"indent\"><li>indent 2 times line 1</li></ul></ul><br>' +\n        '<ul><ul class=\"indent\"><li>indent 2 times line 2</li></ul></ul><br>');\n    expect(results[1][1]).to.be(\n        '\\tindent line 1\\n\\n\\tindent 1 line 2\\n\\t\\tindent 2 times line 1\\n\\n' +\n        '\\t\\tindent 2 times line 2\\n\\n');\n  });\n\n  xit('import with 8 levels of indents and newlines and attributes from html', async function () {\n    const importurl = `${helper.padChrome$.window.location.href}/import`;\n    const htmlWithIndents =\n        '<html><body><ul class=\"list-indent1\"><li>indent line 1</li></ul><br/>' +\n        '<ul class=\"list-indent1\"><li>indent line 2</li>' +\n        '<ul class=\"list-indent2\"><li>indent2 line 1</li></ul></ul><br/>' +\n        '<ul class=\"list-indent1\"><ul class=\"list-indent2\"><ul class=\"list-indent3\">' +\n        '<ul class=\"list-indent4\"><li><span class=\"b s i u\"><b><i><s>' +\n        '<u>indent4 line 2 bisu</u></s></i></b></span></li><li><span class=\"b s \">' +\n        '<b><s>indent4 line 2 bs</s></b></span></li><li><span class=\"u\">' +\n        '<u>indent4 line 2 u</u></span><span class=\"u i s\"><i><s><u>uis</u></s></i></span></li>' +\n        '<ul class=\"list-indent5\"><ul class=\"list-indent6\"><ul class=\"list-indent7\">' +\n        '<ul class=\"list-indent8\"><li><span class=\"\">foo</span></li><li><span class=\"b s\">' +\n        '<b><s>foobar bs</b></s></span></li></ul></ul></ul></ul><ul class=\"list-indent5\">' +\n        '<li>foobar</li></ul></ul></ul></ul></body></html>';\n    importrequest(htmlWithIndents, importurl, 'html');\n    helper.waitFor(() => expect(getinnertext()).to.be(\n        '<ul class=\"list-indent1\"><li><span class=\"\">indent line 1</span></li></ul>\\n<br>\\n' +\n        '<ul class=\"list-indent1\"><li><span class=\"\">indent line 2</span></li></ul>\\n' +\n        '<ul class=\"list-indent2\"><li><span class=\"\">indent2 line 1</span></li></ul>\\n<br>\\n' +\n        '<ul class=\"list-indent4\"><li><span class=\"b i s u\"><b><i><s><u>indent4 ' +\n        'line 2 bisu</u></s></i></b></span></li></ul>\\n' +\n        '<ul class=\"list-indent4\"><li><span class=\"b s\"><b><s>' +\n        'indent4 line 2 bs</s></b></span></li></ul>\\n' +\n        '<ul class=\"list-indent4\"><li><span class=\"u\"><u>indent4 line 2 u</u>' +\n        '</span><span class=\"i s u\"><i><s><u>uis</u></s></i></span></li></ul>\\n' +\n        '<ul class=\"list-indent8\"><li><span class=\"\">foo</span></li></ul>\\n' +\n        '<ul class=\"list-indent8\"><li><span class=\"b s\"><b><s>foobar bs</s></b>' +\n        '</span></li></ul>\\n' +\n        '<ul class=\"list-indent5\"><li><span class=\"\">foobar</span></li></ul>\\n' +\n        '<br>\\n'));\n    const results = exportfunc(helper.padChrome$.window.location.href);\n    expect(results[0][1]).to.be(\n        '<ul class=\"indent\"><li>indent line 1</li></ul><br><ul class=\"indent\">' +\n        '<li>indent line 2</li><ul class=\"indent\"><li>indent2 line 1</li></ul></ul><br>' +\n        '<ul><ul><ul><ul class=\"indent\"><li><strong><em><s><u>indent4 line 2 bisu</u></s>' +\n        '</em></strong></li><li><strong><s>indent4 line 2 bs</s></strong></li><li>' +\n        '<u>indent4 line 2 u<em><s>uis</s></em></u></li><ul><ul><ul><ul class=\"indent\">' +\n        '<li>foo</li><li><strong><s>foobar bs</s></strong></li></ul></ul></ul><li>foobar</li>' +\n        '</ul></ul></ul></ul></ul><br>');\n    expect(results[1][1]).to.be(\n        '\\tindent line 1\\n\\n\\tindent line 2\\n\\t\\tindent2 line 1\\n\\n\\t\\t\\t\\tindent4 line 2 bisu\\n' +\n        '\\t\\t\\t\\tindent4 line 2 bs\\n\\t\\t\\t\\tindent4 line 2 uuis\\n\\t\\t\\t\\t\\t\\t\\t\\tfoo\\n' +\n        '\\t\\t\\t\\t\\t\\t\\t\\tfoobar bs\\n\\t\\t\\t\\t\\tfoobar\\n\\n');\n  });\n});\n"
  },
  {
    "path": "src/tests/frontend/specs/multiple_authors_clear_authorship_colors.js",
    "content": "'use strict';\n\ndescribe('author of pad edition', function () {\n  // author 1 creates a new pad with some content (regular lines and lists)\n  before(async function () {\n    const padId = await helper.aNewPad();\n\n    // make sure pad has at least 3 lines\n    const $firstLine = helper.padInner$('div').first();\n    $firstLine.html('Hello World');\n\n    // wait for lines to be processed by Etherpad\n    await helper.waitForPromise(() => (\n      $firstLine.text() === 'Hello World' && helper.commits.length === 1));\n\n    // Delete token cookie, so author is changed after reloading the pad.\n    const {Cookies} = helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_utils');\n    Cookies.remove('token');\n\n    // Reload pad, to make changes as a second user.\n    await helper.aNewPad({id: padId});\n  });\n\n  // author 2 makes some changes on the pad\n  it('Clears Authorship by second user', async function () {\n    const inner$ = helper.padInner$;\n    const chrome$ = helper.padChrome$;\n\n    // override the confirm dialogue functioon\n    helper.padChrome$.window.confirm = function () {\n      return true;\n    };\n\n    // get the clear authorship colors button and click it\n    const $clearauthorshipcolorsButton = chrome$('.buttonicon-clearauthorship');\n    $clearauthorshipcolorsButton.trigger('click');\n\n    // does the first divs span include an author class?\n    const hasAuthorClass = inner$('div span').first().attr('class').indexOf('author') !== -1;\n\n    expect(hasAuthorClass).to.be(false);\n  });\n});\n"
  },
  {
    "path": "src/tests/frontend/specs/pad_modal.js",
    "content": "'use strict';\n\ndescribe('Pad modal', function () {\n  context('when modal is a \"force reconnect\" message', function () {\n    const MODAL_SELECTOR = '#connectivity';\n\n    beforeEach(async function () {\n      await helper.aNewPad();\n\n      // force a \"slowcommit\" error\n      helper.padChrome$.window.pad.handleChannelStateChange('DISCONNECTED', 'slowcommit');\n\n      // wait for modal to be displayed\n      const $modal = helper.padChrome$(MODAL_SELECTOR);\n      await helper.waitForPromise(() => $modal.hasClass('popup-show'), 50000);\n    });\n\n    it('disables editor', async function () {\n      expect(isEditorDisabled()).to.be(true);\n    });\n\n    context('and user clicks on editor', function () {\n      it('does not close the modal', async function () {\n        clickOnPadInner();\n        const $modal = helper.padChrome$(MODAL_SELECTOR);\n        const modalIsVisible = $modal.hasClass('popup-show');\n\n        expect(modalIsVisible).to.be(true);\n      });\n    });\n\n    context('and user clicks on pad outer', function () {\n      it('does not close the modal', async function () {\n        clickOnPadOuter();\n        const $modal = helper.padChrome$(MODAL_SELECTOR);\n        const modalIsVisible = $modal.hasClass('popup-show');\n\n        expect(modalIsVisible).to.be(true);\n      });\n    });\n  });\n\n  // we use \"settings\" here, but other modals have the same behaviour\n  context('when modal is not an error message', function () {\n    const MODAL_SELECTOR = '#settings';\n\n    beforeEach(async function () {\n      await helper.aNewPad();\n      await openSettingsAndWaitForModalToBeVisible();\n    });\n\n    // This test breaks safari testing\n    xit('does not disable editor', async function () {\n      expect(isEditorDisabled()).to.be(false);\n    });\n\n    context('and user clicks on editor', function () {\n      it('closes the modal', async function () {\n        clickOnPadInner();\n        await helper.waitForPromise(() => isModalOpened(MODAL_SELECTOR) === false);\n      });\n    });\n\n    context('and user clicks on pad outer', function () {\n      it('closes the modal', async function () {\n        clickOnPadOuter();\n        await helper.waitForPromise(() => isModalOpened(MODAL_SELECTOR) === false);\n      });\n    });\n  });\n\n  const clickOnPadInner = () => {\n    const $editor = helper.padInner$('#innerdocbody');\n    $editor.trigger('click');\n  };\n\n  const clickOnPadOuter = () => {\n    const $lineNumbersColumn = helper.padOuter$('#sidedivinner');\n    $lineNumbersColumn.trigger('click');\n  };\n\n  const openSettingsAndWaitForModalToBeVisible = async () => {\n    helper.padChrome$('.buttonicon-settings').trigger('click');\n\n    // wait for modal to be displayed\n    const modalSelector = '#settings';\n    await helper.waitForPromise(() => isModalOpened(modalSelector), 10000);\n  };\n\n  const isEditorDisabled = () => {\n    const editorDocument = helper.padOuter$(\"iframe[name='ace_inner']\").get(0).contentDocument;\n    const editorBody = editorDocument.getElementById('innerdocbody');\n\n    const editorIsDisabled = editorBody.contentEditable === 'false' || // IE/Safari\n                        editorDocument.designMode === 'off'; // other browsers\n\n    return editorIsDisabled;\n  };\n\n  const isModalOpened = (modalSelector) => {\n    const $modal = helper.padChrome$(modalSelector);\n\n    return $modal.hasClass('popup-show');\n  };\n});\n"
  },
  {
    "path": "src/tests/frontend/specs/responsiveness.js",
    "content": "'use strict';\n\n// Test for https://github.com/ether/etherpad-lite/issues/1763\n\n// This test fails in Opera, IE and Safari\n// Opera fails due to a weird way of handling the order of execution,\n// yet actual performance seems fine\n// Safari fails due the delay being too great yet the actual performance seems fine\n// Firefox might panic that the script is taking too long so will fail\n// IE will fail due to running out of memory as it can't fit 2M chars in memory.\n\n// Just FYI Google Docs crashes on large docs whilst trying to Save,\n// it's likely the limitations we are\n// experiencing are more to do with browser limitations than improper implementation.\n// A ueber fix for this would be to have a separate lower cpu priority\n// thread that handles operations that aren't\n// visible to the user.\n\n// Adapted from John McLear's original test case.\n\nxdescribe('Responsiveness of Editor', function () {\n  // create a new pad before each test run\n  beforeEach(async function () {\n    this.timeout(6000);\n    await helper.aNewPad();\n  });\n\n  // JM commented out on 8th Sep 2020 for a release, after release this needs uncommenting\n  // And the test needs to be fixed to work in Firefox 52 on Windows 7.\n  // I am not sure why it fails on this specific platform\n  // The errors show this.timeout... then crash the browser but\n  // I am sure something is actually causing the stack trace and\n  // I just need to narrow down what, offers to help accepted.\n  it('Fast response to keypress in pad with large amount of contents', async function () {\n    // skip on Windows Firefox 52.0\n    if (window.bowser &&\n        window.bowser.windows && window.bowser.firefox && window.bowser.version === '52.0') {\n      this.skip();\n    }\n    const inner$ = helper.padInner$;\n    const chars = '0000000000'; // row of placeholder chars\n    const amount = 200000; // number of blocks of chars we will insert\n    const length = (amount * (chars.length) + 1); // include a counter for each space\n    let text = ''; // the text we're gonna insert\n    this.timeout(amount * 150); // Changed from 100 to 150 to allow Mac OSX Safari to be slow.\n\n    // get keys to send\n    const keyMultiplier = 10; // multiplier * 10 == total number of key events\n    let keysToSend = '';\n    for (let i = 0; i <= keyMultiplier; i++) {\n      keysToSend += chars;\n    }\n\n    const textElement = inner$('div');\n    textElement.sendkeys('{selectall}'); // select all\n    textElement.sendkeys('{del}'); // clear the pad text\n\n    for (let i = 0; i <= amount; i++) {\n      text = `${text + chars} `; // add the chars and space to the text contents\n    }\n    inner$('div').first().text(text); // Put the text contents into the pad\n\n    // Wait for the new contents to be on the pad\n    await helper.waitForPromise(() => inner$('div').text().length > length, 10000);\n\n    // has the text changed?\n    expect(inner$('div').text().length).to.be.greaterThan(length);\n    const start = Date.now(); // get the start time\n\n    // send some new text to the screen (ensure all 3 key events are sent)\n    const el = inner$('div').first();\n    for (let i = 0; i < keysToSend.length; ++i) {\n      const x = keysToSend.charCodeAt(i);\n      ['keyup', 'keypress', 'keydown'].forEach((type) => {\n        const e = new $.Event(type);\n        e.keyCode = x;\n        el.trigger(e);\n      });\n    }\n\n    await helper.waitForPromise(() => { // Wait for the ability to process\n      const el = inner$('body');\n      if (el[0].textContent.length > amount) return true;\n    }, 5000);\n\n    const end = Date.now(); // get the current time\n    const delay = end - start; // get the delay as the current time minus the start time\n\n    expect(delay).to.be.below(600);\n  });\n});\n"
  },
  {
    "path": "src/tests/frontend/specs/scrollTo.js",
    "content": "'use strict';\n\ndescribe('scrollTo.js', function () {\n  describe('scrolls to line', function () {\n    // create a new pad with URL hash set before each test run\n    before(async function () {\n      await helper.aNewPad({hash: 'L4'});\n    });\n\n    it('Scrolls down to Line 4', async function () {\n      const chrome$ = helper.padChrome$;\n      await helper.waitForPromise(() => {\n        const topOffset = parseInt(chrome$('iframe').first('iframe')\n            .contents().find('#outerdocbody').css('top'));\n        return (topOffset >= 100);\n      });\n    });\n  });\n\n  describe('doesnt break on weird hash input', function () {\n    // create a new pad with URL hash set before each test run\n    before(async function () {\n      await helper.aNewPad({hash: '#DEEZ123123NUTS'});\n    });\n\n    it('Does NOT change scroll', async function () {\n      const chrome$ = helper.padChrome$;\n      await helper.waitForPromise(() => {\n        const topOffset = parseInt(chrome$('iframe').first('iframe')\n            .contents().find('#outerdocbody').css('top'));\n        return (!topOffset); // no css top should be set.\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/frontend/specs/select_formatting_buttons.js",
    "content": "'use strict';\n\ndescribe('select formatting buttons when selection has style applied', function () {\n  const STYLES = ['italic', 'bold', 'underline', 'strikethrough'];\n  const SHORTCUT_KEYS = ['I', 'B', 'U', '5']; // italic, bold, underline, strikethrough\n  const FIRST_LINE = 0;\n\n  before(async function () {\n    await helper.aNewPad();\n  });\n\n  const applyStyleOnLine = function (style, line) {\n    const chrome$ = helper.padChrome$;\n    selectLine(line);\n    const $formattingButton = chrome$(`.buttonicon-${style}`);\n    $formattingButton.trigger('click');\n  };\n\n  const isButtonSelected = function (style) {\n    const chrome$ = helper.padChrome$;\n    const $formattingButton = chrome$(`.buttonicon-${style}`);\n    return $formattingButton.parent().hasClass('selected');\n  };\n\n  const selectLine = function (lineNumber, offsetStart, offsetEnd) {\n    const inner$ = helper.padInner$;\n    const $line = inner$('div').eq(lineNumber);\n    helper.selectLines($line, $line, offsetStart, offsetEnd);\n  };\n\n  const placeCaretOnLine = function (lineNumber) {\n    const inner$ = helper.padInner$;\n    const $line = inner$('div').eq(lineNumber);\n    $line.sendkeys('{leftarrow}');\n  };\n\n  const undo = async function () {\n    const originalHTML = helper.padInner$('body').html();\n    const $undoButton = helper.padChrome$('.buttonicon-undo');\n    $undoButton.trigger('click');\n    await helper.waitForPromise(() => helper.padInner$('body').html() !== originalHTML);\n  };\n\n  const testIfFormattingButtonIsDeselected = function (style) {\n    it(`deselects the ${style} button`, async function () {\n      await helper.waitForPromise(() => !isButtonSelected(style));\n    });\n  };\n\n  const testIfFormattingButtonIsSelected = function (style) {\n    it(`selects the ${style} button`, async function () {\n      await helper.waitForPromise(() => isButtonSelected(style));\n    });\n  };\n\n  const applyStyleOnLineAndSelectIt = async function (line, style) {\n    await applyStyleOnLineOnFullLineAndRemoveSelection(line, style, selectLine);\n  };\n\n  const applyStyleOnLineAndPlaceCaretOnit = async function (line, style) {\n    await applyStyleOnLineOnFullLineAndRemoveSelection(line, style, placeCaretOnLine);\n  };\n\n  const applyStyleOnLineOnFullLineAndRemoveSelection = async function (line, style, selectTarget) {\n    // see if line html has changed\n    const inner$ = helper.padInner$;\n    const oldLineHTML = inner$.find('div')[line];\n    applyStyleOnLine(style, line);\n\n    await helper.waitForPromise(() => {\n      const lineHTML = inner$.find('div')[line];\n      return lineHTML !== oldLineHTML;\n    });\n    // remove selection from previous line\n    selectLine(line + 1);\n    // select the text or place the caret on a position that\n    // has the formatting text applied previously\n    selectTarget(line);\n  };\n\n  const pressFormattingShortcutOnSelection = async function (key) {\n    const inner$ = helper.padInner$;\n    const originalHTML = helper.padInner$('body').html();\n\n    // get the first text element out of the inner iframe\n    const $firstTextElement = inner$('div').first();\n\n    // select this text element\n    $firstTextElement.sendkeys('{selectall}');\n\n    const e = new inner$.Event(helper.evtType);\n    e.ctrlKey = true; // Control key\n    e.which = key.charCodeAt(0); // I, U, B, 5\n    inner$('#innerdocbody').trigger(e);\n    await helper.waitForPromise(() => helper.padInner$('body').html() !== originalHTML);\n  };\n\n  STYLES.forEach((style) => {\n    context(`when selection is in a text with ${style} applied`, function () {\n      before(async function () {\n        this.timeout(4000);\n        await applyStyleOnLineAndSelectIt(FIRST_LINE, style);\n      });\n\n      after(async function () {\n        await undo();\n      });\n\n      testIfFormattingButtonIsSelected(style);\n    });\n\n    context(`when caret is in a position with ${style} applied`, function () {\n      before(async function () {\n        this.timeout(4000);\n        await applyStyleOnLineAndPlaceCaretOnit(FIRST_LINE, style);\n      });\n\n      after(async function () {\n        await undo();\n      });\n\n      testIfFormattingButtonIsSelected(style);\n    });\n  });\n\n  context('when user applies a style and the selection does not change', function () {\n    it('selects the style button', async function () {\n      const style = STYLES[0]; // italic\n      applyStyleOnLine(style, FIRST_LINE);\n      await helper.waitForPromise(() => isButtonSelected(style) === true);\n      applyStyleOnLine(style, FIRST_LINE);\n    });\n  });\n\n  SHORTCUT_KEYS.forEach((key, index) => {\n    const styleOfTheShortcut = STYLES[index]; // italic, bold, ...\n    context(`when user presses CMD + ${key}`, function () {\n      before(async function () {\n        await pressFormattingShortcutOnSelection(key);\n      });\n\n      testIfFormattingButtonIsSelected(styleOfTheShortcut);\n\n      context(`and user presses CMD + ${key} again`, function () {\n        before(async function () {\n          await pressFormattingShortcutOnSelection(key);\n        });\n\n        testIfFormattingButtonIsDeselected(styleOfTheShortcut);\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/frontend/specs/timeslider_labels.js",
    "content": "'use strict';\n\ndescribe('timeslider', function () {\n  // create a new pad before each test run\n  beforeEach(async function () {\n    await helper.aNewPad();\n  });\n\n  /**\n   * @todo test authorsList\n   */\n  it(\"Shows a date/time in the timeslider and make sure it doesn't include NaN\", async function () {\n    this.timeout(12000);\n    // make some changes to produce 3 revisions\n    const revs = 3;\n\n    for (let i = 0; i < revs; i++) {\n      await helper.edit('a\\n');\n    }\n\n    await helper.gotoTimeslider(revs);\n    await helper.waitForPromise(() => helper.contentWindow().location.hash === `#${revs}`);\n\n    // the datetime of last edit\n    const timerTimeLast = new Date(helper.timesliderTimerTime()).getTime();\n\n    // the day of this revision, e.g. August 12, 2020 (stripped the string \"Saved\")\n    const dateLast = new Date(helper.revisionDateElem().substr(6)).getTime();\n\n    // the label/revision, ie Version 3\n    const labelLast = helper.revisionLabelElem().text();\n\n    // the datetime should be a date\n    expect(Number.isNaN(timerTimeLast)).to.eql(false);\n\n    // the Date object of the day should not be NaN\n    expect(Number.isNaN(dateLast)).to.eql(false);\n\n    // the label should be Version `Number`\n    expect(labelLast).to.be(`Version ${revs}`);\n\n    // Click somewhere left on the timeslider to go to revision 0\n    helper.sliderClick(1);\n\n    // the datetime of last edit\n    const timerTime = new Date(helper.timesliderTimerTime()).getTime();\n\n    // the day of this revision, e.g. August 12, 2020\n    const date = new Date(helper.revisionDateElem().substr(6)).getTime();\n\n    // the label/revision, e.g. Version 0\n    const label = helper.revisionLabelElem().text();\n\n    // the datetime should be a date\n    expect(Number.isNaN(timerTime)).to.eql(false);\n    // the last revision should be newer or have the same time\n    expect(timerTimeLast).to.not.be.lessThan(timerTime);\n\n    // the Date object of the day should not be NaN\n    expect(Number.isNaN(date)).to.eql(false);\n\n    // the label should be Version 0\n    expect(label).to.be('Version 0');\n  });\n});\n"
  },
  {
    "path": "src/tests/frontend/specs/timeslider_numeric_padID.js",
    "content": "'use strict';\n\ndescribe('timeslider', function () {\n  const padId = 735773577357 + (Math.round(Math.random() * 1000));\n\n  // create a new pad before each test run\n  beforeEach(async function () {\n    await helper.aNewPad({id: padId});\n  });\n\n  it('Makes sure the export URIs are as expected when the padID is numeric', async function () {\n    await helper.edit('a\\n');\n\n    await helper.gotoTimeslider(1);\n\n    // ensure we are on revision 1\n    await helper.waitForPromise(() => helper.contentWindow().location.hash === '#1');\n\n    // expect URI to be similar to\n    // http://192.168.1.48:9001/p/2/1/export/html\n    // http://192.168.1.48:9001/p/735773577399/1/export/html\n    const rev1ExportLink = helper.contentWindow().$('#exporthtmla').attr('href');\n    expect(rev1ExportLink).to.contain('/1/export/html');\n\n    // Click somewhere left on the timeslider to go to revision 0\n    helper.sliderClick(30);\n\n    const rev0ExportLink = helper.contentWindow().$('#exporthtmla').attr('href');\n    expect(rev0ExportLink).to.contain('/0/export/html');\n  });\n});\n"
  },
  {
    "path": "src/tests/frontend/specs/timeslider_revisions.js",
    "content": "'use strict';\n\ndescribe('timeslider', function () {\n  // create a new pad before each test run\n  beforeEach(async function () {\n    await helper.aNewPad();\n  });\n\n  it('loads adds a hundred revisions', async function () {\n    this.timeout(100000);\n    const chrome$ = helper.padChrome$;\n\n    // Create a bunch of revisions.\n    for (let i = 0; i < 99; i++) await helper.edit('a');\n    chrome$('.buttonicon-savedRevision').trigger('click');\n    await helper.waitForPromise(() => helper.padChrome$('.saved-revision').length > 0);\n    // Give some time to send the SAVE_REVISION message to the server before navigating away.\n    await new Promise((resolve) => setTimeout(resolve, 100));\n\n    // go to timeslider\n    $('#iframe-container iframe')\n        .attr('src', `${$('#iframe-container iframe').attr('src')}/timeslider`);\n\n    await new Promise((resolve) => setTimeout(resolve, 6000));\n\n    const timeslider$ = $('#iframe-container iframe')[0].contentWindow.$;\n    const $sliderBar = timeslider$('#ui-slider-bar');\n\n    const latestContents = timeslider$('#innerdocbody').text();\n\n    // Click somewhere on the timeslider\n    let e = new jQuery.Event('mousedown');\n    // sets y co-ordinate of the pad slider modal.\n    const base = (timeslider$('#ui-slider-bar').offset().top - 24);\n    e.clientX = e.pageX = 150;\n    e.clientY = e.pageY = base + 5;\n    $sliderBar.trigger(e);\n\n    e = new jQuery.Event('mousedown');\n    e.clientX = e.pageX = 150;\n    e.clientY = e.pageY = base;\n    $sliderBar.trigger(e);\n\n    e = new jQuery.Event('mousedown');\n    e.clientX = e.pageX = 150;\n    e.clientY = e.pageY = base - 5;\n    $sliderBar.trigger(e);\n\n    $sliderBar.trigger('mouseup');\n\n    await new Promise((resolve) => setTimeout(resolve, 1000));\n\n    // make sure the text has changed\n    expect(timeslider$('#innerdocbody').text()).not.to.eql(latestContents);\n    const starIsVisible = timeslider$('.star').is(':visible');\n    expect(starIsVisible).to.eql(true);\n  });\n\n\n  // Disabled as jquery trigger no longer works properly\n  xit('changes the url when clicking on the timeslider', async function () {\n    // Create some revisions.\n    for (let i = 0; i < 20; i++) await helper.edit('a');\n\n    // go to timeslider\n    $('#iframe-container iframe')\n        .attr('src', `${$('#iframe-container iframe').attr('src')}/timeslider`);\n\n    await new Promise((resolve) => setTimeout(resolve, 6000));\n\n    const timeslider$ = $('#iframe-container iframe')[0].contentWindow.$;\n    const $sliderBar = timeslider$('#ui-slider-bar');\n\n    const oldUrl = $('#iframe-container iframe')[0].contentWindow.location.hash;\n\n    // Click somewhere on the timeslider\n    const e = new jQuery.Event('mousedown');\n    e.clientX = e.pageX = 150;\n    e.clientY = e.pageY = 60;\n    $sliderBar.trigger(e);\n\n    await helper.waitForPromise(\n        () => $('#iframe-container iframe')[0].contentWindow.location.hash !== oldUrl, 6000);\n  });\n\n  it('jumps to a revision given in the url', async function () {\n    const inner$ = helper.padInner$;\n    this.timeout(40000);\n\n    // wait for the text to be loaded\n    await helper.waitForPromise(() => inner$('body').text().length !== 0, 10000);\n\n    const newLines = inner$('body div').length;\n    const oldLength = inner$('body').text().length + newLines / 2;\n    expect(oldLength).to.not.eql(0);\n    inner$('div').first().sendkeys('a');\n    let timeslider$;\n\n    // wait for our additional revision to be added\n    await helper.waitForPromise(() => {\n      // newLines takes the new lines into account which are strippen when using\n      // inner$('body').text(), one <div> is used for one line in ACE.\n      const lenOkay = inner$('body').text().length + newLines / 2 !== oldLength;\n      // this waits for the color to be added to our <span>, which means that the revision\n      // was accepted by the server.\n      const colorOkay = inner$('span').first().attr('class').indexOf('author-') === 0;\n      return lenOkay && colorOkay;\n    }, 10000);\n\n    // go to timeslider with a specific revision set\n    $('#iframe-container iframe')\n        .attr('src', `${$('#iframe-container iframe').attr('src')}/timeslider#0`);\n\n    // wait for the timeslider to be loaded\n    await helper.waitForPromise(() => {\n      try {\n        timeslider$ = $('#iframe-container iframe')[0].contentWindow.$;\n      } catch (e) {\n        // Empty catch block <3\n      }\n      return timeslider$ && timeslider$('#innerdocbody').text().length === oldLength;\n    }, 10000);\n  });\n\n  it('checks the export url', async function () {\n    const inner$ = helper.padInner$;\n    this.timeout(11000);\n    inner$('div').first().sendkeys('a');\n\n    await new Promise((resolve) => setTimeout(resolve, 2500));\n\n    // go to timeslider\n    $('#iframe-container iframe')\n        .attr('src', `${$('#iframe-container iframe').attr('src')}/timeslider#0`);\n    let timeslider$;\n    let exportLink;\n\n    await helper.waitForPromise(() => {\n      try {\n        timeslider$ = $('#iframe-container iframe')[0].contentWindow.$;\n      } catch (e) {\n        // Empty catch block <3\n      }\n      if (!timeslider$) return false;\n      exportLink = timeslider$('#exportplaina').attr('href');\n      if (!exportLink) return false;\n      return exportLink.substr(exportLink.length - 12) === '0/export/txt';\n    }, 6000);\n  });\n});\n"
  },
  {
    "path": "src/tests/frontend/specs/xxauto_reconnect.js",
    "content": "'use strict';\n\ndescribe('Automatic pad reload on Force Reconnect message', function () {\n  let padId, $originalPadFrame;\n\n  beforeEach(async function () {\n    padId = await helper.aNewPad();\n\n    // enable userdup error to have timer to force reconnect\n    const $errorMessageModal = helper.padChrome$('#connectivity .userdup');\n    $errorMessageModal.addClass('with_reconnect_timer');\n\n    // make sure there's a timeout set, otherwise automatic reconnect won't be enabled\n    helper.padChrome$.window.clientVars.automaticReconnectionTimeout = 2;\n\n    // open same pad on another iframe, to force userdup error\n    const $otherIframeWithSamePad = $(`<iframe src=\"/p/${padId}\" style=\"height: 1px;\"></iframe>`);\n    $originalPadFrame = $('#iframe-container iframe');\n    $otherIframeWithSamePad.insertAfter($originalPadFrame);\n\n    // wait for modal to be displayed\n    await helper.waitForPromise(() => $errorMessageModal.is(':visible'), 50000);\n  });\n\n  it('displays a count down timer to automatically reconnect', async function () {\n    const $errorMessageModal = helper.padChrome$('#connectivity .userdup');\n    const $countDownTimer = $errorMessageModal.find('.reconnecttimer');\n\n    expect($countDownTimer.is(':visible')).to.be(true);\n  });\n\n  context('and user clicks on Cancel', function () {\n    beforeEach(async function () {\n      const $errorMessageModal = helper.padChrome$('#connectivity .userdup');\n      $errorMessageModal.find('#cancelreconnect').trigger('click');\n      await helper.waitForPromise(\n          () => helper.padChrome$('#connectivity .userdup').is(':visible') === true);\n    });\n\n    it('does not show Cancel button nor timer anymore', async function () {\n      const $errorMessageModal = helper.padChrome$('#connectivity .userdup');\n      const $countDownTimer = $errorMessageModal.find('.reconnecttimer');\n      const $cancelButton = $errorMessageModal.find('#cancelreconnect');\n\n      expect($countDownTimer.is(':visible')).to.be(false);\n      expect($cancelButton.is(':visible')).to.be(false);\n    });\n  });\n\n  context('and user does not click on Cancel until timer expires', function () {\n    it('reloads the pad', async function () {\n      this.timeout(10000);\n      await new Promise((resolve) => $originalPadFrame.one('load', resolve));\n    });\n  });\n});\n"
  },
  {
    "path": "src/tests/frontend/travis/.gitignore",
    "content": "sauce_connect.log\nsauce_connect.log.*\n"
  },
  {
    "path": "src/tests/frontend/travis/adminrunner.sh",
    "content": "#!/bin/sh\n\npecho() { printf %s\\\\n \"$*\"; }\nlog() { pecho \"$@\"; }\nerror() { log \"ERROR: $@\" >&2; }\nfatal() { error \"$@\"; exit 1; }\ntry() { \"$@\" || fatal \"'$@' failed\"; }\n\n# Move to the Etherpad base directory.\nMY_DIR=$(try cd \"${0%/*}\" && try pwd -P) || exit 1\ntry cd \"${MY_DIR}/../../../..\"\n\nlog \"Assuming src/bin/installDeps.sh has already been run\"\n( cd src && npm run dev --experimental-worker \"${@}\" &\nep_pid=$!)\n\nlog \"Waiting for Etherpad to accept connections (http://localhost:9001)...\"\nconnected=false\ncan_connect() {\n    curl -sSfo /dev/null http://localhost:9001/ || return 1\n    connected=true\n}\nnow() { date +%s; }\nstart=$(now)\nwhile [ $(($(now) - $start)) -le 15 ] && ! can_connect; do\n    sleep 1\ndone\n[ \"$connected\" = true ] \\\n    || fatal \"Timed out waiting for Etherpad to accept connections\"\nlog \"Successfully connected to Etherpad on http://localhost:9001\"\n\n# start the remote runner\ntry cd \"${MY_DIR}\"\nlog \"Starting the remote runner...\"\nnode remote_runner.js admin\nexit_code=$?\n\nkill \"$ep_pid\" && wait \"$ep_pid\"\nlog \"Done.\"\nexit \"$exit_code\"\n"
  },
  {
    "path": "src/tests/frontend/travis/remote_runner.js",
    "content": "'use strict';\n\n// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an\n// unhandled rejection into an uncaught exception, which does cause Node.js to exit.\nprocess.on('unhandledRejection', (err) => { throw err; });\n\nconst async = require('async');\nconst swd = require('selenium-webdriver');\nconst swdChrome = require('selenium-webdriver/chrome');\nconst swdEdge = require('selenium-webdriver/edge');\nconst swdFirefox = require('selenium-webdriver/firefox');\n\nconst isAdminRunner = process.argv[2] === 'admin';\n\nconst colorSubst = {\n  red: '\\x1B[31m',\n  yellow: '\\x1B[33m',\n  green: '\\x1B[32m',\n  clear: '\\x1B[39m',\n};\nconst colorRegex = new RegExp(`\\\\[(${Object.keys(colorSubst).join('|')})\\\\]`, 'g');\n\nconst log = (msg, pfx = '') => {\n  console.log(`${pfx}${msg.replace(colorRegex, (m, p1) => colorSubst[p1])}`);\n};\n\nconst finishedRegex = /FINISHED.*[0-9]+ tests passed, ([0-9]+) tests failed/;\n\nconst sauceTestWorker = async.queue(async ({name, pfx, browser, version, platform}) => {\n  const chromeOptions = new swdChrome.Options()\n      .addArguments('use-fake-device-for-media-stream', 'use-fake-ui-for-media-stream');\n  const edgeOptions = new swdEdge.Options()\n      .addArguments('use-fake-device-for-media-stream', 'use-fake-ui-for-media-stream');\n  const firefoxOptions = new swdFirefox.Options()\n      .setPreference('media.navigator.permission.disabled', true)\n      .setPreference('media.navigator.streams.fake', true);\n  const builder = new swd.Builder()\n      .usingServer('https://ondemand.saucelabs.com/wd/hub')\n      .forBrowser(browser, version, platform)\n      .setChromeOptions(chromeOptions)\n      .setEdgeOptions(edgeOptions)\n      .setFirefoxOptions(firefoxOptions);\n  builder.getCapabilities().set('sauce:options', {\n    username: process.env.SAUCE_USERNAME,\n    accessKey: process.env.SAUCE_ACCESS_KEY,\n    name: [process.env.GIT_HASH].concat(process.env.SAUCE_NAME || [], name).join(' - '),\n    public: true,\n    build: process.env.GIT_HASH,\n    // console.json can be downloaded via saucelabs,\n    // don't know how to print them into output of the tests\n    extendedDebugging: true,\n    tunnelIdentifier: process.env.TRAVIS_JOB_NUMBER,\n  });\n  const driver = await builder.build();\n  const url = `https://saucelabs.com/jobs/${(await driver.getSession()).getId()}`;\n  try {\n    await driver.get('http://localhost:9001/tests/frontend/');\n    log(`Remote sauce test started! ${url}`, pfx);\n    // @TODO this should be configured in testSettings, see\n    // https://wiki.saucelabs.com/display/DOCS/Test+Configuration+Options#TestConfigurationOptions-Timeouts\n    const deadline = Date.now() + 14.5 * 60 * 1000; // Slightly less than overall test timeout.\n    // how many characters of the log have been sent to travis\n    let logIndex = 0;\n    const remoteFn = (skipChars) => {\n      const console = document.getElementById('console'); // eslint-disable-line no-undef\n      if (console == null) return '';\n      let text = '';\n      for (const n of console.childNodes) {\n        if (n.nodeType === n.TEXT_NODE) text += n.data;\n      }\n      return text.substring(skipChars);\n    };\n    while (true) {\n      const consoleText = await driver.executeScript(remoteFn, logIndex);\n      (consoleText ? consoleText.split('\\n') : []).forEach((line) => log(line, pfx));\n      logIndex += consoleText.length;\n      const [finished, nFailedStr] = consoleText.match(finishedRegex) || [];\n      if (finished) {\n        if (nFailedStr !== '0') process.exitCode = 1;\n        break;\n      }\n      if (Date.now() >= deadline) {\n        log('[red]FAILED[clear] allowed test duration exceeded');\n        process.exitCode = 1;\n        break;\n      }\n      await new Promise((resolve) => setTimeout(resolve, 5000));\n    }\n  } finally {\n    log(`Remote sauce test finished! ${url}`, pfx);\n    await driver.quit();\n  }\n}, 6); // run 6 tests in parrallel\n\nPromise.all([\n  {browser: 'chrome', version: 'latest', platform: 'Windows 10'},\n  ...(isAdminRunner ? [] : [\n    {browser: 'safari', version: 'latest', platform: 'macOS 11.00'},\n    {browser: 'firefox', version: 'latest', platform: 'Windows 10'},\n    {browser: 'MicrosoftEdge', version: 'latest', platform: 'Windows 10'},\n  ]),\n].map(async ({browser, version, platform}) => {\n  const name = `${browser} ${version}, ${platform}`;\n  const pfx = `[${name}] `;\n  try {\n    await sauceTestWorker.push({name, pfx, browser, version, platform});\n  } catch (err) {\n    log(`[red]FAILED[clear] ${err.stack || err}`, pfx);\n    process.exitCode = 1;\n  }\n}));\n"
  },
  {
    "path": "src/tests/frontend/travis/runner.sh",
    "content": "#!/bin/sh\n\npecho() { printf %s\\\\n \"$*\"; }\nlog() { pecho \"$@\"; }\nerror() { log \"ERROR: $@\" >&2; }\nfatal() { error \"$@\"; exit 1; }\ntry() { \"$@\" || fatal \"'$@' failed\"; }\n\n# Move to the Etherpad base directory.\nMY_DIR=$(try cd \"${0%/*}\" && try pwd -P) || exit 1\ntry cd \"${MY_DIR}/../../../..\"\n\nlog \"Assuming bin/installDeps.sh has already been run\"\n(cd src && npm run dev --experimental-worker \"${@}\" &\nep_pid=$!)\n\nlog \"Waiting for Etherpad to accept connections (http://localhost:9001)...\"\nconnected=false\ncan_connect() {\n    curl -sSfo /dev/null http://localhost:9001/ || return 1\n    connected=true\n}\nnow() { date +%s; }\nstart=$(now)\nwhile [ $(($(now) - $start)) -le 15 ] && ! can_connect; do\n    sleep 1\ndone\n[ \"$connected\" = true ] \\\n    || fatal \"Timed out waiting for Etherpad to accept connections\"\nlog \"Successfully connected to Etherpad on http://localhost:9001\"\n\n# start the remote runner\ntry cd \"${MY_DIR}\"\nlog \"Starting the remote runner...\"\nnode remote_runner.js\nexit_code=$?\n\nkill \"$ep_pid\" && wait \"$ep_pid\"\nlog \"Done.\"\nexit \"$exit_code\"\n"
  },
  {
    "path": "src/tests/frontend/travis/runnerBackend.sh",
    "content": "#!/bin/sh\n\npecho() { printf %s\\\\n \"$*\"; }\nlog() { pecho \"$@\"; }\nerror() { log \"ERROR: $@\" >&2; }\nfatal() { error \"$@\"; exit 1; }\ntry() { \"$@\" || fatal \"'$@' failed\"; }\n\n# Move to the Etherpad base directory.\nMY_DIR=$(try cd \"${0%/*}\" && try pwd -P) || exit 1\ntry cd \"${MY_DIR}/../../../..\"\n\ntry sed -e '\ns!\"soffice\":[^,]*!\"soffice\": \"/usr/bin/soffice\"!\n# Reduce rate limit aggressiveness\ns!\"max\":[^,]*!\"max\": 100!\ns!\"points\":[^,]*!\"points\": 1000!\n' settings.json.template >settings.json\n\nlog \"Deprecation notice: runnerBackend.sh - Please use: cd src && npm test\"\nlog \"Assuming src/bin/installDeps.sh has already been run\"\n(cd src && npm run dev \"${@}\" &\nep_pid=$!)\n\nlog \"Waiting for Etherpad to accept connections (http://localhost:9001)...\"\nconnected=false\ncan_connect() {\n    curl -sSfo /dev/null http://localhost:9001/ || return 1\n    connected=true\n}\nnow() { date +%s; }\nstart=$(now)\nwhile [ $(($(now) - $start)) -le 15 ] && ! can_connect; do\n    sleep 1\ndone\n[ \"$connected\" = true ] \\\n    || fatal \"Timed out waiting for Etherpad to accept connections\"\nlog \"Successfully connected to Etherpad on http://localhost:9001\"\n\nlog \"Running the backend tests...\"\ntry cd src\nnpm test\nexit_code=$?\n\nkill \"$ep_pid\" && wait \"$ep_pid\"\nlog \"Done.\"\nexit \"$exit_code\"\n"
  },
  {
    "path": "src/tests/frontend/travis/runnerLoadTest.sh",
    "content": "#!/bin/sh\n\nset -e\n\npecho() { printf %s\\\\n \"$*\"; }\nlog() { pecho \"$@\"; }\nerror() { log \"ERROR: $@\" >&2; }\nfatal() { error \"$@\"; exit 1; }\ntry() { \"$@\" || fatal \"'$@' failed\"; }\n\n[ -n \"$1\" ] && [ \"$1\" -gt 0 ] || fatal \"no duration specified\"\n[ -n \"$2\" ] && [ \"$2\" -gt 0 ] || fatal \"no authors specified\"\n\n# Move to the Etherpad base directory.\nMY_DIR=$(try cd \"${0%/*}\" && try pwd -P) || exit 1\ntry cd \"${MY_DIR}/../../../..\"\n\nsed -e '/^ *\"importExportRateLimiting\":/,/^ *\\}/ s/\"max\":.*/\"max\": 100000000/' -i settings.json.template\n\ntry sed -e '\ns!\"loadTest\":[^,]*!\"loadTest\": true!\n# Reduce rate limit aggressiveness\ns!\"points\":[^,]*!\"points\": 1000!\n' settings.json.template >settings.json\n\nlog \"Assuming src/bin/installDeps.sh has already been run\"\n(cd src && pnpm run prod &\nep_pid=$!)\n\nlog \"Waiting for Etherpad to accept connections (http://localhost:9001)...\"\nconnected=false\ncan_connect() {\n    curl -sSfo /dev/null http://localhost:9001/ || return 1\n    connected=true\n}\nnow() { date +%s; }\nstart=$(now)\nwhile [ $(($(now) - $start)) -le 60 ] && ! can_connect; do\n    sleep 1\ndone\n[ \"$connected\" = true ] \\\n    || fatal \"Timed out waiting for Etherpad to accept connections\"\nlog \"Successfully connected to Etherpad on http://localhost:9001\"\n\n# Build the minified files\ntry curl http://localhost:9001/p/minifyme -f -s >/dev/null\n\n# just in case, let's wait for another 10 seconds before going on\nsleep 10\n\nlog \"Running the load tests...\"\n# -d is duration of test, -a is number of authors to test with\n# by specifying the number of authors we set the overall rate of messages\netherpad-loadtest -d \"$1\" -a \"$2\"\nexit_code=$?\n\nkill \"$ep_pid\" && wait \"$ep_pid\"\nlog \"Done.\"\nexit \"$exit_code\"\n"
  },
  {
    "path": "src/tests/frontend-new/admin-spec/adminsettings.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {loginToAdmin, restartEtherpad, saveSettings} from \"../helper/adminhelper\";\n\ntest.beforeEach(async ({ page })=>{\n    await loginToAdmin(page, 'admin', 'changeme1');\n})\n\ntest.describe('admin settings',()=> {\n\n\n    test('Are Settings visible, populated, does save work', async ({page}) => {\n        await page.goto('http://localhost:9001/admin/settings');\n        await page.waitForSelector('.settings');\n        const settings =  page.locator('.settings');\n        await expect(settings).not.toBeEmpty();\n\n        const settingsVal = await settings.inputValue()\n        const settingsLength = settingsVal.length\n\n        await settings.fill(`{\"title\": \"Etherpad123\"}`)\n        const newValue = await settings.inputValue()\n        expect(newValue).toContain('{\"title\": \"Etherpad123\"}')\n        expect(newValue.length).toEqual(24)\n        await saveSettings(page)\n\n        // Check if the changes were actually saved\n        await page.reload()\n        await page.waitForSelector('.settings');\n        await expect(settings).not.toBeEmpty();\n\n        const newSettings =  page.locator('.settings');\n\n        const newSettingsVal = await newSettings.inputValue()\n        expect(newSettingsVal).toContain('{\"title\": \"Etherpad123\"}')\n\n\n        // Change back to old settings\n        await newSettings.fill(settingsVal)\n        await saveSettings(page)\n\n        await page.reload()\n        await page.waitForSelector('.settings');\n        await expect(settings).not.toBeEmpty();\n        const oldSettings =  page.locator('.settings');\n        const oldSettingsVal = await oldSettings.inputValue()\n        expect(oldSettingsVal).toEqual(settingsVal)\n        expect(oldSettingsVal.length).toEqual(settingsLength)\n    })\n\n    test('restart works', async function ({page}) {\n        await page.goto('http://localhost:9001/admin/settings');\n        await page.waitForSelector('.settings')\n        await restartEtherpad(page)\n        await page.waitForSelector('.settings')\n        const settings =  page.locator('.settings');\n        await expect(settings).not.toBeEmpty();\n        await page.waitForSelector('.menu')\n        await page.waitForTimeout(5000)\n    });\n})\n"
  },
  {
    "path": "src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {loginToAdmin} from \"../helper/adminhelper\";\n\ntest.beforeEach(async ({ page })=>{\n    await loginToAdmin(page, 'admin', 'changeme1');\n    await page.goto('http://localhost:9001/admin/help')\n})\n\ntest('Shows troubleshooting page manager', async ({page}) => {\n    await page.goto('http://localhost:9001/admin/help')\n    await page.waitForSelector('.menu')\n    const menu =  page.locator('.menu');\n    await expect(menu.locator('li')).toHaveCount(5);\n})\n\ntest('Shows a version number', async function ({page}) {\n    await page.goto('http://localhost:9001/admin/help')\n    await page.waitForSelector('.menu')\n    const helper = page.locator('.help-block').locator('div').nth(1)\n    const version = (await helper.textContent())!.split('.');\n    expect(version.length).toBe(3)\n});\n\ntest('Lists installed parts', async function ({page}) {\n    await page.goto('http://localhost:9001/admin/help')\n    await page.waitForSelector('.menu')\n    await page.waitForSelector('.innerwrapper ul')\n    const parts = page.locator('.innerwrapper ul').nth(1);\n    expect(await parts.textContent()).toContain('ep_etherpad-lite/adminsettings');\n});\n\ntest('Lists installed hooks', async function ({page}) {\n    await page.goto('http://localhost:9001/admin/help')\n    await page.waitForSelector('.menu')\n    await page.waitForSelector('.innerwrapper ul')\n    const helper = page.locator('.innerwrapper ul').nth(2);\n    expect(await helper.textContent()).toContain('express');\n});\n\n"
  },
  {
    "path": "src/tests/frontend-new/admin-spec/adminupdateplugins.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {loginToAdmin} from \"../helper/adminhelper\";\n\ntest.beforeEach(async ({ page })=>{\n    await loginToAdmin(page, 'admin', 'changeme1');\n    await page.goto('http://localhost:9001/admin/plugins')\n})\n\n\ntest.describe('Plugins page',  ()=> {\n\n    test('List some plugins', async ({page}) => {\n        await page.waitForSelector('.search-field');\n        const pluginTable =  page.locator('table tbody').nth(1);\n        await expect(pluginTable).not.toBeEmpty()\n    })\n\n    test('Searches for a plugin', async ({page}) => {\n        await page.waitForSelector('.search-field');\n        await page.click('.search-field')\n        await page.keyboard.type('ep_font_color')\n        await page.keyboard.press('Enter')\n        const pluginTable =  page.locator('table tbody').nth(1);\n        await expect(pluginTable.locator('tr')).toHaveCount(1)\n        await expect(pluginTable.locator('tr').first()).toContainText('ep_font_color')\n    })\n\n\n    test('Attempt to Install and Uninstall a plugin', async ({page}) => {\n        await page.waitForSelector('.search-field');\n        const pluginTable =  page.locator('table tbody').nth(1);\n        await expect(pluginTable).not.toBeEmpty({\n            timeout: 15000\n        })\n\n        // Now everything is loaded, lets install a plugin\n\n        await page.click('.search-field')\n        await page.keyboard.type('ep_font_color')\n        await page.keyboard.press('Enter')\n\n        await expect(pluginTable.locator('tr')).toHaveCount(1)\n        const pluginRow = pluginTable.locator('tr').first()\n        await expect(pluginRow).toContainText('ep_font_color')\n\n        // Select Installation button\n        await pluginRow.locator('td').nth(4).locator('button').first().click()\n        await page.waitForTimeout(100)\n        await page.waitForSelector('table tbody')\n        const installedPlugins = page.locator('table tbody').first()\n        const installedPluginsRows = installedPlugins.locator('tr')\n        await expect(installedPluginsRows).toHaveCount(2, {\n            timeout: 15000\n        })\n\n        const installedPluginRow = installedPluginsRows.nth(1)\n\n        await expect(installedPluginRow).toContainText('ep_font_color')\n        await installedPluginRow.locator('td').nth(2).locator('button').first().click()\n\n        // Wait for the uninstallation to complete\n        await expect(installedPluginsRows).toHaveCount(1, {\n            timeout: 15000\n        })\n        await page.waitForTimeout(5000)\n    })\n})\n\n\n/*\n  it('Attempt to Update a plugin', async function () {\n    this.timeout(280000);\n\n    await helper.waitForPromise(() => helper.admin$('.results').children().length > 50, 20000);\n\n    if (helper.admin$('.ep_align').length === 0) this.skip();\n\n    await helper.waitForPromise(\n        () => helper.admin$('.ep_align .version').text().split('.').length >= 2);\n\n    const minorVersionBefore =\n        parseInt(helper.admin$('.ep_align .version').text().split('.')[1]);\n\n    if (!minorVersionBefore) {\n      throw new Error('Unable to get minor number of plugin, is the plugin installed?');\n    }\n\n    if (minorVersionBefore !== 2) this.skip();\n\n    helper.waitForPromise(\n        () => helper.admin$('.ep_align .do-update').length === 1);\n\n    await timeout(500); // HACK!  Please submit better fix..\n    const $doUpdateButton = helper.admin$('.ep_align .do-update');\n    $doUpdateButton.trigger('click');\n\n    // ensure its showing as Updating\n    await helper.waitForPromise(\n        () => helper.admin$('.ep_align .message').text() === 'Updating');\n\n    // Ensure it's a higher minor version IE 0.3.x as 0.2.x was installed\n    // Coverage for https://github.com/ether/etherpad-lite/issues/4536\n    await helper.waitForPromise(() => parseInt(helper.admin$('.ep_align .version')\n        .text()\n        .split('.')[1]) > minorVersionBefore, 60000, 1000);\n    // allow 50 seconds, check every 1 second.\n  });\n */\n"
  },
  {
    "path": "src/tests/frontend-new/helper/adminhelper.ts",
    "content": "import {expect, Page} from \"@playwright/test\";\n\nexport const loginToAdmin = async (page: Page, username: string, password: string) => {\n\n    await page.goto('http://localhost:9001/admin/');\n\n    await page.waitForSelector('input[name=\"username\"]');\n    await page.fill('input[name=\"username\"]', username);\n    await page.fill('input[name=\"password\"]', password);\n    await page.click('input[type=\"submit\"]');\n}\n\n\nexport const saveSettings = async (page: Page) => {\n    // Click save\n    await page.locator('.settings-button-bar').locator('button').first().click()\n    await page.waitForSelector('.ToastRootSuccess')\n}\n\nexport const restartEtherpad = async (page: Page) => {\n    // Click restart\n    const restartButton = page.locator('.settings-button-bar').locator('.settingsButton').nth(1)\n    const settings =  page.locator('.settings');\n    await expect(settings).not.toBeEmpty();\n    await expect(restartButton).toBeVisible()\n    await page.locator('.settings-button-bar')\n        .locator('.settingsButton')\n        .nth(1)\n        .click()\n    await page.waitForTimeout(500)\n    await page.waitForSelector('.settings')\n}\n"
  },
  {
    "path": "src/tests/frontend-new/helper/padHelper.ts",
    "content": "import {Frame, Locator, Page} from \"@playwright/test\";\nimport {MapArrayType} from \"../../../node/types/MapType\";\nimport {randomUUID} from \"node:crypto\";\n\nexport const getPadOuter =  async (page: Page): Promise<Frame> => {\n    return page.frame('ace_outer')!;\n}\n\nexport const getPadBody =  async (page: Page): Promise<Locator> => {\n    return page.frame('ace_inner')!.locator('#innerdocbody')\n}\n\nexport const selectAllText = async (page: Page) => {\n    await page.keyboard.down('Control');\n    await page.keyboard.press('A');\n    await page.keyboard.up('Control');\n}\n\nexport const toggleUserList = async (page: Page) => {\n    await page.locator(\"button[data-l10n-id='pad.toolbar.showusers.title']\").click()\n}\n\nexport const setUserName = async (page: Page, userName: string) => {\n    await page.waitForSelector('[class=\"popup popup-show\"]')\n    await page.click(\"input[data-l10n-id='pad.userlist.entername']\");\n    await page.keyboard.type(userName);\n}\n\n\nexport const showChat = async (page: Page) => {\n    const chatIcon = page.locator(\"#chaticon\")\n    const classes = await chatIcon.getAttribute('class')\n    if (classes && !classes.includes('visible')) return\n    await chatIcon.click()\n    await page.waitForFunction(`!document.querySelector('#chaticon').classList.contains('visible')`)\n}\n\nexport const getCurrentChatMessageCount = async (page: Page) => {\n    return await page.locator('#chattext').locator('p').count()\n}\n\nexport const getChatUserName = async (page: Page) => {\n    return await page.locator('#chattext')\n        .locator('p')\n        .locator('b')\n        .innerText()\n}\n\nexport const getChatMessage = async (page: Page) => {\n    return (await page.locator('#chattext')\n        .locator('p')\n        .textContent({}))!\n        .split(await getChatTime(page))[1]\n\n}\n\n\nexport const getChatTime = async (page: Page) => {\n    return await page.locator('#chattext')\n        .locator('p')\n        .locator('.time')\n        .innerText()\n}\n\nexport const sendChatMessage = async (page: Page, message: string) => {\n    let currentChatCount = await getCurrentChatMessageCount(page)\n\n    const chatInput = page.locator('#chatinput')\n    await chatInput.click()\n    await page.keyboard.type(message)\n    await page.keyboard.press('Enter')\n    if(message === \"\") return\n    await page.waitForFunction(`document.querySelector('#chattext').querySelectorAll('p').length >${currentChatCount}`)\n}\n\nexport const isChatBoxShown = async (page: Page):Promise<boolean> => {\n    const classes = await page.locator('#chatbox').getAttribute('class')\n    return classes !==null && classes.includes('visible')\n}\n\nexport const isChatBoxSticky = async (page: Page):Promise<boolean> => {\n    const classes = await page.locator('#chatbox').getAttribute('class')\n    console.log('Chat', classes && classes.includes('stickyChat'))\n    return classes !==null && classes.includes('stickyChat')\n}\n\nexport const hideChat = async (page: Page) => {\n    if(!await isChatBoxShown(page)|| await isChatBoxSticky(page)) return\n    await page.locator('#titlecross').click()\n    await page.waitForFunction(`!document.querySelector('#chatbox').classList.contains('stickyChat')`)\n\n}\n\nexport const enableStickyChatviaIcon = async (page: Page) => {\n    if(await isChatBoxSticky(page)) return\n    await page.locator('#titlesticky').click()\n    await page.waitForFunction(`document.querySelector('#chatbox').classList.contains('stickyChat')`)\n}\n\nexport const disableStickyChatviaIcon = async (page: Page) => {\n    if(!await isChatBoxSticky(page)) return\n    await page.locator('#titlecross').click()\n    await page.waitForFunction(`!document.querySelector('#chatbox').classList.contains('stickyChat')`)\n}\n\n\nexport const appendQueryParams = async (page: Page, queryParameters: MapArrayType<string>) => {\n    const searchParams = new URLSearchParams(page.url().split('?')[1]);\n    Object.keys(queryParameters).forEach((key) => {\n        searchParams.append(key, queryParameters[key]);\n    });\n    await page.goto(page.url()+\"?\"+ searchParams.toString());\n    await page.waitForSelector('iframe[name=\"ace_outer\"]');\n}\n\nexport const goToNewPad = async (page: Page) => {\n    // create a new pad before each test run\n    const padId = \"FRONTEND_TESTS\"+randomUUID();\n    await page.goto('http://localhost:9001/p/'+padId);\n    await page.waitForSelector('iframe[name=\"ace_outer\"]');\n    return padId;\n}\n\nexport const goToPad = async (page: Page, padId: string) => {\n    await page.goto('http://localhost:9001/p/'+padId);\n    await page.waitForSelector('iframe[name=\"ace_outer\"]');\n}\n\n\nexport const clearPadContent = async (page: Page) => {\n    const body = await getPadBody(page);\n    await body.click();\n    await page.keyboard.down('Control');\n    await page.keyboard.press('A');\n    await page.keyboard.up('Control');\n    await page.keyboard.press('Delete');\n}\n\nexport const writeToPad = async (page: Page, text: string) => {\n    const body = await getPadBody(page);\n    await body.click();\n    await page.keyboard.type(text);\n}\n\nexport const clearAuthorship = async (page: Page) => {\n    await page.locator(\"button[data-l10n-id='pad.toolbar.clearAuthorship.title']\").click()\n}\n\nexport const undoChanges = async (page: Page) => {\n    await page.keyboard.down('Control');\n    await page.keyboard.press('z');\n    await page.keyboard.up('Control');\n}\n\nexport const pressUndoButton = async (page: Page) => {\n    await page.locator('.buttonicon-undo').click()\n}\n"
  },
  {
    "path": "src/tests/frontend-new/helper/settingsHelper.ts",
    "content": "import {Page} from \"@playwright/test\";\n\nexport const isSettingsShown = async (page: Page) => {\n    const classes = await page.locator('#settings').getAttribute('class')\n    return classes && classes.includes('popup-show')\n}\n\n\nexport const showSettings = async (page: Page) => {\n    if(await isSettingsShown(page)) return\n    await page.locator(\"button[data-l10n-id='pad.toolbar.settings.title']\").click()\n    await page.waitForFunction(`document.querySelector('#settings').classList.contains('popup-show')`)\n}\n\nexport const hideSettings = async (page: Page) => {\n    if(!await isSettingsShown(page)) return\n    await page.locator(\"button[data-l10n-id='pad.toolbar.settings.title']\").click()\n    await page.waitForFunction(`!document.querySelector('#settings').classList.contains('popup-show')`)\n}\n\nexport const enableStickyChatviaSettings = async (page: Page) => {\n    const stickyChat = page.locator('#options-stickychat')\n    const checked = await stickyChat.isChecked()\n    if(checked) return\n    await stickyChat.check({force: true})\n    await page.waitForSelector('#options-stickychat:checked')\n}\n\nexport const disableStickyChat = async (page: Page) => {\n    const stickyChat = page.locator('#options-stickychat')\n    const checked = await stickyChat.isChecked()\n    if(!checked) return\n    await stickyChat.uncheck({force: true})\n    await page.waitForSelector('#options-stickychat:not(:checked)')\n}\n"
  },
  {
    "path": "src/tests/frontend-new/helper/timeslider.ts",
    "content": "import {Page} from \"@playwright/test\";\n\n/**\n * Sets the src-attribute of the main iframe to the timeslider\n * In case a revision is given, sets the timeslider to this specific revision.\n * Defaults to going to the last revision.\n * It waits until the timer is filled with date and time, because it's one of the\n * last things that happen during timeslider load\n *\n * @param page\n * @param {number} [revision] the optional revision\n * @returns {Promise}\n * @todo for some reason this does only work the first time, you cannot\n * goto rev 0 and then via the same method to rev 5. Use buttons instead\n */\nexport const gotoTimeslider = async (page: Page, revision: number): Promise<any> => {\n    let revisionString = Number.isInteger(revision) ? `#${revision}` : '';\n    await page.goto(`${page.url()}/timeslider${revisionString}`);\n    await page.waitForSelector('#timer')\n};\n"
  },
  {
    "path": "src/tests/frontend-new/specs/alphabet.spec.ts",
    "content": "import {expect, Page, test} from \"@playwright/test\";\nimport {clearPadContent, getPadBody, getPadOuter, goToNewPad} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n    // create a new pad before each test run\n    await goToNewPad(page);\n})\n\ntest.describe('All the alphabet works n stuff', () => {\n    const expectedString = 'abcdefghijklmnopqrstuvwxyz';\n\n    test('when you enter any char it appears right', async ({page}) => {\n\n        // get the inner iframe\n        const innerFrame =  await getPadBody(page!);\n\n        await innerFrame.click();\n\n        // delete possible old content\n        await clearPadContent(page!);\n\n\n        await page.keyboard.type(expectedString);\n        const text = await innerFrame.locator('div').innerText();\n        expect(text).toBe(expectedString);\n    });\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/bold.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {randomInt} from \"node:crypto\";\nimport {getPadBody, goToNewPad, selectAllText} from \"../helper/padHelper\";\nimport exp from \"node:constants\";\n\ntest.beforeEach(async ({ page })=>{\n    await goToNewPad(page);\n})\n\ntest.describe('bold button', ()=>{\n\n    test('makes text bold on click', async ({page}) => {\n// get the inner iframe\n        const innerFrame = await getPadBody(page);\n\n        await innerFrame.click()\n        // Select pad text\n        await selectAllText(page);\n        await page.keyboard.type(\"Hi Etherpad\");\n        await selectAllText(page);\n\n        // click the bold button\n        await page.locator(\"button[data-l10n-id='pad.toolbar.bold.title']\").click();\n\n\n        // check if the text is bold\n        expect(await innerFrame.locator('b').innerText()).toBe('Hi Etherpad');\n    })\n\n    test('makes text bold on keypress', async ({page}) => {\n        // get the inner iframe\n        const innerFrame = await getPadBody(page);\n\n        await innerFrame.click()\n        // Select pad text\n        await selectAllText(page);\n        await page.keyboard.type(\"Hi Etherpad\");\n        await selectAllText(page);\n\n        // Press CTRL + B\n        await page.keyboard.down('Control');\n        await page.keyboard.press('b');\n        await page.keyboard.up('Control');\n\n\n        // check if the text is bold\n        expect(await innerFrame.locator('b').innerText()).toBe('Hi Etherpad');\n    })\n\n})\n"
  },
  {
    "path": "src/tests/frontend-new/specs/change_user_color.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {goToNewPad, sendChatMessage, showChat} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({page}) => {\n    await goToNewPad(page);\n})\n\ntest.describe('change user color', function () {\n\n    test('Color picker matches original color and remembers the user color after a refresh',\n        async function ({page}) {\n\n            // click on the settings button to make settings visible\n            let $userButton = page.locator('.buttonicon-showusers');\n            await $userButton.click()\n\n            let $userSwatch = page.locator('#myswatch');\n            await $userSwatch.click()\n            // Change the color value of the Farbtastic color picker\n\n            const $colorPickerSave = page.locator('#mycolorpickersave');\n            let $colorPickerPreview = page.locator('#mycolorpickerpreview');\n\n            // Same color represented in two different ways\n            const testColorHash = '#abcdef';\n            const testColorRGB = 'rgb(171, 205, 239)';\n\n            // Check that the color picker matches the automatically assigned random color on the swatch.\n            // NOTE: This has a tiny chance of creating a false positive for passing in the\n            // off-chance the randomly assigned color is the same as the test color.\n            expect(await $colorPickerPreview.getAttribute('style')).toContain(await $userSwatch.getAttribute('style'));\n\n            // The swatch updates as the test color is picked.\n            await page.evaluate((testRGBColor) => {\n                document.getElementById('mycolorpickerpreview')!.style.backgroundColor = testRGBColor;\n            }, testColorRGB\n            )\n\n            await $colorPickerSave.click();\n\n            // give it a second to save the color on the server side\n            await page.waitForTimeout(1000)\n\n\n            // get a new pad, but don't clear the cookies\n            await goToNewPad(page)\n\n\n            // click on the settings button to make settings visible\n            await $userButton.click()\n\n            await $userSwatch.click()\n\n\n\n            expect(await $colorPickerPreview.getAttribute('style')).toContain(await $userSwatch.getAttribute('style'));\n        });\n\n    test('Own user color is shown when you enter a chat', async function ({page}) {\n\n        const colorOption = page.locator('#options-colorscheck');\n        if (!(await colorOption.isChecked())) {\n            await colorOption.check();\n        }\n\n        // click on the settings button to make settings visible\n        const $userButton = page.locator('.buttonicon-showusers');\n        await $userButton.click()\n\n        const $userSwatch = page.locator('#myswatch');\n        await $userSwatch.click()\n\n        const $colorPickerSave = page.locator('#mycolorpickersave');\n\n        // Same color represented in two different ways\n        const testColorHash = '#abcdef';\n        const testColorRGB = 'rgb(171, 205, 239)';\n\n        // The swatch updates as the test color is picked.\n        await page.evaluate((testRGBColor) => {\n                document.getElementById('mycolorpickerpreview')!.style.backgroundColor = testRGBColor;\n            }, testColorRGB\n        )\n\n\n        await $colorPickerSave.click();\n        // click on the chat button to make chat visible\n        await showChat(page)\n        await sendChatMessage(page, 'O hi');\n\n        // wait until the chat message shows up\n        const chatP = page.locator('#chattext').locator('p')\n        const chatText = await chatP.innerText();\n\n        expect(chatText).toContain('O hi');\n\n        const color = await chatP.evaluate((el) => {\n            return window.getComputedStyle(el).getPropertyValue('background-color');\n        }, chatText);\n\n        expect(color).toBe(testColorRGB);\n    });\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/change_user_name.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {randomInt} from \"node:crypto\";\nimport {goToNewPad, sendChatMessage, setUserName, showChat, toggleUserList} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n    // create a new pad before each test run\n    await goToNewPad(page);\n})\n\n\ntest(\"Remembers the username after a refresh\", async ({page}) => {\n    await toggleUserList(page);\n    await setUserName(page,'😃')\n    await toggleUserList(page)\n\n    await page.reload();\n    await toggleUserList(page);\n    const usernameField = page.locator(\"input[data-l10n-id='pad.userlist.entername']\");\n    await expect(usernameField).toHaveValue('😃');\n})\n\n\ntest('Own user name is shown when you enter a chat', async ({page})=> {\n    const chatMessage = 'O hi';\n\n    await toggleUserList(page);\n    await setUserName(page,'😃');\n    await toggleUserList(page);\n\n    await showChat(page);\n    await sendChatMessage(page,chatMessage);\n    const chatText = await page.locator('#chattext').locator('p').innerText();\n    expect(chatText).toContain('😃')\n    expect(chatText).toContain(chatMessage)\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/chat.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {randomInt} from \"node:crypto\";\nimport {\n    appendQueryParams,\n    disableStickyChatviaIcon,\n    enableStickyChatviaIcon,\n    getChatMessage,\n    getChatTime,\n    getChatUserName,\n    getCurrentChatMessageCount, goToNewPad, hideChat, isChatBoxShown, isChatBoxSticky,\n    sendChatMessage,\n    showChat,\n} from \"../helper/padHelper\";\nimport {disableStickyChat, enableStickyChatviaSettings, hideSettings, showSettings} from \"../helper/settingsHelper\";\n\n\ntest.beforeEach(async ({ page })=>{\n    await goToNewPad(page);\n})\n\n\ntest('opens chat, sends a message, makes sure it exists on the page and hides chat', async ({page}) => {\n    const chatValue = \"JohnMcLear\"\n\n    // Open chat\n    await showChat(page);\n    await sendChatMessage(page, chatValue);\n\n    expect(await getCurrentChatMessageCount(page)).toBe(1);\n    const username = await getChatUserName(page)\n    const time = await getChatTime(page)\n    const chatMessage = await getChatMessage(page)\n\n    expect(username).toBe('unnamed:');\n    const regex = new RegExp('^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$');\n    expect(time).toMatch(regex);\n    expect(chatMessage).toBe(\" \"+chatValue);\n})\n\ntest(\"makes sure that an empty message can't be sent\", async function ({page}) {\n    const chatValue = 'mluto';\n\n    await showChat(page);\n\n    await sendChatMessage(page,\"\");\n    // Send a message\n    await sendChatMessage(page,chatValue);\n\n    expect(await getCurrentChatMessageCount(page)).toBe(1);\n\n    // check that the received message is not the empty one\n    const username = await getChatUserName(page)\n    const time = await getChatTime(page);\n    const chatMessage = await getChatMessage(page);\n\n    expect(username).toBe('unnamed:');\n    const regex = new RegExp('^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$');\n    expect(time).toMatch(regex);\n    expect(chatMessage).toBe(\" \"+chatValue);\n});\n\ntest('makes chat stick to right side of the screen via settings, remove sticky via settings, close it', async ({page}) =>{\n    await showSettings(page);\n\n    await enableStickyChatviaSettings(page);\n    expect(await isChatBoxShown(page)).toBe(true);\n    expect(await isChatBoxSticky(page)).toBe(true);\n\n    await disableStickyChat(page);\n    expect(await isChatBoxShown(page)).toBe(true);\n    expect(await isChatBoxSticky(page)).toBe(false);\n    await hideSettings(page);\n    await hideChat(page);\n    expect(await isChatBoxShown(page)).toBe(false);\n    expect(await isChatBoxSticky(page)).toBe(false);\n});\n\ntest('makes chat stick to right side of the screen via icon on the top right, ' +\n    'remove sticky via icon, close it', async function ({page}) {\n    await showChat(page);\n\n    await enableStickyChatviaIcon(page);\n    expect(await isChatBoxShown(page)).toBe(true);\n    expect(await isChatBoxSticky(page)).toBe(true);\n\n    await disableStickyChatviaIcon(page);\n    expect(await isChatBoxShown(page)).toBe(true);\n    expect(await isChatBoxSticky(page)).toBe(false);\n\n    await hideChat(page);\n    expect(await isChatBoxSticky(page)).toBe(false);\n    expect(await isChatBoxShown(page)).toBe(false);\n});\n\n\ntest('Checks showChat=false URL Parameter hides chat then' +\n    ' when removed it shows chat', async function ({page}) {\n\n    // get a new pad, but don't clear the cookies\n    await appendQueryParams(page, {\n        showChat: 'false'\n    });\n\n    const chaticon = page.locator('#chaticon')\n\n\n    // chat should be hidden.\n    expect(await chaticon.isVisible()).toBe(false);\n\n    // get a new pad, but don't clear the cookies\n    await goToNewPad(page);\n    const secondChatIcon = page.locator('#chaticon')\n\n    // chat should be visible.\n    expect(await secondChatIcon.isVisible()).toBe(true)\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/clear_authorship_color.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {\n    clearAuthorship,\n    clearPadContent,\n    getPadBody,\n    goToNewPad, pressUndoButton,\n    selectAllText,\n    undoChanges,\n    writeToPad\n} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n    // create a new pad before each test run\n    await goToNewPad(page);\n})\n\ntest('clear authorship color', async ({page}) => {\n    // get the inner iframe\n    const innerFrame =  await getPadBody(page);\n    const padText = \"Hello\"\n\n    // type some text\n    await clearPadContent(page);\n    await writeToPad(page, padText);\n    const retrievedClasses = await innerFrame.locator('div span').nth(0).getAttribute('class')\n    expect(retrievedClasses).toContain('author');\n\n    // select the text\n    await innerFrame.click()\n    await selectAllText(page);\n\n    await clearAuthorship(page);\n    // does the first div include an author class?\n    const firstDivClass = await innerFrame.locator('div').nth(0).getAttribute('class');\n    expect(firstDivClass).not.toContain('author');\n    const classes = page.locator('div.disconnected')\n    expect(await classes.isVisible()).toBe(false)\n})\n\n\ntest(\"makes text clear authorship colors and checks it can't be undone\", async function ({page}) {\n    const innnerPad = await getPadBody(page);\n    const padText = \"Hello\"\n\n    // type some text\n    await clearPadContent(page);\n    await writeToPad(page, padText);\n\n    // get the first text element out of the inner iframe\n    const firstDivClass = innnerPad.locator('div').nth(0)\n    const retrievedClasses = await innnerPad.locator('div span').nth(0).getAttribute('class')\n    expect(retrievedClasses).toContain('author');\n\n\n    await firstDivClass.focus()\n    await clearAuthorship(page);\n    expect(await firstDivClass.getAttribute('class')).not.toContain('author');\n\n    await undoChanges(page);\n    const changedFirstDiv = innnerPad.locator('div').nth(0)\n    expect(await changedFirstDiv.getAttribute('class')).not.toContain('author');\n\n\n    await pressUndoButton(page);\n    const secondChangedFirstDiv = innnerPad.locator('div').nth(0)\n    expect(await secondChangedFirstDiv.getAttribute('class')).not.toContain('author');\n});\n\n\n// Test for https://github.com/ether/etherpad-lite/issues/5128\ntest('clears authorship when first line has line attributes', async function ({page}) {\n    // Make sure there is text with author info. The first line must have a line attribute.\n    const padBody = await getPadBody(page);\n    await padBody.click()\n    await clearPadContent(page);\n    await writeToPad(page,'Hello')\n    await page.locator('.buttonicon-insertunorderedlist').click();\n    const retrievedClasses = await padBody.locator('div span').nth(0).getAttribute('class')\n    expect(retrievedClasses).toContain('author');\n    await padBody.click()\n    await selectAllText(page);\n    await clearAuthorship(page);\n    const retrievedClasses2 = await padBody.locator('div span').nth(0).getAttribute('class')\n    expect(retrievedClasses2).not.toContain('author');\n\n    expect(await page.locator('[class*=\"author-\"]').count()).toBe(0)\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/collab_client.spec.ts",
    "content": "import {clearPadContent, getPadBody, goToNewPad, goToPad, writeToPad} from \"../helper/padHelper\";\nimport {expect, Page, test} from \"@playwright/test\";\n\nlet padId = \"\";\n\ntest.beforeEach(async ({ page })=>{\n    // create a new pad before each test run\n    padId = await goToNewPad(page);\n    const body = await getPadBody(page);\n    await body.click();\n    await clearPadContent(page);\n    await writeToPad(page, \"Hello World\");\n    await page.keyboard.press('Enter');\n    await writeToPad(page, \"Hello World\");\n    await page.keyboard.press('Enter');\n    await writeToPad(page, \"Hello World\");\n    await page.keyboard.press('Enter');\n    await writeToPad(page, \"Hello World\");\n    await page.keyboard.press('Enter');\n    await writeToPad(page, \"Hello World\");\n    await page.keyboard.press('Enter');\n})\n\ntest.describe('Messages in the COLLABROOM', function () {\n    const user1Text = 'text created by user 1';\n    const user2Text = 'text created by user 2';\n\n    const replaceLineText = async (lineNumber: number, newText: string, page: Page) => {\n        const body = await getPadBody(page)\n\n        const div = body.locator('div').nth(lineNumber)\n\n        // simulate key presses to delete content\n        await div.locator('span').selectText() // select all\n        await page.keyboard.press('Backspace') // clear the first line\n        await page.keyboard.type(newText) // insert the string\n    };\n\n    test('bug #4978 regression test', async function ({browser}) {\n        // The bug was triggered by receiving a change from another user while simultaneously composing\n        // a character and waiting for an acknowledgement of a previously sent change.\n\n        // User 1\n        const context1 = await browser.newContext();\n        const page1 = await context1.newPage();\n        await goToPad(page1, padId)\n        const body1 = await getPadBody(page1)\n        // Perform actions as User 1...\n\n        // User 2\n        const context2 = await browser.newContext();\n        const page2 = await context2.newPage();\n        await goToPad(page2, padId)\n        const body2 = await getPadBody(page1)\n\n        await replaceLineText(0, user1Text,page1);\n\n        const text = await body2.locator('div').nth(0).textContent()\n        const res =  text === user1Text\n        expect(res).toBe(true)\n\n            // User 1 starts a character composition.\n\n\n        await replaceLineText(1, user2Text, page2)\n\n        await expect(body1.locator('div').nth(1)).toHaveText(user2Text)\n\n\n        // Users 1 and 2 make some more changes.\n        await replaceLineText(3, user2Text, page2);\n\n        await expect(body1.locator('div').nth(3)).toHaveText(user2Text)\n\n        await replaceLineText(2, user1Text, page1);\n        await expect(body2.locator('div').nth(2)).toHaveText(user1Text)\n\n        // All changes should appear in both views.\n        const expectedLines = [\n            user1Text,\n            user2Text,\n            user1Text,\n            user2Text,\n        ];\n\n        for (let i=0;i<expectedLines.length;i++){\n            expect(await body1.locator('div').nth(i).textContent()).toBe(expectedLines[i]);\n        }\n\n        for (let i=0;i<expectedLines.length;i++){\n            expect(await body2.locator('div').nth(i).textContent()).toBe(expectedLines[i]);\n        }\n    });\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/delete.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {clearPadContent, getPadBody, goToNewPad} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n    // create a new pad before each test run\n    await goToNewPad(page);\n})\n\n\ntest('delete keystroke', async ({page}) => {\n    const padText = \"Hello World this is a test\"\n    const body = await getPadBody(page)\n    await body.click()\n    await clearPadContent(page)\n    await page.keyboard.type(padText)\n    // Navigate to the end of the text\n    await page.keyboard.press('End');\n    // Delete the last character\n    await page.keyboard.press('Backspace');\n    const text = await body.locator('div').innerText();\n    expect(text).toBe(padText.slice(0, -1));\n})\n"
  },
  {
    "path": "src/tests/frontend-new/specs/editbar.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {clearPadContent, getPadBody, goToNewPad} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n  // create a new pad before each test run\n  await goToNewPad(page);\n})\n\ntest('should go to home on pad', async ({page}) => {\n  const homeButton = page.locator('.buttonicon.buttonicon-home')\n  const attribute = await homeButton.getAttribute('data-l10n-id')\n  expect(attribute).toBe('pad.toolbar.home.title');\n\n  await homeButton.click();\n  const url = page.url();\n  expect(url).not.toContain('/p/');\n})\n"
  },
  {
    "path": "src/tests/frontend-new/specs/embed_value.spec.ts",
    "content": "import {expect, Page, test} from \"@playwright/test\";\nimport {goToNewPad} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n    // create a new pad before each test run\n    await goToNewPad(page);\n})\n\ntest.describe('embed links', function () {\n    const objectify = function (str: string) {\n        const hash = {};\n        const parts = str.split('&');\n        for (let i = 0; i < parts.length; i++) {\n            const keyValue = parts[i].split('=');\n            // @ts-ignore\n            hash[keyValue[0]] = keyValue[1];\n        }\n        return hash;\n    };\n\n    const checkiFrameCode = async function (embedCode: string, readonly: boolean, page: Page) {\n        // turn the code into an html element\n\n        await page.setContent(embedCode, {waitUntil: 'load'})\n        const locator = page.locator('body').locator('iframe').last()\n\n\n        // read and check the frame attributes\n        const width = await locator.getAttribute('width');\n        const height = await locator.getAttribute('height');\n        const name = await locator.getAttribute('name');\n        expect(width).toBe('100%');\n        expect(height).toBe('600');\n        expect(name).toBe(readonly ? 'embed_readonly' : 'embed_readwrite');\n\n        // parse the url\n        const src = (await locator.getAttribute('src'))!;\n        const questionMark = src.indexOf('?');\n        const url = src.substring(0, questionMark);\n        const paramsStr = src.substring(questionMark + 1);\n        const params = objectify(paramsStr);\n\n        const expectedParams = {\n            showControls: 'true',\n            showChat: 'true',\n            showLineNumbers: 'true',\n            useMonospaceFont: 'false',\n        };\n\n        // check the url\n        if (readonly) {\n            expect(url.indexOf('r.') > 0).toBe(true);\n        } else {\n            expect(url).toBe(await page.evaluate(() => window.location.href));\n        }\n\n        // check if all parts of the url are like expected\n        expect(params).toEqual(expectedParams);\n    };\n\n    test.describe('read and write', function () {\n        test.beforeEach(async ({ page })=>{\n            // create a new pad before each test run\n            await goToNewPad(page);\n        })\n            test('the share link is the actual pad url', async function ({page}) {\n\n                const shareButton = page.locator('.buttonicon-embed')\n                // open share dropdown\n                await shareButton.click()\n\n                // get the link of the share field + the actual pad url and compare them\n                const shareLink = await page.locator('#linkinput').inputValue()\n                const padURL = page.url();\n                expect(shareLink).toBe(padURL);\n            });\n\n        test('is an iframe with the correct url parameters and correct size', async function ({page}) {\n\n                const shareButton = page.locator('.buttonicon-embed')\n                await shareButton.click()\n\n                // get the link of the share field + the actual pad url and compare them\n                const embedCode = await page.locator('#embedinput').inputValue()\n\n\n                await checkiFrameCode(embedCode, false, page);\n            });\n    });\n\n    test.describe('when read only option is set', function () {\n        test.beforeEach(async ({ page })=>{\n            // create a new pad before each test run\n            await goToNewPad(page);\n        })\n\n            test('the share link shows a read only url', async function ({page}) {\n\n                // open share dropdown\n                const shareButton = page.locator('.buttonicon-embed')\n                await shareButton.click()\n                const readonlyCheckbox = page.locator('#readonlyinput')\n                await readonlyCheckbox.click({\n                    force: true\n                })\n                await page.waitForSelector('#readonlyinput:checked')\n\n                // get the link of the share field + the actual pad url and compare them\n                const shareLink = await page.locator('#linkinput').inputValue()\n                const containsReadOnlyLink = shareLink.indexOf('r.') > 0;\n                expect(containsReadOnlyLink).toBe(true);\n            });\n\n            test('the embed as iframe code is an iframe with the correct url parameters and correct size', async function ({page}) {\n\n\n                // open share dropdown\n                const shareButton = page.locator('.buttonicon-embed')\n                await shareButton.click()\n\n                // check read only checkbox, a bit hacky\n                const readonlyCheckbox = page.locator('#readonlyinput')\n                await readonlyCheckbox.click({\n                    force: true\n                })\n\n                await page.waitForSelector('#readonlyinput:checked')\n\n\n                // get the link of the share field + the actual pad url and compare them\n                const embedCode = await page.locator('#embedinput').inputValue()\n\n                await checkiFrameCode(embedCode, true, page);\n            });\n    })\n})\n"
  },
  {
    "path": "src/tests/frontend-new/specs/enter.spec.ts",
    "content": "'use strict';\nimport {expect, test} from \"@playwright/test\";\nimport {getPadBody, goToNewPad, writeToPad} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n    await goToNewPad(page);\n})\n\ntest.describe('enter keystroke', function () {\n\n    test('creates a new line & puts cursor onto a new line', async function ({page}) {\n        const padBody = await getPadBody(page);\n\n        // get the first text element out of the inner iframe\n        const firstTextElement = padBody.locator('div').nth(0)\n\n        // get the original string value minus the last char\n        const originalTextValue = await firstTextElement.textContent();\n\n        // simulate key presses to enter content\n        await firstTextElement.click()\n        await page.keyboard.press('Home');\n        await page.keyboard.press('Enter');\n\n        const updatedFirstElement = padBody.locator('div').nth(0)\n        expect(await updatedFirstElement.textContent()).toBe('')\n\n        const newSecondLine = padBody.locator('div').nth(1);\n        // expect the second line to be the same as the original first line.\n        expect(await newSecondLine.textContent()).toBe(originalTextValue);\n    });\n\n    test('enter is always visible after event', async function ({page}) {\n        const padBody = await getPadBody(page);\n        const originalLength = await padBody.locator('div').count();\n        let lastLine = padBody.locator('div').last();\n\n        // simulate key presses to enter content\n        let i = 0;\n        const numberOfLines = 15;\n        while (i < numberOfLines) {\n            lastLine = padBody.locator('div').last();\n            await lastLine.focus();\n            await page.keyboard.press('End');\n            await page.keyboard.press('Enter');\n\n            // check we can see the caret..\n            i++;\n        }\n\n        expect(await padBody.locator('div').count()).toBe(numberOfLines + originalLength);\n\n        // is edited line fully visible?\n        const lastDiv = padBody.locator('div').last()\n        const lastDivOffset = await lastDiv.boundingBox();\n        const bottomOfLastLine = lastDivOffset!.y + lastDivOffset!.height;\n        const scrolledWindow = page.frames()[0];\n        const windowOffset = await scrolledWindow.evaluate(() => window.pageYOffset);\n        const windowHeight = await scrolledWindow.evaluate(() => window.innerHeight);\n\n        expect(windowOffset + windowHeight).toBeGreaterThan(bottomOfLastLine);\n    });\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/font_type.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {getPadBody, goToNewPad} from \"../helper/padHelper\";\nimport {showSettings} from \"../helper/settingsHelper\";\n\ntest.beforeEach(async ({ page })=>{\n    // create a new pad before each test run\n    await goToNewPad(page);\n})\n\n\ntest.describe('font select', function () {\n    // create a new pad before each test run\n\n    test('makes text RobotoMono', async function ({page}) {\n        // click on the settings button to make settings visible\n        await showSettings(page);\n\n        // get the font menu and RobotoMono option\n        const viewFontMenu = page.locator('#viewfontmenu');\n\n        // select RobotoMono and fire change event\n        // $RobotoMonooption.attr('selected','selected');\n        // commenting out above will break safari test\n        const dropdown = page.locator('.dropdowns-container .dropdown-line .current').nth(0)\n        await dropdown.click()\n        await page.locator('li:text(\"RobotoMono\")').click()\n\n        await viewFontMenu.dispatchEvent('change');\n        const padBody = await getPadBody(page)\n        const color = await padBody.evaluate((e) => {\n            return window.getComputedStyle(e).getPropertyValue(\"font-family\")\n        })\n\n\n        // check if font changed to RobotoMono\n        const containsStr = color.toLowerCase().indexOf('robotomono');\n        expect(containsStr).not.toBe(-1);\n    });\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/indentation.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {clearPadContent, getPadBody, goToNewPad, writeToPad} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n    await goToNewPad(page);\n})\n\ntest.describe('indentation button', function () {\n    test('indent text with keypress', async function ({page}) {\n        const padBody = await getPadBody(page);\n\n        // get the first text element out of the inner iframe\n        const $firstTextElement = padBody.locator('div').first();\n\n        // select this text element\n        await $firstTextElement.selectText()\n\n        await page.keyboard.press('Tab');\n\n        const uls = padBody.locator('div').first().locator('ul li')\n        await expect(uls).toHaveCount(1);\n    });\n\n    test('indent text with button', async function ({page}) {\n        const padBody = await getPadBody(page);\n        await page.locator('.buttonicon-indent').click()\n\n        const uls = padBody.locator('div').first().locator('ul')\n        await expect(uls).toHaveCount(1);\n    });\n\n\n    test('keeps the indent on enter for the new line', async function ({page}) {\n        const padBody = await getPadBody(page);\n        await padBody.click()\n        await clearPadContent(page)\n\n        await page.locator('.buttonicon-indent').click()\n\n        // type a bit, make a line break and type again\n        await padBody.focus()\n        await page.keyboard.type('line 1')\n        await page.keyboard.press('Enter');\n        await page.keyboard.type('line 2')\n        await page.keyboard.press('Enter');\n\n        const $newSecondLine = padBody.locator('div span').nth(1)\n\n        const hasULElement = padBody.locator('ul li')\n\n        await expect(hasULElement).toHaveCount(3);\n        await expect($newSecondLine).toHaveText('line 2');\n    });\n\n\n    test('indents text with spaces on enter if previous line ends ' +\n        \"with ':', '[', '(', or '{'\", async function ({page}) {\n        const padBody = await getPadBody(page);\n        await padBody.click()\n        await clearPadContent(page)\n        // type a bit, make a line break and type again\n        const $firstTextElement = padBody.locator('div').first();\n        await writeToPad(page, \"line with ':'\");\n        await page.keyboard.press('Enter');\n        await writeToPad(page, \"line with '['\");\n        await page.keyboard.press('Enter');\n        await writeToPad(page, \"line with '('\");\n        await page.keyboard.press('Enter');\n        await writeToPad(page, \"line with '{{}'\");\n\n        await expect(padBody.locator('div').nth(3)).toHaveText(\"line with '{{}'\");\n\n        // we validate bottom to top for easier implementation\n\n\n        // curly braces\n        const $lineWithCurlyBraces = padBody.locator('div').nth(3)\n        await $lineWithCurlyBraces.click();\n        await page.keyboard.press('End');\n        await page.keyboard.type('{{');\n\n        // cannot use sendkeys('{enter}') here, browser does not read the command properly\n        await page.keyboard.press('Enter');\n\n        expect(await padBody.locator('div').nth(4).textContent()).toMatch(/\\s{4}/); // tab === 4 spaces\n\n\n\n        // parenthesis\n        const $lineWithParenthesis = padBody.locator('div').nth(2)\n        await $lineWithParenthesis.click();\n        await page.keyboard.press('End');\n        await page.keyboard.type('(');\n        await page.keyboard.press('Enter');\n        const $lineAfterParenthesis = padBody.locator('div').nth(3)\n        expect(await $lineAfterParenthesis.textContent()).toMatch(/\\s{4}/);\n\n        // bracket\n        const $lineWithBracket = padBody.locator('div').nth(1)\n        await $lineWithBracket.click();\n        await page.keyboard.press('End');\n        await page.keyboard.type('[');\n        await page.keyboard.press('Enter');\n        const $lineAfterBracket = padBody.locator('div').nth(2);\n        expect(await $lineAfterBracket.textContent()).toMatch(/\\s{4}/);\n\n        // colon\n        const $lineWithColon = padBody.locator('div').first();\n        await $lineWithColon.click();\n        await page.keyboard.press('End');\n        await page.keyboard.type(':');\n        await page.keyboard.press('Enter');\n        const $lineAfterColon = padBody.locator('div').nth(1);\n        expect(await $lineAfterColon.textContent()).toMatch(/\\s{4}/);\n    });\n\n    test('appends indentation to the indent of previous line if previous line ends ' +\n        \"with ':', '[', '(', or '{'\", async function ({page}) {\n        const padBody = await getPadBody(page);\n        await padBody.click()\n        await clearPadContent(page)\n\n        // type a bit, make a line break and type again\n        await writeToPad(page, \"  line with some indentation and ':'\")\n        await page.keyboard.press('Enter');\n        await writeToPad(page, \"line 2\")\n\n        const $lineWithColon = padBody.locator('div').first();\n        await $lineWithColon.click();\n        await page.keyboard.press('End');\n        await page.keyboard.type(':');\n        await page.keyboard.press('Enter');\n\n        const $lineAfterColon = padBody.locator('div').nth(1);\n        // previous line indentation + regular tab (4 spaces)\n        expect(await $lineAfterColon.textContent()).toMatch(/\\s{6}/);\n    });\n\n    test(\"issue #2772 shows '*' when multiple indented lines \" +\n        ' receive a style and are outdented', async function ({page}) {\n\n        const padBody = await getPadBody(page);\n        await padBody.click()\n        await clearPadContent(page)\n\n        const inner = padBody.locator('div').first();\n        // make sure pad has more than one line\n        await inner.click()\n        await page.keyboard.type('First');\n        await page.keyboard.press('Enter');\n        await page.keyboard.type('Second');\n\n\n        // indent first 2 lines\n        await padBody.locator('div').nth(0).selectText();\n        await page.locator('.buttonicon-indent').click()\n\n        await padBody.locator('div').nth(1).selectText();\n        await page.locator('.buttonicon-indent').click()\n\n\n        await expect(padBody.locator('ul li')).toHaveCount(2);\n\n\n        // apply bold\n        await padBody.locator('div').nth(0).selectText();\n        await page.locator('.buttonicon-bold').click()\n\n        await padBody.locator('div').nth(1).selectText();\n        await page.locator('.buttonicon-bold').click()\n\n        await expect(padBody.locator('div b')).toHaveCount(2);\n\n        // outdent first 2 lines\n        await padBody.locator('div').nth(0).selectText();\n        await page.locator('.buttonicon-outdent').click()\n\n        await padBody.locator('div').nth(1).selectText();\n        await page.locator('.buttonicon-outdent').click()\n\n        await expect(padBody.locator('ul li')).toHaveCount(0);\n\n        // check if '*' is displayed\n        const secondLine = padBody.locator('div').nth(1);\n        await expect(secondLine).toHaveText('Second');\n    });\n\n    test('makes text indented and outdented', async function ({page}) {\n        // get the inner iframe\n\n        const padBody = await getPadBody(page);\n\n        // get the first text element out of the inner iframe\n        let firstTextElement = padBody.locator('div').first();\n\n        // select this text element\n        await firstTextElement.selectText()\n\n        // get the indentation button and click it\n        await page.locator('.buttonicon-indent').click()\n\n        let newFirstTextElement = padBody.locator('div').first();\n\n        // is there a list-indent class element now?\n        await expect(newFirstTextElement.locator('ul')).toHaveCount(1);\n\n        await expect(newFirstTextElement.locator('li')).toHaveCount(1);\n\n        // indent again\n        await page.locator('.buttonicon-indent').click()\n\n        newFirstTextElement = padBody.locator('div').first();\n\n\n        // is there a list-indent class element now?\n        const ulList = newFirstTextElement.locator('ul').first()\n        await expect(ulList).toHaveCount(1);\n        // expect it to be part of a list\n        expect(await ulList.getAttribute('class')).toBe('list-indent2');\n\n        // make sure the text hasn't changed\n        expect(await newFirstTextElement.textContent()).toBe(await firstTextElement.textContent());\n\n\n        // test outdent\n\n        // get the unindentation button and click it twice\n        newFirstTextElement = padBody.locator('div').first();\n        await newFirstTextElement.selectText()\n        await page.locator('.buttonicon-outdent').click()\n        await page.locator('.buttonicon-outdent').click()\n\n        newFirstTextElement = padBody.locator('div').first();\n\n        // is there a list-indent class element now?\n        await expect(newFirstTextElement.locator('ul')).toHaveCount(0);\n\n        // make sure the text hasn't changed\n        expect(await newFirstTextElement.textContent()).toEqual(await firstTextElement.textContent());\n    });\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/inner_height.spec.ts",
    "content": "'use strict';\n\nimport {expect, test} from \"@playwright/test\";\nimport {clearPadContent, getPadBody, goToNewPad, writeToPad} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n    await goToNewPad(page);\n})\n\ntest.describe('height regression after ace.js refactoring', function () {\n\n    test('clientHeight should equal scrollHeight with few lines', async function ({page}) {\n        const padBody = await getPadBody(page);\n        await padBody.click()\n        await clearPadContent(page)\n\n        const iframe = page.locator('iframe').first()\n        const scrollHeight =  await iframe.evaluate((element) => {\n            return element.scrollHeight;\n        })\n\n        const clientHeight =  await iframe.evaluate((element) => {\n            return element.clientHeight;\n        })\n\n\n        expect(clientHeight).toEqual(scrollHeight);\n    });\n\n    test('client height should be less than scrollHeight with many lines', async function ({page}) {\n        const padBody = await getPadBody(page);\n        await padBody.click()\n        await clearPadContent(page)\n\n        await writeToPad(page,'Test line\\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\\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\\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\\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\\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\\n\\n\\n\\n\\n\\n\\n\\n');\n\n        const iframe = page.locator('iframe').first()\n        const scrollHeight =  await iframe.evaluate((element) => {\n            return element.scrollHeight;\n        })\n\n        const clientHeight =  await iframe.evaluate((element) => {\n            return element.clientHeight;\n        })\n\n        // Need to poll because the heights take some time to settle.\n        expect(clientHeight).toBeLessThanOrEqual(scrollHeight);\n    });\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/italic.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {clearPadContent, getPadBody, goToNewPad, writeToPad} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n    await goToNewPad(page);\n})\n\ntest.describe('italic some text', function () {\n\n    test('makes text italic using button', async function ({page}) {\n        const padBody = await getPadBody(page);\n        await padBody.click()\n        await clearPadContent(page)\n\n        // get the first text element out of the inner iframe\n        const $firstTextElement = padBody.locator('div').first();\n        await $firstTextElement.click()\n        await writeToPad(page, 'Foo')\n\n        // select this text element\n        await padBody.click()\n        await page.keyboard.press('Control+A');\n\n        // get the bold button and click it\n        const $boldButton = page.locator('.buttonicon-italic');\n        await $boldButton.click();\n\n        // ace creates a new dom element when you press a button, just get the first text element again\n        const $newFirstTextElement = padBody.locator('div').first();\n\n        // is there a <i> element now?\n        // expect it to be italic\n        await expect($newFirstTextElement.locator('i')).toHaveCount(1);\n\n\n        // make sure the text hasn't changed\n        expect(await $newFirstTextElement.textContent()).toEqual(await $firstTextElement.textContent());\n    });\n\n    test('makes text italic using keypress', async function ({page}) {\n        const padBody = await getPadBody(page);\n        await padBody.click()\n        await clearPadContent(page)\n\n        // get the first text element out of the inner iframe\n        const $firstTextElement = padBody.locator('div').first();\n\n        // select this text element\n        await writeToPad(page, 'Foo')\n\n        await page.keyboard.press('Control+A');\n\n        await page.keyboard.press('Control+I');\n\n        // ace creates a new dom element when you press a button, just get the first text element again\n        const $newFirstTextElement = padBody.locator('div').first();\n\n        // is there a <i> element now?\n        // expect it to be italic\n        await expect($newFirstTextElement.locator('i')).toHaveCount(1);\n\n        // make sure the text hasn't changed\n        expect(await $newFirstTextElement.textContent()).toBe(await $firstTextElement.textContent());\n    });\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/language.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {getPadBody, goToNewPad} from \"../helper/padHelper\";\nimport {showSettings} from \"../helper/settingsHelper\";\n\ntest.beforeEach(async ({ page, browser })=>{\n    const context = await browser.newContext()\n    await context.clearCookies()\n    await goToNewPad(page);\n})\n\n\n\ntest.describe('Language select and change', function () {\n\n    // Destroy language cookies\n    test('makes text german', async function ({page}) {\n        // click on the settings button to make settings visible\n        await showSettings(page)\n\n        // click the language button\n        const languageDropDown  = page.locator('.nice-select').nth(1)\n\n        await languageDropDown.click()\n        await page.locator('.nice-select').locator('[data-value=de]').click()\n        await expect(languageDropDown.locator('.current')).toHaveText('Deutsch')\n\n        // select german\n        await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title === 'Fett (Strg-B)');\n    });\n\n    test('makes text English', async function ({page}) {\n\n        await showSettings(page)\n\n        // click the language button\n        await page.locator('.nice-select').nth(1).locator('.current').click()\n        await page.locator('.nice-select').locator('[data-value=de]').click()\n\n        // select german\n        await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title === 'Fett (Strg-B)');\n\n\n        // change to english\n        await page.locator('.nice-select').nth(1).locator('.current').click()\n        await page.locator('.nice-select').locator('[data-value=en]').click()\n\n        // check if the language is now English\n        await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title !== 'Fett (Strg-B)');\n    });\n\n    test('changes direction when picking an rtl lang', async function ({page}) {\n\n        await showSettings(page)\n\n        // click the language button\n        await page.locator('.nice-select').nth(1).locator('.current').click()\n        await page.locator('.nice-select').locator('[data-value=de]').click()\n\n        // select german\n        await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title === 'Fett (Strg-B)');\n\n        // click the language button\n        await page.locator('.nice-select').nth(1).locator('.current').click()\n        // select arabic\n        // $languageoption.attr('selected','selected'); // Breaks the test..\n        await page.locator('.nice-select').locator('[data-value=ar]').click()\n\n        await page.waitForSelector('html[dir=\"rtl\"]')\n    });\n\n    test('changes direction when picking an ltr lang', async function ({page}) {\n        await showSettings(page)\n\n        // change to english\n        const languageDropDown  = page.locator('.nice-select').nth(1)\n        await languageDropDown.locator('.current').click()\n        await languageDropDown.locator('[data-value=en]').click()\n\n        await expect(languageDropDown.locator('.current')).toHaveText('English')\n\n        // check if the language is now English\n        await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title !== 'Fett (Strg-B)');\n\n\n        await page.waitForSelector('html[dir=\"ltr\"]')\n\n    });\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/ordered_list.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {clearPadContent, getPadBody, goToNewPad, writeToPad} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n  await goToNewPad(page);\n})\n\n\ntest.describe('ordered_list.js', function () {\n\n    test('issue #4748 keeps numbers increment on OL', async function ({page}) {\n      const padBody = await getPadBody(page);\n      await clearPadContent(page)\n      await writeToPad(page, 'Line 1')\n      await page.keyboard.press('Enter')\n      await writeToPad(page, 'Line 2')\n\n      const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist')\n      await padBody.locator('div').first().selectText()\n      await $insertorderedlistButton.first().click();\n\n      const secondLine = padBody.locator('div').nth(1)\n\n      await secondLine.selectText()\n      await $insertorderedlistButton.click();\n\n      expect(await secondLine.locator('ol').getAttribute('start')).toEqual('2');\n    });\n\n    test('issue #1125 keeps the numbered list on enter for the new line', async function ({page}) {\n      // EMULATES PASTING INTO A PAD\n      const padBody = await getPadBody(page);\n      await clearPadContent(page)\n      await expect(padBody.locator('div')).toHaveCount(1)\n      const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist')\n      await $insertorderedlistButton.click();\n\n      // type a bit, make a line break and type again\n      const firstTextElement = padBody.locator('div').first()\n      await firstTextElement.click()\n      await writeToPad(page, 'line 1')\n      await page.keyboard.press('Enter')\n      await writeToPad(page, 'line 2')\n      await page.keyboard.press('Enter')\n\n      await expect(padBody.locator('div span').nth(1)).toHaveText('line 2');\n\n        const $newSecondLine = padBody.locator('div').nth(1)\n      expect(await $newSecondLine.locator('ol li').count()).toEqual(1);\n        await expect($newSecondLine.locator('ol li').nth(0)).toHaveText('line 2');\n        const hasLineNumber = await $newSecondLine.locator('ol').getAttribute('start');\n      // This doesn't work because pasting in content doesn't work\n      expect(Number(hasLineNumber)).toBe(2);\n    });\n  });\n\n  test.describe('Pressing Tab in an OL increases and decreases indentation', function () {\n\n    test('indent and de-indent list item with keypress', async function ({page}) {\n      const padBody = await getPadBody(page);\n\n      // get the first text element out of the inner iframe\n      const $firstTextElement = padBody.locator('div').first();\n\n      // select this text element\n      await $firstTextElement.selectText()\n\n      const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist')\n      await $insertorderedlistButton.click()\n\n      await page.keyboard.press('Tab')\n\n      await expect(padBody.locator('div').first().locator('.list-number2')).toHaveCount(1)\n\n      await page.keyboard.press('Shift+Tab')\n\n\n      await expect(padBody.locator('div').first().locator('.list-number1')).toHaveCount(1)\n    });\n  });\n\n\n  test.describe('Pressing indent/outdent button in an OL increases and ' +\n      'decreases indentation and bullet / ol formatting', function () {\n\n    test('indent and de-indent list item with indent button', async function ({page}) {\n      const padBody = await getPadBody(page);\n\n      // get the first text element out of the inner iframe\n      const $firstTextElement = padBody.locator('div').first();\n\n      // select this text element\n      await $firstTextElement.selectText()\n\n      const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist')\n      await $insertorderedlistButton.click()\n\n      const $indentButton = page.locator('.buttonicon-indent')\n      await $indentButton.dblclick() // make it indented twice\n\n      const outdentButton = page.locator('.buttonicon-outdent')\n\n      await expect(padBody.locator('div').first().locator('.list-number3')).toHaveCount(1)\n\n      await outdentButton.click(); // make it deindented to 1\n\n      await expect(padBody.locator('div').first().locator('.list-number2')).toHaveCount(1)\n    });\n  });\n"
  },
  {
    "path": "src/tests/frontend-new/specs/redo.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {clearPadContent, getPadBody, goToNewPad, writeToPad} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n    await goToNewPad(page);\n})\n\n\ntest.describe('undo button then redo button', function () {\n\n\n    test('redo some typing with button', async function ({page}) {\n        const padBody = await getPadBody(page);\n\n        // get the first text element inside the editable space\n        const $firstTextElement = padBody.locator('div span').first();\n        const originalValue = await $firstTextElement.textContent(); // get the original value\n        const newString = 'Foo';\n\n        await $firstTextElement.focus()\n        expect(await $firstTextElement.textContent()).toContain(originalValue);\n        await padBody.click()\n        await clearPadContent(page)\n        await writeToPad(page, newString); // send line 1 to the pad\n\n        const modifiedValue = await $firstTextElement.textContent(); // get the modified value\n        expect(modifiedValue).not.toBe(originalValue); // expect the value to change\n\n        // get undo and redo buttons // click the buttons\n        await page.locator('.buttonicon-undo').click() // removes foo\n        await page.locator('.buttonicon-redo').click() // resends foo\n\n        await expect($firstTextElement).toHaveText(newString);\n\n        const finalValue = await padBody.locator('div').first().textContent();\n        expect(finalValue).toBe(modifiedValue); // expect the value to change\n    });\n\n    test('redo some typing with keypress', async function ({page}) {\n        const padBody = await getPadBody(page);\n\n        // get the first text element inside the editable space\n        const $firstTextElement = padBody.locator('div span').first();\n        const originalValue = await $firstTextElement.textContent(); // get the original value\n        const newString = 'Foo';\n\n        await padBody.click()\n        await clearPadContent(page)\n        await writeToPad(page, newString); // send line 1 to the pad\n        const modifiedValue = await $firstTextElement.textContent(); // get the modified value\n        expect(modifiedValue).not.toBe(originalValue); // expect the value to change\n\n        // undo the change\n        await padBody.click()\n        await page.keyboard.press('Control+Z');\n\n        await page.keyboard.press('Control+Y'); // redo the change\n\n\n        await expect($firstTextElement).toHaveText(newString);\n\n        const finalValue = await padBody.locator('div').first().textContent();\n        expect(finalValue).toBe(modifiedValue); // expect the value to change\n    });\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/strikethrough.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {clearPadContent, getPadBody, goToNewPad, writeToPad} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n    await goToNewPad(page);\n})\n\ntest.describe('strikethrough button', function () {\n\n    test('makes text strikethrough', async function ({page}) {\n        const padBody = await getPadBody(page);\n\n        // get the first text element out of the inner iframe\n        const $firstTextElement = padBody.locator('div').first();\n\n        // select this text element\n        await $firstTextElement.selectText()\n\n        // get the strikethrough button and click it\n        await page.locator('.buttonicon-strikethrough').click();\n\n        // ace creates a new dom element when you press a button, just get the first text element again\n\n        // is there a <i> element now?\n        await expect($firstTextElement.locator('s')).toHaveCount(1);\n\n        // make sure the text hasn't changed\n        expect(await $firstTextElement.textContent()).toEqual(await $firstTextElement.textContent());\n    });\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/timeslider.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {clearPadContent, getPadBody, goToNewPad, writeToPad} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n    // create a new pad before each test run\n    await goToNewPad(page);\n})\n\n\n// deactivated, we need a nice way to get the timeslider, this is ugly\ntest.describe('timeslider button takes you to the timeslider of a pad', function () {\n\n    test('timeslider contained in URL', async function ({page}) {\n        const padBody = await getPadBody(page);\n        await clearPadContent(page)\n        await writeToPad(page, 'Foo'); // send line 1 to the pad\n\n        // get the first text element inside the editable space\n        const $firstTextElement = padBody.locator('div span').first();\n        const originalValue = await $firstTextElement.textContent(); // get the original value\n        await $firstTextElement.click()\n        await writeToPad(page, 'Testing'); // send line 1 to the pad\n\n        const modifiedValue = await $firstTextElement.textContent(); // get the modified value\n        expect(modifiedValue).not.toBe(originalValue); // expect the value to change\n\n        const $timesliderButton = page.locator('.buttonicon-history');\n        await $timesliderButton.click(); // So click the timeslider link\n\n        await page.waitForSelector('#timeslider-wrapper')\n\n        const iFrameURL = page.url(); // get the url\n        const inTimeslider = iFrameURL.indexOf('timeslider') !== -1;\n\n        expect(inTimeslider).toBe(true); // expect the value to change\n    });\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/timeslider_follow.spec.ts",
    "content": "'use strict';\nimport {expect, Page, test} from \"@playwright/test\";\nimport {clearPadContent, getPadBody, goToNewPad, writeToPad} from \"../helper/padHelper\";\nimport {gotoTimeslider} from \"../helper/timeslider\";\n\ntest.beforeEach(async ({ page })=>{\n    await goToNewPad(page);\n})\n\n\ntest.describe('timeslider follow', function () {\n\n    // TODO needs test if content is also followed, when user a makes edits\n    // while user b is in the timeslider\n    test(\"content as it's added to timeslider\", async function ({page}) {\n        // send 6 revisions\n        const revs = 6;\n        const message = 'a\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n';\n        const newLines = message.split('\\n').length;\n        for (let i = 0; i < revs; i++) {\n            await writeToPad(page, message)\n        }\n\n        await gotoTimeslider(page,0);\n        expect(page.url()).toContain('#0');\n\n        const originalTop = await page.evaluate(() => {\n            return window.document.querySelector('#innerdocbody')!.getBoundingClientRect().top;\n        });\n\n        // set to follow contents as it arrives\n        await page.check('#options-followContents');\n        await page.click('#playpause_button_icon');\n\n        // wait for the scroll\n        await page.waitForTimeout(1000)\n\n        const currentOffset = await page.evaluate(() => {\n            return window.document.querySelector('#innerdocbody')!.getBoundingClientRect().top;\n        });\n\n        expect(currentOffset).toBeLessThanOrEqual(originalTop);\n    });\n\n    /**\n     * Tests for bug described in #4389\n     * The goal is to scroll to the first line that contains a change right before\n     * the change is applied.\n     */\n    test('only to lines that exist in the pad view, regression test for #4389', async function ({page}) {\n        const padBody = await getPadBody(page)\n        await padBody.click()\n\n        await clearPadContent(page)\n\n        await writeToPad(page,'Test line\\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\\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\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n\\n');\n        await padBody.locator('div').nth(40).click();\n        await writeToPad(page, 'Another test line');\n\n\n        await gotoTimeslider(page, 200);\n\n        // set to follow contents as it arrives\n        await page.check('#options-followContents');\n\n        await page.waitForTimeout(1000)\n\n        const oldYPosition = await page.locator('#editorcontainerbox').evaluate((el) => {\n          return el.scrollTop;\n        })\n        expect(oldYPosition).toBe(0);\n    });\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/undo.spec.ts",
    "content": "'use strict';\n\nimport {expect, test} from \"@playwright/test\";\nimport {clearPadContent, getPadBody, goToNewPad, writeToPad} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n    await goToNewPad(page);\n})\n\n\ntest.describe('undo button', function () {\n\n    test('undo some typing by clicking undo button', async function ({page}) {\n        const padBody = await getPadBody(page);\n        await padBody.click()\n        await clearPadContent(page)\n\n\n        // get the first text element inside the editable space\n        const firstTextElement = padBody.locator('div').first()\n        const originalValue = await firstTextElement.textContent(); // get the original value\n        await firstTextElement.focus()\n\n        await writeToPad(page, 'foo'); // send line 1 to the pad\n\n        const modifiedValue = await firstTextElement.textContent(); // get the modified value\n        expect(modifiedValue).not.toBe(originalValue); // expect the value to change\n\n        // get clear authorship button as a variable\n        const undoButton = page.locator('.buttonicon-undo')\n        await undoButton.click() // click the button\n\n        await expect(firstTextElement).toHaveText(originalValue!);\n    });\n\n    test('undo some typing using a keypress', async function ({page}) {\n        const padBody = await getPadBody(page);\n        await padBody.click()\n        await clearPadContent(page)\n\n        // get the first text element inside the editable space\n        const firstTextElement = padBody.locator('div').first()\n        const originalValue = await firstTextElement.textContent(); // get the original value\n\n        await firstTextElement.focus()\n        await writeToPad(page, 'foo'); // send line 1 to the pad\n        const modifiedValue = await firstTextElement.textContent(); // get the modified value\n        expect(modifiedValue).not.toBe(originalValue); // expect the value to change\n\n        // undo the change\n        await page.keyboard.press('Control+Z');\n        await page.waitForTimeout(1000)\n\n        await expect(firstTextElement).toHaveText(originalValue!);\n    });\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/unordered_list.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {clearPadContent, getPadBody, goToNewPad, writeToPad} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n    // create a new pad before each test run\n    await goToNewPad(page);\n})\n\ntest.describe('unordered_list.js', function () {\n    test.describe('assign unordered list', function () {\n        test('insert unordered list text then removes by outdent', async function ({page}) {\n            const padBody = await getPadBody(page);\n            const originalText = await padBody.locator('div').first().textContent();\n\n            const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist');\n            await $insertunorderedlistButton.click();\n\n            await expect(padBody.locator('div').first()).toHaveText(originalText!);\n            await expect(padBody.locator('div ul li')).toHaveCount(1);\n\n            // remove indentation by bullet and ensure text string remains the same\n            const $outdentButton = page.locator('.buttonicon-outdent');\n            await $outdentButton.click();\n            await expect(padBody.locator('div').first()).toHaveText(originalText!);\n        });\n    });\n\n    test.describe('unassign unordered list', function () {\n        // create a new pad before each test run\n\n\n        test('insert unordered list text then remove by clicking list again', async function ({page}) {\n            const padBody = await getPadBody(page);\n            const originalText = await padBody.locator('div').first().textContent();\n\n            await padBody.locator('div').first().selectText()\n            const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist');\n            await $insertunorderedlistButton.click();\n\n            await expect(padBody.locator('div').first()).toHaveText(originalText!);\n            await expect(padBody.locator('div ul li')).toHaveCount(1);\n\n            // remove indentation by bullet and ensure text string remains the same\n            await $insertunorderedlistButton.click();\n            await expect(padBody.locator('div').locator('ul')).toHaveCount(0)\n        });\n    });\n\n\n    test.describe('keep unordered list on enter key', function () {\n\n        test('Keeps the unordered list on enter for the new line', async function ({page}) {\n            const padBody = await getPadBody(page);\n            await clearPadContent(page)\n            await expect(padBody.locator('div')).toHaveCount(1)\n\n            const $insertorderedlistButton = page.locator('.buttonicon-insertunorderedlist')\n            await $insertorderedlistButton.click();\n\n            // type a bit, make a line break and type again\n            const $firstTextElement = padBody.locator('div').first();\n            await $firstTextElement.click()\n            await page.keyboard.type('line 1');\n            await page.keyboard.press('Enter');\n            await page.keyboard.type('line 2');\n            await page.keyboard.press('Enter');\n\n            await expect(padBody.locator('div span')).toHaveCount(2);\n\n\n            const $newSecondLine = padBody.locator('div').nth(1)\n            await expect($newSecondLine.locator('ul')).toHaveCount(1);\n            await expect($newSecondLine).toHaveText('line 2');\n        });\n    });\n\n    test.describe('Pressing Tab in an UL increases and decreases indentation', function () {\n\n        test('indent and de-indent list item with keypress', async function ({page}) {\n            const padBody = await getPadBody(page);\n            await clearPadContent(page)\n\n            // get the first text element out of the inner iframe\n            const $firstTextElement = padBody.locator('div').first();\n\n            // select this text element\n            await $firstTextElement.selectText();\n\n            const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist');\n            await $insertunorderedlistButton.click();\n\n            await padBody.locator('div').first().click();\n            await page.keyboard.press('Home');\n            await page.keyboard.press('Tab');\n            await expect(padBody.locator('div').first().locator('.list-bullet2')).toHaveCount(1);\n\n            await page.keyboard.press('Shift+Tab');\n\n            await expect(padBody.locator('div').first().locator('.list-bullet1')).toHaveCount(1);\n        });\n    });\n\n    test.describe('Pressing indent/outdent button in an UL increases and decreases indentation ' +\n        'and bullet / ol formatting', function () {\n\n        test('indent and de-indent list item with indent button', async function ({page}) {\n            const padBody = await getPadBody(page);\n\n            // get the first text element out of the inner iframe\n            const $firstTextElement = padBody.locator('div').first();\n\n            // select this text element\n            await $firstTextElement.selectText();\n\n            const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist');\n            await $insertunorderedlistButton.click();\n\n            await page.locator('.buttonicon-indent').click();\n\n            await expect(padBody.locator('div').first().locator('.list-bullet2')).toHaveCount(1);\n            const outdentButton = page.locator('.buttonicon-outdent');\n            await outdentButton.click();\n\n            await expect(padBody.locator('div').first().locator('.list-bullet1')).toHaveCount(1);\n        });\n    });\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/urls_become_clickable.spec.ts",
    "content": "import {expect, test} from \"@playwright/test\";\nimport {clearPadContent, getPadBody, goToNewPad, writeToPad} from \"../helper/padHelper\";\n\ntest.beforeEach(async ({ page })=>{\n    await goToNewPad(page);\n})\n\ntest.describe('entering a URL makes a link', function () {\n    for (const url of ['https://etherpad.org', 'www.etherpad.org', 'https://www.etherpad.org']) {\n        test(url, async function ({page}) {\n            const padBody = await getPadBody(page);\n            await clearPadContent(page)\n            const url = 'https://etherpad.org';\n            await writeToPad(page, url);\n            await expect(padBody.locator('div').first()).toHaveText(url);\n            await expect(padBody.locator('a')).toHaveText(url);\n            await expect(padBody.locator('a')).toHaveAttribute('href', url);\n        });\n    }\n});\n\n\ntest.describe('special characters inside URL', async function () {\n    for (const char of '-:@_.,~%+/?=&#!;()[]$\\'*') {\n        const url = `https://etherpad.org/${char}foo`;\n        test(url, async function ({page}) {\n            const padBody = await getPadBody(page);\n            await clearPadContent(page)\n            await padBody.click()\n            await clearPadContent(page)\n            await writeToPad(page, url);\n            await expect(padBody.locator('div').first()).toHaveText(url);\n            await expect(padBody.locator('a')).toHaveText(url);\n            await expect(padBody.locator('a')).toHaveAttribute('href', url);\n        });\n    }\n});\n\ntest.describe('punctuation after URL is ignored', ()=> {\n    for (const char of ':.,;?!)]\\'*') {\n        const want = 'https://etherpad.org';\n        const input = want + char;\n        test(input, async function ({page}) {\n            const padBody = await getPadBody(page);\n            await clearPadContent(page)\n            await writeToPad(page, input);\n            await expect(padBody.locator('a')).toHaveCount(1);\n            await expect(padBody.locator('a')).toHaveAttribute('href', want);\n        });\n    }\n});\n"
  },
  {
    "path": "src/tests/frontend-new/specs/welcome.spec.test.ts",
    "content": "import { test, expect } from '@playwright/test';\n\ntest.describe('Session Transfer Functionality', () => {\n  test.beforeEach(async ({ page, context }) => {\n    await context.addCookies([\n      {\n        name: 'token',\n        value: 'test-token-123',\n        domain: 'localhost',\n        path: '/',\n      },\n      {\n        name: 'prefsHttp',\n        value: 'test-prefs',\n        domain: 'localhost',\n        path: '/',\n      },\n    ]);\n\n    await page.goto('localhost:9001/');\n  });\n\n  test('should open settings dialog and transfer session', async ({\n                                                                    page,\n                                                                  }) => {\n    await page.route('**/tokenTransfer', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ id: 'transfer-id-12345678-1234-5678' }),\n      });\n    });\n\n    await page.locator('.settings-button').click();\n    const dialog = page.locator('#settings-dialog');\n    await expect(dialog).toBeVisible();\n\n    const transferButton = page.locator(\n      '[data-l10n-id=\"index.transferSessionNow\"]'\n    );\n    await expect(transferButton).toBeVisible();\n\n    await transferButton.click();\n\n    await expect(transferButton).toBeDisabled();\n    await expect(transferButton.locator('svg')).toBeVisible();\n\n    const copyLinkSection = page.locator('#copy-link-section');\n    await expect(copyLinkSection).toBeVisible();\n\n    const copyButton = copyLinkSection.locator('.btn-secondary');\n    await expect(copyButton).toBeVisible();\n  });\n\n  test('should copy transfer ID to clipboard', async ({ page }) => {\n    const transferId = 'abc123-transfer-id-xyz789';\n\n    await page.route('**/tokenTransfer', async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ id: transferId }),\n      });\n    });\n\n    await page.locator('.settings-button').click();\n    await page\n      .locator('[data-l10n-id=\"index.transferSessionNow\"]')\n      .click();\n\n    const copyButton = page.locator('#copy-link-section .btn-secondary');\n    await expect(copyButton).toBeVisible();\n\n    await page.evaluate(() => {\n      // @ts-ignore\n      window.clipboardData = '';\n      navigator.clipboard.writeText = async (text: string) => {\n        // @ts-ignore\n        window.clipboardData = text;\n        return Promise.resolve();\n      };\n    });\n\n    await copyButton.click();\n\n    await expect(copyButton).toBeDisabled();\n    await expect(copyButton.locator('svg')).toBeVisible();\n\n    const clipboardText = await page.evaluate(\n      // @ts-ignore\n      () => window.clipboardData\n    );\n    expect(clipboardText).toBe(transferId);\n  });\n\n  test('should receive session with valid code', async ({ page }) => {\n    const validCode = '12345678-1234-5678-1234-567812345678';\n\n    await page.route(`**/tokenTransfer/${validCode}`, async (route) => {\n      await route.fulfill({\n        status: 200,\n        contentType: 'application/json',\n        body: JSON.stringify({ success: true }),\n      });\n    });\n\n    await page.locator('.settings-button').click();\n\n    await page\n      .locator('#button-bar button[data-l10n-id=\"index.receiveSessionTitle\"]')\n      .click();\n\n    const receiveSection = page.locator('#transfer-to-system-section');\n    await expect(receiveSection).toBeVisible();\n\n    const codeInput = page.locator('#codeInput');\n    await expect(codeInput).toBeVisible();\n\n    const transferButton = page.locator('#transferSessionButton');\n    await expect(transferButton).toBeDisabled();\n\n    await codeInput.fill(validCode);\n\n    await expect(transferButton).not.toBeDisabled();\n\n    await Promise.all([\n      page.waitForNavigation(),\n      transferButton.click(),\n    ]);\n  });\n\n  test('should keep transfer button disabled for invalid code length', async ({\n                                                                                page,\n                                                                              }) => {\n    await page.locator('.settings-button').click();\n\n    await page\n      .locator('#button-bar button[data-l10n-id=\"index.receiveSessionTitle\"]')\n      .click();\n\n    const codeInput = page.locator('#codeInput');\n    const transferButton = page.locator('#transferSessionButton');\n\n    await codeInput.fill('short-code');\n    await expect(transferButton).toBeDisabled();\n\n    await codeInput.fill(\n      '12345678-1234-5678-1234-567812345678-extra'\n    );\n    await expect(transferButton).toBeDisabled();\n\n    await codeInput.fill('');\n    await expect(transferButton).toBeDisabled();\n  });\n\n  test('should switch between tabs in settings dialog', async ({\n                                                                 page,\n                                                               }) => {\n    await page.locator('.settings-button').click();\n\n    const transferTab = page.locator(\n      '#button-bar button[data-l10n-id=\"index.transferSessionTitle\"]'\n    );\n    const receiveTab = page.locator(\n      '#button-bar button[data-l10n-id=\"index.receiveSessionTitle\"]'\n    );\n\n    await expect(transferTab).toHaveClass(/active-btn/);\n\n    await receiveTab.click();\n    await expect(receiveTab).toHaveClass(/active-btn/);\n    await expect(transferTab).not.toHaveClass(/active-btn/);\n\n    await expect(\n      page.locator('#transfer-to-system-section')\n    ).toBeVisible();\n\n    await transferTab.click();\n    await expect(transferTab).toHaveClass(/active-btn/);\n  });\n\n  test('should close dialog when clicking outside', async ({ page }) => {\n    await page.locator('.settings-button').click();\n    const dialog = page.locator('#settings-dialog');\n\n    await expect(dialog).toBeVisible();\n\n    await dialog.evaluate((el) => (el as HTMLElement).click());\n\n    await expect(dialog).not.toBeVisible();\n  });\n});\n"
  },
  {
    "path": "src/tests/ratelimit/Dockerfile.anotherip",
    "content": "FROM node:latest\nWORKDIR /tmp\nRUN npm i etherpad-cli-client\nCOPY ./src/tests/ratelimit/send_changesets.js /tmp/send_changesets.js\n"
  },
  {
    "path": "src/tests/ratelimit/Dockerfile.nginx",
    "content": "FROM nginx\nCOPY ./src/tests/ratelimit/nginx.conf /etc/nginx/nginx.conf\n"
  },
  {
    "path": "src/tests/ratelimit/nginx.conf",
    "content": "events {}\nhttp {\n  server {\n        access_log  /dev/fd/1;\n        error_log   /dev/fd/2;\n        location / {\n            proxy_pass             http://172.23.42.2:9001/;\n            proxy_set_header       Host $host;\n            proxy_pass_header Server;\n            # be careful, this line doesn't override any proxy_buffering on set in a conf.d/file.conf\n            proxy_buffering off;\n            proxy_set_header X-Real-IP $remote_addr;  # http://wiki.nginx.org/HttpProxyModule\n            proxy_set_header X-Forwarded-For $remote_addr; # EP logs to show the actual remote IP\n            proxy_set_header X-Forwarded-Proto $scheme; # for EP to set secure cookie flag when https is used\n            proxy_set_header Host $host;  # pass the host header                                                   \n            proxy_http_version 1.1;  # recommended with keepalive connections                                                    \n            # WebSocket proxying - from http://nginx.org/en/docs/http/websocket.html\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header Connection $connection_upgrade;\n        }\n\t}\n  map $http_upgrade $connection_upgrade {\n    default upgrade;\n    ''      close;\n  }\n}\n"
  },
  {
    "path": "src/tests/ratelimit/send_changesets.js",
    "content": "'use strict';\n\nconst etherpad = require('etherpad-cli-client');\n\nconst pad = etherpad.connect(process.argv[2]);\npad.on('connected', () => {\n  setTimeout(() => {\n    setInterval(() => {\n      pad.append('1');\n    }, process.argv[3]);\n  }, 500); // wait because CLIENT_READY message is included in ratelimit\n\n  setTimeout(() => {\n    process.exit(0);\n  }, 11000);\n});\n// in case of disconnect exit code 1\npad.on('message', (message) => {\n  if (message.disconnect === 'rateLimited') {\n    process.exit(1);\n  }\n});\n"
  },
  {
    "path": "src/tests/ratelimit/testlimits.sh",
    "content": "#!/usr/bin/env bash\n\n#sending changesets every 101ms should not trigger ratelimit\nnode send_changesets.js http://127.0.0.1:8081/p/BACKEND_TEST_ratelimit_101ms 101\nif [[ $? -ne 0 ]];then\n  echo \"FAILED: ratelimit was triggered when sending every 101 ms\"\n  exit 1\nfi\n\n#sending changesets every 99ms should trigger ratelimit\nnode send_changesets.js http://127.0.0.1:8081/p/BACKEND_TEST_ratelimit_99ms 99\nif [[ $? -ne 1 ]];then\n  echo \"FAILED: ratelimit was not triggered when sending every 99 ms\"\n  exit 1\nfi\n\n#sending changesets every 101ms via proxy\nnode send_changesets.js http://127.0.0.1:8081/p/BACKEND_TEST_ratelimit_101ms 101 &\npid1=$!\n\n#sending changesets every 101ms via second IP and proxy\ndocker exec anotherip node /tmp/send_changesets.js http://172.23.42.1:80/p/BACKEND_TEST_ratelimit_101ms_via_second_ip 101 &\npid2=$!\n\nwait $pid1\nexit1=$?\nwait $pid2\nexit2=$?\n\necho \"101ms with proxy returned with ${exit1}\"\necho \"101ms via another ip returned with ${exit2}\"\n\nif [[ $exit1 -eq 1 || $exit2 -eq 1 ]];then\n  echo \"FAILED: ratelimit was triggered during proxy and requests via second ip\"\n  exit 1\nfi\n"
  },
  {
    "path": "src/tests/settings.json",
    "content": "{\"title\":\"Etherpad\",\"favicon\":null,\"skinName\":\"colibris\",\"skinVariants\":\"super-light-toolbar super-light-editor light-background\",\"ip\":\"0.0.0.0\",\"port\":9001,\"showSettingsInAdminPage\":true,\"dbType\":\"dirty\",\"dbSettings\":{\"filename\":\"var/dirty.db\"},\"defaultPadText\":\"Welcome to Etherpad!\\n\\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\\n\\nGet involved with Etherpad at https://etherpad.org\\n\",\"padOptions\":{\"noColors\":false,\"showControls\":true,\"showChat\":true,\"showLineNumbers\":true,\"useMonospaceFont\":false,\"userName\":null,\"userColor\":null,\"rtl\":false,\"alwaysShowChat\":false,\"chatAndUsers\":false,\"lang\":null},\"padShortcutEnabled\":{\"altF9\":true,\"altC\":true,\"cmdShift2\":true,\"delete\":true,\"return\":true,\"esc\":true,\"cmdS\":true,\"tab\":true,\"cmdZ\":true,\"cmdY\":true,\"cmdI\":true,\"cmdB\":true,\"cmdU\":true,\"cmd5\":true,\"cmdShiftL\":true,\"cmdShiftN\":true,\"cmdShift1\":true,\"cmdShiftC\":true,\"cmdH\":true,\"ctrlHome\":true,\"pageUp\":true,\"pageDown\":true},\"suppressErrorsInPadText\":false,\"requireSession\":false,\"editOnly\":false,\"minify\":true,\"maxAge\":21600,\"abiword\":null,\"soffice\":null,\"allowUnknownFileEnds\":true,\"requireAuthentication\":false,\"requireAuthorization\":false,\"trustProxy\":false,\"cookie\":{\"keyRotationInterval\":86400000,\"sameSite\":\"Lax\",\"sessionLifetime\":864000000,\"sessionRefreshInterval\":86400000},\"disableIPlogging\":false,\"automaticReconnectionTimeout\":0,\"scrollWhenFocusLineIsOutOfViewport\":{\"percentage\":{\"editionAboveViewport\":0,\"editionBelowViewport\":0},\"duration\":0,\"scrollWhenCaretIsInTheLastLineOfViewport\":false,\"percentageToScrollWhenUserPressesArrowUp\":0},\"users\":{\"admin\":{\"password\":\"changeme1\",\"is_admin\":true},\"user\":{\"password\":\"changeme1\",\"is_admin\":false}},\"socketTransportProtocols\":[\"websocket\",\"polling\"],\"socketIo\":{\"maxHttpBufferSize\":1000000},\"loadTest\":false,\"dumpOnUncleanExit\":false,\"importExportRateLimiting\":{\"windowMs\":90000,\"max\":10},\"importMaxFileSize\":52428800,\"commitRateLimiting\":{\"duration\":1,\"points\":10},\"exposeVersion\":false,\"loglevel\":\"INFO\",\"customLocaleStrings\":{},\"enableAdminUITests\":true,\"lowerCasePadIds\":false,\"sso\":{\"issuer\":\"${SSO_ISSUER:http://localhost:9001}\",\"clients\":[{\"client_id\":\"${ADMIN_CLIENT:admin_client}\",\"client_secret\":\"${ADMIN_SECRET:admin}\",\"grant_types\":[\"authorization_code\"],\"response_types\":[\"code\"],\"redirect_uris\":[\"${ADMIN_REDIRECT:http://localhost:9001/admin/}\",\"https://oauth.pstmn.io/v1/callback\"]},{\"client_id\":\"${USER_CLIENT:user_client}\",\"client_secret\":\"${USER_SECRET:user}\",\"grant_types\":[\"authorization_code\"],\"response_types\":[\"code\"],\"redirect_uris\":[\"${USER_REDIRECT:http://localhost:9001/}\"]}]}}"
  },
  {
    "path": "src/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    /* Visit https://aka.ms/tsconfig to read more about this file */\n    \"moduleDetection\": \"force\",\n    \"lib\": [\"ES2023\"],\n    /* Language and Environment */\n    \"target\": \"es6\",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */\n    /* Modules */\n    \"module\": \"CommonJS\",                                /* Specify what module code is generated. */\n    \"esModuleInterop\": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */\n    \"forceConsistentCasingInFileNames\": true,            /* Ensure that casing is correct in imports. */\n    /* Type Checking */\n    \"strict\": true,                                      /* Enable all strict type-checking options. */\n    /* Completeness */\n    \"skipLibCheck\": true                                 /* Skip type checking all .d.ts files. */,\n    \"resolveJsonModule\": true\n  }\n}\n"
  },
  {
    "path": "src/vitest.config.ts",
    "content": "import { defineConfig } from 'vitest/config'\n\nexport default defineConfig({\n  test: {\n    include: [\"tests/backend-new/specs/**/*.ts\"],\n  },\n})\n"
  },
  {
    "path": "src/web.config",
    "content": "<configuration>\n  <system.webServer>\n\n    <handlers>\n      <add name=\"iisnode\" path=\"src/node/server.ts\" verb=\"*\" modules=\"iisnode\" />\n    </handlers>\n\n    <rewrite>\n        <rules>\n            <!-- uncomment this section to enable debugging\n            <rule name=\"LogFile\" patternSyntax=\"ECMAScript\" stopProcessing=\"true\">\n                <match url=\"iisnode\"/>\n                <action type=\"Rewrite\" url=\"src/node/iisnode\" />\n            </rule>\n            <rule name=\"NodeInspector\" patternSyntax=\"ECMAScript\" stopProcessing=\"true\">                    \n                <match url=\"^server.ts\\/debug[\\/]?\" />\n            </rule>\n            -->\n            <rule name=\"StaticContent\">\n                 <action type=\"Rewrite\" url=\"public{{REQUEST_URI}}\"/>\n            </rule>\n            <rule name=\"DynamicContent\">\n                 <conditions>\n                      <add input=\"{{REQUEST_FILENAME}}\" matchType=\"IsFile\" negate=\"True\"/>\n                 </conditions>\n                <action type=\"Rewrite\" url=\"src/node/server.ts\" />\n            </rule>\n        </rules>\n    </rewrite>\n\n  </system.webServer>\n</configuration>\n"
  },
  {
    "path": "start.bat",
    "content": "@echo off\r\nREM Windows and symlinks do not get along with each other, so on Windows\r\nREM `node_modules\\ep_etherpad-lite` is sometimes a full copy of `src` not a\r\nREM symlink to `src`. If it is a copy, Node.js sees `src\\foo.js` and\r\nREM `node_modules\\ep_etherpad-lite\\foo.js` as two independent modules with\r\nREM independent state, when they should be treated as the same file. To work\r\nREM around this, everything must consistently use either `src` or\r\nREM `node_modules\\ep_etherpad-lite` on Windows. Because some plugins access\r\nREM Etherpad internals via `require('ep_etherpad-lite/foo')`,\r\nREM `node_modules\\ep_etherpad-lite` is used here.\r\ncd src\r\npnpm run prod\r\n"
  },
  {
    "path": "ui/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "ui/consent.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.ico\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Consent Etherpad</title>\n</head>\n<body>\n<div id=\"app\">\n    <div class=\"login-background login-page\">\n        <div class=\"login-box login-form\">\n            <h1 class=\"login-title\">Etherpad <span id=\"client\"></span></h1>\n            <form class=\"login-inner-box input-control\"  method=\"post\">\n                <input type=\"hidden\" name=\"prompt\" value=\"consent\"/>\n                <input type=\"submit\" value=\"Login\" class=\"login-button\"/>\n                <div id=\"error\"></div>\n            </form>\n        </div>\n    </div>\n</div>\n<script type=\"module\" src=\"/src/consent.ts\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "ui/login.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\"/>\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.ico\"/>\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"/>\n    <title>SSO Etherpad</title>\n</head>\n<body>\n<div id=\"app\">\n    <div class=\"login-background login-page\">\n        <div class=\"login-box login-form\">\n            <h1 class=\"login-title\">Etherpad <span id=\"client\"></span></h1>\n            <form class=\"login-inner-box input-control\">\n                <label>\n                    <input class=\"login-textinput input-control\" required type=\"text\" name=\"login\" placeholder=\"Username\"/>\n                </label>\n                <div class=\"icon-input\">\n                    <label class=\"password-label\">\n                        <input class=\"login-textinput\" type=\"password\" required name=\"password\" placeholder=\"Password\"/>\n                        <svg id=\"eye-visible\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"w-6 h-6 toggle-password-visibility\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z\" />\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z\" />\n                        </svg>\n                        <svg id=\"eye-hide\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"w-6 h-6\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88\" />\n                        </svg>\n                    </label>\n                </div>\n                <input type=\"submit\" value=\"Login\" class=\"login-button\"/>\n                <div id=\"error\"></div>\n            </form>\n        </div>\n    </div>\n</div>\n<script type=\"module\" src=\"/src/main.ts\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "ui/package.json",
    "content": "{\n  \"name\": \"ui\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"tsc && vite build\",\n    \"preview\": \"vite preview\",\n    \"build-copy\": \"tsc && vite build --outDir ../src/static/oidc --emptyOutDir\"\n  },\n  \"devDependencies\": {\n    \"ep_etherpad-lite\": \"workspace:../src\",\n    \"typescript\": \"^5.9.3\",\n    \"vite\": \"npm:rolldown-vite@7.2.10\"\n  },\n  \"overrides\": {\n    \"vite\": \"npm:rolldown-vite@7.2.10\"\n  }\n}\n"
  },
  {
    "path": "ui/pad.html",
    "content": "<!doctype html>\n<html translate=\"no\" class=\"pad  super-light-toolbar super-light-editor light-background\">\n<head>\n\n\n  <title>Etherpad</title>\n  <link rel=\"manifest\" href=\"/manifest.json\" />\n  <script>\n    /*\n    |@licstart  The following is the entire license notice for the\n    JavaScript code in this page.|\n\n    Copyright 2011 Peter Martischka, Primary Technology.\n\n    Licensed under the Apache License, Version 2.0 (the \"License\");\n    you may not use this file except in compliance with the License.\n    You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n    Unless required by applicable law or agreed to in writing, software\n    distributed under the License is distributed on an \"AS IS\" BASIS,\n    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n    See the License for the specific language governing permissions and\n    limitations under the License.\n\n    |@licend  The above is the entire license notice\n    for the JavaScript code in this page.|\n    */\n  </script>\n\n  <meta charset=\"utf-8\">\n  <meta name=\"robots\" content=\"noindex, nofollow\">\n  <meta name=\"referrer\" content=\"no-referrer\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0\">\n  <link rel=\"shortcut icon\" href=\"../favicon.ico\">\n\n\n  <link href=\"../static/css/pad.css?v=5ba315cd\" rel=\"stylesheet\">\n\n\n  <link href=\"../static/skins/colibris/pad.css?v=5ba315cd\" rel=\"stylesheet\">\n\n\n  <style title=\"dynamicsyntax\"></style>\n\n\n  <link rel=\"localizations\" type=\"application/l10n+json\" href=\"../locales.json\" />\n</head>\n<body>\n\n\n<!----------------------------->\n<!--------- TOOLBAR ----------->\n<!----------------------------->\n<div id=\"editbar\" class=\"toolbar\">\n  <div id=\"toolbar-overlay\"></div>\n\n  <ul class=\"menu_left\" role=\"toolbar\">\n\n    <li data-type=\"button\" data-key=\"bold\"><a class=\"grouped-left\" data-l10n-id=\"pad.toolbar.bold.title\"><button class=\" buttonicon buttonicon-bold\" data-l10n-id=\"pad.toolbar.bold.title\"></button></a></li>\n    <li data-type=\"button\" data-key=\"italic\"><a class=\"grouped-middle\" data-l10n-id=\"pad.toolbar.italic.title\"><button class=\" buttonicon buttonicon-italic\" data-l10n-id=\"pad.toolbar.italic.title\"></button></a></li>\n    <li data-type=\"button\" data-key=\"underline\"><a class=\"grouped-middle\" data-l10n-id=\"pad.toolbar.underline.title\"><button class=\" buttonicon buttonicon-underline\" data-l10n-id=\"pad.toolbar.underline.title\"></button></a></li>\n    <li data-type=\"button\" data-key=\"strikethrough\"><a class=\"grouped-right\" data-l10n-id=\"pad.toolbar.strikethrough.title\"><button class=\" buttonicon buttonicon-strikethrough\" data-l10n-id=\"pad.toolbar.strikethrough.title\"></button\n    ></a></li><li class=\"separator\"></li><li data-type=\"button\" data-key=\"insertorderedlist\"><a class=\"grouped-left\" data-l10n-id=\"pad.toolbar.ol.title\"><button class=\" buttonicon buttonicon-insertorderedlist\" data-l10n-id=\"pad.toolbar.ol.title\"></button></a></li>\n    <li data-type=\"button\" data-key=\"insertunorderedlist\"><a class=\"grouped-middle\" data-l10n-id=\"pad.toolbar.ul.title\"><button class=\" buttonicon buttonicon-insertunorderedlist\" data-l10n-id=\"pad.toolbar.ul.title\"></button></a></li>\n    <li data-type=\"button\" data-key=\"indent\"><a class=\"grouped-middle\" data-l10n-id=\"pad.toolbar.indent.title\"><button class=\" buttonicon buttonicon-indent\" data-l10n-id=\"pad.toolbar.indent.title\"></button></a></li>\n    <li data-type=\"button\" data-key=\"outdent\"><a class=\"grouped-right\" data-l10n-id=\"pad.toolbar.unindent.title\"><button class=\" buttonicon buttonicon-outdent\" data-l10n-id=\"pad.toolbar.unindent.title\"></button></a></li><li class=\"separator\"></li><li data-type=\"button\" data-key=\"undo\"><a class=\"grouped-left\" data-l10n-id=\"pad.toolbar.undo.title\"><button class=\" buttonicon buttonicon-undo\" data-l10n-id=\"pad.toolbar.undo.title\"></button></a></li>\n    <li data-type=\"button\" data-key=\"redo\"><a class=\"grouped-right\" data-l10n-id=\"pad.toolbar.redo.title\"><button class=\" buttonicon buttonicon-redo\" data-l10n-id=\"pad.toolbar.redo.title\"></button></a></li><li class=\"separator\"></li\n  ><li data-type=\"button\" data-key=\"clearauthorship\"><a class=\"\" data-l10n-id=\"pad.toolbar.clearAuthorship.title\"><button class=\" buttonicon buttonicon-clearauthorship\" data-l10n-id=\"pad.toolbar.clearAuthorship.title\"></button></a></li>\n\n  </ul>\n  <ul class=\"menu_right\" role=\"toolbar\">\n\n    <li data-type=\"button\" data-key=\"import_export\"><a class=\"grouped-left\" data-l10n-id=\"pad.toolbar.import_export.title\"><button class=\" buttonicon buttonicon-import_export\" data-l10n-id=\"pad.toolbar.import_export.title\"></button></a></li>\n    <li data-type=\"button\" data-key=\"showTimeSlider\"><a class=\"grouped-middle\" data-l10n-id=\"pad.toolbar.timeslider.title\"><button class=\" buttonicon buttonicon-history\" data-l10n-id=\"pad.toolbar.timeslider.title\"></button></a></li>\n    <li data-type=\"button\" data-key=\"savedRevision\"><a class=\"grouped-right\" data-l10n-id=\"pad.toolbar.savedRevision.title\"><button class=\" buttonicon buttonicon-savedRevision\" data-l10n-id=\"pad.toolbar.savedRevision.title\"></button\n    ></a></li><li class=\"separator\"></li><li data-type=\"button\" data-key=\"settings\"><a class=\"grouped-left\" data-l10n-id=\"pad.toolbar.settings.title\"><button class=\" buttonicon buttonicon-settings\" data-l10n-id=\"pad.toolbar.settings.title\"></button></a></li>\n    <li data-type=\"button\" data-key=\"embed\"><a class=\"grouped-right\" data-l10n-id=\"pad.toolbar.embed.title\"><button class=\" buttonicon buttonicon-embed\" data-l10n-id=\"pad.toolbar.embed.title\"></button></a></li><li class=\"separator\"></li><li data-type=\"button\" data-key=\"showusers\"><a class=\"\" data-l10n-id=\"pad.toolbar.showusers.title\"><button class=\" buttonicon buttonicon-showusers\" data-l10n-id=\"pad.toolbar.showusers.title\"></button></a></li>\n\n  </ul>\n  <span class=\"show-more-icon-btn\"></span> <!-- use on small screen to display hidden toolbar buttons -->\n</div>\n\n\n\n<div id=\"editorcontainerbox\" class=\"flex-layout\">\n\n\n\n  <!----------------------------->\n  <!--- PAD EDITOR (in iframe) -->\n  <!----------------------------->\n\n  <div id=\"editorcontainer\" class=\"editorcontainer\"></div>\n\n  <div id=\"editorloadingbox\">\n\n    <div id=\"permissionDenied\">\n      <p data-l10n-id=\"pad.permissionDenied\" class=\"editorloadingbox-message\">\n        You do not have permission to access this pad\n      </p>\n    </div>\n\n\n    <p data-l10n-id=\"pad.loading\" id=\"loading\" class=\"editorloadingbox-message\">\n      <img src='../static/img/brand.svg' class='etherpadBrand'><br/>\n      Loading...\n    </p>\n\n    <noscript>\n      <p class=\"editorloadingbox-message\">\n        <strong>\n          Sorry, you have to enable Javascript in order to use this.\n        </strong>\n      </p>\n    </noscript>\n  </div>\n\n\n  <!------------------------------------------------------------->\n  <!-- SETTINGS POPUP (change font, language, chat parameters) -->\n  <!------------------------------------------------------------->\n\n  <div id=\"settings\" class=\"popup\"><div class=\"popup-content\">\n    <h1 data-l10n-id=\"pad.settings.padSettings\"></h1>\n\n    <h2 data-l10n-id=\"pad.settings.myView\"></h2>\n    <p class=\"hide-for-mobile\">\n      <input type=\"checkbox\" id=\"options-stickychat\">\n      <label for=\"options-stickychat\" data-l10n-id=\"pad.settings.stickychat\"></label>\n    </p>\n    <p class=\"hide-for-mobile\">\n      <input type=\"checkbox\" id=\"options-chatandusers\" onClick=\"chat.chatAndUsers();\">\n      <label for=\"options-chatandusers\" data-l10n-id=\"pad.settings.chatandusers\"></label>\n    </p>\n    <p>\n      <input type=\"checkbox\" id=\"options-colorscheck\">\n      <label for=\"options-colorscheck\" data-l10n-id=\"pad.settings.colorcheck\"></label>\n    </p>\n    <p>\n      <input type=\"checkbox\" id=\"options-linenoscheck\" checked>\n      <label for=\"options-linenoscheck\" data-l10n-id=\"pad.settings.linenocheck\"></label>\n    </p>\n    <p>\n      <input type=\"checkbox\" id=\"options-rtlcheck\">\n      <label for=\"options-rtlcheck\" data-l10n-id=\"pad.settings.rtlcheck\"></label>\n    </p>\n\n\n    <div class=\"dropdowns-container\">\n\n      <p class=\"dropdown-line\">\n        <label for=\"viewfontmenu\" data-l10n-id=\"pad.settings.fontType\">Font type:</label>\n        <select id=\"viewfontmenu\">\n          <option value=\"\" data-l10n-id=\"pad.settings.fontType.normal\">Normal</option>\n          Quicksand,Roboto,Alegreya,PlayfairDisplay,Montserrat,OpenDyslexic,RobotoMono\n\n          <option value=\"Quicksand\">Quicksand</option>\n\n          <option value=\"Roboto\">Roboto</option>\n\n          <option value=\"Alegreya\">Alegreya</option>\n\n          <option value=\"PlayfairDisplay\">PlayfairDisplay</option>\n\n          <option value=\"Montserrat\">Montserrat</option>\n\n          <option value=\"OpenDyslexic\">OpenDyslexic</option>\n\n          <option value=\"RobotoMono\">RobotoMono</option>\n\n        </select>\n      </p>\n\n      <p class=\"dropdown-line\">\n        <label for=\"languagemenu\" data-l10n-id=\"pad.settings.language\">Language:</label>\n        <select id=\"languagemenu\">\n\n          <option value=\"af\">Afrikaans</option>\n\n          <option value=\"ar\">العربية</option>\n\n          <option value=\"ast\">asturianu</option>\n\n          <option value=\"az\">azərbaycanca</option>\n\n          <option value=\"azb\">تورکجه</option>\n\n          <option value=\"bcc\">بلوچی مکرانی</option>\n\n          <option value=\"be-tarask\">беларуская (тарашкевіца)‎</option>\n\n          <option value=\"bg\">български</option>\n\n          <option value=\"bn\">বাংলা</option>\n\n          <option value=\"br\">brezhoneg</option>\n\n          <option value=\"bs\">bosanski</option>\n\n          <option value=\"ca\">català</option>\n\n          <option value=\"cs\">česky</option>\n\n          <option value=\"da\">dansk</option>\n\n          <option value=\"de\">Deutsch</option>\n\n          <option value=\"diq\">Zazaki</option>\n\n          <option value=\"dsb\">dolnoserbski</option>\n\n          <option value=\"el\">Ελληνικά</option>\n\n          <option value=\"en-gb\">British English</option>\n\n          <option value=\"en\">English</option>\n\n          <option value=\"eo\">Esperanto</option>\n\n          <option value=\"es\">español</option>\n\n          <option value=\"et\">eesti</option>\n\n          <option value=\"eu\">euskara</option>\n\n          <option value=\"fa\">فارسی</option>\n\n          <option value=\"ff\">Fulfulde</option>\n\n          <option value=\"fi\">suomi</option>\n\n          <option value=\"fo\">føroyskt</option>\n\n          <option value=\"fr\">français</option>\n\n          <option value=\"fy\">Frysk</option>\n\n          <option value=\"gl\">galego</option>\n\n          <option value=\"gu\">ગુજરાતી</option>\n\n          <option value=\"he\">עברית</option>\n\n          <option value=\"hi\">हिन्दी</option>\n\n          <option value=\"hr\">hrvatski</option>\n\n          <option value=\"hsb\">hornjoserbsce</option>\n\n          <option value=\"hu\">magyar</option>\n\n          <option value=\"hy\">Հայերեն</option>\n\n          <option value=\"ia\">interlingua</option>\n\n          <option value=\"id\">Bahasa Indonesia</option>\n\n          <option value=\"is\">íslenska</option>\n\n          <option value=\"it\">italiano</option>\n\n          <option value=\"ja\">日本語</option>\n\n          <option value=\"kab\">Taqbaylit</option>\n\n          <option value=\"km\">ភាសាខ្មែរ</option>\n\n          <option value=\"kn\">ಕನ್ನಡ</option>\n\n          <option value=\"ko\">한국어</option>\n\n          <option value=\"krc\">къарачай-малкъар</option>\n\n          <option value=\"ksh\">Ripoarisch</option>\n\n          <option value=\"ku-latn\">Kurdî (latînî)‎</option>\n\n          <option value=\"lb\">Lëtzebuergesch</option>\n\n          <option value=\"lt\">lietuvių</option>\n\n          <option value=\"lv\">latviešu</option>\n\n          <option value=\"map-bms\">Basa Banyumasan</option>\n\n          <option value=\"mg\">Malagasy</option>\n\n          <option value=\"mk\">македонски</option>\n\n          <option value=\"ml\">മലയാളം</option>\n\n          <option value=\"mn\">монгол</option>\n\n          <option value=\"mnw\">ဘာသာ မန်</option>\n\n          <option value=\"mr\">मराठी</option>\n\n          <option value=\"ms\">Bahasa Melayu</option>\n\n          <option value=\"my\">မြန်မာဘာသာ</option>\n\n          <option value=\"nah\">Nāhuatl</option>\n\n          <option value=\"nap\">Nnapulitano</option>\n\n          <option value=\"nb\">norsk (bokmål)‎</option>\n\n          <option value=\"nds\">Plattdüütsch</option>\n\n          <option value=\"ne\">नेपाली</option>\n\n          <option value=\"nl\">Nederlands</option>\n\n          <option value=\"nn\">norsk (nynorsk)‎</option>\n\n          <option value=\"oc\">occitan</option>\n\n          <option value=\"os\">Ирон</option>\n\n          <option value=\"pa\">ਪੰਜਾਬੀ</option>\n\n          <option value=\"pl\">polski</option>\n\n          <option value=\"pms\">Piemontèis</option>\n\n          <option value=\"ps\">پښتو</option>\n\n          <option value=\"pt-br\">português do Brasil</option>\n\n          <option value=\"pt\">português</option>\n\n          <option value=\"qqq\">Message documentation</option>\n\n          <option value=\"ro\">română</option>\n\n          <option value=\"ru\">русский</option>\n\n          <option value=\"sc\">sardu</option>\n\n          <option value=\"sco\">Scots</option>\n\n          <option value=\"sd\">سنڌي</option>\n\n          <option value=\"sh\">srpskohrvatski / српскохрватски</option>\n\n          <option value=\"shn\">လိၵ်ႈတႆး</option>\n\n          <option value=\"sk\">slovenčina</option>\n\n          <option value=\"sl\">slovenščina</option>\n\n          <option value=\"sq\">shqip</option>\n\n          <option value=\"sr-ec\">српски (ћирилица)‎</option>\n\n          <option value=\"sr-el\">srpski (latinica)‎</option>\n\n          <option value=\"sv\">svenska</option>\n\n          <option value=\"sw\">Kiswahili</option>\n\n          <option value=\"ta\">தமிழ்</option>\n\n          <option value=\"tcy\">ತುಳು</option>\n\n          <option value=\"te\">తెలుగు</option>\n\n          <option value=\"th\">ไทย</option>\n\n          <option value=\"tr\">Türkçe</option>\n\n          <option value=\"uk\">українська</option>\n\n          <option value=\"vec\">vèneto</option>\n\n          <option value=\"vi\">Tiếng Việt</option>\n\n          <option value=\"zh-hans\">中文（简体）‎</option>\n\n          <option value=\"zh-hant\">中文（繁體）‎</option>\n\n        </select>\n      </p>\n\n    </div>\n    <button data-l10n-id=\"pad.settings.delete\">Delete pad</button>\n    <h2 data-l10n-id=\"pad.settings.about\">About</h2>\n    <span data-l10n-id=\"pad.settings.poweredBy\">Powered by</span>\n    <a href=\"https://etherpad.org\">Etherpad</a>\n\n  </div></div>\n\n\n  <!------------------------->\n  <!-- IMPORT EXPORT POPUP -->\n  <!------------------------->\n\n  <div id=\"import_export\" class=\"popup\"><div class=\"popup-content\">\n    <h1 data-l10n-id=\"pad.importExport.import_export\"></h1>\n    <div class=\"acl-write\">\n\n      <h2 data-l10n-id=\"pad.importExport.import\"></h2>\n      <div class=\"importmessage\" id=\"importmessageabiword\" data-l10n-id=\"pad.importExport.abiword.innerHTML\"></div><br>\n      <form id=\"importform\" method=\"post\" action=\"\" target=\"importiframe\" enctype=\"multipart/form-data\">\n        <div class=\"importformdiv\" id=\"importformfilediv\">\n          <input type=\"file\" name=\"file\" size=\"10\" id=\"importfileinput\">\n          <div class=\"importmessage\" id=\"importmessagefail\"></div>\n        </div>\n        <div id=\"import\"></div>\n        <div class=\"importmessage\" id=\"importmessagesuccess\" data-l10n-id=\"pad.importExport.importSuccessful\"></div>\n        <div class=\"importformdiv\" id=\"importformsubmitdiv\">\n                      <span class=\"nowrap\">\n                          <input type=\"submit\" class=\"btn btn-primary\" name=\"submit\" value=\"Import Now\" disabled=\"disabled\" id=\"importsubmitinput\">\n                          <div alt=\"\" id=\"importstatusball\" class=\"loadingAnimation\" align=\"top\"></div>\n                      </span>\n        </div>\n      </form>\n\n    </div>\n    <div id=\"exportColumn\">\n      <h2 data-l10n-id=\"pad.importExport.export\"></h2>\n\n      <a id=\"exportetherpada\" target=\"_blank\" class=\"exportlink\">\n        <span class=\"exporttype buttonicon buttonicon-file-powerpoint\" id=\"exportetherpad\" data-l10n-id=\"pad.importExport.exportetherpad\"></span>\n      </a>\n      <a id=\"exporthtmla\" target=\"_blank\" class=\"exportlink\">\n        <span class=\"exporttype buttonicon buttonicon-file-code\" id=\"exporthtml\" data-l10n-id=\"pad.importExport.exporthtml\"></span>\n      </a>\n      <a id=\"exportplaina\" target=\"_blank\" class=\"exportlink\">\n        <span class=\"exporttype buttonicon buttonicon-file\" id=\"exportplain\" data-l10n-id=\"pad.importExport.exportplain\"></span>\n      </a>\n      <a id=\"exportworda\" target=\"_blank\" class=\"exportlink\">\n        <span class=\"exporttype buttonicon buttonicon-file-word\" id=\"exportword\" data-l10n-id=\"pad.importExport.exportword\"></span>\n      </a>\n      <a id=\"exportpdfa\" target=\"_blank\" class=\"exportlink\">\n        <span class=\"exporttype buttonicon buttonicon-file-pdf\" id=\"exportpdf\" data-l10n-id=\"pad.importExport.exportpdf\"></span>\n      </a>\n      <a id=\"exportopena\" target=\"_blank\" class=\"exportlink\">\n        <span class=\"exporttype buttonicon buttonicon-file-alt\" id=\"exportopen\" data-l10n-id=\"pad.importExport.exportopen\"></span>\n      </a>\n\n    </div>\n  </div></div>\n\n\n  <!---------------------------------------------------->\n  <!-- CONNECTIVITY POPUP (when you get disconnected) -->\n  <!---------------------------------------------------->\n\n  <div id=\"connectivity\" class=\"popup\"><div class=\"popup-content\">\n\n    <div class=\"connected visible\">\n      <h2 data-l10n-id=\"pad.modals.connected\"></h2>\n    </div>\n    <div class=\"reconnecting\">\n      <h1 data-l10n-id=\"pad.modals.reconnecting\"></h1>\n      <i class='buttonicon buttonicon-spin5 icon-spin'>\n        <img src='../static/img/brand.svg' class='etherpadBrand'><br/>\n      </i>\n    </div>\n    <div class=\"userdup\">\n      <h1 data-l10n-id=\"pad.modals.userdup\"></h1>\n      <h2 data-l10n-id=\"pad.modals.userdup.explanation\"></h2>\n      <p id=\"defaulttext\" data-l10n-id=\"pad.modals.userdup.advice\"></p>\n      <button id=\"forcereconnect\" class=\"btn btn-primary\" data-l10n-id=\"pad.modals.forcereconnect\"></button>\n    </div>\n    <div class=\"unauth\">\n      <h1 data-l10n-id=\"pad.modals.unauth\"></h1>\n      <p id=\"defaulttext\" data-l10n-id=\"pad.modals.unauth.explanation\"></p>\n      <button id=\"forcereconnect\" class=\"btn btn-primary\" data-l10n-id=\"pad.modals.forcereconnect\"></button>\n    </div>\n    <div class=\"looping\">\n      <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n      <h2 data-l10n-id=\"pad.modals.looping.explanation\"></h2>\n      <p data-l10n-id=\"pad.modals.looping.cause\"></p>\n    </div>\n    <div class=\"initsocketfail\">\n      <h1 data-l10n-id=\"pad.modals.initsocketfail\"></h1>\n      <h2 data-l10n-id=\"pad.modals.initsocketfail.explanation\"></h2>\n      <p data-l10n-id=\"pad.modals.initsocketfail.cause\"></p>\n    </div>\n    <div class=\"slowcommit with_reconnect_timer\">\n      <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n      <h2 data-l10n-id=\"pad.modals.slowcommit.explanation\"></h2>\n      <p id=\"defaulttext\" data-l10n-id=\"pad.modals.slowcommit.cause\"></p>\n      <button id=\"forcereconnect\" class=\"btn btn-primary\" data-l10n-id=\"pad.modals.forcereconnect\"></button>\n    </div>\n    <div class=\"badChangeset with_reconnect_timer\">\n      <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n      <h2 data-l10n-id=\"pad.modals.badChangeset.explanation\"></h2>\n      <p id=\"defaulttext\" data-l10n-id=\"pad.modals.badChangeset.cause\"></p>\n      <button id=\"forcereconnect\" class=\"btn btn-primary\" data-l10n-id=\"pad.modals.forcereconnect\"></button>\n    </div>\n    <div class=\"corruptPad\">\n      <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n      <h2 data-l10n-id=\"pad.modals.corruptPad.explanation\"></h2>\n      <p data-l10n-id=\"pad.modals.corruptPad.cause\"></p>\n    </div>\n    <div class=\"deleted\">\n      <h1 data-l10n-id=\"pad.modals.deleted\"></h1>\n      <p data-l10n-id=\"pad.modals.deleted.explanation\"></p>\n    </div>\n    <div class=\"rateLimited\">\n      <h1 data-l10n-id=\"pad.modals.rateLimited\"></h1>\n      <p data-l10n-id=\"pad.modals.rateLimited.explanation\"></p>\n    </div>\n    <div class=\"rejected\">\n      <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n      <h2 data-l10n-id=\"pad.modals.rejected.explanation\"></h2>\n      <p data-l10n-id=\"pad.modals.rejected.cause\"></p>\n    </div>\n    <div class=\"disconnected with_reconnect_timer\">\n\n      <h1 data-l10n-id=\"pad.modals.disconnected\"></h1>\n      <h2 data-l10n-id=\"pad.modals.disconnected.explanation\"></h2>\n      <p id=\"defaulttext\" data-l10n-id=\"pad.modals.disconnected.cause\"></p>\n      <button id=\"forcereconnect\" class=\"btn btn-primary\" data-l10n-id=\"pad.modals.forcereconnect\"></button>\n\n    </div>\n    <form id=\"reconnectform\" method=\"post\" action=\"/ep/pad/reconnect\" accept-charset=\"UTF-8\" style=\"display: none;\">\n      <input type=\"hidden\" class=\"padId\" name=\"padId\">\n      <input type=\"hidden\" class=\"diagnosticInfo\" name=\"diagnosticInfo\">\n      <input type=\"hidden\" class=\"missedChanges\" name=\"missedChanges\">\n    </form>\n\n  </div></div>\n\n\n  <!-------------------------------->\n  <!-- EMBED POPUP (Share, embed) -->\n  <!-------------------------------->\n\n  <div id=\"embed\" class=\"popup\"><div class=\"popup-content\">\n\n    <h1 data-l10n-id=\"pad.share\"></h1>\n    <div id=\"embedreadonly\" class=\"acl-write\">\n      <input type=\"checkbox\" id=\"readonlyinput\">\n      <label for=\"readonlyinput\" data-l10n-id=\"pad.share.readonly\"></label>\n    </div>\n    <div id=\"linkcode\">\n      <h2 data-l10n-id=\"pad.share.link\"></h2>\n      <input id=\"linkinput\" type=\"text\" value=\"\" onclick=\"this.select()\">\n    </div>\n    <div id=\"embedcode\">\n      <h2 data-l10n-id=\"pad.share.emebdcode\"></h2>\n      <input id=\"embedinput\" type=\"text\" value=\"\" onclick=\"this.select()\">\n    </div>\n\n  </div></div>\n\n  <div class=\"sticky-container\">\n\n    <!---------------------------------------------------------------------->\n    <!-- USERS POPUP (set username, color, see other users names & color) -->\n    <!---------------------------------------------------------------------->\n\n    <div id=\"users\" class=\"popup\"><div class=\"popup-content\">\n\n      <div id=\"connectionstatus\"></div>\n      <div id=\"myuser\">\n        <div id=\"mycolorpicker\" class=\"popup\"><div class=\"popup-content\">\n          <div id=\"colorpicker\"></div>\n          <div class=\"btn-container\">\n            <button id=\"mycolorpickersave\" data-l10n-id=\"pad.colorpicker.save\" class=\"btn btn-primary\"></button>\n            <button id=\"mycolorpickercancel\" data-l10n-id=\"pad.colorpicker.cancel\" class=\"btn btn-default\"></button>\n            <span id=\"mycolorpickerpreview\" class=\"myswatchboxhoverable\"></span>\n          </div>\n        </div></div>\n        <div id=\"myswatchbox\"><div id=\"myswatch\"></div></div>\n        <div id=\"myusernameform\">\n          <input type=\"text\" id=\"myusernameedit\" disabled=\"disabled\" data-l10n-id=\"pad.userlist.entername\">\n        </div>\n      </div>\n      <div id=\"otherusers\" aria-role=\"document\">\n        <table id=\"otheruserstable\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\">\n          <tr><td></td></tr>\n        </table>\n      </div>\n      <div id=\"userlistbuttonarea\"></div>\n\n    </div></div>\n\n\n    <!----------------------------->\n    <!----------- CHAT ------------>\n    <!----------------------------->\n\n    <div id=\"chaticon\" class=\"visible\" onclick=\"chat.show();return false;\" title=\"Chat (Alt C)\">\n      <span id=\"chatlabel\" data-l10n-id=\"pad.chat\"></span>\n      <span class=\"buttonicon buttonicon-chat\"></span>\n      <span id=\"chatcounter\">0</span>\n    </div>\n\n    <div id=\"chatbox\">\n      <div class=\"chat-content\">\n        <div id=\"titlebar\">\n          <h1 id =\"titlelabel\" data-l10n-id=\"pad.chat\"></h1>\n          <a id=\"titlecross\" class=\"hide-reduce-btn\" onClick=\"chat.hide();return false;\">-&nbsp;</a>\n          <a id=\"titlesticky\" class=\"stick-to-screen-btn\" onClick=\"chat.stickToScreen(true);return false;\" data-l10n-id=\"pad.chat.stick.title\">█&nbsp;&nbsp;</a>\n        </div>\n        <div id=\"chattext\" class=\"thin-scrollbar\" aria-live=\"polite\" aria-relevant=\"additions removals text\" role=\"log\" aria-atomic=\"false\">\n          <div alt=\"loading..\" id=\"chatloadmessagesball\" class=\"chatloadmessages loadingAnimation\" align=\"top\"></div>\n          <button id=\"chatloadmessagesbutton\" class=\"chatloadmessages\" data-l10n-id=\"pad.chat.loadmessages\"></button>\n        </div>\n        <div id=\"chatinputbox\">\n          <form>\n            <textarea id=\"chatinput\" maxlength=\"999\" data-l10n-id=\"pad.chat.writeMessage.placeholder\"></textarea>\n          </form>\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <!------------------------------------------------------------------>\n  <!-- SKIN VARIANTS BUILDER (Customize rendering, only for admins) -->\n  <!------------------------------------------------------------------>\n\n  <div id=\"skin-variants\" class=\"popup\"><div class=\"popup-content\">\n    <h1>Skin Builder</h1>\n\n    <div class=\"dropdowns-container\">\n\n\n      <p class=\"dropdown-line\">\n        <label class=\"skin-variant-container\">toolbar</label>\n        <select class=\"skin-variant skin-variant-color\" data-container=\"toolbar\">\n          <option value=\"super-light\">Super Light</option>\n          <option value=\"light\">Light</option>\n          <option value=\"dark\">Dark</option>\n          <option value=\"super-dark\">Super Dark</option>\n        </select>\n      </p>\n\n      <p class=\"dropdown-line\">\n        <label class=\"skin-variant-container\">background</label>\n        <select class=\"skin-variant skin-variant-color\" data-container=\"background\">\n          <option value=\"super-light\">Super Light</option>\n          <option value=\"light\">Light</option>\n          <option value=\"dark\">Dark</option>\n          <option value=\"super-dark\">Super Dark</option>\n        </select>\n      </p>\n\n      <p class=\"dropdown-line\">\n        <label class=\"skin-variant-container\">editor</label>\n        <select class=\"skin-variant skin-variant-color\" data-container=\"editor\">\n          <option value=\"super-light\">Super Light</option>\n          <option value=\"light\">Light</option>\n          <option value=\"dark\">Dark</option>\n          <option value=\"super-dark\">Super Dark</option>\n        </select>\n      </p>\n\n    </div>\n\n    <p>\n      <input type=\"checkbox\" id=\"skin-variant-full-width\" class=\"skin-variant\"/>\n      <label for=\"skin-variant-full-width\">Full Width Editor</label>\n    </p>\n\n    <p>\n      <label>Result to copy in settings.json</label>\n      <input id=\"skin-variants-result\" type=\"text\" readonly class=\"disabled\" />\n    </p>\n  </div></div>\n\n\n\n\n</div> <!-- End of #editorcontainerbox -->\n\n\n\n\n<!----------------------------->\n<!-------- JAVASCRIPT --------->\n<!----------------------------->\n\n<script type=\"text/javascript\" src=\"../static/skins/colibris/pad.js?v=5ba315cd\"></script>\n\n<div style=\"display:none\"><a href=\"/javascript\" data-jslicense=\"1\">JavaScript license information</a></div>\n<script type=\"module\" src=\"./node_modules/ep_etherpad-lite/templates/padViteBootstrap.js\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "ui/src/consent.ts",
    "content": "import \"./style.css\"\n//import {MapArrayType} from \"ep_etherpad-lite/node/types/MapType\";\n\nconst form = document.querySelector('form')!;\nconst sessionId = new URLSearchParams(window.location.search).get('state');\n\nform.action = '/interaction/' + sessionId;\n\n/*form.addEventListener('submit', function (event) {\n    event.preventDefault();\n    const formData = new FormData(form);\n    const data: MapArrayType<any> = {};\n    formData.forEach((value, key) => {\n        data[key] = value;\n    });\n    const sessionId = new URLSearchParams(window.location.search).get('state');\n\n    fetch('/interaction/' + sessionId, {\n        method: 'POST',\n        headers: {\n            'Content-Type': 'application/json',\n        },\n        body: JSON.stringify(data),\n    }).then(response => {\n        if (response.ok) {\n            if (response.redirected) {\n                window.location.href = response.url;\n            }\n        } else {\n            document.getElementById('error')!.innerText = \"Error signing in\";\n        }\n    }).catch(error => {\n        document.getElementById('error')!.innerText = \"Error signing in\" + error;\n    })\n});*/\n"
  },
  {
    "path": "ui/src/main.ts",
    "content": "import './style.css'\nimport {MapArrayType} from \"ep_etherpad-lite/node/types/MapType.ts\";\n\nconst searchParams = new URLSearchParams(window.location.search);\n\n\ndocument.getElementById('client')!.innerText = searchParams.get('client_id')!;\n\nconst form = document.querySelector('form')!;\nform.addEventListener('submit', function (event) {\n    event.preventDefault();\n    const formData = new FormData(form);\n    const data: MapArrayType<any> = {};\n    formData.forEach((value, key) => {\n        data[key] = value;\n    });\n    const sessionId = new URLSearchParams(window.location.search).get('state');\n\n    fetch('/interaction/' + sessionId, {\n        method: 'POST',\n        headers: {\n            'Content-Type': 'application/json',\n        },\n        redirect: 'follow',\n        body: JSON.stringify(data),\n    }).then(response => {\n        if (response.ok) {\n            if (response.redirected) {\n                window.location.href = response.url;\n            }\n        } else {\n            document.getElementById('error')!.innerText = \"Error signing in\";\n        }\n    }).catch(error => {\n        document.getElementById('error')!.innerText = \"Error signing in\" + error;\n    })\n});\n\nconst hidePassword = document.querySelector('.toggle-password-visibility')! as HTMLElement\nconst showPassword = document.getElementById('eye-hide')! as HTMLElement\nconst togglePasswordVisibility = () => {\n    const passwordInput = document.getElementsByName('password')[0] as HTMLInputElement;\n    if (passwordInput.type === 'password') {\n        showPassword.style.display = 'block';\n        hidePassword.style.display = 'none';\n        passwordInput.type = 'text';\n    } else {\n        showPassword.style.display = 'none';\n        hidePassword.style.display = 'block';\n        passwordInput.type = 'password';\n    }\n}\n\n\nhidePassword.addEventListener('click', togglePasswordVisibility);\nshowPassword.addEventListener('click', togglePasswordVisibility);\n\n\n"
  },
  {
    "path": "ui/src/style.css",
    "content": ":root {\n    font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;\n    line-height: 1.5;\n    font-weight: 400;\n\n\n    font-synthesis: none;\n    text-rendering: optimizeLegibility;\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n    --color-etherpad: #0f775b;\n}\n\nbody {\n    font-size: 16px;\n    margin: 0;\n    display: flex;\n    place-items: center;\n    min-width: 320px;\n    min-height: 100vh;\n}\n\n#app {\n    max-width: 1280px;\n    margin: auto;\n    padding: 2rem;\n}\n\n\nbutton {\n    border-radius: 8px;\n    border: 1px solid transparent;\n    padding: 0.6em 1.2em;\n    font-size: 1em;\n    font-weight: 500;\n    font-family: inherit;\n    background-color: #1a1a1a;\n    cursor: pointer;\n    transition: border-color 0.25s;\n}\n\nbutton:hover {\n    border-color: #646cff;\n}\n\nbutton:focus,\nbutton:focus-visible {\n    outline: 4px auto -webkit-focus-ring-color;\n}\n\n@media (prefers-color-scheme: light) {\n    :root {\n        color: #213547;\n        background-color: #ffffff;\n    }\n\n    a:hover {\n        color: #747bff;\n    }\n\n    button {\n        background-color: #f9f9f9;\n    }\n}\n\n.login-box {\n    background-color: #f2f6f7;\n    padding: 40px;\n    border-radius: 20px;\n    color: #607278;\n}\n\nbody {\n    background: radial-gradient(100% 100% at 50% 0%, var(--color-etherpad) 0%, #003A47 100%) fixed\n}\n\ninput {\n    border-radius: 8px;\n    border: 1px solid #d1d1d1;\n    padding: 0.6em 1.2em;\n    font-size: 1em;\n    font-weight: 500;\n    font-family: inherit;\n    background-color: #f9f9f9;\n    transition: border-color 0.25s;\n}\n\n.login-inner-box {\n    display: flex;\n    flex-direction: column;\n    gap: 10px;\n}\n\n.login-inner-box input[type=submit] {\n    background-color: var(--color-etherpad);\n    color: white;\n    border: none;\n    cursor: pointer;\n    margin-top: 20px;\n}\n\n.password-label {\n    position: relative;\n}\n\n.password-label svg {\n    position: absolute;\n    right: 10px;\n    top: 50%;\n    transform: translateY(-50%);\n    cursor: pointer;\n    width: 16px;\n}\n\n#eye-hide {\n    display: none;\n}\n\nlabel {\n    display: flex;\n}\n\nlabel input {\n    flex-grow: 1;\n}\n"
  },
  {
    "path": "ui/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "ui/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"module\": \"ESNext\",\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "ui/vite.config.ts",
    "content": "// vite.config.js\nimport { resolve } from 'path'\nimport { defineConfig } from 'vite'\n\nexport default defineConfig({\n  base: '/views/',\n    build: {\n        outDir: resolve(__dirname, '../src/static/oidc'),\n        rolldownOptions: {\n            input: {\n                main: resolve(__dirname, 'consent.html'),\n                nested: resolve(__dirname, 'login.html'),\n            },\n        },\n        emptyOutDir: true,\n    },\n  server:{\n      proxy:{\n        '/static':{\n            target: 'http://localhost:9001',\n            changeOrigin: true,\n            secure: false,\n        },\n        '/views/manifest.json':{\n            target: 'http://localhost:9001',\n            changeOrigin: true,\n            secure: false,\n          rewrite: (path) => path.replace(/^\\/views/, ''),\n        },\n        '/locales.json':{\n            target: 'http://localhost:9001',\n            changeOrigin: true,\n            secure: false,\n          rewrite: (path) => path.replace(/^\\/views/, ''),\n        },\n        '/locales':{\n            target: 'http://localhost:9001',\n            changeOrigin: true,\n            secure: false,\n          rewrite: (path) => path.replace(/^\\/views/, ''),\n        },\n      }\n  }\n})\n"
  },
  {
    "path": "var/.gitignore",
    "content": "sqlite.db\nminified*\ninstalled_plugins.json\ndirty.db\nrusty.db\n"
  }
]