[
  {
    "path": ".cursorrules",
    "content": "# Shiori Test Commands\n\n# Run the entire test suite\nmake unittest\n\n# Run SQLite database tests only\ngo test -timeout 10s -count=1 -tags test_sqlite_only ./internal/database\n"
  },
  {
    "path": ".dockerignore",
    "content": "dev-data*\ndocs\n!docs/swagger\nDockerfile\n*.md\n/.*\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Report problems with Shiori\ntitle: One line description of the bug\nlabels: type:bug\nassignees: ''\n---\n\n## Data\n- **Shiori version**: If unknown, run `shiori version` in your terminal or check your server logs. If you don't have the command or information in your logs you are probably running an older version (1.5.4 or older).\n- **Database Engine**: If unknown, SQLite is the default.\n- **Operating system**:\n- **CLI/Web interface/Web Extension**:\n\n## Describe the bug / actual behavior\nA clear and concise description of what the bug is.\n\n## Expected behavior\nA clear and concise description of what you expected to happen.\n\n## To Reproduce\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n## Screenshots\nIf applicable, add screenshots to help explain your problem.\n\n## Notes\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: PLEASE READ ISSUE\ntitle: ''\nlabels: ''\nassignees: ''\n---\n\nPlease report feature requests in the [discussions section](https://github.com/go-shiori/shiori/discussions/categories/feature-requests).\n\nFeature requests in issues would be likely moved on there until we plan to work on them somewhere in the future. Having them in discussions helps with the conversation and voting of future new features, as well as to keep the issues section clean and with items to address only.\n\nThank you.\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates\n\nversion: 2\nupdates:\n  # Maintain dependencies for GitHub Actions\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n    groups:\n      all:\n        patterns:\n          - \"*\"\n\n  # Maintain dependencies for Golang\n  - package-ecosystem: \"gomod\"\n    directory: \"/\"\n    schedule:\n      interval: \"monthly\"\n    groups:\n      all:\n        patterns:\n          - \"*\"\n"
  },
  {
    "path": ".github/stale.yml",
    "content": "# Number of days of inactivity before an issue becomes stale\ndaysUntilStale: 30\n# Number of days of inactivity before a stale issue is closed\ndaysUntilClose: -1\n# Issues with these labels will never be considered stale\nexemptLabels:\n- tag:no-stale\n- type:bug\n- type:enhancement\n- type:documentation\n# Label to use when marking an issue as stale\nstaleLabel: tag:stale\n# Comment to post when marking an issue as stale. Set to `false` to disable\nmarkComment: >\n  This issue has been automatically marked as stale because it has not had any\n  activity for quite some time.\n\n  It will be closed if no further activity occurs.\n\n  Thank you for your contributions.\n# Comment to post when closing a stale issue. Set to `false` to disable\ncloseComment: false\n\npulls:\n  daysUntilStale: 10\n  daysUntilClose: -1\n  onlyLabels:\n    - tag:stalebot\n"
  },
  {
    "path": ".github/workflows/_buildx.yml",
    "content": "name: \"Build Docker\"\n\non:\n  workflow_call:\n    secrets:\n      DOCKERHUB_USERNAME:\n        required: true\n      DOCKERHUB_TOKEN:\n        required: true\n    inputs:\n      tag_prefix:\n        description: 'The tag prefix to use'\n        required: false\n        type: string\n        default: ''\n      dockerfile:\n        description: 'The Dockerfile to use'\n        required: false\n        type: string\n        default: 'Dockerfile'\n\njobs:\n  buildx:\n    runs-on: ubuntu-latest\n    env:\n      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n    name: Build Docker\n    steps:\n      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0\n        with:\n          fetch-depth: 0\n      - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # 5.0.0\n        with:\n          name: dist\n          path: ./dist\n\n      # Every pull request that goes into master\n      - name: Prepare master push tags\n        if: github.event_name == 'push' && github.ref == 'refs/heads/master'\n        run: |\n          REPO=ghcr.io/${{ github.repository }}\n          TAG=$(git describe --tags)\n          echo \"tag_flags=--tag $REPO:${{ inputs.tag_prefix }}$TAG\" >> $GITHUB_ENV\n\n      # New tagged version\n      - name: Prepare version push tags\n        if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')\n        run: |\n          REPO=ghcr.io/${{ github.repository }}\n          DOCKERHUB_REPO=shioriapp/shiori\n          TAG=$(git describe --tags)\n          if [[ \"$TAG\" == *\"rc\"* ]]\n          then\n            TAG2=\"dev\"\n          else\n            TAG2=\"latest\"\n          fi\n          echo \"tag_flags=--tag $REPO:${{ inputs.tag_prefix }}$TAG --tag $REPO:${{ inputs.tag_prefix }}$TAG2 --tag $DOCKERHUB_REPO:${{ inputs.tag_prefix }}$TAG --tag $DOCKERHUB_REPO:${{ inputs.tag_prefix }}$TAG2\" >> $GITHUB_ENV\n\n      # Every pull request\n      - name: Prepare pull request tags\n        if: github.event_name == 'pull_request'\n        run: |\n          echo \"tag_flags=--tag ${{ github.ref }}\" >> $GITHUB_ENV\n          REPO=ghcr.io/${{ github.repository }}\n          echo \"tag_flags=--tag $REPO:${{ inputs.tag_prefix }}pr-${{ github.event.pull_request.number }}\" >> $GITHUB_ENV\n\n      - name: Buildx\n        run: |\n          set -x\n          echo \"${{ secrets.GITHUB_TOKEN }}\" | docker login -u \"${{ github.repository_owner }}\" --password-stdin ghcr.io\n          # Login to DockerHub for versioned releases\n          if [[ \"${{ github.event_name }}\" == \"push\" && \"${{ github.ref }}\" == refs/tags/v* ]]; then\n            echo \"${{ secrets.DOCKERHUB_TOKEN }}\" | docker login -u \"${{ secrets.DOCKERHUB_USERNAME }}\" --password-stdin\n          fi\n          make buildx CONTAINERFILE_NAME=${{ inputs.dockerfile }} CONTAINER_BUILDX_OPTIONS=\"--push ${{ env.tag_flags }}\"\n"
  },
  {
    "path": ".github/workflows/_delete-registry-tag.yml",
    "content": "name: Delete registry tag\n\non:\n  workflow_call:\n    inputs:\n      tag_name:\n        description: 'The docker tag to remove'\n        required: true\n        type: string\n\njobs:\n  purge-image-tag:\n    name: Delete image from ghcr.io\n    runs-on: ubuntu-latest\n    steps:\n      - name: Delete image tag\n        uses: bots-house/ghcr-delete-image-action@3827559c68cb4dcdf54d813ea9853be6d468d3a4 # v1.1.0\n        with:\n          owner: go-shiori\n          name: shiori\n          token: ${{ secrets.GITHUB_TOKEN }}\n          tag: ${{ inputs.tag_name }}\n"
  },
  {
    "path": ".github/workflows/_e2e.yml",
    "content": "name: \"E2E Tests\"\n\non: workflow_call\n\njobs:\n  e2e-tests:\n    runs-on: ubuntu-latest\n\n    name: Tests\n    steps:\n    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0\n\n    - name: Setup go\n      uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0\n      with:\n        go-version-file: ./go.mod\n\n    - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v4.1.2\n\n    - name: Install browsers\n      run: npx playwright install --with-deps\n\n    - run: make e2e\n\n    - name: Upload test report\n      if: always()\n      uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2\n      with:\n        name: e2e-test-report\n        path: e2e-report.html\n        if-no-files-found: ignore\n"
  },
  {
    "path": ".github/workflows/_golangci-lint.yml",
    "content": "name: \"golangci-lint\"\n\non: workflow_call\n\npermissions:\n  contents: read\n  pull-requests: read\njobs:\n  golangci:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0\n      - name: golangci-lint\n        uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # 8.0.0\n        with:\n          version: \"v2.5.0\"\n\n          # Optional: working directory, useful for monorepos\n          # working-directory: somedir\n\n          # Optional: golangci-lint command line arguments.\n          # args: --issues-exit-code=0\n\n          # Optional: show only new issues if it's a pull request. The default value is `false`.\n          only-new-issues: true\n\n          # Optional: if set to true then the action will use pre-installed Go.\n          # skip-go-installation: true\n\n          # Optional: if set to true then the action don't cache or restore ~/go/pkg.\n          # skip-pkg-cache: true\n\n          # Optional: if set to true then the action don't cache or restore ~/.cache/go-build.\n          # skip-build-cache: true\n"
  },
  {
    "path": ".github/workflows/_gorelease.yml",
    "content": "name: \"Goreleaser\"\n\non: workflow_call\n\npermissions:\n  contents: write\n\njobs:\n  goreleaser:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0\n        with:\n          fetch-depth: 0\n      - if: ${{ !startsWith(github.ref, 'refs/tags/v') }}\n        run: echo \"flags=--snapshot\" >> $GITHUB_ENV\n      - name: Setup Go\n        uses:  actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0\n        with:\n          go-version-file: 'go.mod'\n      - name: Run GoReleaser\n        uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # 6.4.0\n        with:\n          distribution: goreleaser\n          version: v2.4.8\n          args: release --clean ${{ env.flags }}\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2\n        with:\n          name: dist\n          path: ./dist/*\n"
  },
  {
    "path": ".github/workflows/_mkdocs-check.yml",
    "content": "name: \"Check mkdocs documentation\"\n\non: workflow_call\n\njobs:\n  mkdocs-check:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0\n\n      - name: Set up Python\n        uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0\n\n      - name: check\n        run: make docs\n        env:\n          MKDOCS_EXTRA_FLAGS: --strict\n"
  },
  {
    "path": ".github/workflows/_mkdocs-publish.yml",
    "content": "name: \"Publish documentation\"\n\non: workflow_call\n\npermissions:\n  contents: write\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0\n\n      - name: Set up Python\n        uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0\n\n      - run: make docs\n        env:\n          DOCS_COMMAND: publish\n"
  },
  {
    "path": ".github/workflows/_styles-check.yml",
    "content": "name: \"styles-check\"\n\non: workflow_call\n\njobs:\n  styles-check:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0\n\n      - name: Setup Bun\n        uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v1\n        with:\n          bun-version: \"1.0.1\"\n\n      - name: Check\n        run: make styles-check\n\n"
  },
  {
    "path": ".github/workflows/_swagger-check.yml",
    "content": "name: \"swagger-check\"\n\non: workflow_call\n\njobs:\n  swagger-check:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0\n\n      - name: Setup Go\n        uses:  actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0\n        with:\n          go-version-file: 'go.mod'\n\n      - name: Install dependencies\n        run: go install $(cat go.mod | grep swaggo/swag | cut -d \" \" -f 1)/cmd/swag@$(cat go.mod | grep swaggo/swag | cut -d \" \" -f 2)\n\n      - name: check\n        run: make swag-check\n\n"
  },
  {
    "path": ".github/workflows/_test.yml",
    "content": "name: \"Unit Tests\"\n\non:\n  workflow_call:\n    secrets:\n      CODECOV_TOKEN:\n        required: true\n\nenv:\n  CGO_ENABLED: 0\n\njobs:\n  test-linux:\n    runs-on: ubuntu-latest\n    services:\n      postgres:\n        image: postgres:13.18\n        env:\n          POSTGRES_PASSWORD: shiori\n          POSTGRES_USER: shiori\n        options: >-\n          --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5\n        ports:\n          - 5432:5432\n      mariadb:\n        image: mariadb:10.5.27\n        env:\n          MYSQL_USER: shiori\n          MYSQL_PASSWORD: shiori\n          MYSQL_DATABASE: shiori\n          MYSQL_ROOT_PASSWORD: shiori\n        options: >-\n          --health-cmd=\"/usr/local/bin/healthcheck.sh --connect\" --health-interval 10s --health-timeout 5s --health-retries 5\n        ports:\n          - 3306:3306\n      mysql:\n        image: mysql:8.0.40\n        env:\n          MYSQL_USER: shiori\n          MYSQL_PASSWORD: shiori\n          MYSQL_DATABASE: shiori\n          MYSQL_ROOT_PASSWORD: shiori\n        options: >-\n          --health-cmd=\"mysqladmin ping\"\n          --health-interval=10s\n          --health-timeout=5s\n          --health-retries=3\n        ports:\n          - 3307:3306\n\n    name: Go unit tests (ubuntu-latest)\n    steps:\n      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0\n\n      - name: Setup go\n        uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0\n        with:\n          go-version-file: ./go.mod\n\n      - name: Set up gotestfmt\n        uses: gotesttools/gotestfmt-action@8b4478c7019be847373babde9300210e7de34bfb # v2.2.0\n\n      - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # 4.3.0\n        with:\n          path: |\n            ~/.cache/go-build\n            ~/go/pkg\n          key: golangci-lint.cache-{platform-arch}-{interval_number}-{go.mod_hash}\n          restore-keys: |\n            golangci-lint.cache-{interval_number}-\n            golangci-lint.cache-\n\n      - run: make unittest\n        env:\n          SHIORI_TEST_PG_URL: \"postgres://shiori:shiori@localhost:5432/shiori?sslmode=disable\"\n          SHIORI_TEST_MYSQL_URL: \"shiori:shiori@(localhost:3306)/shiori\"\n          SHIORI_TEST_MARIADB_URL: \"shiori:shiori@(localhost:3307)/shiori\"\n          CGO_ENABLED: 1 # go test -race requires cgo\n\n      - run: go build -tags osusergo,netgo -ldflags=\"-s -w -X main.version=$(git describe --tags) -X main.date=$(date --iso-8601=seconds)\"\n\n      - name: Upload coverage reports to Codecov\n        uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # 5.5.1\n        env:\n          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}\n\n  test-windows-macos:\n    strategy:\n      matrix:\n        os: [windows-latest, macos-latest]\n    runs-on: ${{ matrix.os }}\n    name: Go unit tests (${{ matrix.os }})\n    steps:\n      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0\n\n      - name: Setup go\n        uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0\n        with:\n          go-version-file: ./go.mod\n\n      - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # 4.3.0\n        with:\n          path: |\n            ~/.cache/go-build\n            ~/go/pkg\n          key: golangci-lint.cache-{platform-arch}-{interval_number}-{go.mod_hash}\n          restore-keys: |\n            golangci-lint.cache-{interval_number}-\n            golangci-lint.cache-\n\n      - run: make unittest GO_TEST_FLAGS=\"-tags test_sqlite_only -race -v -count=1\"\n        env:\n          CGO_ENABLED: 1 # go test -race requires cgo\n\n      - run: go build -tags osusergo,netgo -ldflags=\"-s -w -X main.version=$(git describe --tags) -X main.date=$(date --iso-8601=seconds)\"\n\n  # Please note BSD support is offered on a best-effort basis, this check is not blocking but for us to be aware of issues.\n  # This test also does not take into consideration the go version specified in the go.mod file and just uses the\n  # latest version available in the openbsd package repository.\n  test-bsd:\n    continue-on-error: true\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        os:\n          - name: openbsd\n            architecture: x86-64\n            version: \"7.7\"\n    steps:\n      - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0\n\n      - name: Test on ${{ matrix.os.name }}\n        uses: cross-platform-actions/action@e8a7b572196ff79ded1979dc2bb9ee67d1ddb252 # v0.29.0\n        with:\n          environment_variables: GO_VERSION\n          operating_system: ${{ matrix.os.name }}\n          architecture: ${{ matrix.os.architecture }}\n          version: ${{ matrix.os.version }}\n          shell: bash\n          memory: 1G\n          cpu_count: 1\n          run: |\n            sudo pkg_add -u\n            sudo pkg_add gmake git\n            curl -L https://go.dev/dl/go1.25.1.openbsd-amd64.tar.gz | sudo tar -C /usr/local -xzf -\n            export PATH=$PATH:/usr/local/go/bin\n            gmake unittest GO_TEST_FLAGS=\"-tags test_sqlite_only -v -count=1\"\n"
  },
  {
    "path": ".github/workflows/pull_request.yml",
    "content": "name: 'Pull Request'\n\non:\n  pull_request:\n    branches:\n    - master\n\nconcurrency:\n  group: ci-tests-${{ github.ref }}-1\n  cancel-in-progress: true\n\njobs:\n  call-lint:\n    uses: ./.github/workflows/_golangci-lint.yml\n  call-test:\n    uses: ./.github/workflows/_test.yml\n    secrets:\n      CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}\n  call-swagger-check:\n    uses: ./.github/workflows/_swagger-check.yml\n  call-mkdocs-check:\n    uses: ./.github/workflows/_mkdocs-check.yml\n  call-styles-check:\n    uses: ./.github/workflows/_styles-check.yml\n  call-e2e:\n    needs: [call-lint, call-test, call-swagger-check, call-styles-check, call-mkdocs-check]\n    uses: ./.github/workflows/_e2e.yml\n  call-gorelease:\n    needs: [call-e2e]\n    uses: ./.github/workflows/_gorelease.yml\n  call-buildx:\n    needs: call-gorelease\n    if: ${{ !startsWith(github.head_ref, 'dependabot/') }}\n    uses: ./.github/workflows/_buildx.yml\n    secrets:\n      DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}\n      DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}\n  call-buildx-alpine:\n    needs: call-gorelease\n    if: ${{ !startsWith(github.head_ref, 'dependabot/') }}\n    uses: ./.github/workflows/_buildx.yml\n    secrets:\n      DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}\n      DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}\n    with:\n      tag_prefix: alpine-\n      dockerfile: Dockerfile.alpine\n"
  },
  {
    "path": ".github/workflows/pull_request_closed.yml",
    "content": "name: 'Clean up Docker images from PR'\n\non:\n  pull_request:\n    types:\n    - closed\n\njobs:\n  delete-tag:\n    uses: ./.github/workflows/_delete-registry-tag.yml\n    if: github.event.pull_request.head.repo.fork == false\n    with:\n      tag_name: pr-${{ github.event.pull_request.number }}\n"
  },
  {
    "path": ".github/workflows/push.yml",
    "content": "name: 'Push'\n\non:\n  push:\n    branches:\n      - \"master\"\n    tags:\n      - \"v*\"\n\nconcurrency:\n  group: ci-tests-${{ github.ref }}-1\n  cancel-in-progress: true\n\njobs:\n  call-lint:\n    uses: ./.github/workflows/_golangci-lint.yml\n  call-test:\n    uses: ./.github/workflows/_test.yml\n    secrets:\n      CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN}}\n  call-e2e:\n    uses: ./.github/workflows/_e2e.yml\n  call-gorelease:\n    needs: [call-lint, call-test, call-e2e]\n    uses: ./.github/workflows/_gorelease.yml\n  call-buildx:\n    needs: call-gorelease\n    uses: ./.github/workflows/_buildx.yml\n    secrets:\n      DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}\n      DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}\n  call-buildx-alpine:\n    needs: call-gorelease\n    # only build on pull requests from the same repo for now\n    uses: ./.github/workflows/_buildx.yml\n    secrets:\n      DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}\n      DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}\n    with:\n      tag_prefix: alpine-\n      dockerfile: Dockerfile.alpine\n"
  },
  {
    "path": ".github/workflows/version_bump.yml",
    "content": "name: \"Tag release\"\n\non:\n  workflow_dispatch:\n    inputs:\n      version:\n        description: \"Version to bump to, example: v1.5.2\"\n        required: true\n      ref:\n        description: \"Ref to release from\"\n        required: true\n        type: string\n        default: master\n\njobs:\n  tag-release:\n    runs-on: ubuntu-latest\n    env:\n      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0\n        with:\n          fetch-depth: 0\n          ref: ${{ inputs.ref }}\n      - name: Tag release\n        run: |\n          git config user.email \"${{github.repository_owner}}@users.noreply.github.com\"\n          git config user.name \"${{github.repository_owner}}\"\n          git tag -a ${{ github.event.inputs.version }} -m \"tag release ${{ github.event.inputs.version }}\"\n          git push --follow-tags\n  call-gorelease:\n    needs: tag-release\n    uses: ./.github/workflows/_gorelease.yml\n  call-buildx:\n    needs: call-gorelease\n    uses: ./.github/workflows/_buildx.yml\n    secrets:\n      DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}\n      DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}\n  call-buildx-alpine:\n    needs: call-gorelease\n    uses: ./.github/workflows/_buildx.yml\n    secrets:\n      DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}\n      DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}\n    with:\n      tag_prefix: alpine-\n      dockerfile: Dockerfile.alpine\n  call-mkdocs-publish:\n    needs: [call-buildx, call-buildx-alpine]\n    uses: ./.github/workflows/_mkdocs-publish.yml\n"
  },
  {
    "path": ".gitignore",
    "content": "# Exclude IDE\n.vscode/\n.idea/\n\n# Exclude config file\n*.toml\n\n# Exclude executable file\n/shiori*\n\n# Exclude development data\n/dev-data*\n\n# Tests\n/coverage.*\ne2e-report.html\n\n# Dist files\n/dist\n\n# macOS trash files\n.DS_Store\n\n# frontend\nnode_modules\n\n# golang\ngo.work*\n\n# workaround for buildx using podman\ntype=docker\n\n# Docs\ndocs/.venv\nbuild/docs\n"
  },
  {
    "path": ".golangci.bck.yml",
    "content": "# Docs: https://golangci-lint.run/usage/configuration/#config-file\nrun:\n  timeout: 5m\n\nissues:\n  max-issues-per-linter: 0\n  max-same-issues: 0\n  exclude-dirs:\n    - internal/mocks\n\nlinters-settings:\n  gofmt:\n    simplify: true\n  govet:\n    enable-all: true\n    disable:\n      - fieldalignment\n\nlinters:\n  disable-all: true\n  enable:\n    - copyloopvar\n    - gofmt\n    - gosimple\n    # - govet # Re-enable when all shadow declarations are fixed\n    - ineffassign\n    - predeclared\n    - staticcheck\n    - unconvert\n    - unused\n"
  },
  {
    "path": ".golangci.yml",
    "content": "version: \"2\"\nlinters:\n  default: none\n  enable:\n    - copyloopvar\n    - ineffassign\n    - predeclared\n    - staticcheck\n    - unconvert\n    - unused\n  settings:\n    govet:\n      disable:\n        - fieldalignment\n      enable-all: true\n  exclusions:\n    generated: lax\n    presets:\n      - comments\n      - common-false-positives\n      - legacy\n      - std-error-handling\n    paths:\n      - internal/mocks\n      - third_party$\n      - builtin$\n      - examples$\nissues:\n  max-issues-per-linter: 0\n  max-same-issues: 0\nformatters:\n  enable:\n    - gofmt\n  settings:\n    gofmt:\n      simplify: true\n  exclusions:\n    generated: lax\n    paths:\n      - internal/mocks\n      - third_party$\n      - builtin$\n      - examples$\n"
  },
  {
    "path": ".goreleaser.yaml",
    "content": "version: 2\n\nbefore:\n  hooks:\n    - go mod tidy\n\ngit:\n  ignore_tags:\n    - \"{{ if not .IsNightly }}*-rc*{{ end }}\"\n\nbuilds:\n  - binary: shiori\n    env:\n      - CGO_ENABLED=0\n      - GIN_MODE=release\n    tags:\n      - netgo\n      - osusergo\n    goos:\n      - linux\n      - windows\n      - darwin\n    goarch:\n      - amd64\n      - arm\n      - arm64\n    goarm:\n      - \"7\"\n    ignore:\n      - goos: darwin\n        goarch: arm\n      - goos: windows\n        goarch: arm\n      - goos: windows\n        goarch: arm64\n\narchives:\n  - id: shiori\n    name_template: >-\n      {{ .ProjectName }}_\n      {{- if eq .Os \"darwin\" }}Darwin{{- else if eq .Os \"linux\" }}Linux{{- else if eq .Os \"windows\" }}Windows{{- else }}{{ .Os }}{{ end }}_\n      {{- if eq .Arch \"amd64\" }}x86_64{{- else if eq .Arch \"arm64\" }}aarch64{{- else }}{{ .Arch }}{{ end }}_{{ .Version }}\n    format_overrides:\n    - goos: windows\n      format: zip\n\n# TODO:\n# upx:\n#   - enabled: true\n#     ids:\n#       - shiori\n#     goos: [linux, darwin]\n#     goarch: [amd64, arm, arm64]\n#     goarm: [\"7\"]\n\nchecksum:\n  name_template: 'checksums.txt'\n\nsnapshot:\n  version_template: \"{{ incpatch .Version }}-next\"\n\nchangelog:\n  sort: asc\n  groups:\n    - title: Features\n      regexp: '^.*?feat(\\([[:word:]]+\\))??!?:.+$'\n      order: 0\n    - title: \"Fixes\"\n      regexp: '^.*?fix(\\([[:word:]]+\\))??!?:.+$'\n      order: 1\n    - title: \"Performance\"\n      regexp: '^.*?perf(\\([[:word:]]+\\))??!?:.+$'\n      order: 2\n    - title: API\n      regexp: '^.*?api(\\([[:word:]]+\\))??!?:.+$'\n      order: 3\n    - title: Documentation\n      regexp: '^.*?docs(\\([[:word:]]+\\))??!?:.+$'\n      order: 4\n    - title: \"Tests\"\n      regexp: '^.*?test(\\([[:word:]]+\\))??!?:.+$'\n      order: 5\n    - title: CI and Delivery\n      regexp: '^.*?ci(\\([[:word:]]+\\))??!?:.+$'\n      order: 6\n    - title: Others\n      order: 999\n  filters:\n    exclude:\n      - \"^deps:\"\n      - \"^chore\\\\(deps\\\\):\"\n\nrelease:\n  prerelease: auto\n"
  },
  {
    "path": ".prettierignore",
    "content": "# Ignore some files we don't want to format\n*.html\n*.json\n*.md\n*.yml\n*.yaml\n\n# Ignore build artifacts\ninternal/view/assets/css/\n\n# Ignore bundled dependencies\ninternal/view/assets/js/dayjs.min.js\ninternal/view/assets/js/url.js\ninternal/view/assets/js/url.min.js\ninternal/view/assets/js/vue.js\ninternal/view/assets/js/vue.min.js\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"useTabs\": true\n}\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Community Conduct Guideline\n\nThe following conduct guideline is based on [Ruby's](https://www.ruby-lang.org/en/conduct/) code of conduct.\n\nThis document provides community guidelines for a safe, respectful, productive, and collaborative place for any person who is willing to contribute to the Shiori community. It applies to all \"collaborative space\", which is defined as community communications channels (such as issues, mailing lists, submitted patches, commit comments, etc.).\n\n- Participants will be tolerant of opposing views.\n- Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.\n- When interpreting the words and actions of others, participants should always assume good intentions.\n- Behaviour which can be reasonably considered harassment will not be tolerated.\n\nInstances of abusive, harassing, or otherwise unacceptable behaviour may be reported by contacting the project maintainer at deanishe@deanishe.net.\n"
  },
  {
    "path": "Dockerfile",
    "content": "# Build stage\nARG ALPINE_VERSION=3.19\n\nFROM docker.io/library/alpine:${ALPINE_VERSION} AS builder\nARG TARGETARCH\nARG TARGETOS\nARG TARGETVARIANT\nCOPY dist/shiori_${TARGETOS}_${TARGETARCH}${TARGETVARIANT}/shiori /usr/bin/shiori\nRUN apk add --no-cache ca-certificates tzdata && \\\n    chmod +x /usr/bin/shiori && \\\n    rm -rf /tmp/*\n\n# Server image\nFROM scratch\n\nENV PORT=8080\nENV SHIORI_DIR=/shiori\nWORKDIR ${SHIORI_DIR}\n\nLABEL org.opencontainers.image.source=\"https://github.com/go-shiori/shiori\"\nLABEL maintainer=\"Felipe Martin <github@fmartingr.com>\"\n\nCOPY --from=builder /tmp /tmp\nCOPY --from=builder /usr/bin/shiori /usr/bin/shiori\nCOPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo\nCOPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt\n\nEXPOSE ${PORT}\n\nENTRYPOINT [\"/usr/bin/shiori\"]\nCMD [\"server\"]\n"
  },
  {
    "path": "Dockerfile.alpine",
    "content": "ARG ALPINE_VERSION=3.19\n\nFROM docker.io/library/alpine:${ALPINE_VERSION}\nARG TARGETARCH\nARG TARGETOS\nARG TARGETVARIANT\nCOPY dist/shiori_${TARGETOS}_${TARGETARCH}${TARGETVARIANT}/shiori /usr/bin/shiori\nRUN apk add --no-cache ca-certificates tzdata && \\\n    chmod +x /usr/bin/shiori && \\\n    rm -rf /tmp/* && \\\n    apk cache clean\n\nENV PORT=8080\nENV SHIORI_DIR=/shiori\nWORKDIR ${SHIORI_DIR}\n\nLABEL org.opencontainers.image.source=\"https://github.com/go-shiori/shiori\"\nLABEL maintainer=\"Felipe Martin <github@fmartingr.com>\"\n\nEXPOSE ${PORT}\n\nENTRYPOINT [\"/usr/bin/shiori\"]\nCMD [\"server\"]\n"
  },
  {
    "path": "Dockerfile.compose",
    "content": "# This Dockerfile is intented for Development purposes only to use\n# with the provided docker-compose.yaml file.\n# Please do not run this Dockerfile in any environment that is not\n# a local development scenario as this is not throroughly updated nor\n# tested.\nFROM docker.io/golang:1.22-alpine\n\nWORKDIR /src/shiori\n\nENTRYPOINT [\"go\", \"run\", \"main.go\"]\nCMD [\"server\"]\n"
  },
  {
    "path": "Dockerfile.e2e",
    "content": "ARG ALPINE_VERSION\nARG GOLANG_VERSION\n\nFROM docker.io/golang:${GOLANG_VERSION}-alpine${ALPINE_VERSION}\n\nWORKDIR /src/shiori\n\nCOPY . /src/shiori\n\nRUN apk --update add git && \\\n    go run main.go version # Using this to force go dep download by running the main command.\n\nENTRYPOINT [\"go\", \"run\", \"main.go\"]\nCMD [\"server\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2018-present Radhi Fadlillah\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": "GO ?= $(shell command -v go 2> /dev/null)\nBASH ?= $(shell command -v bash 2> /dev/null)\nGOLANG_VERSION := $(shell head -n 4 go.mod | tail -n 1 | cut -d \" \" -f 2)\n\n# Development\nSHIORI_DIR ?= dev-data\nSOURCE_FILES ?=./internal/...\n\n# Build\nCGO_ENABLED ?= 0\nBUILD_TIME := $(shell date -u +%Y%m%d.%H%M%S)\nBUILD_HASH := $(shell git describe --tags)\nBUILD_TAGS ?= osusergo,netgo,fts5\nLDFLAGS += -s -w -X main.version=$(BUILD_HASH) -X main.date=$(BUILD_TIME)\n\n# Build (container)\nCONTAINER_RUNTIME := docker\nCONTAINERFILE_NAME := Dockerfile\nCONTAINER_ALPINE_VERSION := 3.22\nBUILDX_PLATFORMS := linux/amd64,linux/arm64,linux/arm/v7\n\n# This is used for local development only, forcing linux to create linux only images but with the arch\n# of the running machine. Far from perfect but works.\nLOCAL_BUILD_PLATFORM = linux/$(shell go env GOARCH)\n\n# Testing\nGO_TEST_FLAGS ?= -v -race -count=1 -tags $(BUILD_TAGS) -covermode=atomic -coverprofile=coverage.out\nGOTESTFMT_FLAGS ?=\nSHIORI_TEST_MYSQL_URL ?=shiori:shiori@tcp(127.0.0.1:3306)/shiori\nSHIORI_TEST_MARIADB_URL ?= shiori:shiori@tcp(127.0.0.1:3307)/shiori\nSHIORI_TEST_PG_URL ?= postgres://shiori:shiori@127.0.0.1:5432/shiori?sslmode=disable\n\n# Development\nGIN_MODE ?= debug\nSHIORI_DEVELOPMENT ?= true\n\n# Swagger\nSWAG_VERSION := $(shell grep \"swaggo/swag\" go.mod | cut -d \" \" -f 2)\nSWAGGER_DOCS_PATH ?= ./docs/swagger\n\n# Frontend\nCLEANCSS_OPTS ?= --with-rebase\n\n# Common exports\nexport GOLANG_VERSION\nexport CONTAINER_RUNTIME\nexport CONTAINERFILE_NAME\nexport CONTAINER_ALPINE_VERSION\nexport BUILDX_PLATFORMS\n\nexport SOURCE_FILES\n\nexport SHIORI_TEST_MYSQL_URL\nexport SHIORI_TEST_MARIADB_URL\nexport SHIORI_TEST_PG_URL\n\n# Help documentatin à la https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html\n.PHONY: help\nhelp:\n\t@cat Makefile | grep -v '\\.PHONY' |  grep -v '\\help:' | grep -B1 -E '^[a-zA-Z0-9_.-]+:.*' | sed -e \"s/:.*//\" | sed -e \"s/^## //\" |  grep -v '\\-\\-' | sed '1!G;h;$$!d' | awk 'NR%2{printf \"\\033[36m%-30s\\033[0m\",$$0;next;}1' | sort\n\n## Cleans up build artifacts\n.PHONY: clean\nclean:\n\trm -rf dist\n\n## Runs server for local development\n.PHONY: run-server\nrun-server: generate\n\tGIN_MODE=$(GIN_MODE) SHIORI_DEVELOPMENT=$(SHIORI_DEVELOPMENT) go run main.go server --log-level debug\n\n## Runs server for local development with v2 web UI\n.PHONY: run-server-v2\nrun-server-v2: generate\n\tGIN_MODE=$(GIN_MODE) SHIORI_DEVELOPMENT=$(SHIORI_DEVELOPMENT) SHIORI_HTTP_SERVE_WEB_UI_V2=true go run main.go server --log-level debug\n\n## Generate swagger docs\n.PHONY: swagger\nswagger:\n\tSWAGGER_DOCS_PATH=$(SWAGGER_DOCS_PATH) $(BASH) ./scripts/swagger.sh\n\n.PHONY: swag-check\nswag-check:\n\tREQUIRED_SWAG_VERSION=$(SWAG_VERSION) SWAGGER_DOCS_PATH=$(SWAGGER_DOCS_PATH) $(BASH) ./scripts/swagger_check.sh\n\n.PHONY: swag-fmt\nswag-fmt:\n\tswag fmt --dir internal/http\n\tgo fmt ./internal/http/...\n\n## Run linters\n.PHONY: lint\nlint: golangci-lint swag-check\n\n## Run golangci-lint\n.PHONY: golangci-lint\ngolangci-lint:\n\tgolangci-lint run\n\n## Run unit tests\n.PHONY: unittest\nunittest:\n\tGIN_MODE=$(GIN_MODE) GO_TEST_FLAGS=\"$(GO_TEST_FLAGS)\" GOTESTFMT_FLAGS=\"$(GOTESTFMT_FLAGS)\" $(BASH) -xe ./scripts/test.sh\n\n## Run end to end tests\n.PHONY: e2e\ne2e:\n\t$(BASH) -xe ./scripts/e2e.sh\n\n## Build styles\n.PHONY: styles\nstyles:\n\tCLEANCSS_OPTS=$(CLEANCSS_OPTS) $(BASH) ./scripts/styles.sh\n\n## Build styles\n.PHONY: styles-check\nstyles-check:\n\tCLEANCSS_OPTS=$(CLEANCSS_OPTS) $(BASH) ./scripts/styles_check.sh\n\n## Build binary\n.PHONY: build\nbuild: clean\n\tGIN_MODE=$(GIN_MODE) goreleaser build --clean --snapshot\n\n## Build binary for current targer\nbuild-local: clean\n\tGIN_MODE=$(GIN_MODE) goreleaser build --clean --snapshot --single-target\n\n## Build docker image using Buildx.\n# used for multi-arch builds suing mainly the CI, that's why the task does not\n# build the binaries using a dependency task.\n.PHONY: buildx\nbuildx:\n\t$(info: Make: Buildx)\n\t@bash scripts/buildx.sh\n\n## Build docker image for local development\nbuildx-local: build-local\n\t$(info: Make: Build image locally)\n\tCONTAINER_BUILDX_OPTIONS=\"-t shiori:localdev --output type=docker\" BUILDX_PLATFORMS=$(LOCAL_BUILD_PLATFORM) scripts/buildx.sh\n\n## Creates a coverage report\n.PHONY: coverage\ncoverage:\n\t$(GO) test $(GO_TEST_FLAGS) -coverprofile=coverage.txt $(SOURCE_FILES)\n\t$(GO) tool cover -html=coverage.txt\n\n## Run generate accross the project\n.PHONY: generate\ngenerate:\n\t$(GO) generate ./...\n"
  },
  {
    "path": "Procfile",
    "content": "web: bin/shiori server -p $PORT\n"
  },
  {
    "path": "README.md",
    "content": "# Shiori\n\n[![IC](https://github.com/go-shiori/shiori/actions/workflows/push.yml/badge.svg?branch=master)](https://github.com/go-shiori/shiori/actions/workflows/push.yml)\n[![Go Report Card](https://goreportcard.com/badge/github.com/go-shiori/shiori)](https://goreportcard.com/report/github.com/go-shiori/shiori)\n[![#shiori-general:matrix.org](https://img.shields.io/badge/matrix-%23shiori-orange)](https://matrix.to/#/#shiori:matrix.org)\n[![Containers](https://img.shields.io/static/v1?label=Container&message=Images&color=1488C6&logo=docker)](https://github.com/go-shiori/shiori/pkgs/container/shiori)\n\n**Check out our latest [Announcements](https://github.com/go-shiori/shiori/discussions/categories/announcements)**\n\nShiori is a simple bookmarks manager written in the Go language. Intended as a simple clone of [Pocket][pocket]. You can use it as a command line application or as a web application. This application is distributed as a single binary, which means it can be installed and used easily.\n\n![Screenshot][screenshot]\n\n## Features\n\n- Basic bookmarks management i.e. add, edit, delete and search.\n- Import and export bookmarks from and to Netscape Bookmark file.\n- Import bookmarks from Pocket.\n- Simple and clean command line interface.\n- Simple and pretty web interface for those who don't want to use a command line app.\n- Portable, thanks to its single binary format.\n- Support for sqlite3, PostgreSQL, MariaDB and MySQL as its database.\n- Where possible, by default `shiori` will parse the readable content and create an offline archive of the webpage.\n- [BETA] [web extension][web-extension] support for Firefox and Chrome.\n\n![Comparison of reader mode and archive mode][mode-comparison]\n\n## Documentation\n\nAll documentation is available in the [docs folder][documentation]. If you think there is incomplete or incorrect information, feel free to edit it by submitting a pull request.\n\n## License\n\nShiori is distributed under the terms of the [MIT license][mit], which means you can use it and modify it however you want. However, if you make an enhancement for it, if possible, please send a pull request.\n\n[documentation]: https://github.com/go-shiori/shiori/blob/master/docs/index.md\n[mit]: https://choosealicense.com/licenses/mit/\n[web-extension]: https://github.com/go-shiori/shiori-web-ext\n[screenshot]: https://raw.githubusercontent.com/go-shiori/shiori/master/docs/assets/screenshots/cover.png\n[mode-comparison]: https://raw.githubusercontent.com/go-shiori/shiori/master/docs/assets/screenshots/comparison.png\n[pocket]: https://getpocket.com/\n[256]: https://github.com/go-shiori/shiori/issues/256\n"
  },
  {
    "path": "app.json",
    "content": "{\n  \"name\": \"Shiori\",\n  \"description\": \"Shiori is a simple bookmarks manager written in Go language. Intended as a simple clone of Pocket\",\n  \"keywords\": [\n    \"bookmark\",\n    \"go\",\n    \"pocket\"\n  ],\n  \"website\": \"http://github.com/go-shiori/shiori\",\n  \"repository\": \"http://github.com/go-shiori/shiori\"\n}\n"
  },
  {
    "path": "codecov.yml",
    "content": "github_checks:\n  annotations: false\n"
  },
  {
    "path": "docker-compose.yaml",
    "content": "# Docker compose for development purposes only.\n# Edit it to fit your current development needs.\nservices:\n  shiori:\n    build:\n      context: .\n      dockerfile: Dockerfile.compose\n    container_name: shiori\n    command:\n      - \"server\"\n      - \"--log-level\"\n      - \"debug\"\n    ports:\n      - \"8080:8080\"\n    volumes:\n      - \"./dev-data:/srv/shiori\"\n      - \".:/src/shiori\"\n      - \"go-mod-cache:/go/pkg/mod\"\n    restart: unless-stopped\n    links:\n      - \"postgres\"\n      - \"mariadb\"\n    environment:\n      SHIORI_DIR: /srv/shiori\n      # SHIORI_HTTP_ROOT_PATH: /shiori/\n      # SHIORI_DATABASE_URL: mysql://shiori:shiori@(mariadb)/shiori?charset=utf8mb4\n      # SHIORI_DATABASE_URL: postgres://shiori:shiori@postgres/shiori?sslmode=disable\n\n  nginx:\n    image: nginx:alpine\n    ports:\n      - \"8081:8081\"\n    volumes:\n      - \"./testdata/nginx.conf:/etc/nginx/nginx.conf:ro\"\n    depends_on:\n      - shiori\n\n  postgres:\n    image: postgres:13.18\n    environment:\n      POSTGRES_PASSWORD: shiori\n      POSTGRES_USER: shiori\n    ports:\n      - \"5432:5432\"\n\n  mariadb:\n    image: mariadb:10.5.27\n    environment:\n      MYSQL_ROOT_PASSWORD: toor\n      MYSQL_DATABASE: shiori\n      MYSQL_USER: shiori\n      MYSQL_PASSWORD: shiori\n    ports:\n      - \"3306:3306\"\n\n  mysql:\n    image: mysql:8.0.40\n    environment:\n      MYSQL_ROOT_PASSWORD: toor\n      MYSQL_DATABASE: shiori\n      MYSQL_USER: shiori\n      MYSQL_PASSWORD: shiori\n    ports:\n      - \"3307:3306\"\n\nvolumes:\n  go-mod-cache:\n"
  },
  {
    "path": "docs/API.md",
    "content": "This is a brief explanation of Shiori's API. For more examples you can import this [collection](https://github.com/go-shiori/shiori/blob/master/docs/postman/shiori.postman_collection.json) in Postman.\n\n> ⚠️ **This is the documentation for the old API. This API is deprecated and will be removed in the future. Please refer and start migrating to the [API v1](./APIv1.md) instead.**\n\n<!-- TOC -->\n\n- [Auth](#auth)\n    - [Log in](#log-in)\n    - [Log out](#log-out)\n- [Bookmarks](#bookmarks)\n    - [Get bookmarks](#get-bookmarks)\n    - [Add bookmark](#add-bookmark)\n    - [Edit bookmark](#edit-bookmark)\n    - [Delete bookmark](#delete-bookmark)\n- [Tags](#tags)\n    - [Get tags](#get-tags)\n    - [Rename tag](#rename-tag)\n- [Accounts](#accounts)\n    - [List accounts](#list-accounts)\n    - [Create account](#create-account)\n    - [Edit account](#edit-account)\n    - [Delete accounts](#delete-accounts)\n\n<!-- /TOC -->\n\n# Auth\n\n## Log in\nMost actions require a session id. For that, you'll need to log in using your username and password.\n|Request info|Value|\n|-|-|\n|Endpoint|`/api/login`|\n|Method|`POST`|\n\nBody:\n```json\n{\n\t\"username\": \"shiori\",\n\t\"password\": \"gopher\",\n\t\"remember\": true,\n\t\"owner\": true\n}\n```\n\nIt will return your session ID in a JSON:\n```json\n{\n    \"session\": \"YOUR_SESSION_ID\",\n    \"account\": {\n        \"id\": 1,\n        \"username\": \"shiori\",\n        \"owner\": true\n    }\n}\n```\n\n## Log out\nLog out of a session ID.\n|Request info|Value|\n|-|-|\n|Endpoint|`/api/logout`|\n|Method|`POST`|\n|`X-Session-Id` Header|`sessionId`|\n\n# Bookmarks\n## Get bookmarks\nGets the last 30 bookmarks (last page).\n|Request info|Value|\n|-|-|\n|Endpoint|`/api/bookmarks`|\n|Method|`GET`|\n|`X-Session-Id` Header|`sessionId`|\n\nReturns:\n```json\n{\n    \"bookmarks\": [\n        {\n            \"id\": 825,\n            \"url\": \"https://interesting_cool_article.com\",\n            \"title\": \"Cool Interesting Article\",\n            \"excerpt\": \"An interesting and cool article indeed!\",\n            \"author\": \"\",\n            \"public\": 0,\n            \"modified\": \"2020-12-06 00:00:00\",\n            \"imageURL\": \"\",\n            \"hasContent\": true,\n            \"hasArchive\": true,\n            \"tags\": [\n                {\n                    \"id\": 7,\n                    \"name\": \"TAG\"\n                }\n            ],\n            \"createArchive\": false\n        },\n    ],\n    \"maxPage\": 19,\n    \"page\": 1\n}\n```\n\n## Add bookmark\nAdd a bookmark. For some reason, Shiori ignores the provided title and excerpt, and instead fetches them automatically. Note the tag format, a regular JSON list will result in an error.\n\n|Request info|Value|\n|-|-|\n|Endpoint|`/api/bookmarks`|\n|Method|`POST`|\n|`X-Session-Id` Header|`sessionId`|\n\nBody:\n```json\n{\n\t\"url\": \"https://interesting_cool_article.com\",\n\t\"createArchive\": true,\n\t\"public\": 1,\n\t\"tags\": [{\"name\": \"Interesting\"}, {\"name\": \"Cool\"}],\n\t\"title\": \"Cool Interesting Article\",\n\t\"excerpt\": \"An interesting and cool article indeed!\"\n}\n```\nReturns:\n```json\n{\n    \"id\": 827,\n    \"url\": \"https://interesting_cool_article.com\",\n    \"title\": \"TITLE\",\n    \"excerpt\": \"EXCERPT\",\n    \"author\": \"AUTHOR\",\n    \"public\": 1,\n    \"modified\": \"DATE\",\n    \"html\": \"HTML\",\n    \"imageURL\": \"/bookmark/827/thumb\",\n    \"hasContent\": false,\n    \"hasArchive\": true,\n    \"tags\": [\n        {\n             \"name\": \"Interesting\"\n        },\n        {\n             \"name\": \"Cool\"\n        }\n    ],\n    \"createArchive\": true\n}\n```\n\n## Edit bookmark\nModifies a bookmark, by ID.\n|Request info|Value|\n|-|-|\n|Endpoint|`/api/bookmarks`|\n|Method|`PUT`|\n|`X-Session-Id` Header|`sessionId`|\n\nBody:\n```json\n{\n    \"id\": 3,\n    \"url\": \"https://interesting_cool_article.com\",\n    \"title\": \"Cool Interesting Article\",\n    \"excerpt\": \"An interesting and cool article indeed!\",\n    \"author\": \"AUTHOR\",\n    \"public\": 1,\n    \"modified\": \"2019-09-22 00:00:00\",\n    \"imageURL\": \"/bookmark/3/thumb\",\n    \"hasContent\": false,\n    \"hasArchive\": false,\n    \"tags\": [],\n    \"createArchive\": false\n}\n```\nAfter providing the ID, provide the modified fields. The syntax is the same as [adding](#Add-a-bookmark).\n\n## Delete bookmark\nDeletes a list of bookmarks, by their IDs.\n|Request info|Value|\n|-|-|\n|Endpoint|`/api/bookmarks`|\n|Method|`DEL`|\n|`X-Session-Id` Header|`sessionId`|\n\nBody:\n```json\n[1, 2, 3]\n```\n\n# Tags\n## Get tags\nGets the list of tags, their IDs and the number of entries that have those tags.\n|Request info|Value|\n|-|-|\n|Endpoint|`/api/tags`|\n|Method|`GET`|\n|`X-Session-Id` Header|`sessionId`|\n\nReturns:\n```json\n[\n    {\n        \"id\": 1,\n        \"name\": \"Cool\",\n        \"nBookmarks\": 1\n    },\n    {\n        \"id\": 2,\n        \"name\": \"Interesting\",\n        \"nBookmarks\": 1\n    }\n```\n\n## Rename tag\nRenames a tag, provided its ID.\n|Request info|Value|\n|-|-|\n|Endpoint|`/api/tags`|\n|Method|`PUT`|\n|`X-Session-Id` Header|`sessionId`|\n\nBody:\n```json\n{\n    \"id\": 1,\n    \"name\": \"TAG_NEW_NAME\"\n}\n```\n\n# Accounts\n## List accounts\nGets the list of all user accounts, their IDs, and whether or not they are owners.\n|Request info|Value|\n|-|-|\n|Endpoint|`/api/accounts`|\n|Method|`GET`|\n|`X-Session-Id` Header|`sessionId`|\n\nReturns:\n```json\n[\n    {\n        \"id\": 1,\n        \"username\": \"shiori\",\n        \"owner\": true\n    }\n]\n```\n\n## Create account\nCreates a new user.\n|Request info|Value|\n|-|-|\n|Endpoint|`/api/accounts`|\n|Method|`POST`|\n|`X-Session-Id` Header|`sessionId`|\nBody:\n```json\n{\n\t\"username\": \"shiori2\",\n\t\"password\": \"gopher\",\n\t\"owner\": false\n}\n```\n\n## Edit account\nChanges an account's password or owner status.\n|Request info|Value|\n|-|-|\n|Endpoint|`/api/accounts`|\n|Method|`PUT`|\n|`X-Session-Id` Header|`sessionId`|\nBody:\n```json\n{\n\t\"username\": \"shiori\",\n\t\"oldPassword\": \"gopher\",\n\t\"newPassword\": \"gopher\",\n\t\"owner\": true\n}\n```\n\n## Delete accounts\nDeletes a list of users.\n|Request info|Value|\n|-|-|\n|Endpoint|`/api/accounts`|\n|Method|`DEL`|\n|`X-Session-Id` Header|`sessionId`|\n\nBody:\n```json\n[\"shiori\", \"shiori2\"]\n```\n"
  },
  {
    "path": "docs/APIv1.md",
    "content": "# API v1\n\n> ℹ️ **This is the documentation for the new API. This API is still in development and though the finished endpoints should not change please consider that breaking changes may occur once its properly released. If you are looking for the current API, please [see here](./API.md).**\n\nThe new API is an ongoing effort to migrate the current API to a more modern and standard API.\n\nThe main goals of this new API are:\n- Ease of development\n- Use of a [modern framework](https://gin-gonic.com)\n- Use of a [standard API specification](https://swagger.io/specification/)\n- Self-documented API using [Swag](https://github.com/swaggo/swag)\n- Improved authentication and sessions using [JWT](https://jwt.io)\n- Deduplicate code between the webserver and the API by refactoring the logic into domains\n- Improve testability by using interfaces and dependency injection\n\nThe current status of this new API can be checked [here](https://github.com/go-shiori/shiori/issues/640).\n\nSince the API is self-docummented, you can check the API documentation by [running the server locally](./Contribute.md#running-the-server-locally) and visiting the [`/swagger/index.html` endpoint](http://localhost:8080/swagger/index.html).\n"
  },
  {
    "path": "docs/CLI.md",
    "content": "Content\n---\n\n<!-- TOC -->\n\n- [Add bookmark](#add-bookmark)\n\n<!-- /TOC -->\n\nAdd bookmark\n---\n\nTo add bookmark with CLI you can use `shiori add`.\n\nShiori has flags to add bookmark: `shiori add --help`\n\n```\nBookmark the specified URL\n\nUsage:\n  shiori add url [flags]\n\nFlags:\n  -e, --excerpt string   Custom excerpt for this bookmark\n  -h, --help             help for add\n      --log-archival     Log the archival process\n  -a, --no-archival      Save bookmark without creating offline archive\n  -o, --offline          Save bookmark without fetching data from internet\n  -t, --tags strings     Comma-separated tags for this bookmark\n  -i, --title string     Custom title for this bookmark\n\nGlobal Flags:\n      --log-caller                 logrus report caller or not\n      --log-level string           set logrus loglevel (default \"info\")\n      --portable                   run shiori in portable mode\n      --storage-directory string   path to store shiori data\n```\n\nExamples:\n\nAdd url:\n`shiori add https://example.com`\n\nAdd url with tags:\n`shiori add https://example.com -t \"example-1,example-2\"`\n\nAdd url with custom title:\n`shiori add https://example.com --title \"example example\"`\n"
  },
  {
    "path": "docs/Configuration.md",
    "content": "# Configuration\n\n<!-- TOC -->\n\n- [Overall Configuration](#overall-configuration)\n  - [Global configuration](#global-configuration)\n  - [HTTP configuration variables](#http-configuration-variables)\n  - [Storage Configuration](#storage-configuration)\n    - [The data Directory](#the-data-directory)\n  - [Database Configuration](#database-configuration)\n    - [MySQL](#mysql)\n    - [PostgreSQL](#postgresql)\n- [Reverse proxies and the webroot path](#reverse-proxies-and-the-webroot-path)\n  - [Nginx](#nginx)\n\n<!-- /TOC -->\n\n## Overall Configuration\n\nMost configuration can be set directly using environment variables or flags. The available flags can be found by running `shiori --help`. The available environment variables are listed below.\n\n### Global configuration\n\n| Environment variable | Default | Required | Description                            |\n| -------------------- | ------- | -------- | -------------------------------------- |\n| `SHIORI_DEVELOPMENT` | `False` | No       | Specifies if the server is in dev mode |\n\n### HTTP configuration variables\n\n| Environment variable                       | Default        | Required | Description                                           |\n| ------------------------------------------ | -------        | -------- | ----------------------------------------------------- |\n| `SHIORI_HTTP_ENABLED`                      | True           | No       | Enable HTTP service                                   |\n| `SHIORI_HTTP_PORT`                         | 8080           | No       | Port number for the HTTP service                      |\n| `SHIORI_HTTP_ADDRESS`                      | :              | No       | Address for the HTTP service                          |\n| `SHIORI_HTTP_ROOT_PATH`                    | /              | No       | Root path for the HTTP service                        |\n| `SHIORI_HTTP_ACCESS_LOG`                   | True           | No       | Logging accessibility for HTTP requests               |\n| `SHIORI_HTTP_SERVE_WEB_UI`                 | True           | No       | Serving Web UI via HTTP. Disable serves only the API. |\n| `SHIORI_HTTP_SECRET_KEY`                   |                | **Yes**  | Secret key for HTTP sessions.                         |\n| `SHIORI_HTTP_BODY_LIMIT`                   | 1024           | No       | Limit for request body size                           |\n| `SHIORI_HTTP_READ_TIMEOUT`                 | 10s            | No       | Maximum duration for reading the entire request       |\n| `SHIORI_HTTP_WRITE_TIMEOUT`                | 10s            | No       | Maximum duration before timing out writes             |\n| `SHIORI_HTTP_IDLE_TIMEOUT`                 | 10s            | No       | Maximum amount of time to wait for the next request   |\n| `SHIORI_HTTP_DISABLE_KEEP_ALIVE`           | true           | No       | Disable HTTP keep-alive connections                   |\n| `SHIORI_HTTP_DISABLE_PARSE_MULTIPART_FORM` | true           | No       | Disable pre-parsing of multipart form                 |\n| `SHIORI_SSO_PROXY_AUTH_ENABLED`            | false          | No       | Enable SSO Auth Proxy Header                          |\n| `SHIORI_SSO_PROXY_AUTH_HEADER_NAME`        | Remote-User    | No       | List of CIDRs of trusted proxies                      |\n| `SHIORI_SSO_PROXY_AUTH_TRUSTED`            | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7    | No       | List of CIDRs of trusted proxies                 |\n\n### Storage Configuration\n\nThe `StorageConfig` struct contains settings related to storage.\n\n| Environment variable | Default       | Required | Description                             |\n| -------------------- | ------------- | -------- | --------------------------------------- |\n| `SHIORI_DIR`         | (current dir) | No       | Directory where Shiori stores its data. |\n\n#### The data Directory\n\nShiori is designed to work out of the box, but you can change where it stores your bookmarks if you need to.\n\nBy default, Shiori saves your bookmarks in one of the following directories:\n\n| Platform | Directory                                                    |\n| -------- | ------------------------------------------------------------ |\n| Linux    | `${XDG_DATA_HOME}/shiori` (default: `~/.local/share/shiori`) |\n| macOS    | `~/Library/Application Support/shiori`                       |\n| Windows  | `%LOCALAPPDATA%/shiori`                                      |\n\nIf you pass the flag `--portable` to Shiori, your data will be stored  in the `shiori-data` subdirectory alongside the shiori executable.\n\nTo specify a custom path, set the `SHIORI_DIR` environment variable.\n\n### Database Configuration\n\n| Environment variable       | Default | Required | Description                                     |\n| -------------------------- | ------- | -------- | ----------------------------------------------- |\n| `SHIORI_DBMS` (deprecated) | `DBMS`  | No       | Deprecated (Use environment variables for DBMS) |\n| `SHIORI_DATABASE_URL`      | `URL`   | No       | URL for the database (required)                 |\n\n> `SHIORI_DBMS` is deprecated and will be removed in a future release. Please use `SHIORI_DATABASE_URL` instead.\n\nShiori uses an SQLite3 database stored in the above [data directory by default](#storage-configuration). If you prefer, you can also use MySQL or PostgreSQL database by setting the `SHIORI_DATABASE_URL` environment variable.\n\n#### MySQL\n\nMySQL example: `SHIORI_DATABASE_URL=\"mysql://username:password@(hostname:port)/database?charset=utf8mb4\"`\n\nYou can find additional details in [go mysql sql driver documentation](https://github.com/go-sql-driver/mysql#dsn-data-source-name).\n\n#### PostgreSQL\n\nPostgreSQL example: `SHIORI_DATABASE_URL=\"postgres://pqgotest:password@hostname/database?sslmode=verify-full\"`\n\nYou can find additional details in [go postgres sql driver documentation](https://pkg.go.dev/github.com/lib/pq).\n\n## Reverse proxies and the webroot path\n\nIf you want to serve Shiori behind a reverse proxy, you can set the `SHIORI_HTTP_ROOT_PATH` environment variable to the path where Shiori is served, e.g. `/shiori/`.\n\nKeep in mind this configuration wont make Shiori accessible from `/shiori` path so you need to setup your reverse proxy accordingly so it can strip the webroot path.\n\nWe provide some examples for popular reverse proxies below. Please follow your reverse proxy documentation in order to setup it properly.\n\n### Nginx\n\nFox nginx, you can use the following configuration as a example. The important part **is the trailing slash in `proxy_pass` directive**:\n\n```nginx\nlocation /shiori/ {\n    proxy_pass http://localhost:8080/;\n    proxy_set_header Host $host;\n    proxy_set_header X-Real-IP $remote_addr;\n}\n```\n"
  },
  {
    "path": "docs/Contribute.md",
    "content": "# Contribute\n\n1. [Running the server locally](#running-the-server-locally)\n2. [Updating the API documentation](#updating-the-api-documentation)\n3. [Lint the code](#lint-the-code)\n4. [Running tests](#running-tests)\n\n## Running the server locally\n\nTo run the current development server with the defaults you can run the following command:\n\n```bash\nmake run-server\n```\n\n## Updating the API documentation\n\n> **ℹ️ Note:** This only applies for the Rest API documentation under the `internal/http` folder, **not** the one under `internal/webserver`.\n\nIf you make any changes to the Rest API endpoints, you need to update the swagger documentation. In order to do that, you need to have installed [swag](https://github.com/swaggo/swag).\n\nThen, run the following command:\n\n```bash\nmake swagger\n```\n\n## Updating the frontend styles\n\nThe styles that are bundled with Shiori are stored under `internal/view/assets/css/style.css` and `internal/view/assets/css/archive.css` and created from the less files under `internal/views/assets/less`.\n\nIf you want to make frontend changes you need to do that under the less files and then compile them to css. In order to do that, you need to have installed [bun](https://bun.sh).\n\nThen, run the following command:\n\n```bash\nmake styles\n```\n\nThe `style.css`/`archive.css` will be updated and changes **needs to be committed** to the repository.\n\n## Lint the code\n\nIn order to lint the code, you need to have installed [golangci-lint](https://golangci-lint.run) and [swag](https://github.com/swaggo/swag).\n\nAfter that, run the following command:\n\n```bash\nmake lint\n```\n\nIf any errors are found please fix them before submitting your PR.\n\n## Running tests\n\nIn order to run the test suite, you need to have running a local instance of MariaDB and PostgreSQL.\nIf you have docker, you can do this by running the following command with the compose file provided:\n\n```bash\ndocker-compose up -d mariadb mysql postgres\n```\n\nAfter that, provide the environment variables for the unitest to connect to the database engines:\n\n- `SHIORI_TEST_MYSQL_URL` for MySQL\n- `SHIORI_TEST_MARIADB_URL` for MariaDB\n- `SHIORI_TEST_PG_URL` for PostgreSQL\n\n```\nSHIORI_TEST_PG_URL=postgres://shiori:shiori@127.0.0.1:5432/shiori?sslmode=disable\nSHIORI_TEST_MYSQL_URL=shiori:shiori@tcp(127.0.0.1:3306)/shiori\nSHIORI_TEST_MARIADB_URL=shiori:shiori@tcp(127.0.0.1:3307)/shiori\n```\n\nFinally, run the tests with the following command:\n\n```bash\nmake unittest\n```\n\n## Building the documentation\n\nThe documentation is built using MkDocs with the Material theme. For installation instructions, please refer to the [MkDocs installation guide](https://www.mkdocs.org/user-guide/installation/).\n\nTo preview the documentation locally while making changes, run:\n\n```bash\nmkdocs serve\n```\n\nThis will start a local server at `http://127.0.0.1:8000` where you can preview your changes in real-time.\n\nDocumentation for production is generated automatically on every release and published using github pages.\n\n## Running the server with docker\n\nTo run the development server using Docker, you can use the provided `docker-compose.yaml` file which includes both PostgreSQL and MariaDB databases:\n\n```bash\ndocker compose up shiori\n```\n\nThis will start the Shiori server on port 8080 with hot-reload enabled. Any changes you make to the code will automatically rebuild and restart the server.\n\nBy default, it uses SQLite mounting the local `dev-data` folder in the source code path. To use MariaDB or PostgreSQL instead, uncomment the `SHIORI_DATABASE_URL` line for the appropriate engine in the `docker-compose.yaml` file.\n\n## Running the server using an nginx reverse proxy and a custom webroot\n\nTo test Shiori behind an nginx reverse proxy with a custom webroot (e.g., `/shiori/`), you can use the provided nginx configuration:\n\n1. First, ensure the `SHIORI_HTTP_ROOT_PATH` environment variable is uncommented in `docker-compose.yaml`:\n   ```yaml\n   SHIORI_HTTP_ROOT_PATH: /shiori/\n   ```\n\n2. Then start both Shiori and nginx services:\n   ```bash\n   docker compose up shiori nginx\n   ```\n\nThis will start the shiori service along with nginx. You can access Shiori using [http://localhost:8081/shiori](http://localhost:8081/shiori).\n\nThe nginx configuration in `testdata/nginx.conf` handles all the necessary configuration.\n"
  },
  {
    "path": "docs/Installation.md",
    "content": "There are several installation methods available :\n\n<!-- TOC -->\n\n- [Supported](#supported)\n  - [Using Precompiled Binary](#using-precompiled-binary)\n  - [Building From Source](#building-from-source)\n  - [Using Docker Image](#using-docker-image)\n- [Community provided](#community-provided)\n  - [Using Kubernetes manifests](#using-kubernetes-manifests)\n- [Managed Hosting](#managed-hosting)\n  - [PikaPods](#pikapods)\n\n<!-- /TOC -->\n\n## Supported\n\n### Using Precompiled Binary\n\nDownload the latest version of `shiori` from [the release page](https://github.com/go-shiori/shiori/releases/latest), then put it in your `PATH`.\n\nOn Linux or MacOS, you can do it by adding this line to your profile file (either `$HOME/.bash_profile` or `$HOME/.profile`):\n\n```\nexport PATH=$PATH:/path/to/shiori\n```\n\nNote that this will not automatically update your path for the remainder of the session. To do this, you should run:\n\n```\nsource $HOME/.bash_profile\nor\nsource $HOME/.profile\n```\n\nOn Windows, you can simply set the `PATH` by using the advanced system settings.\n\n### Building From Source\n\nShiori uses Go module so make sure you have version of `go >= 1.14.1` installed, then run:\n\n```\ngo get -u -v github.com/go-shiori/shiori\n```\n\n### Using Docker Image\n\nTo use Docker image, you can pull the latest automated build from Docker Hub :\n\n```\ndocker pull ghcr.io/go-shiori/shiori\n```\n\nIf you want to build the Docker image on your own, Shiori already has its [Dockerfile](https://github.com/go-shiori/shiori/blob/master/Dockerfile), so you can build the Docker image by running :\n\n```\ndocker build -t shiori .\n```\n\n## Community provided\n\nBelow this there are other ways to deploy Shiori which are not supported by the team but were provided by the community to help others have a starting point.\n\n### Using Kubernetes manifests\n\nIf you're self-hosting with a Kubernetes cluster, here are manifest files that\nyou can use to deploy Shiori:\n\n`deploy.yaml`:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: shiori\n  labels:\n    app: shiori\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: shiori\n  template:\n    metadata:\n      labels:\n        app: shiori\n    spec:\n      volumes:\n      - name: app\n        hostPath:\n          path: /path/to/data/dir\n      - name: tmp\n        emptyDir:\n          medium: Memory\n      containers:\n      - name: shiori\n        image: ghcr.io/go-shiori/shiori:latest\n        command: [\"/usr/bin/shiori\", \"serve\"]\n        imagePullPolicy: Always\n        ports:\n        - containerPort: 8080\n        env:\n        - name: SHIORI_DIR\n          value: /srv/shiori\n        volumeMounts:\n        - mountPath: /srv/shiori\n          name: app\n        - mountPath: /tmp\n          name: tmp\n```\n\nHere we are using a local directory to persist Shiori's data. You will need\nto replace `/path/to/data/dir` with the path to the directory where you want\nto keep your data. We are also mounting an `EmptyDir` volume for `/tmp` so\nwe can successfully generate ebooks.\n\nSince we haven't configured a database in particular,\nShiori will use SQLite. I don't think Postgres or MySQL is worth it for\nsuch an app, but that's up to you. If you decide to use SQLite, I strongly\nsuggest to keep `replicas` set to 1 since SQLite usually allows at most\none writer to proceed concurrently.\n\nTo route requests to your deployment, you will need a `Service` that gets used\nby an `Ingress` to handle routing. If you wand to add a path suffix or use a\nsub domain, you can do so through the ingress config. We only show the bare\nminimum config to get you started.\n\n`service.yaml`\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: shiori\nspec:\n  type: LoadBalancer\n  selector:\n    app: shiori\n  ports:\n    - port: 8080\n      targetPort: 8080\n```\n\nThis is using a `LoadBalancer` type which gives the most flexibility.\n\n`ingress.yaml`:\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: shiori\nspec:\n  ingressClassName: nginx\n  rules:\n  - http:\n      paths:\n      - path: /\n        pathType: Prefix\n        backend:\n          service:\n            name: shiori\n            port:\n              number: 8080\n```\n\n## Managed Hosting\n\nIf you don't manage your own server, the below providers will host Shiori for you. None are endorsed by or affiliated with the team. Support is provided by the providers.\n\n### CloudBreak\n\n[CloudBreak](https://cloudbreak.app/products/shiori?utm_medium=referral&utm_source=shiori-docs&rby=shiori-docs) offers Shiori hosting from $12/year ($1/month).  Get $3 off with coupon `SHIORI`.\n\n<a href=\"https://cloudbreak.app/products/shiori?utm_medium=referral&utm_source=shiori-docs&rby=shiori-docs\">\n  <img src=\"https://cloudbreak.app/external/subscribe-button.png\" alt=\"Subscribe on CloudBreak\" width=\"149\" height=\"64\">\n</a>\n\n### PikaPods\n\n[PikaPods](https://www.pikapods.com/) offers Shiori hosting from $1.20/month with $5 free welcome credit. EU and US regions available. Updates are applied weekly and user data backed up daily.\n\n[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=shiori)\n"
  },
  {
    "path": "docs/Screenshots.md",
    "content": "# Desktop Screenshots\n\n## Login Screen\n\n=== \"Light Theme\"\n    [![Login Screen Light](./assets/screenshots/01-login.png)](./assets/screenshots/01-login.png)\n\n=== \"Dark Theme\"\n    [![Login Screen Dark](./assets/screenshots/05-dark-login.png)](./assets/screenshots/05-dark-login.png)\n\n## Grid Mode\n\n=== \"Light Theme\"\n    [![Grid Mode Light](./assets/screenshots/02-home.png)](./assets/screenshots/02-home.png)\n\n=== \"Dark Theme\"\n    [![Grid Mode Dark](./assets/screenshots/06-dark-home.png)](./assets/screenshots/06-dark-home.png)\n\n## List Mode\n\n=== \"Light Theme\"\n    [![List Mode Light](./assets/screenshots/03-home-list.png)](./assets/screenshots/03-home-list.png)\n\n=== \"Dark Theme\"\n    [![List Mode Dark](./assets/screenshots/07-dark-home-list.png)](./assets/screenshots/07-dark-home-list.png)\n\n## Options Page\n\n=== \"Light Theme\"\n    [![Options Light](./assets/screenshots/04-options.png)](./assets/screenshots/04-options.png)\n\n=== \"Dark Theme\"\n    [![Options Dark](./assets/screenshots/08-dark-options.png)](./assets/screenshots/08-dark-options.png)\n\n# Mobile Screenshots\n\n## Login Screen\n\n=== \"Light Theme\"\n    [![Mobile Login Light](./assets/screenshots/09-mobile-login.png)](./assets/screenshots/09-mobile-login.png)\n\n=== \"Dark Theme\"\n    [![Mobile Login Dark](./assets/screenshots/13-mobile-dark-login.png)](./assets/screenshots/13-mobile-dark-login.png)\n\n## Grid Mode\n\n=== \"Light Theme\"\n    [![Mobile Grid Light](./assets/screenshots/10-mobile-home.png)](./assets/screenshots/10-mobile-home.png)\n\n=== \"Dark Theme\"\n    [![Mobile Grid Dark](./assets/screenshots/14-mobile-dark-home.png)](./assets/screenshots/14-mobile-dark-home.png)\n\n## List Mode\n\n=== \"Light Theme\"\n    [![Mobile List Light](./assets/screenshots/11-mobile-home-list.png)](./assets/screenshots/11-mobile-home-list.png)\n\n=== \"Dark Theme\"\n    [![Mobile List Dark](./assets/screenshots/15-mobile-dark-home-list.png)](./assets/screenshots/15-mobile-dark-home-list.png)\n\n## Options Page\n\n=== \"Light Theme\"\n    [![Mobile Options Light](./assets/screenshots/12-mobile-options.png)](./assets/screenshots/12-mobile-options.png)\n\n=== \"Dark Theme\"\n    [![Mobile Options Dark](./assets/screenshots/16-mobile-dark-options.png)](./assets/screenshots/16-mobile-dark-options.png)\n"
  },
  {
    "path": "docs/Storage.md",
    "content": "# Storage\n\nShiori requires a folder to store several pieces of data, such as the bookmark archives, thumbnails, ebooks, and others. If the database engine used is sqlite, then the database file will also be stored in this folder.\n\nYou can specify the storage folder by using `--storage-dir` or `--portable` flags when running Shiori.\n\nIf none specified, Shiori will try to find the correct app folder for your OS.\n\nFor example:\n- In Windows, Shiori will use `%APPDATA%`.\n- In Linux, it will use `$XDG_CONFIG_HOME` or `$HOME/.local/share` if `$XDG_CONFIG_HOME` is not set.\n- In macOS, it will use `$HOME/Library/Application Support`.\n\n> For more and up to date information about app folder discovery check [muesli/go-app-paths](https://github.com/muesli/go-app-paths)\n"
  },
  {
    "path": "docs/Usage.md",
    "content": "Before using `shiori`, make sure it has been installed on your system. By default, `shiori` will store its data in directory `$HOME/.local/share/shiori`. If you want to set the data directory to another location, you can set the environment variable `SHIORI_DIR` (`ENV_SHIORI_DIR` when you are before `1.5.0`) to your desired path.\n\n<!-- TOC -->\n\n- [Running Docker Container](#running-docker-container)\n- [Using Command Line Interface](#using-command-line-interface)\n  - [Search syntax](#search-syntax)\n- [Using Web Interface](#using-web-interface)\n- [Community contributions](#community-contributions)\n  - [Improved import from Pocket](#improved-import-from-pocket)\n  - [Import from Wallabag](#import-from-wallabag)\n  - [Add URL to Shiori from Android](#add-url-to-shiori-from-android)\n\n<!-- /TOC -->\n\n## Running Docker Container\n\n> If you are not using `shiori` from Docker image, you can skip this section.\n\nAfter building or pulling the image, you will be able to start a container from it. To preserve the data, you need to bind the directory for storing database, thumbnails and archive. In this example we're binding the data directory to our current working directory :\n\n```\ndocker run -d --rm --name shiori -p 8080:8080 -v $(pwd):/shiori ghcr.io/go-shiori/shiori\n```\n\nThe above command will :\n\n- Creates a new container from image `ghcr.io/go-shiori/shiori`.\n- Set the container name to `shiori` (option `--name`).\n- Bind the host current working directory to `/shiori` inside container (option `-v`).\n- Expose port `8080` in container to port `8080` in host machine (option `-p`).\n- Run the container in background (option `-d`).\n- Automatically remove the container when it stopped (option `--rm`).\n\nAfter you've run the container in background, you can access console of the container:\n\n> In order to be able to access the container and execute commands you need to use the `alpine-` prefixed images.\n\n```\ndocker exec -it shiori ash\n```\n\nNow you can use `shiori` like normal. If you've finished, you can stop and remove the container by running :\n\n```\ndocker stop shiori\n```\n\n## Using Command Line Interface\n\nShiori is composed by several subcommands. To see the documentation, run `shiori -h` :\n\n```\nSimple command-line bookmark manager built with Go\n\nUsage:\n  shiori [command]\n\nAvailable Commands:\n  add         Bookmark the specified URL\n  check       Find bookmarked sites that no longer exists on the internet\n  delete      Delete the saved bookmarks\n  export      Export bookmarks into HTML file in Netscape Bookmark format\n  help        Help about any command\n  import      Import bookmarks from HTML file in Netscape Bookmark format\n  open        Open the saved bookmarks\n  pocket      Import bookmarks from Pocket's exported HTML file\n  print       Print the saved bookmarks\n  server      Run the Shiori webserver\n  update      Update the saved bookmarks\n  version     Output the shiori version\n\nFlags:\n  -h, --help       help for shiori\n      --portable   run shiori in portable mode\n\nUse \"shiori [command] --help\" for more information about a command.\n```\n\n### Search syntax\n\nWith the `print` command line interface, you can use `-s` flag to submit keywords that will be searched either in url, title, excerpts or cached content.\nYou may also use `-t` flag to include tags and `-e` flag to exclude tags.\n\n## Using Web Interface\n\nTo access web interface run `shiori server` or start Docker container following tutorial above. If you want to use a different port instead of 8080, you can simply run `shiori server -p <portnumber>`. Once started you can access the web interface in `http://localhost:8080` or `http://localhost:<portnumber>` if you customized it. You will be greeted with login screen like this :\n\n![Login screen](https://raw.githubusercontent.com/go-shiori/shiori/master/docs/screenshots/01-login.png)\n\nSince this is our first time, we don't have any account registered yet. With that said, we can use the default user to access web interface:\n\n```\nusername: shiori\npassword: gopher\n```\n\nOnce login succeed you will be able to use the web interface. To add the new account, open the settings page and add accounts as needed:\n\n![Options page](https://raw.githubusercontent.com/go-shiori/shiori/master/docs/screenshots/04-options.png)\n\nWhen searching for bookmarks, you may use `tag:tagname` to include tags and `-tag:tagname` to exclude tags in the search bar. You can also use tags dialog to do this :\n\n- `Click` on the tag name to include it;\n- `Alt + Click` on the tag name to exclude it.\n\n## Community contributions\n\n### Improved import from Pocket\n\nShiori offers a [Command Line Interface](https://github.com/go-shiori/shiori/blob/master/docs/Usage.md#using-command-line-interface) with the command `shiori pocket` to import Pocket entries but with this can only import them as links and not as complete entries.\n\nTo import your bookmarks from [Pocket](https://getpocket.com/) with the text and images follow these simple steps (based on [Issue 252](https://github.com/go-shiori/shiori/issues/252)):\n\n1. Export your entries from Pocket by visiting https://getpocket.com/export\n\n2. Download [this shell script](https://gist.github.com/fmartingr/88a258bfad47fb00a3ef9d6c38e5699e). [*You need to download this in your docker container or on the device that you are hosting shiori*]. Name it for instance `pocket2shiori.sh`.\n\n   > Tip: checkout the documentation for [opening a console in the docker container](https://github.com/go-shiori/shiori/blob/master/docs/Usage.md#running-docker-container).\n\n3. Execute the shell script.\n\nHere are the commands you need to run:\n   ```sh\n   wget 'https://gist.githubusercontent.com/fmartingr/88a258bfad47fb00a3ef9d6c38e5699e/raw/a21afb20b56d5383b8b975410e0eb538de02b422/pocket2shiori.sh'\n   chmod +x pocket2shiori.sh\n   pocket2shiori.sh 'path_to_your/pocket_export.html'\n   ```\n\n   > Tip: If you’re using shiori's docker container, ensure that the exported HTML from pocket is accessible inside the docker container.\n\nYou should now see `shiori` importing your Pocket entries properly with the text and images.\nThis is optional, but once the import is complete you can clean up by running:\n\n```sh\nrm pocket2shiori.sh 'path_to_your/pocket_export.html'\n```\n\n###  Import from Wallabag\n\n\n1. Export your entries from Wallabag as a json file\n\n2. Install [jq](https://stedolan.github.io/jq/download/). You will need this installed before running the script.\n\n3. Download the shell script\n[here](https://gist.githubusercontent.com/Aerex/01499c66f6b36a5d997f97ca1b0ab5b1/raw/bf793515540278fc675c7769be74a77ca8a41e62/wallabag2shiori). Similar to the `pocket2shiori.sh` script if you are shiori is in a docker container you will next to run this script\ninside the container.\n\n4. Execute the script. Here are the commands that you can run.\n\n  ```sh\n    curl -sSOL\n    https://gist.githubusercontent.com/Aerex/01499c66f6b36a5d997f97ca1b0ab5b1/raw/bf793515540278fc675c7769be74a77ca8a41e62/wallabag2shiori'\n    chmod +x wallabag2shiori\n    ./wallabag2shiori 'path/to/to/wallabag_export_json_file'\n  ```\n\n\n###  Add URL to Shiori from Android\n\n\n1. Install [Termux](https://termux.dev/en/)\n2. Open termux and run bellow command\n```bash\nmkdir -p ~/bin\ntouch ~/bin/termux-url-opener\nchmod +x ~/bin/termux-url-opener\nnano ~/bin/termux-url-opener\n```\n3. Edit bellow code and replace `Shiori_URL`, `Username`, `Password` with yours\n```bash\n#!/bin/bash\n\n# shiori settings\nShiori_URL=\"http://127.0.0.1:8080\"\nUsername=\"shiori\"\nPassword=\"gopher\"\n\ntoken=$(curl -s -X POST -H \"Content-Type: application/json\" -d '{\"username\": \"'\"$Username\"'\" , \"password\": \"'\"$Password\"'\", \"remember\": true}' $Shiori_URL/api/v1/auth/login | grep -oP '(?<=\"token\":\")[^\"]*')\n\ncurl -s -X POST -H \"Content-Type: application/json\" -H \"Authorization: Bearer $token\" -d '{ \"url\": \"'\"$1\"'\", \"createArchive\": false, \"public\": 1, \"tags\": [], \"title\": \"\", \"excerpt\": \"\" }' $Shiori_URL/api/bookmarks\nexit\n```\n4. Paste above content in editor and `Volume-down` and `o` than Enter to save file.\n5. `Volume-down` and `x` to exit editor.\n6. close termux\n\n\nYou can share links with termux from Share menu links will automatically add to Shiori from mobile device.\n"
  },
  {
    "path": "docs/assets/css/style.css",
    "content": "[data-md-color-scheme=\"shiori\"] {\n    --md-primary-fg-color: rgb(244, 67, 54);\n}\n"
  },
  {
    "path": "docs/faq.md",
    "content": "# Frequently asked questions\n\n<!-- TOC -->\n\n- [General](#general)\n  - [What is this project ?](#what-is-this-project-)\n  - [How does it compare to other bookmarks manager ?](#how-does-it-compare-to-other-bookmarks-manager-)\n  - [What are the system requirements ?](#what-are-the-system-requirements-)\n  - [What is the status for this app ?](#what-is-the-status-for-this-app-)\n  - [Is this app actively maintained ?](#is-this-app-actively-maintained-)\n  - [How to make a contribution ?](#how-to-make-a-contribution-)\n  - [How to make a donation ?](#how-to-make-a-donation-)\n- [Common Issues](#common-issues)\n  - [What is the default account to login at the first time ?](#what-is-the-default-account-to-login-at-the-first-time-)\n  - [Why my old accounts can't do anything after upgrading Shiori to v1.5.0 ?](#why-my-old-accounts-cant-do-anything-after-upgrading-shiori-to-v150-)\n  - [`Failed to get bookmarks: failed to fetch data: no such module: fts4` ?](#failed-to-get-bookmarks-failed-to-fetch-data-no-such-module-fts4-)\n- [Advanced](#advanced)\n  - [How to run `shiori` on start up (Linux)?](#how-to-run-shiori-on-start-up-linux)\n  - [How to run `shiori` on start up (macOS)?](#how-to-run-shiori-on-start-up-macos)\n\n<!-- /TOC -->\n\n## General\n\n### What is this project ?\n\nShiori is a bookmarks manager that built with Go. I've got the idea to make this after reading a comment on HN back in [April 2017](https://news.ycombinator.com/item?id=14203383) :\n\n```\n... for me the dream bookmark manager would be something really simple\nwith two commands like:\n\n$ bookmark add http://...\n\nThat will:\n\na. Download a static copy of the webpage in a single HTML file, with a\n   PDF exported copy, that also take care of removing ads and\n   unrelated content from the stored content.\nb. Run something like http://smmry.com/ to create a summary of the page\n   in few sentences and store it.\nc. Use NLP techniques to extract the principle keywords and use them\n   as tags\n\nAnd another command like:\n\n$ bookmark search \"...\"\n\nThat will:\n\nd. Not use regexp or complicated search pattern, but instead;\ne. Search titles, tags, page content smartly and interactively, and;\nf. Sort/filter results smartly by relevance, number of matches,\n   frequency, or anything else useful\ng. Storing everything in a git repository or simple file structure\n   for easy synchronization, bonus point for browsers integrations.\n```\n\nI do like using bookmarks and those idea sounds useful to me. More importantly, it seems possible enough to do. Not too hard that it's impossible for me, but not too easy that it doesn't teach me anything. Looking back now, the only thing that I (kind of) managed to do is a, b, d and e. But it's enough for me, so it's fine I guess :laughing:.\n\n### How does it compare to other bookmarks manager ?\n\nTo be honest I don't know. The only bookmarks manager that I've used is Pocket and the one that bundled in web browser. I do like Pocket though. However, since bookmarks is kind of sensitive data, I prefer it stays offline or in my own server.\n\n### What are the system requirements ?\n\nIt runs in the lowest tier of Digital Ocean VPS, so I guess it should be able to run anywhere.\n\n### What is the status for this app ?\n\nIt's stable enough to use and the database shouldn't be changed anymore. However, my bookmarks at most is only several hundred entries, therefore I haven't test whether it able to process or imports huge amount of bookmarks. If you would, please do try it.\n\n### Is this app actively maintained ?\n\nYes, however the development pace might be really slow. @fmartingr is the current active maintainer though @RadhiFadlillah or @deanishe may step and work on stuff from time to time or in other [go-shiori projects](https://github.com/go-shiori)\n\n### How to make a contribution ?\n\nJust like other open source projects, you can make a contribution by submitting issues or pull requests.\n\n### How to make a donation ?\n\nIf you like this project, you can donate to maintainers via:\n\n- **fmartingr** [PayPal](https://www.paypal.me/fmartingr), [Ko-Fi](https://ko-fi.com/fmartingr)\n- **RadhiFadlillah** [PayPal](https://www.paypal.me/RadhiFadlillah), [Ko-Fi](https://ko-fi.com/radhifadlillah)\n\n## Common Issues\n\n### What is the default account to login at the first time ?\n\nA default account is created with the credentials:\n\n- Username: `shiori`\n- Password: `gopher`\n\n### Why my old accounts can't do anything after upgrading Shiori to v1.5.0 ?\n\nThis issue happened because in Shiori v1.0.0 there are no account level, which means everyone is treated as owner. However, in Shiori v1.5.0 there are two account levels i.e. owner and visitor. The level difference is stored in [database](https://github.com/go-shiori/shiori/blob/master/internal/database/sqlite.go#L42-L48) as boolean value in column `owner` with default value false (which means by default all account is visitor, unless specified otherwise).\n\nBecause in v1.5.0 by default all account is visitor, when updating from v1.0 to v1.5 all of the old accounts by default will be marked as visitor. Fortunately, when there are no owner registered in database, we can login as owner using default account.\n\nSo, as workaround for this issue, you should :\n\n- Login as default account.\n- Go to options page.\n- Remove your old accounts.\n- Recreate them, but now as owner.\n\nFor more details see [#148](https://github.com/go-shiori/shiori/issues/148).\n\n### `Failed to get bookmarks: failed to fetch data: no such module: fts4` ?\n\nThis happens to SQLite users that upgrade from 1.5.0 to 1.5.1 because of a breaking change. Please check the\n[announcement](https://github.com/go-shiori/shiori/discussions/383) to understand how to migrate your database and move forward.\n\n## Advanced\n\n### How to run `shiori` on start up (Linux)?\n\nThere are several methods to run `shiori` on start up, however the most recommended is running it as a service.\n\n1. Create a service unit for `systemd` at `/etc/systemd/system/shiori.service`.\n\n* Shiori is run via `docker` :\n\n    ```ini\n    [Unit]\n    Description=Shiori container\n    After=docker.service\n\n    [Service]\n    Restart=always\n    ExecStartPre=-/usr/bin/docker rm shiori-1\n    ExecStart=/usr/bin/docker run \\\n      --rm \\\n      --name shiori-1 \\\n      -p 8080:8080 \\\n      -v /srv/machines/shiori:/shiori \\\n       ghcr.io/go-shiori/shiori\n    ExecStop=/usr/bin/docker stop -t 2 shiori-1\n\n    [Install]\n    WantedBy=multi-user.target\n    ```\n\n* Shiori without `docker`. Set absolute path to `shiori` binary. `--portable` sets the data directory to be alongside the executable.\n\n    ```ini\n    [Unit]\n    Description=Shiori service\n\n    [Service]\n    ExecStart=/home/user/go/bin/shiori server --portable\n    Restart=always\n\n    [Install]\n    WantedBy=multi-user.target\n    ```\n\n* Shiori without `docker` and without `--portable` but secure.\n\n   ```ini\n   [Unit]\n   Description=shiori service\n   Requires=network-online.target\n   After=network-online.target\n\n   [Service]\n   Type=simple\n   ExecStart=/usr/bin/shiori server\n   Restart=always\n   User=shiori\n   Group=shiori\n\n   Environment=\"SHIORI_DIR=/var/lib/shiori\"\n   DynamicUser=true\n   PrivateUsers=true\n   ProtectHome=true\n   ProtectKernelLogs=true\n   RestrictAddressFamilies=AF_INET AF_INET6\n   StateDirectory=shiori\n   SystemCallErrorNumber=EPERM\n   SystemCallFilter=@system-service\n   SystemCallFilter=~@chown\n   SystemCallFilter=~@keyring\n   SystemCallFilter=~@memlock\n   SystemCallFilter=~@setuid\n   DeviceAllow=\n\n   CapabilityBoundingSet=\n   LockPersonality=true\n   MemoryDenyWriteExecute=true\n   NoNewPrivileges=true\n   PrivateDevices=true\n   PrivateTmp=true\n   ProtectControlGroups=true\n   ProtectKernelTunables=true\n   ProtectSystem=full\n   ProtectClock=true\n   ProtectKernelModules=true\n   ProtectProc=noaccess\n   ProtectHostname=true\n   ProcSubset=pid\n   RestrictNamespaces=true\n   RestrictRealtime=true\n   RestrictSUIDSGID=true\n   SystemCallArchitectures=native\n   SystemCallFilter=~@clock\n   SystemCallFilter=~@debug\n   SystemCallFilter=~@module\n   SystemCallFilter=~@mount\n   SystemCallFilter=~@raw-io\n   SystemCallFilter=~@reboot\n   SystemCallFilter=~@privileged\n   SystemCallFilter=~@resources\n   SystemCallFilter=~@cpu-emulation\n   SystemCallFilter=~@obsolete\n   UMask=0077\n\n   [Install]\n   WantedBy=multi-user.target\n   ```\n\n2. Set up data directory if Shiori with `docker`\n\n    This assumes, that the Shiori container has a runtime directory to store their\n    database, which is at `/srv/machines/shiori`. If you want to modify that,\n    make sure, to fix your `shiori.service` as well.\n\n    ```sh\n    install -d /srv/machines/shiori\n    ```\n\n3. Enable and start the service\n\n    ```sh\n    systemctl enable --now shiori\n    ```\n\n### How to run `shiori` on start up (macOS)?\n\nCreate `local.app.shiori.plist` file in `~/Library/LaunchAgents` and use the template below. Add your own secret key and paths. The filename can be anything but it's a good practice to start it with `local`:\n\n```xml\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n  <key>Label</key>\n  <string>local.app.shiori</string>\n  <key>EnvironmentVariables</key>\n  <dict>\n      <key>SHIORI_HTTP_SECRET_KEY</key>\n      <string>somerandomvalue123489</string>\n  </dict>\n  <key>ProgramArguments</key>\n  <array>\n    <string>/absolute/path/to/shiori/binary</string>\n    <string>server</string>\n    <string>--storage-directory</string>\n    <string>/absolute/path/to/shiori/storage/directory</string>\n    </array>\n  <key>RunAtLoad</key>\n  <true/>\n  <key>ServiceDescription</key>\n  <string>Shiori Bookmarking Service</string>\n</dict>\n</plist>\n```\n\nYou also need to update your Mac's `System Settings > General > Login Items & Extensions > Allow in the background`. Next time you log in to your Mac, the Shiori server will automatically start and the Shiori login state will persist. To remove the service, delete the `plist` file.\n"
  },
  {
    "path": "docs/index.md",
    "content": "# Documentation\n\nShiori is a simple bookmarks manager written in Go language. Intended as a simple clone of [Pocket](https://getpocket.com/), it can be used as both a command line and web application. Features include:\n\n- Basic bookmarks management (add, edit, delete and search)\n- Import/export bookmarks from Netscape Bookmark file\n- Import from Pocket\n- Simple web interface\n- Offline webpage archiving\n- Support for SQLite, PostgreSQL and MySQL\n"
  },
  {
    "path": "docs/postman/shiori.postman_collection.json",
    "content": "{\n\t\"info\": {\n\t\t\"_postman_id\": \"aeadb2db-90b7-40f3-87d2-de76f8e8972a\",\n\t\t\"name\": \"shiori\",\n\t\t\"schema\": \"https://schema.getpostman.com/json/collection/v2.1.0/collection.json\"\n\t},\n\t\"item\": [\n\t\t{\n\t\t\t\"name\": \"Auth\",\n\t\t\t\"item\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"/api/login\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"name\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"value\": \"application/json\",\n\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\n\\t\\\"username\\\": \\\"shiori\\\",\\n\\t\\\"password\\\": \\\"gopher\\\",\\n\\t\\\"remember\\\": true,\\n\\t\\\"owner\\\": true\\n}\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"{{host}}/api/login\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"login\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"/api/logout\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"X-Session-Id\",\n\t\t\t\t\t\t\t\t\"value\": \"{{sessionId}}\",\n\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"{{host}}/api/logout\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"logout\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t{\n\t\t\t\"name\": \"Tags\",\n\t\t\t\"item\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"/api/tags\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"X-Session-Id\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"{{sessionId}}\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"{{host}}/api/tags\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"tags\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"/api/tag\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"X-Session-Id\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"{{sessionId}}\"\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"name\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"value\": \"application/json\",\n\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\n\\t\\\"id\\\": 1,\\n    \\\"name\\\": \\\"renamed_tag_7\\\"\\n}\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"{{host}}/api/tag\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"tag\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t{\n\t\t\t\"name\": \"Bookmarks\",\n\t\t\t\"item\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"/api/bookmarks\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"X-Session-Id\",\n\t\t\t\t\t\t\t\t\"value\": \"{{sessionId}}\",\n\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"{{host}}/api/bookmarks\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"bookmarks\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"/api/bookmarks\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"X-Session-Id\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"{{sessionId}}\"\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"name\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"value\": \"application/json\",\n\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\n\\t\\\"url\\\": \\\"https://hckrnews.com\\\",\\n\\t\\\"createArchive\\\": false,\\n\\t\\\"public\\\": 1,\\n\\t\\\"tags\\\": [],\\n\\t\\\"title\\\": \\\"\\\",\\n\\t\\\"excerpt\\\": \\\"\\\"\\n}\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"{{host}}/api/bookmarks\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"bookmarks\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"/api/bookmarks\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"X-Session-Id\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"{{sessionId}}\"\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"name\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\n    \\\"id\\\": 3,\\n    \\\"url\\\": \\\"https://hckrnews.com\\\",\\n    \\\"title\\\": \\\"Hacker News sorted by time\\\",\\n    \\\"excerpt\\\": \\\"An unofficial, alternative interface to Hacker News\\\",\\n    \\\"author\\\": \\\"Wayne Larsen\\\",\\n    \\\"public\\\": 1,\\n    \\\"modified\\\": \\\"2019-09-22 06:05:54\\\",\\n    \\\"imageURL\\\": \\\"/bookmark/3/thumb\\\",\\n    \\\"hasContent\\\": false,\\n    \\\"hasArchive\\\": false,\\n    \\\"tags\\\": [],\\n    \\\"createArchive\\\": false\\n}\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"{{host}}/api/bookmarks\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"bookmarks\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"/api/bookmarks\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"X-Session-Id\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"{{sessionId}}\"\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"name\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"[1]\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"{{host}}/api/bookmarks\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"bookmarks\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t{\n\t\t\t\"name\": \"BFF\",\n\t\t\t\"item\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"/api/cache\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"X-Session-Id\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"{{sessionId}}\"\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"name\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\n\\t\\\"ids\\\": [1, 2],\\n\\t\\\"keepMetadata\\\": false,\\n\\t\\\"createArchive\\\": false\\n}\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"{{host}}/api/cache\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"cache\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"/api/bookmarks/tags\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"X-Session-Id\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"{{sessionId}}\"\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"name\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\n    \\\"ids\\\": [\\n        1\\n    ],\\n    \\\"tags\\\": [\\n        {\\n            \\\"id\\\": 1,\\n            \\\"name\\\": \\\"new_tag\\\"\\n        }\\n    ]\\n}\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"{{host}}/api/bookmarks/tags\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"bookmarks\",\n\t\t\t\t\t\t\t\t\"tags\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"description\": \"Performs bulk insertion of new tags into the specified bookmarks\"\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t{\n\t\t\t\"name\": \"Accounts\",\n\t\t\t\"item\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"/api/accounts\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disableBodyPruning\": true\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"X-Session-Id\",\n\t\t\t\t\t\t\t\t\"value\": \"{{sessionId}}\",\n\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"value\": \"application/json\",\n\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"{{host}}/api/accounts\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"accounts\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"/api/accounts\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"X-Session-Id\",\n\t\t\t\t\t\t\t\t\"value\": \"{{sessionId}}\",\n\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"value\": \"application/json\",\n\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\n\\t\\\"username\\\": \\\"shiori\\\",\\n\\t\\\"oldPassword\\\": \\\"gopher\\\",\\n\\t\\\"newPassword\\\": \\\"gopher\\\",\\n\\t\\\"owner\\\": true\\n}\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"{{host}}/api/accounts\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"accounts\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"/api/accounts\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"X-Session-Id\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"{{sessionId}}\"\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\n\\t\\\"username\\\": \\\"shiori3\\\",\\n\\t\\\"password\\\": \\\"gopher\\\",\\n\\t\\\"owner\\\": false\\n}\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"{{host}}/api/accounts\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"accounts\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"/api/accounts\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"X-Session-Id\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"{{sessionId}}\"\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Content-Type\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"[\\\"shiori\\\"]\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"{{host}}/api/accounts\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"accounts\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t],\n\t\"event\": [\n\t\t{\n\t\t\t\"listen\": \"prerequest\",\n\t\t\t\"script\": {\n\t\t\t\t\"id\": \"d17b19de-37c1-472d-b919-d56e0f05f311\",\n\t\t\t\t\"type\": \"text/javascript\",\n\t\t\t\t\"exec\": [\n\t\t\t\t\t\"\"\n\t\t\t\t]\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\t\"listen\": \"test\",\n\t\t\t\"script\": {\n\t\t\t\t\"id\": \"a14c27ed-a4aa-4171-b5eb-ade9dd6d9dfb\",\n\t\t\t\t\"type\": \"text/javascript\",\n\t\t\t\t\"exec\": [\n\t\t\t\t\t\"\"\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t],\n\t\"variable\": [\n\t\t{\n\t\t\t\"id\": \"822ed4ee-d050-46c7-b30e-eb16335e4de6\",\n\t\t\t\"key\": \"host\",\n\t\t\t\"value\": \"localhost:8080\",\n\t\t\t\"type\": \"string\"\n\t\t},\n\t\t{\n\t\t\t\"id\": \"89ec47f1-aae0-4872-86b1-4a721967c502\",\n\t\t\t\"key\": \"sessionId\",\n\t\t\t\"value\": \"a4cbd539-e54b-40a8-833a-58885f8397ba\",\n\t\t\t\"type\": \"string\"\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "docs/swagger/docs.go",
    "content": "// Package swagger Code generated by swaggo/swag. DO NOT EDIT\npackage swagger\n\nimport \"github.com/swaggo/swag\"\n\nconst docTemplate = `{\n    \"schemes\": {{ marshal .Schemes }},\n    \"swagger\": \"2.0\",\n    \"info\": {\n        \"description\": \"{{escape .Description}}\",\n        \"title\": \"{{.Title}}\",\n        \"contact\": {},\n        \"version\": \"{{.Version}}\"\n    },\n    \"host\": \"{{.Host}}\",\n    \"basePath\": \"{{.BasePath}}\",\n    \"paths\": {\n        \"/api/v1/accounts\": {\n            \"get\": {\n                \"description\": \"List accounts\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"accounts\"\n                ],\n                \"summary\": \"List accounts\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/model.AccountDTO\"\n                            }\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\",\n                        \"schema\": {\n                            \"type\": \"string\"\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"accounts\"\n                ],\n                \"summary\": \"Create an account\",\n                \"responses\": {\n                    \"201\": {\n                        \"description\": \"Created\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.AccountDTO\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Bad Request\"\n                    },\n                    \"409\": {\n                        \"description\": \"Account already exists\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/accounts/{id}\": {\n            \"delete\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"accounts\"\n                ],\n                \"summary\": \"Delete an account\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Account ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No content\"\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid ID\"\n                    },\n                    \"404\": {\n                        \"description\": \"Account not found\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\"\n                    }\n                }\n            },\n            \"patch\": {\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"accounts\"\n                ],\n                \"summary\": \"Update an account\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Account ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"Account data\",\n                        \"name\": \"account\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.updateAccountPayload\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.AccountDTO\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid ID/data\"\n                    },\n                    \"404\": {\n                        \"description\": \"Account not found\"\n                    },\n                    \"409\": {\n                        \"description\": \"Account already exists\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/auth/account\": {\n            \"patch\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Update account information\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Account data\",\n                        \"name\": \"payload\",\n                        \"in\": \"body\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.updateAccountPayload\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.Account\"\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/auth/login\": {\n            \"post\": {\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Login to an account using username and password\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Login data\",\n                        \"name\": \"payload\",\n                        \"in\": \"body\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.loginRequestPayload\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Login successful\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.loginResponseMessage\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid login data\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/auth/logout\": {\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Logout from the current session\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Logout successful\"\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/auth/me\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Get information for the current logged in user\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.Account\"\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/auth/refresh\": {\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Refresh a token for an account\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Refresh successful\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.loginResponseMessage\"\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/bookmarks/bulk/tags\": {\n            \"put\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Bulk update tags for multiple bookmarks.\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Bulk Update Bookmark Tags Payload\",\n                        \"name\": \"payload\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.bulkUpdateBookmarkTagsPayload\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/model.BookmarkDTO\"\n                            }\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid request payload\"\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    },\n                    \"404\": {\n                        \"description\": \"No bookmarks found\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/bookmarks/cache\": {\n            \"put\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Update Cache and Ebook on server.\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Update Cache Payload\",\n                        \"name\": \"payload\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.updateCachePayload\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.BookmarkDTO\"\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/bookmarks/id/readable\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Get readable version of bookmark.\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.readableResponseMessage\"\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/bookmarks/{id}/tags\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Get tags for a bookmark.\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Bookmark ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/model.TagDTO\"\n                            }\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    },\n                    \"404\": {\n                        \"description\": \"Bookmark not found\"\n                    }\n                }\n            },\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Add a tag to a bookmark.\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Bookmark ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"Add Tag Payload\",\n                        \"name\": \"payload\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.bookmarkTagPayload\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    },\n                    \"404\": {\n                        \"description\": \"Bookmark or tag not found\"\n                    }\n                }\n            },\n            \"delete\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Remove a tag from a bookmark.\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Bookmark ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"Remove Tag Payload\",\n                        \"name\": \"payload\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.bookmarkTagPayload\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    },\n                    \"404\": {\n                        \"description\": \"Bookmark not found\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/system/info\": {\n            \"get\": {\n                \"description\": \"Get general system information like Shiori version, database, and OS\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"System\"\n                ],\n                \"summary\": \"Get general system information\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.infoResponse\"\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Only owners can access this endpoint\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/tags\": {\n            \"get\": {\n                \"description\": \"List all tags\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Tags\"\n                ],\n                \"summary\": \"List tags\",\n                \"parameters\": [\n                    {\n                        \"type\": \"boolean\",\n                        \"description\": \"Include bookmark count for each tag\",\n                        \"name\": \"with_bookmark_count\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Filter tags by bookmark ID\",\n                        \"name\": \"bookmark_id\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"Search tags by name\",\n                        \"name\": \"search\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/model.TagDTO\"\n                            }\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Authentication required\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal server error\"\n                    }\n                }\n            },\n            \"post\": {\n                \"description\": \"Create a new tag\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Tags\"\n                ],\n                \"summary\": \"Create tag\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Tag data\",\n                        \"name\": \"tag\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.TagDTO\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"201\": {\n                        \"description\": \"Created\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.TagDTO\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid request\"\n                    },\n                    \"403\": {\n                        \"description\": \"Authentication required\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal server error\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/tags/{id}\": {\n            \"get\": {\n                \"description\": \"Get a tag by ID\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Tags\"\n                ],\n                \"summary\": \"Get tag\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Tag ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.TagDTO\"\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Authentication required\"\n                    },\n                    \"404\": {\n                        \"description\": \"Tag not found\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal server error\"\n                    }\n                }\n            },\n            \"put\": {\n                \"description\": \"Update an existing tag\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Tags\"\n                ],\n                \"summary\": \"Update tag\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Tag ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"Tag data\",\n                        \"name\": \"tag\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.TagDTO\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.TagDTO\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid request\"\n                    },\n                    \"403\": {\n                        \"description\": \"Authentication required\"\n                    },\n                    \"404\": {\n                        \"description\": \"Tag not found\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal server error\"\n                    }\n                }\n            },\n            \"delete\": {\n                \"description\": \"Delete a tag\",\n                \"tags\": [\n                    \"Tags\"\n                ],\n                \"summary\": \"Delete tag\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Tag ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    },\n                    \"403\": {\n                        \"description\": \"Authentication required\"\n                    },\n                    \"404\": {\n                        \"description\": \"Tag not found\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal server error\"\n                    }\n                }\n            }\n        }\n    },\n    \"definitions\": {\n        \"api_v1.bookmarkTagPayload\": {\n            \"type\": \"object\",\n            \"required\": [\n                \"tag_id\"\n            ],\n            \"properties\": {\n                \"tag_id\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"api_v1.bulkUpdateBookmarkTagsPayload\": {\n            \"type\": \"object\",\n            \"required\": [\n                \"bookmark_ids\",\n                \"tag_ids\"\n            ],\n            \"properties\": {\n                \"bookmark_ids\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"integer\"\n                    }\n                },\n                \"tag_ids\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"integer\"\n                    }\n                }\n            }\n        },\n        \"api_v1.infoResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"database\": {\n                    \"type\": \"string\"\n                },\n                \"os\": {\n                    \"type\": \"string\"\n                },\n                \"version\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"commit\": {\n                            \"type\": \"string\"\n                        },\n                        \"date\": {\n                            \"type\": \"string\"\n                        },\n                        \"tag\": {\n                            \"type\": \"string\"\n                        }\n                    }\n                }\n            }\n        },\n        \"api_v1.loginRequestPayload\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"password\": {\n                    \"type\": \"string\"\n                },\n                \"remember_me\": {\n                    \"type\": \"boolean\"\n                },\n                \"username\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"api_v1.loginResponseMessage\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"expires\": {\n                    \"type\": \"integer\"\n                },\n                \"token\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"api_v1.readableResponseMessage\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"content\": {\n                    \"type\": \"string\"\n                },\n                \"html\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"api_v1.updateAccountPayload\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"config\": {\n                    \"$ref\": \"#/definitions/model.UserConfig\"\n                },\n                \"new_password\": {\n                    \"type\": \"string\"\n                },\n                \"old_password\": {\n                    \"type\": \"string\"\n                },\n                \"owner\": {\n                    \"type\": \"boolean\"\n                },\n                \"username\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"api_v1.updateCachePayload\": {\n            \"type\": \"object\",\n            \"required\": [\n                \"ids\"\n            ],\n            \"properties\": {\n                \"create_archive\": {\n                    \"type\": \"boolean\"\n                },\n                \"create_ebook\": {\n                    \"type\": \"boolean\"\n                },\n                \"ids\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"integer\"\n                    }\n                },\n                \"keep_metadata\": {\n                    \"type\": \"boolean\"\n                },\n                \"skip_exist\": {\n                    \"type\": \"boolean\"\n                }\n            }\n        },\n        \"model.Account\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"config\": {\n                    \"$ref\": \"#/definitions/model.UserConfig\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"owner\": {\n                    \"type\": \"boolean\"\n                },\n                \"password\": {\n                    \"type\": \"string\"\n                },\n                \"username\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"model.AccountDTO\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"config\": {\n                    \"$ref\": \"#/definitions/model.UserConfig\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"owner\": {\n                    \"type\": \"boolean\"\n                },\n                \"passowrd\": {\n                    \"description\": \"Used only to store, not to retrieve\",\n                    \"type\": \"string\"\n                },\n                \"username\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"model.BookmarkDTO\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"author\": {\n                    \"type\": \"string\"\n                },\n                \"create_archive\": {\n                    \"description\": \"TODO: migrate outside the DTO\",\n                    \"type\": \"boolean\"\n                },\n                \"create_ebook\": {\n                    \"description\": \"TODO: migrate outside the DTO\",\n                    \"type\": \"boolean\"\n                },\n                \"createdAt\": {\n                    \"type\": \"string\"\n                },\n                \"excerpt\": {\n                    \"type\": \"string\"\n                },\n                \"hasArchive\": {\n                    \"type\": \"boolean\"\n                },\n                \"hasContent\": {\n                    \"type\": \"boolean\"\n                },\n                \"hasEbook\": {\n                    \"type\": \"boolean\"\n                },\n                \"html\": {\n                    \"type\": \"string\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"imageURL\": {\n                    \"type\": \"string\"\n                },\n                \"modifiedAt\": {\n                    \"type\": \"string\"\n                },\n                \"public\": {\n                    \"type\": \"integer\"\n                },\n                \"tags\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/model.TagDTO\"\n                    }\n                },\n                \"title\": {\n                    \"type\": \"string\"\n                },\n                \"url\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"model.TagDTO\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"bookmark_count\": {\n                    \"description\": \"Number of bookmarks with this tag\",\n                    \"type\": \"integer\"\n                },\n                \"deleted\": {\n                    \"description\": \"Marks when a tag is deleted from a bookmark\",\n                    \"type\": \"boolean\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"model.UserConfig\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"createEbook\": {\n                    \"type\": \"boolean\"\n                },\n                \"hideExcerpt\": {\n                    \"type\": \"boolean\"\n                },\n                \"hideThumbnail\": {\n                    \"type\": \"boolean\"\n                },\n                \"keepMetadata\": {\n                    \"type\": \"boolean\"\n                },\n                \"listMode\": {\n                    \"type\": \"boolean\"\n                },\n                \"makePublic\": {\n                    \"type\": \"boolean\"\n                },\n                \"showId\": {\n                    \"type\": \"boolean\"\n                },\n                \"theme\": {\n                    \"type\": \"string\"\n                },\n                \"useArchive\": {\n                    \"type\": \"boolean\"\n                }\n            }\n        }\n    }\n}`\n\n// SwaggerInfo holds exported Swagger Info so clients can modify it\nvar SwaggerInfo = &swag.Spec{\n\tVersion:          \"\",\n\tHost:             \"\",\n\tBasePath:         \"\",\n\tSchemes:          []string{},\n\tTitle:            \"\",\n\tDescription:      \"\",\n\tInfoInstanceName: \"swagger\",\n\tSwaggerTemplate:  docTemplate,\n\tLeftDelim:        \"{{\",\n\tRightDelim:       \"}}\",\n}\n\nfunc init() {\n\tswag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)\n}\n"
  },
  {
    "path": "docs/swagger/swagger.json",
    "content": "{\n    \"swagger\": \"2.0\",\n    \"info\": {\n        \"contact\": {}\n    },\n    \"paths\": {\n        \"/api/v1/accounts\": {\n            \"get\": {\n                \"description\": \"List accounts\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"accounts\"\n                ],\n                \"summary\": \"List accounts\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/model.AccountDTO\"\n                            }\n                        }\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\",\n                        \"schema\": {\n                            \"type\": \"string\"\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"accounts\"\n                ],\n                \"summary\": \"Create an account\",\n                \"responses\": {\n                    \"201\": {\n                        \"description\": \"Created\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.AccountDTO\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Bad Request\"\n                    },\n                    \"409\": {\n                        \"description\": \"Account already exists\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/accounts/{id}\": {\n            \"delete\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"accounts\"\n                ],\n                \"summary\": \"Delete an account\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Account ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No content\"\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid ID\"\n                    },\n                    \"404\": {\n                        \"description\": \"Account not found\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\"\n                    }\n                }\n            },\n            \"patch\": {\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"accounts\"\n                ],\n                \"summary\": \"Update an account\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Account ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"Account data\",\n                        \"name\": \"account\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.updateAccountPayload\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.AccountDTO\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid ID/data\"\n                    },\n                    \"404\": {\n                        \"description\": \"Account not found\"\n                    },\n                    \"409\": {\n                        \"description\": \"Account already exists\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/auth/account\": {\n            \"patch\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Update account information\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Account data\",\n                        \"name\": \"payload\",\n                        \"in\": \"body\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.updateAccountPayload\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.Account\"\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/auth/login\": {\n            \"post\": {\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Login to an account using username and password\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Login data\",\n                        \"name\": \"payload\",\n                        \"in\": \"body\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.loginRequestPayload\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Login successful\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.loginResponseMessage\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid login data\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/auth/logout\": {\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Logout from the current session\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Logout successful\"\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/auth/me\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Get information for the current logged in user\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.Account\"\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/auth/refresh\": {\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Refresh a token for an account\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"Refresh successful\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.loginResponseMessage\"\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/bookmarks/bulk/tags\": {\n            \"put\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Bulk update tags for multiple bookmarks.\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Bulk Update Bookmark Tags Payload\",\n                        \"name\": \"payload\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.bulkUpdateBookmarkTagsPayload\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/model.BookmarkDTO\"\n                            }\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid request payload\"\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    },\n                    \"404\": {\n                        \"description\": \"No bookmarks found\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/bookmarks/cache\": {\n            \"put\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Update Cache and Ebook on server.\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Update Cache Payload\",\n                        \"name\": \"payload\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.updateCachePayload\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.BookmarkDTO\"\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/bookmarks/id/readable\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Get readable version of bookmark.\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.readableResponseMessage\"\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/bookmarks/{id}/tags\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Get tags for a bookmark.\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Bookmark ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/model.TagDTO\"\n                            }\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    },\n                    \"404\": {\n                        \"description\": \"Bookmark not found\"\n                    }\n                }\n            },\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Add a tag to a bookmark.\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Bookmark ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"Add Tag Payload\",\n                        \"name\": \"payload\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.bookmarkTagPayload\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    },\n                    \"404\": {\n                        \"description\": \"Bookmark or tag not found\"\n                    }\n                }\n            },\n            \"delete\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Auth\"\n                ],\n                \"summary\": \"Remove a tag from a bookmark.\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Bookmark ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"Remove Tag Payload\",\n                        \"name\": \"payload\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.bookmarkTagPayload\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    },\n                    \"403\": {\n                        \"description\": \"Token not provided/invalid\"\n                    },\n                    \"404\": {\n                        \"description\": \"Bookmark not found\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/system/info\": {\n            \"get\": {\n                \"description\": \"Get general system information like Shiori version, database, and OS\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"System\"\n                ],\n                \"summary\": \"Get general system information\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/api_v1.infoResponse\"\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Only owners can access this endpoint\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/tags\": {\n            \"get\": {\n                \"description\": \"List all tags\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Tags\"\n                ],\n                \"summary\": \"List tags\",\n                \"parameters\": [\n                    {\n                        \"type\": \"boolean\",\n                        \"description\": \"Include bookmark count for each tag\",\n                        \"name\": \"with_bookmark_count\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Filter tags by bookmark ID\",\n                        \"name\": \"bookmark_id\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"Search tags by name\",\n                        \"name\": \"search\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/model.TagDTO\"\n                            }\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Authentication required\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal server error\"\n                    }\n                }\n            },\n            \"post\": {\n                \"description\": \"Create a new tag\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Tags\"\n                ],\n                \"summary\": \"Create tag\",\n                \"parameters\": [\n                    {\n                        \"description\": \"Tag data\",\n                        \"name\": \"tag\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.TagDTO\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"201\": {\n                        \"description\": \"Created\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.TagDTO\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid request\"\n                    },\n                    \"403\": {\n                        \"description\": \"Authentication required\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal server error\"\n                    }\n                }\n            }\n        },\n        \"/api/v1/tags/{id}\": {\n            \"get\": {\n                \"description\": \"Get a tag by ID\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Tags\"\n                ],\n                \"summary\": \"Get tag\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Tag ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.TagDTO\"\n                        }\n                    },\n                    \"403\": {\n                        \"description\": \"Authentication required\"\n                    },\n                    \"404\": {\n                        \"description\": \"Tag not found\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal server error\"\n                    }\n                }\n            },\n            \"put\": {\n                \"description\": \"Update an existing tag\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Tags\"\n                ],\n                \"summary\": \"Update tag\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Tag ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"Tag data\",\n                        \"name\": \"tag\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.TagDTO\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/model.TagDTO\"\n                        }\n                    },\n                    \"400\": {\n                        \"description\": \"Invalid request\"\n                    },\n                    \"403\": {\n                        \"description\": \"Authentication required\"\n                    },\n                    \"404\": {\n                        \"description\": \"Tag not found\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal server error\"\n                    }\n                }\n            },\n            \"delete\": {\n                \"description\": \"Delete a tag\",\n                \"tags\": [\n                    \"Tags\"\n                ],\n                \"summary\": \"Delete tag\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"Tag ID\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    },\n                    \"403\": {\n                        \"description\": \"Authentication required\"\n                    },\n                    \"404\": {\n                        \"description\": \"Tag not found\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal server error\"\n                    }\n                }\n            }\n        }\n    },\n    \"definitions\": {\n        \"api_v1.bookmarkTagPayload\": {\n            \"type\": \"object\",\n            \"required\": [\n                \"tag_id\"\n            ],\n            \"properties\": {\n                \"tag_id\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"api_v1.bulkUpdateBookmarkTagsPayload\": {\n            \"type\": \"object\",\n            \"required\": [\n                \"bookmark_ids\",\n                \"tag_ids\"\n            ],\n            \"properties\": {\n                \"bookmark_ids\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"integer\"\n                    }\n                },\n                \"tag_ids\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"integer\"\n                    }\n                }\n            }\n        },\n        \"api_v1.infoResponse\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"database\": {\n                    \"type\": \"string\"\n                },\n                \"os\": {\n                    \"type\": \"string\"\n                },\n                \"version\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"commit\": {\n                            \"type\": \"string\"\n                        },\n                        \"date\": {\n                            \"type\": \"string\"\n                        },\n                        \"tag\": {\n                            \"type\": \"string\"\n                        }\n                    }\n                }\n            }\n        },\n        \"api_v1.loginRequestPayload\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"password\": {\n                    \"type\": \"string\"\n                },\n                \"remember_me\": {\n                    \"type\": \"boolean\"\n                },\n                \"username\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"api_v1.loginResponseMessage\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"expires\": {\n                    \"type\": \"integer\"\n                },\n                \"token\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"api_v1.readableResponseMessage\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"content\": {\n                    \"type\": \"string\"\n                },\n                \"html\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"api_v1.updateAccountPayload\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"config\": {\n                    \"$ref\": \"#/definitions/model.UserConfig\"\n                },\n                \"new_password\": {\n                    \"type\": \"string\"\n                },\n                \"old_password\": {\n                    \"type\": \"string\"\n                },\n                \"owner\": {\n                    \"type\": \"boolean\"\n                },\n                \"username\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"api_v1.updateCachePayload\": {\n            \"type\": \"object\",\n            \"required\": [\n                \"ids\"\n            ],\n            \"properties\": {\n                \"create_archive\": {\n                    \"type\": \"boolean\"\n                },\n                \"create_ebook\": {\n                    \"type\": \"boolean\"\n                },\n                \"ids\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"integer\"\n                    }\n                },\n                \"keep_metadata\": {\n                    \"type\": \"boolean\"\n                },\n                \"skip_exist\": {\n                    \"type\": \"boolean\"\n                }\n            }\n        },\n        \"model.Account\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"config\": {\n                    \"$ref\": \"#/definitions/model.UserConfig\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"owner\": {\n                    \"type\": \"boolean\"\n                },\n                \"password\": {\n                    \"type\": \"string\"\n                },\n                \"username\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"model.AccountDTO\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"config\": {\n                    \"$ref\": \"#/definitions/model.UserConfig\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"owner\": {\n                    \"type\": \"boolean\"\n                },\n                \"passowrd\": {\n                    \"description\": \"Used only to store, not to retrieve\",\n                    \"type\": \"string\"\n                },\n                \"username\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"model.BookmarkDTO\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"author\": {\n                    \"type\": \"string\"\n                },\n                \"create_archive\": {\n                    \"description\": \"TODO: migrate outside the DTO\",\n                    \"type\": \"boolean\"\n                },\n                \"create_ebook\": {\n                    \"description\": \"TODO: migrate outside the DTO\",\n                    \"type\": \"boolean\"\n                },\n                \"createdAt\": {\n                    \"type\": \"string\"\n                },\n                \"excerpt\": {\n                    \"type\": \"string\"\n                },\n                \"hasArchive\": {\n                    \"type\": \"boolean\"\n                },\n                \"hasContent\": {\n                    \"type\": \"boolean\"\n                },\n                \"hasEbook\": {\n                    \"type\": \"boolean\"\n                },\n                \"html\": {\n                    \"type\": \"string\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"imageURL\": {\n                    \"type\": \"string\"\n                },\n                \"modifiedAt\": {\n                    \"type\": \"string\"\n                },\n                \"public\": {\n                    \"type\": \"integer\"\n                },\n                \"tags\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/model.TagDTO\"\n                    }\n                },\n                \"title\": {\n                    \"type\": \"string\"\n                },\n                \"url\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"model.TagDTO\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"bookmark_count\": {\n                    \"description\": \"Number of bookmarks with this tag\",\n                    \"type\": \"integer\"\n                },\n                \"deleted\": {\n                    \"description\": \"Marks when a tag is deleted from a bookmark\",\n                    \"type\": \"boolean\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"model.UserConfig\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"createEbook\": {\n                    \"type\": \"boolean\"\n                },\n                \"hideExcerpt\": {\n                    \"type\": \"boolean\"\n                },\n                \"hideThumbnail\": {\n                    \"type\": \"boolean\"\n                },\n                \"keepMetadata\": {\n                    \"type\": \"boolean\"\n                },\n                \"listMode\": {\n                    \"type\": \"boolean\"\n                },\n                \"makePublic\": {\n                    \"type\": \"boolean\"\n                },\n                \"showId\": {\n                    \"type\": \"boolean\"\n                },\n                \"theme\": {\n                    \"type\": \"string\"\n                },\n                \"useArchive\": {\n                    \"type\": \"boolean\"\n                }\n            }\n        }\n    }\n}"
  },
  {
    "path": "docs/swagger/swagger.yaml",
    "content": "definitions:\n  api_v1.bookmarkTagPayload:\n    properties:\n      tag_id:\n        type: integer\n    required:\n    - tag_id\n    type: object\n  api_v1.bulkUpdateBookmarkTagsPayload:\n    properties:\n      bookmark_ids:\n        items:\n          type: integer\n        type: array\n      tag_ids:\n        items:\n          type: integer\n        type: array\n    required:\n    - bookmark_ids\n    - tag_ids\n    type: object\n  api_v1.infoResponse:\n    properties:\n      database:\n        type: string\n      os:\n        type: string\n      version:\n        properties:\n          commit:\n            type: string\n          date:\n            type: string\n          tag:\n            type: string\n        type: object\n    type: object\n  api_v1.loginRequestPayload:\n    properties:\n      password:\n        type: string\n      remember_me:\n        type: boolean\n      username:\n        type: string\n    type: object\n  api_v1.loginResponseMessage:\n    properties:\n      expires:\n        type: integer\n      token:\n        type: string\n    type: object\n  api_v1.readableResponseMessage:\n    properties:\n      content:\n        type: string\n      html:\n        type: string\n    type: object\n  api_v1.updateAccountPayload:\n    properties:\n      config:\n        $ref: '#/definitions/model.UserConfig'\n      new_password:\n        type: string\n      old_password:\n        type: string\n      owner:\n        type: boolean\n      username:\n        type: string\n    type: object\n  api_v1.updateCachePayload:\n    properties:\n      create_archive:\n        type: boolean\n      create_ebook:\n        type: boolean\n      ids:\n        items:\n          type: integer\n        type: array\n      keep_metadata:\n        type: boolean\n      skip_exist:\n        type: boolean\n    required:\n    - ids\n    type: object\n  model.Account:\n    properties:\n      config:\n        $ref: '#/definitions/model.UserConfig'\n      id:\n        type: integer\n      owner:\n        type: boolean\n      password:\n        type: string\n      username:\n        type: string\n    type: object\n  model.AccountDTO:\n    properties:\n      config:\n        $ref: '#/definitions/model.UserConfig'\n      id:\n        type: integer\n      owner:\n        type: boolean\n      passowrd:\n        description: Used only to store, not to retrieve\n        type: string\n      username:\n        type: string\n    type: object\n  model.BookmarkDTO:\n    properties:\n      author:\n        type: string\n      create_archive:\n        description: 'TODO: migrate outside the DTO'\n        type: boolean\n      create_ebook:\n        description: 'TODO: migrate outside the DTO'\n        type: boolean\n      createdAt:\n        type: string\n      excerpt:\n        type: string\n      hasArchive:\n        type: boolean\n      hasContent:\n        type: boolean\n      hasEbook:\n        type: boolean\n      html:\n        type: string\n      id:\n        type: integer\n      imageURL:\n        type: string\n      modifiedAt:\n        type: string\n      public:\n        type: integer\n      tags:\n        items:\n          $ref: '#/definitions/model.TagDTO'\n        type: array\n      title:\n        type: string\n      url:\n        type: string\n    type: object\n  model.TagDTO:\n    properties:\n      bookmark_count:\n        description: Number of bookmarks with this tag\n        type: integer\n      deleted:\n        description: Marks when a tag is deleted from a bookmark\n        type: boolean\n      id:\n        type: integer\n      name:\n        type: string\n    type: object\n  model.UserConfig:\n    properties:\n      createEbook:\n        type: boolean\n      hideExcerpt:\n        type: boolean\n      hideThumbnail:\n        type: boolean\n      keepMetadata:\n        type: boolean\n      listMode:\n        type: boolean\n      makePublic:\n        type: boolean\n      showId:\n        type: boolean\n      theme:\n        type: string\n      useArchive:\n        type: boolean\n    type: object\ninfo:\n  contact: {}\npaths:\n  /api/v1/accounts:\n    get:\n      description: List accounts\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            items:\n              $ref: '#/definitions/model.AccountDTO'\n            type: array\n        \"500\":\n          description: Internal Server Error\n          schema:\n            type: string\n      summary: List accounts\n      tags:\n      - accounts\n    post:\n      consumes:\n      - application/json\n      produces:\n      - application/json\n      responses:\n        \"201\":\n          description: Created\n          schema:\n            $ref: '#/definitions/model.AccountDTO'\n        \"400\":\n          description: Bad Request\n        \"409\":\n          description: Account already exists\n        \"500\":\n          description: Internal Server Error\n      summary: Create an account\n      tags:\n      - accounts\n  /api/v1/accounts/{id}:\n    delete:\n      parameters:\n      - description: Account ID\n        in: path\n        name: id\n        required: true\n        type: integer\n      produces:\n      - application/json\n      responses:\n        \"204\":\n          description: No content\n        \"400\":\n          description: Invalid ID\n        \"404\":\n          description: Account not found\n        \"500\":\n          description: Internal Server Error\n      summary: Delete an account\n      tags:\n      - accounts\n    patch:\n      consumes:\n      - application/json\n      parameters:\n      - description: Account ID\n        in: path\n        name: id\n        required: true\n        type: integer\n      - description: Account data\n        in: body\n        name: account\n        required: true\n        schema:\n          $ref: '#/definitions/api_v1.updateAccountPayload'\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            $ref: '#/definitions/model.AccountDTO'\n        \"400\":\n          description: Invalid ID/data\n        \"404\":\n          description: Account not found\n        \"409\":\n          description: Account already exists\n        \"500\":\n          description: Internal Server Error\n      summary: Update an account\n      tags:\n      - accounts\n  /api/v1/auth/account:\n    patch:\n      parameters:\n      - description: Account data\n        in: body\n        name: payload\n        schema:\n          $ref: '#/definitions/api_v1.updateAccountPayload'\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            $ref: '#/definitions/model.Account'\n        \"403\":\n          description: Token not provided/invalid\n      summary: Update account information\n      tags:\n      - Auth\n  /api/v1/auth/login:\n    post:\n      consumes:\n      - application/json\n      parameters:\n      - description: Login data\n        in: body\n        name: payload\n        schema:\n          $ref: '#/definitions/api_v1.loginRequestPayload'\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: Login successful\n          schema:\n            $ref: '#/definitions/api_v1.loginResponseMessage'\n        \"400\":\n          description: Invalid login data\n      summary: Login to an account using username and password\n      tags:\n      - Auth\n  /api/v1/auth/logout:\n    post:\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: Logout successful\n        \"403\":\n          description: Token not provided/invalid\n      summary: Logout from the current session\n      tags:\n      - Auth\n  /api/v1/auth/me:\n    get:\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            $ref: '#/definitions/model.Account'\n        \"403\":\n          description: Token not provided/invalid\n      summary: Get information for the current logged in user\n      tags:\n      - Auth\n  /api/v1/auth/refresh:\n    post:\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: Refresh successful\n          schema:\n            $ref: '#/definitions/api_v1.loginResponseMessage'\n        \"403\":\n          description: Token not provided/invalid\n      summary: Refresh a token for an account\n      tags:\n      - Auth\n  /api/v1/bookmarks/{id}/tags:\n    delete:\n      parameters:\n      - description: Bookmark ID\n        in: path\n        name: id\n        required: true\n        type: integer\n      - description: Remove Tag Payload\n        in: body\n        name: payload\n        required: true\n        schema:\n          $ref: '#/definitions/api_v1.bookmarkTagPayload'\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n        \"403\":\n          description: Token not provided/invalid\n        \"404\":\n          description: Bookmark not found\n      summary: Remove a tag from a bookmark.\n      tags:\n      - Auth\n    get:\n      parameters:\n      - description: Bookmark ID\n        in: path\n        name: id\n        required: true\n        type: integer\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            items:\n              $ref: '#/definitions/model.TagDTO'\n            type: array\n        \"403\":\n          description: Token not provided/invalid\n        \"404\":\n          description: Bookmark not found\n      summary: Get tags for a bookmark.\n      tags:\n      - Auth\n    post:\n      parameters:\n      - description: Bookmark ID\n        in: path\n        name: id\n        required: true\n        type: integer\n      - description: Add Tag Payload\n        in: body\n        name: payload\n        required: true\n        schema:\n          $ref: '#/definitions/api_v1.bookmarkTagPayload'\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n        \"403\":\n          description: Token not provided/invalid\n        \"404\":\n          description: Bookmark or tag not found\n      summary: Add a tag to a bookmark.\n      tags:\n      - Auth\n  /api/v1/bookmarks/bulk/tags:\n    put:\n      parameters:\n      - description: Bulk Update Bookmark Tags Payload\n        in: body\n        name: payload\n        required: true\n        schema:\n          $ref: '#/definitions/api_v1.bulkUpdateBookmarkTagsPayload'\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            items:\n              $ref: '#/definitions/model.BookmarkDTO'\n            type: array\n        \"400\":\n          description: Invalid request payload\n        \"403\":\n          description: Token not provided/invalid\n        \"404\":\n          description: No bookmarks found\n      summary: Bulk update tags for multiple bookmarks.\n      tags:\n      - Auth\n  /api/v1/bookmarks/cache:\n    put:\n      parameters:\n      - description: Update Cache Payload\n        in: body\n        name: payload\n        required: true\n        schema:\n          $ref: '#/definitions/api_v1.updateCachePayload'\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            $ref: '#/definitions/model.BookmarkDTO'\n        \"403\":\n          description: Token not provided/invalid\n      summary: Update Cache and Ebook on server.\n      tags:\n      - Auth\n  /api/v1/bookmarks/id/readable:\n    get:\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            $ref: '#/definitions/api_v1.readableResponseMessage'\n        \"403\":\n          description: Token not provided/invalid\n      summary: Get readable version of bookmark.\n      tags:\n      - Auth\n  /api/v1/system/info:\n    get:\n      description: Get general system information like Shiori version, database, and\n        OS\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            $ref: '#/definitions/api_v1.infoResponse'\n        \"403\":\n          description: Only owners can access this endpoint\n      summary: Get general system information\n      tags:\n      - System\n  /api/v1/tags:\n    get:\n      description: List all tags\n      parameters:\n      - description: Include bookmark count for each tag\n        in: query\n        name: with_bookmark_count\n        type: boolean\n      - description: Filter tags by bookmark ID\n        in: query\n        name: bookmark_id\n        type: integer\n      - description: Search tags by name\n        in: query\n        name: search\n        type: string\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            items:\n              $ref: '#/definitions/model.TagDTO'\n            type: array\n        \"403\":\n          description: Authentication required\n        \"500\":\n          description: Internal server error\n      summary: List tags\n      tags:\n      - Tags\n    post:\n      consumes:\n      - application/json\n      description: Create a new tag\n      parameters:\n      - description: Tag data\n        in: body\n        name: tag\n        required: true\n        schema:\n          $ref: '#/definitions/model.TagDTO'\n      produces:\n      - application/json\n      responses:\n        \"201\":\n          description: Created\n          schema:\n            $ref: '#/definitions/model.TagDTO'\n        \"400\":\n          description: Invalid request\n        \"403\":\n          description: Authentication required\n        \"500\":\n          description: Internal server error\n      summary: Create tag\n      tags:\n      - Tags\n  /api/v1/tags/{id}:\n    delete:\n      description: Delete a tag\n      parameters:\n      - description: Tag ID\n        in: path\n        name: id\n        required: true\n        type: integer\n      responses:\n        \"204\":\n          description: No Content\n        \"403\":\n          description: Authentication required\n        \"404\":\n          description: Tag not found\n        \"500\":\n          description: Internal server error\n      summary: Delete tag\n      tags:\n      - Tags\n    get:\n      description: Get a tag by ID\n      parameters:\n      - description: Tag ID\n        in: path\n        name: id\n        required: true\n        type: integer\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            $ref: '#/definitions/model.TagDTO'\n        \"403\":\n          description: Authentication required\n        \"404\":\n          description: Tag not found\n        \"500\":\n          description: Internal server error\n      summary: Get tag\n      tags:\n      - Tags\n    put:\n      consumes:\n      - application/json\n      description: Update an existing tag\n      parameters:\n      - description: Tag ID\n        in: path\n        name: id\n        required: true\n        type: integer\n      - description: Tag data\n        in: body\n        name: tag\n        required: true\n        schema:\n          $ref: '#/definitions/model.TagDTO'\n      produces:\n      - application/json\n      responses:\n        \"200\":\n          description: OK\n          schema:\n            $ref: '#/definitions/model.TagDTO'\n        \"400\":\n          description: Invalid request\n        \"403\":\n          description: Authentication required\n        \"404\":\n          description: Tag not found\n        \"500\":\n          description: Internal server error\n      summary: Update tag\n      tags:\n      - Tags\nswagger: \"2.0\"\n"
  },
  {
    "path": "e2e/e2eutil/containers.go",
    "content": "package e2eutil\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/testcontainers/testcontainers-go\"\n\t\"github.com/testcontainers/testcontainers-go/wait\"\n)\n\nconst (\n\tshioriPort                   = \"8080/tcp\"\n\tshioriExpectedStartupMessage = \"started http server\"\n\tshioriExpectedStartupSeconds = 5\n)\n\nvar testContainersProviderType testcontainers.ProviderType = testcontainers.ProviderDocker\n\nfunc init() {\n\t// If TESTCONTAINERS_PROVIDER is set to podman, use podman\n\t// NOTE: This is EXPERIMENTAL since there are some issues running the e2e tests using podman,\n\t// testcontainers implies that it supports podman but I couldn't make it run in my tests.\n\t// YMMV.\n\t// More info: https://golang.testcontainers.org/system_requirements/using_podman/\n\tif os.Getenv(\"TESTCONTAINERS_PROVIDER\") == \"podman\" {\n\t\ttestContainersProviderType = testcontainers.ProviderPodman\n\t}\n}\n\nfunc newBuildArg(value string) *string {\n\treturn &value\n}\n\ntype ShioriContainer struct {\n\tt *testing.T\n\n\tContainer testcontainers.Container\n}\n\nfunc (sc *ShioriContainer) GetPort() string {\n\tmappedPort, err := sc.Container.MappedPort(context.Background(), shioriPort)\n\trequire.NoError(sc.t, err)\n\treturn mappedPort.Port()\n}\n\n// NewShioriContainer creates a new ShioriContainer which is a wrapper around a testcontainers.Container\n// with some helpers for using while running Shiori E2E tests.\nfunc NewShioriContainer(t *testing.T, tag string) ShioriContainer {\n\tcontainerDefinition := testcontainers.GenericContainerRequest{\n\t\tProviderType: testContainersProviderType,\n\t\tContainerRequest: testcontainers.ContainerRequest{\n\t\t\tCmd:          []string{\"server\", \"--log-level\", \"debug\"},\n\t\t\tExposedPorts: []string{shioriPort},\n\t\t\tWaitingFor:   wait.ForLog(shioriExpectedStartupMessage).WithStartupTimeout(shioriExpectedStartupSeconds * time.Second),\n\t\t},\n\t\tStarted: true,\n\t}\n\n\tif tag != \"\" {\n\t\tcontainerDefinition.Image = \"ghcr.io/go-shiori/shiori:\" + tag\n\t} else {\n\t\tcontainerDefinition.FromDockerfile = testcontainers.FromDockerfile{\n\t\t\tPrintBuildLog: false,\n\t\t\tContext:       \"../..\",\n\t\t\tDockerfile:    \"Dockerfile.e2e\",\n\t\t\tKeepImage:     true,\n\t\t\tBuildArgs: map[string]*string{\n\t\t\t\t\"ALPINE_VERSION\": newBuildArg(os.Getenv(\"CONTAINER_ALPINE_VERSION\")),\n\t\t\t\t\"GOLANG_VERSION\": newBuildArg(os.Getenv(\"GOLANG_VERSION\")),\n\t\t\t},\n\t\t}\n\t}\n\n\tcontainer, err := testcontainers.GenericContainer(context.Background(), containerDefinition)\n\trequire.NoError(t, err)\n\tt.Cleanup(func() {\n\t\t// Print container logs on test failure for debugging\n\t\tif t.Failed() {\n\t\t\tprintContainerLogs(t, container, \"Container logs on test failure:\")\n\t\t}\n\n\t\t// Terminate container with error handling\n\t\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\t\tdefer cancel()\n\t\tif err := container.Terminate(ctx); err != nil {\n\t\t\t// Log the error but don't fail the test cleanup\n\t\t\tt.Logf(\"Warning: Failed to terminate container: %v\", err)\n\t\t}\n\t})\n\n\treturn ShioriContainer{\n\t\tt:         t,\n\t\tContainer: container,\n\t}\n}\n\n// printContainerLogs prints the container logs for debugging purposes\nfunc printContainerLogs(t *testing.T, container testcontainers.Container, prefix string) {\n\tif container == nil {\n\t\tt.Logf(\"%s Container is nil, cannot retrieve logs\", prefix)\n\t\treturn\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tlogs, err := container.Logs(ctx)\n\tif err != nil {\n\t\tt.Logf(\"%s Failed to get container logs: %v\", prefix, err)\n\t\treturn\n\t}\n\tdefer logs.Close()\n\n\tlogBytes, err := io.ReadAll(logs)\n\tif err != nil {\n\t\tt.Logf(\"%s Failed to read container logs: %v\", prefix, err)\n\t\treturn\n\t}\n\n\tif len(logBytes) == 0 {\n\t\tt.Logf(\"%s No container logs available\", prefix)\n\t\treturn\n\t}\n\n\t// Split logs into lines and add prefix\n\tlogLines := strings.Split(strings.TrimSpace(string(logBytes)), \"\\n\")\n\tt.Logf(\"%s\", prefix)\n\tfor i, line := range logLines {\n\t\tif line != \"\" {\n\t\t\tt.Logf(\"  [%d] %s\", i+1, line)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "e2e/playwright/accounts_test.go",
    "content": "package playwright\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/e2e/e2eutil\"\n\t\"github.com/playwright-community/playwright-go\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestE2EAccounts(t *testing.T) {\n\t// Start a new Shiori container\n\tcontainer := e2eutil.NewShioriContainer(t, \"\")\n\tbaseURL := fmt.Sprintf(\"http://localhost:%s\", container.GetPort())\n\n\tmainTestHelper, err := NewTestHelper(t, \"main\")\n\trequire.NoError(t, err)\n\tdefer mainTestHelper.Close()\n\n\tt.Run(\"001 login as admin\", func(t *testing.T) {\n\t\t// Navigate to the login page\n\t\t_, err = mainTestHelper.page.Goto(baseURL)\n\t\tmainTestHelper.Require().NoError(t, err, \"Navigate to base URL\")\n\n\t\t// Get locators for form elements\n\t\tusernameLocator := mainTestHelper.page.Locator(\"#username\")\n\t\tpasswordLocator := mainTestHelper.page.Locator(\"#password\")\n\t\tbuttonLocator := mainTestHelper.page.Locator(\".button\")\n\n\t\t// Wait for and fill the login form\n\t\tmainTestHelper.Require().NoError(t, usernameLocator.WaitFor(), \"Wait for username field\")\n\t\tmainTestHelper.Require().NoError(t, usernameLocator.Fill(\"shiori\"), \"Fill username field\")\n\t\tmainTestHelper.Require().NoError(t, passwordLocator.Fill(\"gopher\"), \"Fill password field\")\n\n\t\t// Click login and wait for success\n\t\tmainTestHelper.Require().NoError(t, buttonLocator.Click(), \"Click login button\")\n\t\tmainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(\"#bookmarks-grid\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\tTimeout: playwright.Float(1000),\n\t\t}), \"Wait for bookmarks section to show up\")\n\t})\n\n\tt.Run(\"002 create new admin account\", func(t *testing.T) {\n\t\t// Navigate to settings page\n\t\tmainTestHelper.Require().NoError(t,\n\t\t\tmainTestHelper.page.Locator(`[title=\"Settings\"]`).Click(),\n\t\t\t\"Click on settings button\")\n\t\tmainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(\".setting-container\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\tTimeout: playwright.Float(1000),\n\t\t}), \"Wait for settings page to show up\")\n\n\t\t// Click on \"Add new account\" <a> element\n\t\tmainTestHelper.page.Locator(`[title=\"Add new account\"]`).Click()\n\t\tmainTestHelper.page.Locator(\".custom-dialog\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\tState: playwright.WaitForSelectorStateVisible,\n\t\t})\n\n\t\t// Fill modal\n\t\tmainTestHelper.page.Locator(`[name=\"username\"]`).Fill(\"admin2\")\n\t\tmainTestHelper.page.Locator(`[name=\"password\"]`).Fill(\"admin2\")\n\t\tmainTestHelper.page.Locator(`[name=\"repeat_password\"]`).Fill(\"admin2\")\n\t\tmainTestHelper.page.Locator(`[name=\"admin\"]`).Check()\n\n\t\t// Click on \"Ok\" button\n\t\tmainTestHelper.page.Locator(`.custom-dialog-button.main`).Click()\n\n\t\t// Wait for modal to disappear\n\t\tmainTestHelper.Require().NoError(t,\n\t\t\tmainTestHelper.page.Locator(\".custom-dialog\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\t\tState:   playwright.WaitForSelectorStateHidden,\n\t\t\t\tTimeout: playwright.Float(1000),\n\t\t\t}),\n\t\t\t\"Wait for modal to disappear\")\n\n\t\t// Refresh account list\n\t\tmainTestHelper.Require().NoError(t,\n\t\t\tmainTestHelper.page.Locator(`a[title=\"Refresh accounts\"]`).Click(),\n\t\t\t\"Click on refresh accounts button\")\n\t\tmainTestHelper.Require().NoError(t,\n\t\t\tmainTestHelper.page.Locator(\".loading-overlay\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\t\tState:   playwright.WaitForSelectorStateHidden,\n\t\t\t\tTimeout: playwright.Float(1000),\n\t\t\t}),\n\t\t\t\"Wait for loading overlay to disappear\")\n\n\t\t// Check if new account is created\n\t\taccountsCount, err := mainTestHelper.page.Locator(\".accounts-list li\").Count()\n\t\tmainTestHelper.Require().NoError(t, err, \"Count accounts in list\")\n\t\tmainTestHelper.Require().Equal(t, 2, accountsCount, \"Verify 2 accounts present after creating new admin account\")\n\t})\n\n\tt.Run(\"003 create new user account\", func(t *testing.T) {\n\t\t// Click on \"Add new account\" <a> element\n\t\tmainTestHelper.page.Locator(`[title=\"Add new account\"]`).Click()\n\t\tmainTestHelper.page.Locator(\".custom-dialog\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\tTimeout: playwright.Float(1000),\n\t\t})\n\n\t\t// Fill modal\n\t\tmainTestHelper.page.Locator(`[name=\"username\"]`).Fill(\"user1\")\n\t\tmainTestHelper.page.Locator(`[name=\"password\"]`).Fill(\"user1\")\n\t\tmainTestHelper.page.Locator(`[name=\"repeat_password\"]`).Fill(\"user1\")\n\n\t\t// Click on \"Ok\" button\n\t\tmainTestHelper.page.Locator(`.custom-dialog-button.main`).Click()\n\n\t\t// Wait for modal to disappear\n\t\tmainTestHelper.page.Locator(\".custom-dialog\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\tState:   playwright.WaitForSelectorStateHidden,\n\t\t\tTimeout: playwright.Float(1000),\n\t\t})\n\n\t\t// Refresh account list\n\t\tmainTestHelper.page.Locator(`a[title=\"Refresh accounts\"]`).Click()\n\t\tmainTestHelper.page.Locator(\".loading-overlay\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\tState:   playwright.WaitForSelectorStateHidden,\n\t\t\tTimeout: playwright.Float(1000),\n\t\t})\n\n\t\t// Check if new account is created\n\t\taccountsCount, err := mainTestHelper.page.Locator(\".accounts-list li\").Count()\n\t\tmainTestHelper.Require().NoError(t, err, \"Failed to count accounts in list\")\n\t\tmainTestHelper.Require().Equal(t, 3, accountsCount, \"Expected 3 accounts after creating user account\")\n\t})\n\n\tt.Run(\"004 check admin account created successfully\", func(t *testing.T) {\n\t\tth, err := NewTestHelper(t, t.Name())\n\t\trequire.NoError(t, err, \"Create test helper\")\n\t\tdefer th.Close()\n\n\t\t// Navigate to the login page\n\t\t_, err = th.page.Goto(baseURL)\n\t\tth.Require().NoError(t, err, \"Navigate to base URL\")\n\n\t\t// Get locators for form elements\n\t\tusernameLocator := th.page.Locator(\"#username\")\n\t\tpasswordLocator := th.page.Locator(\"#password\")\n\t\tbuttonLocator := th.page.Locator(\".button\")\n\n\t\t// Wait for and fill the login form\n\t\tth.Require().NoError(t, usernameLocator.WaitFor(), \"Wait for username field\")\n\t\tth.Require().NoError(t, usernameLocator.Fill(\"admin2\"), \"Fill username field\")\n\t\tth.Require().NoError(t, passwordLocator.Fill(\"admin2\"), \"Fill password field\")\n\n\t\t// Click login and wait for success\n\t\tth.Require().NoError(t, buttonLocator.Click(), \"Click login button\")\n\t\tth.Require().NoError(t, th.page.Locator(\"#bookmarks-grid\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\tTimeout: playwright.Float(1000),\n\t\t}), \"Wait for bookmarks section to show up\")\n\n\t\t// Navigate to settings\n\t\tth.Require().NoError(t,\n\t\t\tth.page.Locator(`[title=\"Settings\"]`).Click(),\n\t\t\t\"Click on settings button\")\n\t\tth.Require().NoError(t,\n\t\t\tth.page.Locator(\".setting-container\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\t\tTimeout: playwright.Float(1000),\n\t\t\t}),\n\t\t\t\"Wait for settings page to show up\")\n\n\t\t// Check if can see system info (admin only)\n\t\tvisible, err := th.page.Locator(`#setting-system-info`).IsVisible()\n\t\tth.Require().NoError(t, err, \"Check visibility of system info section\")\n\t\tth.Require().True(t, visible, \"Verify system info section visibility for admin user\")\n\t})\n\n\tt.Run(\"005 check user account created successfully\", func(t *testing.T) {\n\t\tth, err := NewTestHelper(t, t.Name())\n\t\trequire.NoError(t, err, \"Create test helper\")\n\n\t\tdefer th.Close()\n\n\t\t// Navigate to the login page\n\t\t_, err = th.page.Goto(baseURL)\n\t\tth.Require().NoError(t, err, \"Navigate to base URL\")\n\n\t\t// Get locators for form elements\n\t\tusernameLocator := th.page.Locator(\"#username\")\n\t\tpasswordLocator := th.page.Locator(\"#password\")\n\t\tbuttonLocator := th.page.Locator(\".button\")\n\n\t\t// Wait for and fill the login form\n\t\tth.Require().NoError(t, usernameLocator.WaitFor(), \"Wait for username field\")\n\t\tth.Require().NoError(t, usernameLocator.Fill(\"user1\"), \"Fill username field\")\n\t\tth.Require().NoError(t, passwordLocator.Fill(\"user1\"), \"Fill password field\")\n\n\t\t// Click login and wait for success\n\t\tth.Require().NoError(t, buttonLocator.Click(), \"Click login button\")\n\t\tth.Require().NoError(t, th.page.Locator(\"#bookmarks-grid\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\tTimeout: playwright.Float(1000),\n\t\t}), \"Wait for bookmarks section to show up\")\n\n\t\t// Navigate to settings\n\t\tth.Require().NoError(t,\n\t\t\tth.page.Locator(`[title=\"Settings\"]`).Click(),\n\t\t\t\"Click on settings button\")\n\t\tth.Require().NoError(t, th.page.Locator(\".setting-container\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\tTimeout: playwright.Float(1000),\n\t\t}), \"Wait for settings page to show up\")\n\n\t\t// Check if can see system info (admin only)\n\t\tvisible, err := th.page.Locator(`#setting-system-info`).IsVisible()\n\t\tth.Require().NoError(t, err, \"Check visibility of system info section\")\n\t\tth.Require().False(t, visible, \"Verify system info section not visible for regular user\")\n\n\t\t// My account settings is visible\n\t\tvisible, err = th.page.Locator(`#setting-my-account`).IsVisible()\n\t\tth.Require().NoError(t, err, \"Check visibility of account settings\")\n\t\tth.Require().True(t, visible, \"Verify account settings visibility for user\")\n\n\t\t// Check change password requires current password\n\t\tth.Require().NoError(t,\n\t\t\tth.page.Locator(`li[shiori-username=\"user1\"] a[title=\"Change password\"]`).Click(),\n\t\t\t\"Click on change password button\")\n\t\tth.Require().NoError(t,\n\t\t\tth.page.Locator(\".custom-dialog\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\t\tTimeout: playwright.Float(1000),\n\t\t\t}),\n\t\t\t\"Wait for change password modal to show up\")\n\t\tvisible, err = th.page.Locator(`[name=\"old_password\"]`).IsVisible()\n\t\tth.Require().NoError(t, err, \"Check visibility of old password field\")\n\t\tth.Require().True(t, visible, \"Verify old password field visibility when changing password\")\n\n\t\t// Fill modal\n\t\tth.Require().NoError(t,\n\t\t\tth.page.Locator(`[name=\"old_password\"]`).Fill(\"user1\"),\n\t\t\t\"Fill old password field\")\n\t\tth.Require().NoError(t,\n\t\t\tth.page.Locator(`[name=\"new_password\"]`).Fill(\"new_user1\"),\n\t\t\t\"Fill new password field\")\n\t\tth.Require().NoError(t,\n\t\t\tth.page.Locator(`[name=\"repeat_password\"]`).Fill(\"new_user1\"),\n\t\t\t\"Fill repeat password field\")\n\n\t\t// Click on \"Ok\" button\n\t\tth.Require().NoError(t,\n\t\t\tth.page.Locator(`.custom-dialog-button.main`).Click(),\n\t\t\t\"Click on ok button\")\n\n\t\t// Wait for modal to display text: \"Password has been changed.\"\n\t\tdialogContent := th.page.Locator(\".custom-dialog-content\")\n\t\tth.Require().NoError(t,\n\t\t\tdialogContent.WaitFor(playwright.LocatorWaitForOptions{\n\t\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\t\tTimeout: playwright.Float(1000),\n\t\t\t}),\n\t\t\t\"Wait for dialog content to show up\")\n\n\t\tcontentText, err := dialogContent.TextContent()\n\t\tth.Require().NoError(t, err, \"Get dialog content text\")\n\t\tth.Require().Equal(t, \"Password has been changed.\", contentText, \"Verify password change confirmation message\")\n\t})\n\n\tt.Run(\"006 delete user account\", func(t *testing.T) {\n\t\t// Click on \"Delete\" button\n\t\tmainTestHelper.page.Locator(`li[shiori-username=\"user1\"] a[title=\"Delete account\"]`).Click()\n\t\tmainTestHelper.page.Locator(\".custom-dialog\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\tTimeout: playwright.Float(1000),\n\t\t})\n\n\t\t// Click on \"Ok\" button\n\t\tmainTestHelper.page.Locator(`.custom-dialog-button.main`).Click()\n\n\t\t// Wait for modal to disappear\n\t\tmainTestHelper.page.Locator(\".custom-dialog\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\tState:   playwright.WaitForSelectorStateHidden,\n\t\t\tTimeout: playwright.Float(1000),\n\t\t})\n\n\t\t// Refresh account list\n\t\tmainTestHelper.page.Locator(`a[title=\"Refresh accounts\"]`).Click()\n\t\tmainTestHelper.page.Locator(\".loading-overlay\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\tState:   playwright.WaitForSelectorStateHidden,\n\t\t\tTimeout: playwright.Float(1000),\n\t\t})\n\n\t\t// Check if account is deleted\n\t\taccountsCount, err := mainTestHelper.page.Locator(\".accounts-list li\").Count()\n\t\tmainTestHelper.Require().NoError(t, err, \"Count accounts in list\")\n\t\tmainTestHelper.Require().Equal(t, 2, accountsCount, \"Verify 2 accounts present after creating admin account\")\n\n\t\ttime.Sleep(5 * time.Second)\n\t})\n\n\tt.Run(\"007 change password for admin account\", func(t *testing.T) {\n\t\t// Click on \"Change password\" button\n\t\tmainTestHelper.Require().NoError(t,\n\t\t\tmainTestHelper.page.Locator(`li[shiori-username=\"admin2\"] a[title=\"Change password\"]`).Click(),\n\t\t\t\"Click change password button\")\n\t\tmainTestHelper.Require().NoError(t,\n\t\t\tmainTestHelper.page.Locator(\".custom-dialog\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\t\tTimeout: playwright.Float(1000),\n\t\t\t}),\n\t\t\t\"Wait for password dialog to appear\")\n\n\t\t// Fill modal\n\t\tmainTestHelper.Require().NoError(t,\n\t\t\tmainTestHelper.page.Locator(`[name=\"new_password\"]`).Fill(\"admin3\"),\n\t\t\t\"Fill new password\")\n\t\tmainTestHelper.Require().NoError(t,\n\t\t\tmainTestHelper.page.Locator(`[name=\"repeat_password\"]`).Fill(\"admin3\"),\n\t\t\t\"Fill repeat password\")\n\n\t\t// Click on \"Ok\" button\n\t\tmainTestHelper.Require().NoError(t,\n\t\t\tmainTestHelper.page.Locator(`.custom-dialog-button.main`).Click(),\n\t\t\t\"Click ok button\")\n\n\t\t// Wait for modal to display text: \"Password has been changed.\"\n\t\tdialogContent := mainTestHelper.page.Locator(\".custom-dialog-content\")\n\t\tmainTestHelper.Require().NoError(t,\n\t\t\tdialogContent.WaitFor(playwright.LocatorWaitForOptions{\n\t\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\t\tTimeout: playwright.Float(1000),\n\t\t\t}),\n\t\t\t\"Wait for dialog content to show up\")\n\n\t\tcontentText, err := dialogContent.TextContent()\n\t\tmainTestHelper.Require().NoError(t, err, \"Get dialog content text\")\n\t\tmainTestHelper.Require().Equal(t, \"Password has been changed.\", contentText, \"Verify password change confirmation message\")\n\n\t\t// Click on \"Ok\" button\n\t\tmainTestHelper.Require().NoError(t,\n\t\t\tmainTestHelper.page.Locator(`.custom-dialog-button.main`).Click(),\n\t\t\t\"Click ok button\")\n\n\t\t// Wait for modal to disappear\n\t\tmainTestHelper.Require().NoError(t,\n\t\t\tmainTestHelper.page.Locator(\".custom-dialog\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\t\tState:   playwright.WaitForSelectorStateHidden,\n\t\t\t\tTimeout: playwright.Float(2000),\n\t\t\t}),\n\t\t\t\"Wait for dialog to close\")\n\n\t\t// Refresh account list\n\t\tmainTestHelper.Require().NoError(t,\n\t\t\tmainTestHelper.page.Locator(`a[title=\"Refresh accounts\"]`).Click(),\n\t\t\t\"Click refresh accounts\")\n\t\tmainTestHelper.Require().NoError(t,\n\t\t\tmainTestHelper.page.Locator(\".loading-overlay\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\t\tState:   playwright.WaitForSelectorStateHidden,\n\t\t\t\tTimeout: playwright.Float(1000),\n\t\t\t}),\n\t\t\t\"Wait for refresh to complete\")\n\n\t\tt.Run(\"0071 login with new password\", func(t *testing.T) {\n\t\t\tth, err := NewTestHelper(t, t.Name())\n\t\t\trequire.NoError(t, err, \"Failed to create test helper\")\n\t\t\tdefer th.Close()\n\n\t\t\t// Navigate to the login page\n\t\t\t_, err = th.page.Goto(baseURL)\n\t\t\tth.Require().NoError(t, err, \"Navigate to base URL\")\n\n\t\t\t// Wait for login page\n\t\t\tth.Require().NoError(t,\n\t\t\t\tth.page.Locator(\"#username\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\t\t\tTimeout: playwright.Float(1000),\n\t\t\t\t}),\n\t\t\t\t\"Wait for login page\")\n\t\t\tth.Require().NoError(t, th.page.Locator(\"#username\").Fill(\"admin2\"), \"Fill username field\")\n\t\t\tth.Require().NoError(t, th.page.Locator(\"#password\").Fill(\"admin3\"), \"Fill password field\")\n\t\t\tth.Require().NoError(t, th.page.Locator(\".button\").Click(), \"Click login button\")\n\t\t\tth.Require().NoError(t, th.page.Locator(\"#bookmarks-grid\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\t\tTimeout: playwright.Float(1000),\n\t\t\t}), \"Wait for bookmarks section to show up\")\n\t\t})\n\t})\n\n\tt.Run(\"008 logout\", func(t *testing.T) {\n\t\t// Click on \"Logout\" button\n\t\tmainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(`a[title=\"Logout\"]`).Click(), \"Click on logout button\")\n\n\t\t// Wait for modal to display text\n\t\tdialogContent := mainTestHelper.page.Locator(\".custom-dialog-content\")\n\t\tmainTestHelper.Require().NoError(t,\n\t\t\tdialogContent.WaitFor(playwright.LocatorWaitForOptions{\n\t\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\t\tTimeout: playwright.Float(1000),\n\t\t\t}),\n\t\t\t\"Wait for dialog content to show up\")\n\n\t\tcontentText, err := dialogContent.TextContent()\n\t\tmainTestHelper.Require().NoError(t, err, \"Get dialog content text\")\n\t\tmainTestHelper.Require().Equal(t, \"Are you sure you want to log out ?\", contentText, \"Verify logout confirmation message\")\n\n\t\t// Click on \"Yes\" button\n\t\tmainTestHelper.Require().NoError(t,\n\t\t\tmainTestHelper.page.Locator(`.custom-dialog-button.main`).Click(),\n\t\t\t\"Click Yes button\")\n\n\t\t// Wait for login page\n\t\terr = mainTestHelper.page.Locator(\"#login-scene\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\tTimeout: playwright.Float(1000),\n\t\t})\n\t\tmainTestHelper.Require().NoError(t, err, \"Wait for login page\")\n\t})\n}\n"
  },
  {
    "path": "e2e/playwright/auth_test.go",
    "content": "package playwright\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/e2e/e2eutil\"\n\t\"github.com/playwright-community/playwright-go\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestAuth(t *testing.T) {\n\t// Start a new Shiori container\n\tcontainer := e2eutil.NewShioriContainer(t, \"\")\n\tbaseURL := fmt.Sprintf(\"http://localhost:%s\", container.GetPort())\n\n\tmainTestHelper, err := NewTestHelper(t, \"main\")\n\trequire.NoError(t, err)\n\tdefer mainTestHelper.Close()\n\n\tt.Run(\"successful login with default credentials\", func(t *testing.T) {\n\t\t// Navigate to the login page\n\t\t_, err = mainTestHelper.page.Goto(baseURL)\n\t\tmainTestHelper.Require().NoError(t, err, \"Navigate to base URL\")\n\n\t\t// Get locators for form elements\n\t\tusernameLocator := mainTestHelper.page.Locator(\"#username\")\n\t\tpasswordLocator := mainTestHelper.page.Locator(\"#password\")\n\t\tbuttonLocator := mainTestHelper.page.Locator(\".button\")\n\n\t\t// Wait for and fill the login form\n\t\tmainTestHelper.Require().NoError(t, usernameLocator.WaitFor(), \"Wait for username field\")\n\t\tmainTestHelper.Require().NoError(t, usernameLocator.Fill(\"shiori\"), \"Fill username field\")\n\t\tmainTestHelper.Require().NoError(t, passwordLocator.Fill(\"gopher\"), \"Fill password field\")\n\n\t\t// Click login and wait for success\n\t\tmainTestHelper.Require().NoError(t, buttonLocator.Click(), \"Click login button\")\n\t\tmainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(\"#bookmarks-grid\").WaitFor(playwright.LocatorWaitForOptions{\n\t\t\tState:   playwright.WaitForSelectorStateVisible,\n\t\t\tTimeout: playwright.Float(1000),\n\t\t}), \"Wait for bookmarks section to show up\")\n\t})\n\n\tt.Run(\"failed login with wrong username\", func(t *testing.T) {\n\t\tth, err := NewTestHelper(t, t.Name())\n\t\trequire.NoError(t, err)\n\t\tdefer th.Close()\n\n\t\t// Navigate to the login page\n\t\t_, err = th.page.Goto(baseURL)\n\t\tth.Require().NoError(t, err, \"Navigate to base URL\")\n\n\t\t// Get locators for form elements\n\t\tusernameLocator := th.page.Locator(\"#username\")\n\t\tpasswordLocator := th.page.Locator(\"#password\")\n\t\tbuttonLocator := th.page.Locator(\".button\")\n\t\terrorLocator := th.page.Locator(\".error-message\")\n\n\t\t// Wait for and fill the login form\n\t\tth.Require().NoError(t, usernameLocator.WaitFor(), \"Wait for username field\")\n\t\tth.Require().NoError(t, usernameLocator.Fill(\"wrong_user\"), \"Fill username field\")\n\t\tth.Require().NoError(t, passwordLocator.Fill(\"gopher\"), \"Fill password field\")\n\n\t\t// Click login and verify error\n\t\tth.Require().NoError(t, buttonLocator.Click(), \"Click login button\")\n\t\terrorText, err := errorLocator.TextContent()\n\t\tth.Require().NoError(t, err, \"Get error message text\")\n\t\tth.Require().Contains(t, errorText, \"username or password do not match\")\n\t})\n\n\tt.Run(\"failed login with wrong password\", func(t *testing.T) {\n\t\tth, err := NewTestHelper(t, t.Name())\n\t\trequire.NoError(t, err)\n\t\tdefer th.Close()\n\n\t\t// Navigate to the login page\n\t\t_, err = th.page.Goto(baseURL)\n\t\tth.Require().NoError(t, err, \"Navigate to base URL\")\n\n\t\t// Get locators for form elements\n\t\tusernameLocator := th.page.Locator(\"#username\")\n\t\tpasswordLocator := th.page.Locator(\"#password\")\n\t\tbuttonLocator := th.page.Locator(\".button\")\n\t\terrorLocator := th.page.Locator(\".error-message\")\n\n\t\t// Wait for and fill the login form\n\t\tth.Require().NoError(t, usernameLocator.WaitFor(), \"Wait for username field\")\n\t\tth.Require().NoError(t, usernameLocator.Fill(\"shiori\"), \"Fill username field\")\n\t\tth.Require().NoError(t, passwordLocator.Fill(\"wrong_password\"), \"Fill password field\")\n\n\t\t// Click login and verify error\n\t\tth.Require().NoError(t, buttonLocator.Click(), \"Click login button\")\n\t\terrorText, err := errorLocator.TextContent()\n\t\tth.Require().NoError(t, err, \"Get error message text\")\n\t\tth.Require().Contains(t, errorText, \"username or password do not match\")\n\t})\n\n\tt.Run(\"empty username validation\", func(t *testing.T) {\n\t\tth, err := NewTestHelper(t, t.Name())\n\t\trequire.NoError(t, err)\n\t\tdefer th.Close()\n\n\t\t// Navigate to the login page\n\t\t_, err = th.page.Goto(baseURL)\n\t\tth.Require().NoError(t, err, \"Navigate to base URL\")\n\n\t\t// Get locators for form elements\n\t\tusernameLocator := th.page.Locator(\"#username\")\n\t\tpasswordLocator := th.page.Locator(\"#password\")\n\t\tbuttonLocator := th.page.Locator(\".button\")\n\t\terrorLocator := th.page.Locator(\".error-message\")\n\n\t\t// Wait for form and fill only password\n\t\tth.Require().NoError(t, usernameLocator.WaitFor(), \"Wait for username field\")\n\t\tth.Require().NoError(t, passwordLocator.Fill(\"gopher\"), \"Fill password field\")\n\n\t\t// Click login and verify error\n\t\tth.Require().NoError(t, buttonLocator.Click(), \"Click login button\")\n\t\terrorText, err := errorLocator.TextContent()\n\t\tth.Require().NoError(t, err, \"Get error message text\")\n\t\tth.Require().Contains(t, errorText, \"Username must not empty\")\n\t})\n}\n"
  },
  {
    "path": "e2e/playwright/playwright_test.go",
    "content": "package playwright\n\nimport \"github.com/playwright-community/playwright-go\"\n\nfunc init() {\n\tplaywright.Install()\n}\n"
  },
  {
    "path": "e2e/playwright/reporter.go",
    "content": "package playwright\n\nimport (\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype AssertionResult struct {\n\tMessage    string\n\tStatus     string\n\tError      string\n\tScreenshot string // Base64 screenshot, only for failures\n}\n\ntype TestResult struct {\n\tName       string\n\tStatus     string\n\tTimestamp  time.Time\n\tAssertions []AssertionResult\n}\n\ntype TestReporter struct {\n\tResults map[string]*TestResult\n}\n\nvar globalReporter = &TestReporter{\n\tResults: make(map[string]*TestResult),\n}\n\nfunc GetReporter() *TestReporter {\n\treturn globalReporter\n}\n\nfunc (r *TestReporter) AddResult(testName string, passed bool, screenshotPath string, message, errorMessage string) {\n\tstatus := \"Passed\"\n\tif !passed {\n\t\tstatus = \"Failed\"\n\t}\n\n\tvar screenshot string\n\tif !passed && screenshotPath != \"\" {\n\t\timageFile, err := os.Open(screenshotPath)\n\t\tif err == nil {\n\t\t\tdefer imageFile.Close()\n\t\t\tif data, err := io.ReadAll(imageFile); err == nil {\n\t\t\t\tscreenshot = \"data:image/png;base64,\" + base64.StdEncoding.EncodeToString(data)\n\t\t\t} else {\n\t\t\t\tfmt.Printf(\"Failed to read screenshot %s: %v\\n\", screenshotPath, err)\n\t\t\t}\n\t\t} else {\n\t\t\tfmt.Printf(\"Failed to open screenshot %s: %v\\n\", screenshotPath, err)\n\t\t}\n\t}\n\n\t// Get or create test result\n\ttestResult, exists := r.Results[testName]\n\tif !exists {\n\t\ttestResult = &TestResult{\n\t\t\tName:       testName,\n\t\t\tStatus:     \"Passed\",\n\t\t\tTimestamp:  time.Now(),\n\t\t\tAssertions: make([]AssertionResult, 0),\n\t\t}\n\t\tr.Results[testName] = testResult\n\t}\n\n\t// Add assertion result\n\ttestResult.Assertions = append(testResult.Assertions, AssertionResult{\n\t\tMessage:    message,\n\t\tError:      errorMessage,\n\t\tStatus:     status,\n\t\tScreenshot: screenshot,\n\t})\n\n\t// Update test status if any assertion failed\n\tif !passed {\n\t\ttestResult.Status = \"Failed\"\n\t}\n}\n\nfunc (r *TestReporter) GenerateHTML() error {\n\tconst tmpl = `\n<!DOCTYPE html>\n<html>\n<head>\n    <title>Test Results</title>\n    <style>\n        body { font-family: Arial, sans-serif; margin: 20px; }\n        .test { margin: 20px 0; padding: 15px; border: 1px solid #ddd; }\n        .test.passed { background-color: #e8f5e9; }\n        .test.failed { background-color: #ffebee; }\n        .assertions { margin: 10px 0; }\n        .assertion.failed { font-weight: bold; }\n        img { max-width: 800px; margin: 10px 0; }\n        .assertion-msg { font-weight: bold; }\n        .error-details { color: #d32f2f; margin: 5px 0; }\n    </style>\n</head>\n<body>\n    <h1>Test Results</h1>\n    {{range .Results}}\n    <div class=\"test {{.Status | toLowerCase}}\">\n        <h3>{{.Name}}</h3>\n        <p><b>Status:</b> {{.Status}}</p>\n\t\t{{if eq .Status \"Failed\"}}\n        <ul class=\"assertions\">\n            {{range .Assertions}}\n                <li class=\"assertion {{.Status | toLowerCase}}\">\n                    <p>{{if eq .Status \"Passed\"}}✓ {{end}}{{.Message}}</p>\n                    <p class=\"error-details\">{{.Error}}</p>\n                    {{if .Screenshot}}<p><img src=\"{{.Screenshot | safeURL}}\" alt=\"Failure screenshot\"></p>{{end}}\n                </li>\n            {{end}}\n        </ul>\n  \t\t{{end}}\n    </div>\n    {{end}}\n</body>\n</html>`\n\n\tt := template.New(\"report\")\n\tt = t.Funcs(template.FuncMap{\n\t\t\"toLowerCase\": strings.ToLower,\n\t\t\"safeHTML\":    func(s string) template.HTML { return template.HTML(s) },\n\t\t\"safeURL\": func(s string) template.URL {\n\t\t\treturn template.URL(s)\n\t\t},\n\t})\n\n\tt, err := t.Parse(tmpl)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse template: %v\", err)\n\t}\n\n\tif err := os.MkdirAll(\"test-results\", 0755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create results directory: %v\", err)\n\t}\n\n\tbasePath := os.Getenv(\"CONTEXT_PATH\")\n\tif basePath == \"\" {\n\t\tbasePath = \".\"\n\t}\n\n\tf, err := os.Create(filepath.Join(basePath, \"e2e-report.html\"))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create report file: %v\", err)\n\t}\n\tdefer f.Close()\n\n\treturn t.Execute(f, r)\n}\n"
  },
  {
    "path": "e2e/playwright/testhelper.go",
    "content": "package playwright\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/playwright-community/playwright-go\"\n\t\"github.com/stretchr/testify/require\"\n)\n\n// TestHelper wraps common test functionality\ntype TestHelper struct {\n\tname    string\n\tpage    playwright.Page\n\tbrowser playwright.Browser\n\tcontext playwright.BrowserContext\n\tt       require.TestingT\n}\n\n// NewTestHelper creates a new test helper instance\nfunc NewTestHelper(t require.TestingT, name string) (*TestHelper, error) {\n\tpw, err := playwright.Run()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"could not start playwright: %v\", err)\n\t}\n\n\tbrowser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{\n\t\tHeadless: playwright.Bool(true),\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"could not launch browser: %v\", err)\n\t}\n\n\tcontext, err := browser.NewContext()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"could not create context: %v\", err)\n\t}\n\n\tpage, err := context.NewPage()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"could not create page: %v\", err)\n\t}\n\n\treturn &TestHelper{\n\t\tname:    name,\n\t\tpage:    page,\n\t\tbrowser: browser,\n\t\tcontext: context,\n\t\tt:       t,\n\t}, nil\n}\n\n// Require returns a custom assertion object that takes screenshots on failure\nfunc (th *TestHelper) Require() *PlaywrightRequire {\n\treturn &PlaywrightRequire{\n\t\tAssertions: require.New(th.t),\n\t\thelper:     th,\n\t}\n}\n\nfunc (th *TestHelper) HandleError(t *testing.T, screenshotPath string, msg, err string) {\n\tGetReporter().AddResult(t.Name(), false, screenshotPath, msg, err)\n\tt.Error(msg) // Also log the error to the test output\n}\n\nfunc (th *TestHelper) HandleSuccess(t *testing.T, message string) {\n\tGetReporter().AddResult(t.Name(), true, \"\", message, \"\")\n}\n\n// PlaywrightRequire wraps require.Assertions to add screenshot capability\ntype PlaywrightRequire struct {\n\t*require.Assertions\n\thelper *TestHelper\n}\n\n// captureScreenshot saves a screenshot to the screenshots directory\nfunc (th *TestHelper) captureScreenshot(testName string) string {\n\ttimestamp := time.Now().Format(\"20060102-150405\")\n\ttmpDir, err := os.MkdirTemp(\"\", \"playwright-screenshots\")\n\tif err != nil {\n\t\tth.t.Errorf(\"Failed to create temporary directory: %v\\n\", err)\n\t\treturn \"\"\n\t}\n\tfilePath := filepath.Join(tmpDir, fmt.Sprintf(\"%s-%s.png\", testName, timestamp))\n\n\t// Get the full path without the filename from `filename` and create the directories\n\tif err := os.MkdirAll(path.Dir(filePath), 0755); err != nil {\n\t\tth.t.Errorf(\"Failed to create screenshots directory: %v\\n\", err)\n\t\treturn \"\"\n\t}\n\n\t// Create screenshots directory if it doesn't exist\n\tif err := os.MkdirAll(\"screenshots\", 0755); err != nil {\n\t\tth.t.Errorf(\"Failed to create screenshots directory: %v\\n\", err)\n\t\treturn \"\"\n\t}\n\n\t// Take screenshot\n\tif _, err := th.page.Screenshot(playwright.PageScreenshotOptions{\n\t\tPath:     playwright.String(filePath),\n\t\tFullPage: playwright.Bool(true),\n\t}); err != nil {\n\t\tth.t.Errorf(\"Failed to capture screenshot: %v\\n\", err)\n\t\treturn \"\"\n\t}\n\n\tfmt.Printf(\"Screenshot saved: %s\\n\", filePath)\n\n\treturn filePath\n}\n\nfunc (pr *PlaywrightRequire) Assert(t *testing.T, assertFn func() error, msgAndArgs ...interface{}) {\n\terr := assertFn()\n\tvar msg string\n\tif len(msgAndArgs) > 0 {\n\t\tif format, ok := msgAndArgs[0].(string); ok && len(msgAndArgs) > 1 {\n\t\t\tmsg = fmt.Sprintf(format, msgAndArgs[1:]...)\n\t\t} else {\n\t\t\tmsg = fmt.Sprint(msgAndArgs...)\n\t\t}\n\t}\n\tif err == nil {\n\t\tpr.helper.HandleSuccess(t, msg)\n\t} else {\n\t\tscreenshotPath := pr.helper.captureScreenshot(t.Name())\n\t\tpr.helper.HandleError(t, screenshotPath, msg, err.Error())\n\t}\n}\n\n// True asserts that the specified value is true and takes a screenshot on failure\nfunc (pr *PlaywrightRequire) True(t *testing.T, value bool, msgAndArgs ...interface{}) {\n\tpr.Assert(t, func() error {\n\t\tvar err error\n\t\tif !value {\n\t\t\terr = fmt.Errorf(\"expected value to be true but got false in test '%s'\", t.Name())\n\t\t}\n\t\treturn err\n\t}, msgAndArgs...)\n\tpr.Assertions.True(value, msgAndArgs...)\n}\n\n// False asserts that the specified value is false and takes a screenshot on failure\nfunc (pr *PlaywrightRequire) False(t *testing.T, value bool, msgAndArgs ...interface{}) {\n\tpr.Assert(t, func() error {\n\t\tvar err error\n\t\tif value {\n\t\t\terr = fmt.Errorf(\"expected value to be false but got true in test '%s'\", t.Name())\n\t\t}\n\t\treturn err\n\t}, msgAndArgs...)\n\tpr.Assertions.False(value, msgAndArgs...)\n}\n\n// Equal asserts that two objects are equal and takes a screenshot on failure\nfunc (pr *PlaywrightRequire) Equal(t *testing.T, expected, actual interface{}, msgAndArgs ...interface{}) {\n\tpr.Assert(t, func() error {\n\t\tvar err error\n\t\tif expected != actual {\n\t\t\terr = fmt.Errorf(\"expected values to be equal in test '%s':\\nexpected: %v\\nactual: %v\", t.Name(), expected, actual)\n\t\t}\n\t\treturn err\n\t}, msgAndArgs...)\n\tpr.Assertions.Equal(expected, actual, msgAndArgs...)\n}\n\n// NoError asserts that a function returned no error and takes a screenshot on failure\nfunc (pr *PlaywrightRequire) NoError(t *testing.T, err error, msgAndArgs ...interface{}) {\n\tpr.Assert(t, func() error {\n\t\tvar assertErr error\n\t\tif err != nil {\n\t\t\tassertErr = fmt.Errorf(\"expected no error but got error in test '%s': %v\", t.Name(), err)\n\t\t}\n\t\treturn assertErr\n\t}, msgAndArgs...)\n\tpr.Assertions.NoError(err, msgAndArgs...)\n}\n\n// Error asserts that a function returned an error and takes a screenshot on failure\nfunc (pr *PlaywrightRequire) Error(t *testing.T, err error, msgAndArgs ...interface{}) {\n\tpr.Assert(t, func() error {\n\t\tvar assertErr error\n\t\tif err == nil {\n\t\t\tassertErr = fmt.Errorf(\"expected error but got none in test '%s'\", t.Name())\n\t\t}\n\t\treturn assertErr\n\t}, msgAndArgs...)\n\tpr.Assertions.Error(err, msgAndArgs...)\n}\n\nfunc (pr *PlaywrightRequire) Contains(t *testing.T, text, expected string, msgAndArgs ...interface{}) {\n\tpr.Assert(t, func() error {\n\t\tif !strings.Contains(text, expected) {\n\t\t\treturn fmt.Errorf(\"expected text to contain '%s' but got '%s'\", expected, text)\n\t\t}\n\t\treturn nil\n\t}, msgAndArgs...)\n}\n\n// Close cleans up resources and generates the report\nfunc (th *TestHelper) Close() {\n\tif err := GetReporter().GenerateHTML(); err != nil {\n\t\tfmt.Printf(\"Failed to generate HTML report: %v\\n\", err)\n\t}\n\tif th.page != nil {\n\t\tth.page.Close()\n\t}\n\tif th.context != nil {\n\t\tth.context.Close()\n\t}\n\tif th.browser != nil {\n\t\tth.browser.Close()\n\t}\n}\n"
  },
  {
    "path": "e2e/server/auth_test.go",
    "content": "package e2e\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/e2e/e2eutil\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestAuthLogin(t *testing.T) {\n\tcontainer := e2eutil.NewShioriContainer(t, \"\")\n\n\tt.Run(\"login ok\", func(t *testing.T) {\n\t\treq, err := http.Post(\n\t\t\t\"http://localhost:\"+container.GetPort()+\"/api/v1/auth/login\",\n\t\t\t\"application/json\",\n\t\t\tbytes.NewReader([]byte(`{\"username\": \"shiori\", \"password\": \"gopher\"}`)),\n\t\t)\n\t\trequire.NoError(t, err)\n\t\trequire.Equal(t, http.StatusOK, req.StatusCode)\n\t})\n\n\tt.Run(\"wrong credentials\", func(t *testing.T) {\n\t\treq, err := http.Post(\n\t\t\t\"http://localhost:\"+container.GetPort()+\"/api/v1/auth/login\",\n\t\t\t\"application/json\",\n\t\t\tbytes.NewReader([]byte(`{\"username\": \"wrong\", \"password\": \"wrong\"}`)),\n\t\t)\n\t\trequire.NoError(t, err)\n\t\trequire.Equal(t, http.StatusBadRequest, req.StatusCode)\n\t})\n}\n"
  },
  {
    "path": "e2e/server/basic_test.go",
    "content": "package e2e\n\nimport (\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/e2e/e2eutil\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestServerBasic(t *testing.T) {\n\tcontainer := e2eutil.NewShioriContainer(t, \"\")\n\n\tt.Run(\"liveness endpoint\", func(t *testing.T) {\n\t\treq, err := http.Get(\"http://localhost:\" + container.GetPort() + \"/system/liveness\")\n\t\trequire.NoError(t, err)\n\t\trequire.Equal(t, http.StatusOK, req.StatusCode)\n\t})\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/go-shiori/shiori\n\n// +heroku goVersion go1.25.1\ngo 1.25.1\n\nrequire (\n\tgit.sr.ht/~emersion/go-sqlite3-fts5 v0.0.0-20250706113457-213d0e8755e5\n\tgithub.com/PuerkitoBio/goquery v1.10.3\n\tgithub.com/blang/semver v3.5.1+incompatible\n\tgithub.com/disintegration/imaging v1.6.2\n\tgithub.com/fatih/color v1.18.0\n\tgithub.com/go-shiori/go-epub v1.2.2-0.20241010194245-bd691046db94\n\tgithub.com/go-shiori/go-readability v0.0.0-20250217085726-9f5bf5ca7612\n\tgithub.com/go-shiori/warc v0.0.0-20200621032813-359908319d1d\n\tgithub.com/go-sql-driver/mysql v1.9.3\n\tgithub.com/gofrs/uuid/v5 v5.3.2\n\tgithub.com/golang-jwt/jwt/v5 v5.3.0\n\tgithub.com/huandu/go-sqlbuilder v1.37.0\n\tgithub.com/jmoiron/sqlx v1.4.0\n\tgithub.com/julienschmidt/httprouter v1.3.0\n\tgithub.com/lib/pq v1.10.9\n\tgithub.com/mattn/go-sqlite3 v1.14.32\n\tgithub.com/muesli/go-app-paths v0.2.2\n\tgithub.com/patrickmn/go-cache v2.1.0+incompatible\n\tgithub.com/pkg/errors v0.9.1\n\tgithub.com/playwright-community/playwright-go v0.5200.0\n\tgithub.com/sethvargo/go-envconfig v1.3.0\n\tgithub.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92\n\tgithub.com/sirupsen/logrus v1.9.3\n\tgithub.com/spf13/afero v1.15.0\n\tgithub.com/spf13/cobra v1.10.1\n\tgithub.com/spf13/pflag v1.0.10\n\tgithub.com/stretchr/testify v1.11.1\n\tgithub.com/swaggo/http-swagger/v2 v2.0.2\n\tgithub.com/swaggo/swag v1.16.4\n\tgithub.com/testcontainers/testcontainers-go v0.37.0\n\tgolang.org/x/crypto v0.42.0\n\tgolang.org/x/image v0.31.0\n\tgolang.org/x/net v0.44.0\n\tgolang.org/x/term v0.35.0\n\tmodernc.org/sqlite v1.39.0\n)\n\nrequire (\n\tdario.cat/mergo v1.0.2 // indirect\n\tfilippo.io/edwards25519 v1.1.0 // indirect\n\tgithub.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect\n\tgithub.com/KyleBanks/depth v1.2.1 // indirect\n\tgithub.com/Microsoft/go-winio v0.6.2 // indirect\n\tgithub.com/andybalholm/cascadia v1.3.3 // indirect\n\tgithub.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect\n\tgithub.com/cenkalti/backoff/v4 v4.3.0 // indirect\n\tgithub.com/containerd/errdefs v1.0.0 // indirect\n\tgithub.com/containerd/errdefs/pkg v0.3.0 // indirect\n\tgithub.com/containerd/log v0.1.0 // indirect\n\tgithub.com/containerd/platforms v0.2.1 // indirect\n\tgithub.com/cpuguy83/dockercfg v0.3.2 // indirect\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/deckarep/golang-set/v2 v2.8.0 // indirect\n\tgithub.com/distribution/reference v0.6.0 // indirect\n\tgithub.com/docker/docker v28.2.2+incompatible // indirect\n\tgithub.com/docker/go-connections v0.5.0 // indirect\n\tgithub.com/docker/go-units v0.5.0 // indirect\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\n\tgithub.com/ebitengine/purego v0.8.2 // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.10 // indirect\n\tgithub.com/go-jose/go-jose/v3 v3.0.4 // indirect\n\tgithub.com/go-logr/logr v1.4.3 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/go-ole/go-ole v1.3.0 // indirect\n\tgithub.com/go-openapi/jsonpointer v0.22.0 // indirect\n\tgithub.com/go-openapi/jsonreference v0.21.1 // indirect\n\tgithub.com/go-openapi/spec v0.21.0 // indirect\n\tgithub.com/go-openapi/swag v0.25.1 // indirect\n\tgithub.com/go-openapi/swag/cmdutils v0.25.1 // indirect\n\tgithub.com/go-openapi/swag/conv v0.25.1 // indirect\n\tgithub.com/go-openapi/swag/fileutils v0.25.1 // indirect\n\tgithub.com/go-openapi/swag/jsonname v0.25.1 // indirect\n\tgithub.com/go-openapi/swag/jsonutils v0.25.1 // indirect\n\tgithub.com/go-openapi/swag/loading v0.25.1 // indirect\n\tgithub.com/go-openapi/swag/mangling v0.25.1 // indirect\n\tgithub.com/go-openapi/swag/netutils v0.25.1 // indirect\n\tgithub.com/go-openapi/swag/stringutils v0.25.1 // indirect\n\tgithub.com/go-openapi/swag/typeutils v0.25.1 // indirect\n\tgithub.com/go-openapi/swag/yamlutils v0.25.1 // indirect\n\tgithub.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect\n\tgithub.com/go-stack/stack v1.8.1 // indirect\n\tgithub.com/gogo/protobuf v1.3.2 // indirect\n\tgithub.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/huandu/go-clone v1.7.3 // indirect\n\tgithub.com/huandu/xstrings v1.5.0 // indirect\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // indirect\n\tgithub.com/klauspost/compress v1.18.0 // indirect\n\tgithub.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect\n\tgithub.com/magiconair/properties v1.8.10 // indirect\n\tgithub.com/mattn/go-colorable v0.1.14 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mitchellh/go-homedir v1.1.0 // indirect\n\tgithub.com/moby/docker-image-spec v1.3.1 // indirect\n\tgithub.com/moby/go-archive v0.1.0 // indirect\n\tgithub.com/moby/patternmatcher v0.6.0 // indirect\n\tgithub.com/moby/sys/atomicwriter v0.1.0 // indirect\n\tgithub.com/moby/sys/sequential v0.6.0 // indirect\n\tgithub.com/moby/sys/user v0.4.0 // indirect\n\tgithub.com/moby/sys/userns v0.1.0 // indirect\n\tgithub.com/moby/term v0.5.2 // indirect\n\tgithub.com/morikuni/aec v1.0.0 // indirect\n\tgithub.com/ncruces/go-strftime v0.1.9 // indirect\n\tgithub.com/opencontainers/go-digest v1.0.0 // indirect\n\tgithub.com/opencontainers/image-spec v1.1.1 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect\n\tgithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect\n\tgithub.com/shirou/gopsutil/v4 v4.25.1 // indirect\n\tgithub.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect\n\tgithub.com/swaggo/files/v2 v2.0.2 // indirect\n\tgithub.com/tdewolff/parse v2.3.4+incompatible // indirect\n\tgithub.com/tklauser/go-sysconf v0.3.15 // indirect\n\tgithub.com/tklauser/numcpus v0.10.0 // indirect\n\tgithub.com/vincent-petithory/dataurl v1.0.0 // indirect\n\tgithub.com/yusufpapurcu/wmi v1.2.4 // indirect\n\tgo.etcd.io/bbolt v1.4.3 // indirect\n\tgo.opentelemetry.io/auto/sdk v1.1.0 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect\n\tgo.opentelemetry.io/otel v1.36.0 // indirect\n\tgo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.36.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.36.0 // indirect\n\tgo.yaml.in/yaml/v3 v3.0.4 // indirect\n\tgolang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect\n\tgolang.org/x/sys v0.36.0 // indirect\n\tgolang.org/x/text v0.29.0 // indirect\n\tgolang.org/x/tools v0.37.0 // indirect\n\tgolang.org/x/tools/godoc v0.1.0-deprecated // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect\n\tgoogle.golang.org/grpc v1.73.0 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n\tmodernc.org/libc v1.66.9 // indirect\n\tmodernc.org/mathutil v1.7.1 // indirect\n\tmodernc.org/memory v1.11.0 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=\ndario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=\nfilippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=\nfilippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=\ngit.sr.ht/~emersion/go-sqlite3-fts5 v0.0.0-20250706113457-213d0e8755e5 h1:1p/YpbpaXZFUg/519qWxpWLvCX4uMuWaisP8DKCHPyc=\ngit.sr.ht/~emersion/go-sqlite3-fts5 v0.0.0-20250706113457-213d0e8755e5/go.mod h1:W+na+JMhhelFn525wvV3enh0zvvhtZF8kndnRanLLq0=\ngithub.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=\ngithub.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=\ngithub.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=\ngithub.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=\ngithub.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=\ngithub.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=\ngithub.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=\ngithub.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=\ngithub.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=\ngithub.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=\ngithub.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=\ngithub.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=\ngithub.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=\ngithub.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=\ngithub.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=\ngithub.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=\ngithub.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=\ngithub.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=\ngithub.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=\ngithub.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=\ngithub.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=\ngithub.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=\ngithub.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=\ngithub.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=\ngithub.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=\ngithub.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=\ngithub.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=\ngithub.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=\ngithub.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=\ngithub.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=\ngithub.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=\ngithub.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=\ngithub.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=\ngithub.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=\ngithub.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=\ngithub.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=\ngithub.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=\ngithub.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=\ngithub.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=\ngithub.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=\ngithub.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=\ngithub.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=\ngithub.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=\ngithub.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=\ngithub.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=\ngithub.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=\ngithub.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=\ngithub.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=\ngithub.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=\ngithub.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=\ngithub.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=\ngithub.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM=\ngithub.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU=\ngithub.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA=\ngithub.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8=\ngithub.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=\ngithub.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=\ngithub.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8=\ngithub.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo=\ngithub.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY=\ngithub.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=\ngithub.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=\ngithub.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=\ngithub.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU=\ngithub.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M=\ngithub.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=\ngithub.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=\ngithub.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=\ngithub.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=\ngithub.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=\ngithub.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=\ngithub.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=\ngithub.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=\ngithub.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync=\ngithub.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ=\ngithub.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4=\ngithub.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE=\ngithub.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=\ngithub.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=\ngithub.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=\ngithub.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=\ngithub.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=\ngithub.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=\ngithub.com/go-shiori/dom v0.0.0-20190930082056-9d974a4f8b25/go.mod h1:360KoNl36ftFYhjLHuEty78kWUGw8i1opEicvIDLfRk=\ngithub.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w=\ngithub.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM=\ngithub.com/go-shiori/go-epub v1.2.2-0.20241010194245-bd691046db94 h1:fDswMm2PrEwdnbVvz4QI/Hjm5eZ8HROzuCRYZd/Wung=\ngithub.com/go-shiori/go-epub v1.2.2-0.20241010194245-bd691046db94/go.mod h1:3q72SS/xhacgTr51ykGWJGSh3/l2lpB10CcLW+gO3Rw=\ngithub.com/go-shiori/go-readability v0.0.0-20250217085726-9f5bf5ca7612 h1:BYLNYdZaepitbZreRIa9xeCQZocWmy/wj4cGIH0qyw0=\ngithub.com/go-shiori/go-readability v0.0.0-20250217085726-9f5bf5ca7612/go.mod h1:wgqthQa8SAYs0yyljVeCOQlZ027VW5CmLsbi9jWC08c=\ngithub.com/go-shiori/warc v0.0.0-20200621032813-359908319d1d h1:+SEf4hYDaAt2eyq8Xu3YyWCpnMsK8sZfbYsDRFCUgBM=\ngithub.com/go-shiori/warc v0.0.0-20200621032813-359908319d1d/go.mod h1:uaK5DAxFig7atOzy+aqLzhs6qJacMDfs8NxHV5+shzc=\ngithub.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=\ngithub.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=\ngithub.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=\ngithub.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=\ngithub.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=\ngithub.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0=\ngithub.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=\ngithub.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=\ngithub.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=\ngithub.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=\ngithub.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=\ngithub.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=\ngithub.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=\ngithub.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=\ngithub.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=\ngithub.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U=\ngithub.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs=\ngithub.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs=\ngithub.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ=\ngithub.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE=\ngithub.com/huandu/go-sqlbuilder v1.37.0 h1:hXgk2rTnlgFgKsmFpizhe6g/oz1wxef4qk3ixFhK6a0=\ngithub.com/huandu/go-sqlbuilder v1.37.0/go.mod h1:zdONH67liL+/TvoUMwnZP/sUYGSSvHh9psLe/HpXn8E=\ngithub.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=\ngithub.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=\ngithub.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=\ngithub.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=\ngithub.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=\ngithub.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=\ngithub.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=\ngithub.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=\ngithub.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=\ngithub.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=\ngithub.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=\ngithub.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=\ngithub.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=\ngithub.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=\ngithub.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=\ngithub.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=\ngithub.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=\ngithub.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=\ngithub.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=\ngithub.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=\ngithub.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=\ngithub.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=\ngithub.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=\ngithub.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=\ngithub.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=\ngithub.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=\ngithub.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=\ngithub.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=\ngithub.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=\ngithub.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=\ngithub.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=\ngithub.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=\ngithub.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=\ngithub.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=\ngithub.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=\ngithub.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=\ngithub.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=\ngithub.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=\ngithub.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=\ngithub.com/muesli/go-app-paths v0.2.2 h1:NqG4EEZwNIhBq/pREgfBmgDmt3h1Smr1MjZiXbpZUnI=\ngithub.com/muesli/go-app-paths v0.2.2/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho=\ngithub.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=\ngithub.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=\ngithub.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=\ngithub.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=\ngithub.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=\ngithub.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=\ngithub.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=\ngithub.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/playwright-community/playwright-go v0.5200.0 h1:z/5LGuX2tBrg3ug1HupMXLjIG93f1d2MWdDsNhkMQ9c=\ngithub.com/playwright-community/playwright-go v0.5200.0/go.mod h1:UnnyQZaqUOO5ywAZu60+N4EiWReUqX1MQBBA3Oofvf8=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=\ngithub.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=\ngithub.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=\ngithub.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=\ngithub.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=\ngithub.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=\ngithub.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U=\ngithub.com/sethvargo/go-envconfig v1.3.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw=\ngithub.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=\ngithub.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=\ngithub.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c h1:aqg5Vm5dwtvL+YgDpBcK1ITf3o96N/K7/wsRXQnUTEs=\ngithub.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c/go.mod h1:owqhoLW1qZoYLZzLnBw+QkPP9WZnjlSWihhxAJC1+/M=\ngithub.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 h1:OfRzdxCzDhp+rsKWXuOO2I/quKMJ/+TQwVbIP/gltZg=\ngithub.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92/go.mod h1:7/OT02F6S6I7v6WXb+IjhMuZEYfH/RJ5RwEWnEo5BMg=\ngithub.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=\ngithub.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=\ngithub.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=\ngithub.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=\ngithub.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=\ngithub.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=\ngithub.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=\ngithub.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=\ngithub.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=\ngithub.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=\ngithub.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg=\ngithub.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ=\ngithub.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=\ngithub.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=\ngithub.com/tdewolff/parse v2.3.4+incompatible h1:x05/cnGwIMf4ceLuDMBOdQ1qGniMoxpP46ghf0Qzh38=\ngithub.com/tdewolff/parse v2.3.4+incompatible/go.mod h1:8oBwCsVmUkgHO8M5iCzSIDtpzXOT0WXX9cWhz+bIzJQ=\ngithub.com/tdewolff/test v1.0.0 h1:jOwzqCXr5ePXEPGJaq2ivoR6HOCi+D5TPfpoyg8yvmU=\ngithub.com/tdewolff/test v1.0.0/go.mod h1:DiQUlutnqlEvdvhSn2LPGy4TFwRauAaYDsL+683RNX4=\ngithub.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg=\ngithub.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM=\ngithub.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=\ngithub.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=\ngithub.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=\ngithub.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=\ngithub.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI=\ngithub.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U=\ngithub.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngithub.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=\ngithub.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=\ngo.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=\ngo.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=\ngo.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=\ngo.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=\ngo.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=\ngo.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=\ngo.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ=\ngo.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=\ngo.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=\ngo.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=\ngo.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=\ngo.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=\ngo.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=\ngo.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=\ngo.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=\ngo.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=\ngo.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=\ngo.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=\ngolang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=\ngolang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=\ngolang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=\ngolang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=\ngolang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=\ngolang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU=\ngolang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=\ngolang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=\ngolang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=\ngolang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=\ngolang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=\ngolang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190926025831-c00fd9afed17/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=\ngolang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=\ngolang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=\ngolang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=\ngolang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=\ngolang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=\ngolang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=\ngolang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=\ngolang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190927073244-c990c680b611/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=\ngolang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=\ngolang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=\ngolang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=\ngolang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=\ngolang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=\ngolang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=\ngolang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=\ngolang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=\ngolang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=\ngolang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=\ngolang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=\ngolang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngolang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=\ngolang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=\ngolang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=\ngolang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=\ngolang.org/x/tools/godoc v0.1.0-deprecated h1:o+aZ1BOj6Hsx/GBdJO/s815sqftjSnrZZwyYTHODvtk=\ngolang.org/x/tools/godoc v0.1.0-deprecated/go.mod h1:qM63CriJ961IHWmnWa9CjZnBndniPt4a3CK0PVB9bIg=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=\ngoogle.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=\ngoogle.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=\ngoogle.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=\ngoogle.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=\ngotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=\nmodernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s=\nmodernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=\nmodernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=\nmodernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=\nmodernc.org/fileutil v1.3.28 h1:Vp156KUA2nPu9F1NEv036x9UGOjg2qsi5QlWTjZmtMk=\nmodernc.org/fileutil v1.3.28/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=\nmodernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=\nmodernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=\nmodernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=\nmodernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=\nmodernc.org/libc v1.66.9 h1:YkHp7E1EWrN2iyNav7JE/nHasmshPvlGkon1VxGqOw0=\nmodernc.org/libc v1.66.9/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k=\nmodernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=\nmodernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=\nmodernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=\nmodernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=\nmodernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=\nmodernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=\nmodernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=\nmodernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=\nmodernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=\nmodernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=\nmodernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=\nmodernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=\nmodernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=\nmodernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=\n"
  },
  {
    "path": "internal/assets.go",
    "content": "package internal\n\nimport \"embed\"\n\n//go:embed view\nvar Assets embed.FS\n"
  },
  {
    "path": "internal/cmd/add.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/go-shiori/shiori/internal/core\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc addCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"add url\",\n\t\tShort: \"Bookmark the specified URL\",\n\t\tArgs:  cobra.ExactArgs(1),\n\t\tRun:   addHandler,\n\t}\n\n\tcmd.Flags().StringP(\"title\", \"i\", \"\", \"Custom title for this bookmark\")\n\tcmd.Flags().StringP(\"excerpt\", \"e\", \"\", \"Custom excerpt for this bookmark\")\n\tcmd.Flags().StringSliceP(\"tags\", \"t\", []string{}, \"Comma-separated tags for this bookmark\")\n\tcmd.Flags().BoolP(\"offline\", \"o\", false, \"Save bookmark without fetching data from internet\")\n\tcmd.Flags().BoolP(\"no-archival\", \"a\", false, \"Save bookmark without creating offline archive\")\n\tcmd.Flags().Bool(\"log-archival\", false, \"Log the archival process\")\n\n\treturn cmd\n}\n\nfunc addHandler(cmd *cobra.Command, args []string) {\n\tcfg, deps := initShiori(cmd.Context(), cmd)\n\n\t// Read flag and arguments\n\turl := args[0]\n\ttitle, _ := cmd.Flags().GetString(\"title\")\n\texcerpt, _ := cmd.Flags().GetString(\"excerpt\")\n\ttags, _ := cmd.Flags().GetStringSlice(\"tags\")\n\toffline, _ := cmd.Flags().GetBool(\"offline\")\n\tnoArchival, _ := cmd.Flags().GetBool(\"no-archival\")\n\tlogArchival, _ := cmd.Flags().GetBool(\"log-archival\")\n\n\t// Normalize input\n\ttitle = validateTitle(title, \"\")\n\texcerpt = normalizeSpace(excerpt)\n\n\t// Create bookmark item\n\tbook := model.BookmarkDTO{\n\t\tURL:           url,\n\t\tTitle:         title,\n\t\tExcerpt:       excerpt,\n\t\tCreateArchive: !noArchival,\n\t}\n\n\t// Set bookmark tags\n\tbook.Tags = make([]model.TagDTO, len(tags))\n\tfor i, tag := range tags {\n\t\tbook.Tags[i].Name = strings.TrimSpace(tag)\n\t}\n\n\t// Clean up bookmark URL\n\tvar err error\n\tbook.URL, err = core.RemoveUTMParams(book.URL)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to clean URL: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\t// Make sure bookmark's title not empty\n\tif book.Title == \"\" {\n\t\tbook.Title = book.URL\n\t}\n\n\t// Save bookmark to database\n\tbooks, err := deps.Database().SaveBookmarks(cmd.Context(), true, book)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to save bookmark: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\tbook = books[0]\n\n\t// If it's not offline mode, fetch data from internet.\n\tif !offline {\n\t\tcInfo.Println(\"Downloading article...\")\n\n\t\tvar isFatalErr bool\n\t\tcontent, contentType, err := core.DownloadBookmark(book.URL)\n\t\tif err != nil {\n\t\t\tcError.Printf(\"Failed to download: %v\\n\", err)\n\t\t}\n\n\t\tif err == nil && content != nil {\n\t\t\trequest := core.ProcessRequest{\n\t\t\t\tDataDir:     cfg.Storage.DataDir,\n\t\t\t\tBookmark:    book,\n\t\t\t\tContent:     content,\n\t\t\t\tContentType: contentType,\n\t\t\t\tLogArchival: logArchival,\n\t\t\t\tKeepTitle:   title != \"\",\n\t\t\t\tKeepExcerpt: excerpt != \"\",\n\t\t\t}\n\n\t\t\tbook, isFatalErr, err = core.ProcessBookmark(deps, request)\n\t\t\tcontent.Close()\n\n\t\t\tif err != nil {\n\t\t\t\tcError.Printf(\"Failed: %v\\n\", err)\n\t\t\t}\n\n\t\t\tif isFatalErr {\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t}\n\n\t\t// Save bookmark to database\n\t\t_, err = deps.Database().SaveBookmarks(cmd.Context(), false, book)\n\t\tif err != nil {\n\t\t\tcError.Printf(\"Failed to save bookmark with content: %v\\n\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t}\n\n\t// Print added bookmark\n\tfmt.Println()\n\tprintBookmarks(book)\n}\n"
  },
  {
    "path": "internal/cmd/check.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"sort\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc checkCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"check\",\n\t\tShort: \"Find bookmarked sites that no longer exists on the internet\",\n\t\tLong: \"Check all bookmarks and find bookmarked sites that no longer exists on the internet. \" +\n\t\t\t\"It might take a long time depending on how many bookmarks that you have and want to check. \" +\n\t\t\t\"If there are no arguments, it will check ALL of your bookmarks.\",\n\t\tRun: checkHandler,\n\t}\n\n\tcmd.Flags().BoolP(\"yes\", \"y\", false, \"Skip confirmation prompt and check ALL bookmarks\")\n\n\treturn cmd\n}\n\nfunc checkHandler(cmd *cobra.Command, args []string) {\n\t_, deps := initShiori(cmd.Context(), cmd)\n\n\t// Parse flags\n\tskipConfirm, _ := cmd.Flags().GetBool(\"yes\")\n\n\t// If no arguments (i.e all bookmarks going to be checked), confirm to user\n\tif len(args) == 0 && !skipConfirm {\n\t\tconfirmCheck := \"\"\n\t\tfmt.Print(\"Check ALL bookmarks? (y/N): \")\n\t\tfmt.Scanln(&confirmCheck)\n\n\t\tif confirmCheck != \"y\" {\n\t\t\tfmt.Println(\"No bookmarks checked\")\n\t\t\treturn\n\t\t}\n\t}\n\n\t// Convert args to ids\n\tids, err := parseStrIndices(args)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to parse args: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\t// Fetch bookmarks from database\n\tfilterOptions := model.DBGetBookmarksOptions{IDs: ids}\n\tbookmarks, err := deps.Database().GetBookmarks(cmd.Context(), filterOptions)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to get bookmarks: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\t// Create HTTP client\n\thttpClient := &http.Client{Timeout: time.Minute}\n\n\t// Test each bookmark item\n\tunreachableIDs := []int{}\n\n\twg := sync.WaitGroup{}\n\tchDone := make(chan struct{})\n\tchProblem := make(chan int, 10)\n\tchMessage := make(chan interface{}, 10)\n\tsemaphore := make(chan struct{}, 10)\n\n\tfor i, book := range bookmarks {\n\t\twg.Add(1)\n\n\t\tgo func(i int, book model.BookmarkDTO) {\n\t\t\t// Make sure to finish the WG\n\t\t\tdefer wg.Done()\n\n\t\t\t// Register goroutine to semaphore\n\t\t\tsemaphore <- struct{}{}\n\t\t\tdefer func() {\n\t\t\t\t<-semaphore\n\t\t\t}()\n\n\t\t\t// Ping bookmark's URL\n\t\t\t_, err := httpClient.Get(book.URL)\n\t\t\tif err != nil {\n\t\t\t\tchProblem <- book.ID\n\t\t\t\tchMessage <- fmt.Errorf(\"failed to reach %s: %v\", book.URL, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Send success message\n\t\t\tchMessage <- fmt.Sprintf(\"Reached %s\", book.URL)\n\t\t}(i, book)\n\t}\n\n\t// Watch messages from channels\n\tgo func(nBookmark int) {\n\t\tlogIndex := 0\n\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-chDone:\n\t\t\t\tcInfo.Println(\"Check finished\")\n\t\t\t\treturn\n\t\t\tcase id := <-chProblem:\n\t\t\t\tunreachableIDs = append(unreachableIDs, id)\n\t\t\tcase msg := <-chMessage:\n\t\t\t\tlogIndex++\n\n\t\t\t\tswitch msg.(type) {\n\t\t\t\tcase error:\n\t\t\t\t\tcError.Printf(\"[%d/%d] %v\\n\", logIndex, nBookmark, msg)\n\t\t\t\tcase string:\n\t\t\t\t\tcInfo.Printf(\"[%d/%d] %s\\n\", logIndex, nBookmark, msg)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}(len(bookmarks))\n\n\t// Wait until all download finished\n\twg.Wait()\n\tclose(chDone)\n\n\t// Print the unreachable bookmarks\n\tfmt.Println()\n\n\tvar code int\n\tif len(unreachableIDs) == 0 {\n\t\tcInfo.Println(\"All bookmarks are reachable.\")\n\t} else {\n\t\tsort.Ints(unreachableIDs)\n\t\tcode = 1\n\t\tcError.Println(\"Encountered some unreachable bookmarks:\")\n\t\tfor _, id := range unreachableIDs {\n\t\t\tcError.Printf(\"%d \", id)\n\t\t}\n\t\tfmt.Println()\n\t}\n\tos.Exit(code)\n}\n"
  },
  {
    "path": "internal/cmd/delete.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\tfp \"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n)\n\nfunc deleteCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"delete [indices]\",\n\t\tShort: \"Delete the saved bookmarks\",\n\t\tLong: \"Delete bookmarks. \" +\n\t\t\t\"When a record is deleted, the last record is moved to the removed index. \" +\n\t\t\t\"Accepts space-separated list of indices (e.g. 5 6 23 4 110 45), \" +\n\t\t\t\"hyphenated range (e.g. 100-200) or both (e.g. 1-3 7 9). \" +\n\t\t\t\"If no arguments, ALL records will be deleted.\",\n\t\tAliases: []string{\"rm\"},\n\t\tRun:     deleteHandler,\n\t}\n\n\tcmd.Flags().BoolP(\"yes\", \"y\", false, \"Skip confirmation prompt and delete ALL bookmarks\")\n\n\treturn cmd\n}\n\nfunc deleteHandler(cmd *cobra.Command, args []string) {\n\tcfg, deps := initShiori(cmd.Context(), cmd)\n\n\t// Parse flags\n\tskipConfirm, _ := cmd.Flags().GetBool(\"yes\")\n\n\t// If no arguments (i.e all bookmarks going to be deleted), confirm to user\n\tif len(args) == 0 && !skipConfirm {\n\t\tconfirmDelete := \"\"\n\t\tfmt.Print(\"Remove ALL bookmarks? (y/N): \")\n\t\tfmt.Scanln(&confirmDelete)\n\n\t\tif confirmDelete != \"y\" {\n\t\t\tfmt.Println(\"No bookmarks deleted\")\n\t\t\treturn\n\t\t}\n\t}\n\n\t// Convert args to ids\n\tids, err := parseStrIndices(args)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to parse args: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\t// Delete bookmarks from database\n\terr = deps.Database().DeleteBookmarks(cmd.Context(), ids...)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to delete bookmarks: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\t// Delete thumbnail image and archives from local disk\n\tif len(ids) == 0 {\n\t\tthumbDir := fp.Join(cfg.Storage.DataDir, \"thumb\")\n\t\tarchiveDir := fp.Join(cfg.Storage.DataDir, \"archive\")\n\t\tos.RemoveAll(thumbDir)\n\t\tos.RemoveAll(archiveDir)\n\t} else {\n\t\tfor _, id := range ids {\n\t\t\tstrID := strconv.Itoa(id)\n\t\t\timgPath := fp.Join(cfg.Storage.DataDir, \"thumb\", strID)\n\t\t\tarchivePath := fp.Join(cfg.Storage.DataDir, \"archive\", strID)\n\n\t\t\tos.Remove(imgPath)\n\t\t\tos.Remove(archivePath)\n\t\t}\n\t}\n\n\t// Show finish message\n\tswitch len(args) {\n\tcase 0:\n\t\tfmt.Println(\"All bookmarks have been deleted\")\n\tcase 1, 2, 3, 4, 5:\n\t\tfmt.Printf(\"Bookmark(s) %s have been deleted\\n\", strings.Join(args, \", \"))\n\tdefault:\n\t\tfmt.Println(\"Bookmark(s) have been deleted\")\n\t}\n}\n"
  },
  {
    "path": "internal/cmd/export.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\tfp \"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc exportCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"export target-file\",\n\t\tShort: \"Export bookmarks into HTML file in Netscape Bookmark format\",\n\t\tArgs:  cobra.ExactArgs(1),\n\t\tRun:   exportHandler,\n\t}\n\n\treturn cmd\n}\n\nfunc exportHandler(cmd *cobra.Command, args []string) {\n\t_, deps := initShiori(cmd.Context(), cmd)\n\n\t// Fetch bookmarks from database\n\tbookmarks, err := deps.Database().GetBookmarks(cmd.Context(), model.DBGetBookmarksOptions{})\n\tif err != nil {\n\t\tcError.Printf(\"Failed to get bookmarks: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\tif len(bookmarks) == 0 {\n\t\tcError.Println(\"No saved bookmarks yet\")\n\t\treturn\n\t}\n\n\t// Make sure destination directory exist\n\tdstDir := fp.Dir(args[0])\n\tif err := os.MkdirAll(dstDir, model.DataDirPerm); err != nil {\n\t\tcError.Printf(\"Error crating destination directory: %s\", err)\n\t}\n\n\t// Create destination file\n\tdstFile, err := os.Create(args[0])\n\tif err != nil {\n\t\tcError.Printf(\"Failed to create destination file: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\tdefer dstFile.Close()\n\n\t// Write exported bookmark to file\n\tfmt.Fprintln(dstFile, ``+\n\t\t`<!DOCTYPE NETSCAPE-Bookmark-file-1>`+\n\t\t`<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">`+\n\t\t`<TITLE>Bookmarks</TITLE>`+\n\t\t`<H1>Bookmarks</H1>`+\n\t\t`<DL>`)\n\n\tfor _, book := range bookmarks {\n\t\t// Create Unix timestamp for bookmark\n\t\tmodifiedTime, err := time.Parse(model.DatabaseDateFormat, book.ModifiedAt)\n\t\tif err != nil {\n\t\t\tmodifiedTime = time.Now()\n\t\t}\n\t\tunixTimestamp := modifiedTime.Unix()\n\n\t\t// Create tags for bookmarks\n\t\ttags := []string{}\n\t\tfor _, tag := range book.Tags {\n\t\t\ttags = append(tags, tag.Name)\n\t\t}\n\t\tstrTags := strings.Join(tags, \",\")\n\n\t\t// Make sure title is valid\n\t\tbook.Title = validateTitle(book.Title, book.URL)\n\n\t\t// Write to file\n\t\texportLine := fmt.Sprintf(`<DT><A HREF=\"%s\" ADD_DATE=\"%d\" LAST_MODIFIED=\"%d\" TAGS=\"%s\">%s</A>`,\n\t\t\tbook.URL, unixTimestamp, unixTimestamp, strTags, book.Title)\n\t\tfmt.Fprintln(dstFile, exportLine)\n\t}\n\n\tfmt.Fprintln(dstFile, \"</DL>\")\n\n\t// Flush data to storage\n\terr = dstFile.Sync()\n\tif err != nil {\n\t\tcError.Printf(\"Failed to export the bookmarks: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\tfmt.Println(\"Export finished\")\n}\n"
  },
  {
    "path": "internal/cmd/import.go",
    "content": "package cmd\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"github.com/go-shiori/shiori/internal/core\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc importCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"import source-file\",\n\t\tShort: \"Import bookmarks from HTML file in Netscape Bookmark format\",\n\t\tArgs:  cobra.ExactArgs(1),\n\t\tRun:   importHandler,\n\t}\n\n\tcmd.Flags().BoolP(\"generate-tag\", \"t\", false, \"Auto generate tag from bookmark's category\")\n\n\treturn cmd\n}\n\nfunc importHandler(cmd *cobra.Command, args []string) {\n\t_, deps := initShiori(cmd.Context(), cmd)\n\n\t// Parse flags\n\tgenerateTag := cmd.Flags().Changed(\"generate-tag\")\n\n\t// If user doesn't specify, ask if tag need to be generated\n\tif !generateTag {\n\t\tvar submit string\n\t\tfmt.Print(\"Add parents folder as tag? (y/N): \")\n\t\tfmt.Scanln(&submit)\n\n\t\tgenerateTag = submit == \"y\"\n\t}\n\n\t// Open bookmark's file\n\tsrcFile, err := os.Open(args[0])\n\tif err != nil {\n\t\tcError.Printf(\"Failed to open %s: %v\\n\", args[0], err)\n\t\tos.Exit(1)\n\t}\n\tdefer srcFile.Close()\n\n\t// Parse bookmark's file\n\tbookmarks := []model.BookmarkDTO{}\n\tmapURL := make(map[string]struct{})\n\n\tdoc, err := goquery.NewDocumentFromReader(srcFile)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to parse bookmark: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\tdoc.Find(\"dt>a\").Each(func(_ int, a *goquery.Selection) {\n\t\t// Get related elements\n\t\tdt := a.Parent()\n\t\tdl := dt.Parent()\n\t\th3 := dl.Parent().Find(\"h3\").First()\n\n\t\t// Get metadata\n\t\ttitle := a.Text()\n\t\turl, _ := a.Attr(\"href\")\n\t\tstrTags, _ := a.Attr(\"tags\")\n\n\t\tdateStr, fieldExists := a.Attr(\"last_modified\")\n\t\tif !fieldExists {\n\t\t\tdateStr, _ = a.Attr(\"add_date\")\n\t\t}\n\n\t\t// Using now as default date in case no last_modified nor add_date are present\n\t\tmodifiedDate := time.Now()\n\t\tif dateStr != \"\" {\n\t\t\tmodifiedTsInt, err := strconv.Atoi(dateStr)\n\t\t\tif err != nil {\n\t\t\t\tcError.Printf(\"Skip %s: date field is not valid: %s\", url, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tmodifiedDate = time.Unix(int64(modifiedTsInt), 0)\n\t\t}\n\n\t\t// Clean up URL\n\t\turl, err = core.RemoveUTMParams(url)\n\t\tif err != nil {\n\t\t\tcError.Printf(\"Skip %s: URL is not valid\\n\", url)\n\t\t\treturn\n\t\t}\n\n\t\t// Make sure title is valid Utf-8\n\t\ttitle = validateTitle(title, url)\n\n\t\t// Check if the URL already exist before, both in bookmark\n\t\t// file or in database\n\t\tif _, exist := mapURL[url]; exist {\n\t\t\tcError.Printf(\"Skip %s: URL already exists\\n\", url)\n\t\t\treturn\n\t\t}\n\n\t\t_, exist, err := deps.Database().GetBookmark(cmd.Context(), 0, url)\n\t\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\t\tcError.Printf(\"Skip %s: Get Bookmark fail, %v\", url, err)\n\t\t\treturn\n\t\t}\n\n\t\tif exist {\n\t\t\tcError.Printf(\"Skip %s: URL already exists\\n\", url)\n\t\t\tmapURL[url] = struct{}{}\n\t\t\treturn\n\t\t}\n\n\t\t// Get bookmark tags\n\t\ttags := []model.TagDTO{}\n\t\tfor _, strTag := range strings.Split(strTags, \",\") {\n\t\t\tstrTag = normalizeSpace(strTag)\n\t\t\tif strTag != \"\" {\n\t\t\t\ttags = append(tags, model.TagDTO{\n\t\t\t\t\tTag: model.Tag{Name: strTag},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\n\t\t// Get category name for this bookmark\n\t\t// and add it as tags (if necessary)\n\t\tcategory := normalizeSpace(h3.Text())\n\t\tif category != \"\" && generateTag {\n\t\t\ttags = append(tags, model.TagDTO{\n\t\t\t\tTag: model.Tag{Name: category},\n\t\t\t})\n\t\t}\n\n\t\t// Add item to list\n\t\tbookmark := model.BookmarkDTO{\n\t\t\tURL:        url,\n\t\t\tTitle:      title,\n\t\t\tTags:       tags,\n\t\t\tModifiedAt: modifiedDate.Format(model.DatabaseDateFormat),\n\t\t}\n\n\t\tmapURL[url] = struct{}{}\n\t\tbookmarks = append(bookmarks, bookmark)\n\t})\n\n\t// Save bookmark to database\n\tbookmarks, err = deps.Database().SaveBookmarks(cmd.Context(), true, bookmarks...)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to save bookmarks: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\t// Print imported bookmark\n\tfmt.Println()\n\tprintBookmarks(bookmarks...)\n}\n"
  },
  {
    "path": "internal/cmd/open.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\tfp \"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/warc\"\n\t\"github.com/julienschmidt/httprouter\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc openCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"open [indices]\",\n\t\tShort: \"Open the saved bookmarks\",\n\t\tLong: \"Open bookmarks in browser. \" +\n\t\t\t\"Accepts space-separated list of indices (e.g. 5 6 23 4 110 45), \" +\n\t\t\t\"hyphenated range (e.g. 100-200) or both (e.g. 1-3 7 9). \" +\n\t\t\t\"If no arguments, ALL bookmarks will be opened.\",\n\t\tRun: openHandler,\n\t}\n\n\tcmd.Flags().BoolP(\"yes\", \"y\", false, \"Skip confirmation prompt and open ALL bookmarks\")\n\tcmd.Flags().BoolP(\"archive\", \"a\", false, \"Open the bookmark's archived content\")\n\tcmd.Flags().IntP(\"archive-port\", \"p\", 0, \"Port number that used to serve archive\")\n\tcmd.Flags().BoolP(\"text-cache\", \"t\", false, \"Open the bookmark's text cache in terminal\")\n\n\treturn cmd\n}\n\nfunc openHandler(cmd *cobra.Command, args []string) {\n\tcfg, deps := initShiori(cmd.Context(), cmd)\n\n\t// Parse flags\n\tskipConfirm, _ := cmd.Flags().GetBool(\"yes\")\n\tarchiveMode, _ := cmd.Flags().GetBool(\"archive\")\n\tarchivePort, _ := cmd.Flags().GetInt(\"archive-port\")\n\ttextCacheMode, _ := cmd.Flags().GetBool(\"text-cache\")\n\n\t// Convert args to ids\n\tids, err := parseStrIndices(args)\n\tif err != nil {\n\t\tcError.Println(err)\n\t\tos.Exit(1)\n\t}\n\n\t// If in archive mode, only one bookmark allowed\n\tif len(ids) > 1 && archiveMode {\n\t\tcError.Println(\"In archive mode, only one bookmark allowed\")\n\t\tos.Exit(1)\n\t}\n\n\t// If no arguments (i.e all bookmarks will be opened),\n\t// confirm to user\n\tif len(args) == 0 && !skipConfirm {\n\t\tconfirmOpen := \"\"\n\t\tfmt.Print(\"Open ALL bookmarks? (y/N): \")\n\t\tfmt.Scanln(&confirmOpen)\n\n\t\tif confirmOpen != \"y\" {\n\t\t\treturn\n\t\t}\n\t}\n\n\t// Read bookmarks from database\n\tgetOptions := model.DBGetBookmarksOptions{\n\t\tIDs:         ids,\n\t\tWithContent: true,\n\t}\n\n\tbookmarks, err := deps.Database().GetBookmarks(cmd.Context(), getOptions)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to get bookmarks: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\tif len(bookmarks) == 0 {\n\t\tif len(ids) > 0 {\n\t\t\tcError.Println(\"No matching index found\")\n\t\t\tos.Exit(1)\n\t\t} else {\n\t\t\tcError.Println(\"No bookmarks saved yet\")\n\t\t\tos.Exit(1)\n\t\t}\n\t\treturn\n\t}\n\n\t// If not text cache mode nor archive mode, open bookmarks in browser\n\tif !textCacheMode && !archiveMode {\n\t\tvar code int\n\t\tfor _, book := range bookmarks {\n\t\t\terr = openBrowser(book.URL)\n\t\t\tif err != nil {\n\t\t\t\tcError.Printf(\"Failed to open %s: %v\\n\", book.URL, err)\n\t\t\t\tcode = 1\n\t\t\t}\n\t\t}\n\t\tos.Exit(code)\n\t}\n\n\t// Show bookmarks content in terminal\n\tif textCacheMode {\n\t\ttermWidth := getTerminalWidth()\n\n\t\tvar code int\n\t\tfor _, book := range bookmarks {\n\t\t\tcIndex.Printf(\"%d. \", book.ID)\n\t\t\tcTitle.Println(book.Title)\n\t\t\tfmt.Println()\n\n\t\t\tif book.Content == \"\" {\n\t\t\t\tcError.Println(\"This bookmark doesn't have any cached content\")\n\t\t\t\tcode = 1\n\t\t\t} else {\n\t\t\t\tbook.Content = strings.Join(strings.Fields(book.Content), \" \")\n\t\t\t\tfmt.Println(book.Content)\n\t\t\t}\n\n\t\t\tfmt.Println()\n\t\t\tcSymbol.Println(strings.Repeat(\"=\", termWidth))\n\t\t\tfmt.Println()\n\t\t}\n\t\tos.Exit(code)\n\t}\n\n\t// Open archive\n\tid := strconv.Itoa(bookmarks[0].ID)\n\tarchivePath := fp.Join(cfg.Storage.DataDir, \"archive\", id)\n\n\tarchive, err := warc.Open(archivePath)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to open archive: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\tdefer archive.Close()\n\n\t// Create simple server\n\trouter := httprouter.New()\n\trouter.GET(\"/*filename\", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {\n\t\tfilename := ps.ByName(\"filename\")\n\t\tresourceName := fp.Base(filename)\n\t\tif resourceName == \"/\" {\n\t\t\tresourceName = \"\"\n\t\t}\n\n\t\tcontent, contentType, err := archive.Read(resourceName)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", contentType)\n\t\tw.Header().Set(\"Content-Encoding\", \"gzip\")\n\t\tif _, err = w.Write(content); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t})\n\n\trouter.PanicHandler = func(w http.ResponseWriter, r *http.Request, arg interface{}) {\n\t\thttp.Error(w, fmt.Sprint(arg), 500)\n\t}\n\n\t// Choose random port\n\tlistenerAddr := fmt.Sprintf(\":%d\", archivePort)\n\tlistener, err := net.Listen(\"tcp\", listenerAddr)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to serve archive: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\tportNumber := listener.Addr().(*net.TCPAddr).Port\n\tlocalhostAddr := fmt.Sprintf(\"http://localhost:%d\", portNumber)\n\tcInfo.Printf(\"Archive served in %s\\n\", localhostAddr)\n\n\t// Open browser\n\tgo func() {\n\t\ttime.Sleep(time.Second)\n\n\t\terr := openBrowser(localhostAddr)\n\t\tif err != nil {\n\t\t\tcError.Printf(\"Failed to open browser: %v\\n\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t}()\n\n\t// Serve archive\n\terr = http.Serve(listener, router)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to serve archive: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "internal/cmd/pocket.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"encoding/csv\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n\t\"github.com/go-shiori/shiori/internal/core\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc pocketCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"pocket source-file\",\n\t\tShort: \"Import bookmarks from Pocket's data export file\",\n\t\tArgs:  cobra.ExactArgs(1),\n\t\tRun:   pocketHandler,\n\t}\n\n\treturn cmd\n}\n\nfunc pocketHandler(cmd *cobra.Command, args []string) {\n\tctx := cmd.Context()\n\t_, deps := initShiori(ctx, cmd)\n\n\t// Open pocket's file\n\tfilePath := args[0]\n\tsrcFile, err := os.Open(filePath)\n\tif err != nil {\n\t\tcError.Println(err)\n\t\tos.Exit(1)\n\t}\n\tdefer srcFile.Close()\n\n\tvar bookmarks []model.BookmarkDTO\n\tswitch filepath.Ext(filePath) {\n\tcase \".html\":\n\t\tbookmarks = parseHtmlExport(ctx, deps.Database(), srcFile)\n\tcase \".csv\":\n\t\tbookmarks = parseCsvExport(ctx, deps.Database(), srcFile)\n\tdefault:\n\t\tcError.Println(\"Invalid file format. Only HTML and CSV are supported.\")\n\t\tos.Exit(1)\n\t}\n\n\t// Save bookmark to database\n\tbookmarks, err = deps.Database().SaveBookmarks(ctx, true, bookmarks...)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to save bookmarks: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\t// Print imported bookmarks\n\tfmt.Println()\n\tprintBookmarks(bookmarks...)\n}\n\n// Parse bookmarks from HTML file\nfunc parseHtmlExport(ctx context.Context, db model.DB, srcFile *os.File) []model.BookmarkDTO {\n\tbookmarks := []model.BookmarkDTO{}\n\tmapURL := make(map[string]struct{})\n\n\tdoc, err := goquery.NewDocumentFromReader(srcFile)\n\tif err != nil {\n\t\tcError.Println(err)\n\t\tos.Exit(1)\n\t}\n\n\tdoc.Find(\"a\").Each(func(_ int, a *goquery.Selection) {\n\t\t// Get metadata\n\t\ttitle := a.Text()\n\t\turl, _ := a.Attr(\"href\")\n\t\ttagsStr, _ := a.Attr(\"tags\")\n\t\ttimeAddedStr, _ := a.Attr(\"time_added\")\n\n\t\ttitle, url, timeAdded, tags, err := verifyMetadata(title, url, timeAddedStr, tagsStr)\n\t\tif err != nil {\n\t\t\tcError.Printf(\"Skip %s: %v\\n\", url, err)\n\t\t\treturn\n\t\t}\n\n\t\tif err = handleDuplicates(ctx, db, mapURL, url); err != nil {\n\t\t\tcError.Printf(\"Skip %s: %v\\n\", url, err)\n\t\t\treturn\n\t\t}\n\n\t\t// Add item to list\n\t\tbookmark := model.BookmarkDTO{\n\t\t\tURL:        url,\n\t\t\tTitle:      title,\n\t\t\tModifiedAt: timeAdded.Format(model.DatabaseDateFormat),\n\t\t\tCreatedAt:  timeAdded.Format(model.DatabaseDateFormat),\n\t\t\tTags:       tags,\n\t\t}\n\n\t\tmapURL[url] = struct{}{}\n\t\tbookmarks = append(bookmarks, bookmark)\n\t})\n\n\treturn bookmarks\n}\n\n// Parse bookmarks from CSV file\nfunc parseCsvExport(ctx context.Context, db model.DB, srcFile *os.File) []model.BookmarkDTO {\n\tbookmarks := []model.BookmarkDTO{}\n\tmapURL := make(map[string]struct{})\n\n\treader := csv.NewReader(srcFile)\n\trecords, err := reader.ReadAll()\n\tif err != nil {\n\t\tcError.Println(err)\n\t\tos.Exit(1)\n\t}\n\n\tvar titleIdx, urlIdx, timeAddedIdx, tagsIdx int\n\tfor i, cols := range records {\n\t\t// Check and skip header\n\t\tif i == 0 {\n\t\t\ttitleIdx = slices.Index(cols, \"title\")\n\t\t\turlIdx = slices.Index(cols, \"url\")\n\t\t\ttimeAddedIdx = slices.Index(cols, \"time_added\")\n\t\t\ttagsIdx = slices.Index(cols, \"tags\")\n\t\t\tif titleIdx == -1 || urlIdx == -1 || timeAddedIdx == -1 || tagsIdx == -1 {\n\t\t\t\tcError.Printf(\"Invalid CSV format. Header must contain: title, url, time_added, tags\\n\")\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\t// Get metadata\n\t\ttitle, url, timeAdded, tags, err := verifyMetadata(cols[titleIdx], cols[urlIdx], cols[timeAddedIdx], cols[tagsIdx])\n\t\tif err != nil {\n\t\t\tcError.Printf(\"Skip %s: %v\\n\", url, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif err = handleDuplicates(ctx, db, mapURL, url); err != nil {\n\t\t\tcError.Printf(\"Skip %s: %v\\n\", url, err)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Add item to list\n\t\tbookmark := model.BookmarkDTO{\n\t\t\tURL:        url,\n\t\t\tTitle:      title,\n\t\t\tModifiedAt: timeAdded.Format(model.DatabaseDateFormat),\n\t\t\tCreatedAt:  timeAdded.Format(model.DatabaseDateFormat),\n\t\t\tTags:       tags,\n\t\t}\n\n\t\tmapURL[url] = struct{}{}\n\t\tbookmarks = append(bookmarks, bookmark)\n\t}\n\n\treturn bookmarks\n}\n\n// Parse metadata and verify it's validity\nfunc verifyMetadata(title, url, timeAddedStr, tags string) (string, string, time.Time, []model.TagDTO, error) {\n\t// Clean up URL\n\tvar err error\n\turl, err = core.RemoveUTMParams(url)\n\tif err != nil {\n\t\terr = fmt.Errorf(\"URL is not valid, %w\", err)\n\t\treturn \"\", \"\", time.Time{}, nil, err\n\t}\n\n\t// Make sure title is valid Utf-8\n\ttitle = validateTitle(title, url)\n\n\t// Parse time added\n\ttimeAddedInt, err := strconv.ParseInt(timeAddedStr, 10, 64)\n\tif err != nil {\n\t\terr = fmt.Errorf(\"invalid time added, %w\", err)\n\t\treturn \"\", \"\", time.Time{}, nil, err\n\t}\n\ttimeAdded := time.Unix(timeAddedInt, 0)\n\n\t// Get bookmark tags\n\ttagsList := []model.TagDTO{}\n\t// We need to split tags by both comma or pipe,\n\t// because Pocket's CSV export use pipe as separator,\n\t// while HTML export use comma.\n\tfor _, tag := range regexp.MustCompile(`[,|]`).Split(tags, -1) {\n\t\tif tag != \"\" {\n\t\t\ttagsList = append(tagsList, model.TagDTO{\n\t\t\t\tTag: model.Tag{Name: tag},\n\t\t\t})\n\t\t}\n\t}\n\n\treturn title, url, timeAdded, tagsList, nil\n}\n\n// Checks if the URL already exist, both in bookmark\n// file or in database\nfunc handleDuplicates(ctx context.Context, db model.DB, mapURL map[string]struct{}, url string) error {\n\tif _, exists := mapURL[url]; exists {\n\t\treturn errors.New(\"URL already exists\")\n\t}\n\n\t_, exists, err := db.GetBookmark(ctx, 0, url)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed getting bookmark, %w\", err)\n\t}\n\n\tif exists {\n\t\treturn errors.New(\"URL already exists\")\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/cmd/pocket_test.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/database\"\n)\n\nfunc Test_parseCsvExport_old_format(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tfileName string\n\t}{\n\t\t{\n\t\t\tname:     \"Test old file format\",\n\t\t\tfileName: \"pocket-old.csv\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Test new file format\",\n\t\t\tfileName: \"pocket-new.csv\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tfile, err := os.Open(\"../../testdata/\" + tt.fileName)\n\t\t\tif err != nil {\n\t\t\t\tt.Error(err.Error())\n\t\t\t}\n\t\t\tdefer file.Close()\n\t\t\tctx := context.TODO()\n\n\t\t\ttmpDir, err := os.MkdirTemp(\"\", \"shiori-test-*\")\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to create temp dir: %v\", err)\n\t\t\t}\n\t\t\tdefer os.RemoveAll(tmpDir)\n\n\t\t\tdbPath := filepath.Join(tmpDir, \"shiori.db\")\n\t\t\tdb, err := database.OpenSQLiteDatabase(ctx, dbPath)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to open sqlite database: %v\", err)\n\t\t\t}\n\n\t\t\tif err := db.Migrate(ctx); err != nil {\n\t\t\t\tt.Fatalf(\"failed to migrate sqlite database: %v\", err)\n\t\t\t}\n\n\t\t\tbookmarks := parseCsvExport(ctx, db, file)\n\t\t\tif len(bookmarks) != 1 {\n\t\t\t\tt.Errorf(\"Expected 1 bookmarks, got %d\", len(bookmarks))\n\t\t\t}\n\t\t\tbm := bookmarks[0]\n\t\t\tif bm.Title != \"Shiori\" {\n\t\t\t\tt.Errorf(\"Expected Title Shiori got %s\", bm.URL)\n\t\t\t}\n\t\t\tif bm.URL != \"https://github.com/go-shiori/shiori\" {\n\t\t\t\tt.Errorf(\"Expected URL https://github.com/go-shiori/shiori, got %s\", bm.URL)\n\t\t\t}\n\t\t\tif len(bm.Tags) != 1 {\n\t\t\t\tt.Errorf(\"Expected 1 tags, got %d\", len(bm.Tags))\n\t\t\t}\n\t\t\tif bm.Tags[0].Name != \"shiori\" {\n\t\t\t\tt.Errorf(\"Expected tag shiori, got %s\", bm.Tags[0].Name)\n\t\t\t}\n\t\t\tif bm.CreatedAt == \"\" {\n\t\t\t\tt.Error(\"Expected CreatedAt to be not empty\")\n\t\t\t}\n\t\t\tif bm.ModifiedAt == \"\" {\n\t\t\t\tt.Error(\"Expected CreatedAt to be not empty\")\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/cmd/print.go",
    "content": "package cmd\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc printCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"print [indices]\",\n\t\tShort: \"Print the saved bookmarks\",\n\t\tLong: \"Show the saved bookmarks by its database index. \" +\n\t\t\t\"Accepts space-separated list of indices (e.g. 5 6 23 4 110 45), \" +\n\t\t\t\"hyphenated range (e.g. 100-200) or both (e.g. 1-3 7 9). \" +\n\t\t\t\"If no arguments, all records with actual index from database are shown.\",\n\t\tAliases: []string{\"list\", \"ls\"},\n\t\tRun:     printHandler,\n\t}\n\n\tcmd.Flags().BoolP(\"json\", \"j\", false, \"Output data in JSON format\")\n\tcmd.Flags().BoolP(\"latest\", \"l\", false, \"Sort bookmark by latest instead of ID\")\n\tcmd.Flags().BoolP(\"index-only\", \"i\", false, \"Only print the index of bookmarks\")\n\tcmd.Flags().StringP(\"search\", \"s\", \"\", \"Search bookmark with specified keyword\")\n\tcmd.Flags().StringSliceP(\"tags\", \"t\", []string{}, \"Print bookmarks with matching tag(s)\")\n\tcmd.Flags().StringSliceP(\"exclude-tags\", \"e\", []string{}, \"Print bookmarks without these tag(s)\")\n\n\treturn cmd\n}\n\nfunc printHandler(cmd *cobra.Command, args []string) {\n\t_, deps := initShiori(cmd.Context(), cmd)\n\n\t// Read flags\n\ttags, _ := cmd.Flags().GetStringSlice(\"tags\")\n\tkeyword, _ := cmd.Flags().GetString(\"search\")\n\tuseJSON, _ := cmd.Flags().GetBool(\"json\")\n\tindexOnly, _ := cmd.Flags().GetBool(\"index-only\")\n\torderLatest, _ := cmd.Flags().GetBool(\"latest\")\n\texcludedTags, _ := cmd.Flags().GetStringSlice(\"exclude-tags\")\n\n\t// Convert args to ids\n\tids, err := parseStrIndices(args)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to parse args: %v\\n\", err)\n\t\treturn\n\t}\n\n\t// Read bookmarks from database\n\torderMethod := model.DefaultOrder\n\tif orderLatest {\n\t\torderMethod = model.ByLastModified\n\t}\n\n\tsearchOptions := model.DBGetBookmarksOptions{\n\t\tIDs:          ids,\n\t\tTags:         tags,\n\t\tExcludedTags: excludedTags,\n\t\tKeyword:      keyword,\n\t\tOrderMethod:  orderMethod,\n\t}\n\n\tbookmarks, err := deps.Database().GetBookmarks(cmd.Context(), searchOptions)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to get bookmarks: %v\\n\", err)\n\t\treturn\n\t}\n\n\tif len(bookmarks) == 0 {\n\t\tswitch {\n\t\tcase len(ids) > 0:\n\t\t\tcError.Println(\"No matching index found\")\n\t\tcase keyword != \"\", len(tags) > 0:\n\t\t\tcError.Println(\"No matching bookmarks found\")\n\t\tdefault:\n\t\t\tcError.Println(\"No bookmarks saved yet\")\n\t\t}\n\t\treturn\n\t}\n\n\t// Print data\n\tif useJSON {\n\t\tbt, err := json.MarshalIndent(&bookmarks, \"\", \"    \")\n\t\tif err != nil {\n\t\t\tcError.Println(err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tfmt.Println(string(bt))\n\t\treturn\n\t}\n\n\tif indexOnly {\n\t\tfor _, bookmark := range bookmarks {\n\t\t\tfmt.Printf(\"%d \", bookmark.ID)\n\t\t}\n\n\t\tfmt.Println()\n\t\treturn\n\t}\n\n\tprintBookmarks(bookmarks...)\n}\n"
  },
  {
    "path": "internal/cmd/root.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\tfp \"path/filepath\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/config\"\n\t\"github.com/go-shiori/shiori/internal/database\"\n\t\"github.com/go-shiori/shiori/internal/dependencies\"\n\t\"github.com/go-shiori/shiori/internal/domains\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/spf13/afero\"\n\t\"github.com/spf13/cobra\"\n\t\"golang.org/x/net/context\"\n)\n\n// ShioriCmd returns the root command for shiori\nfunc ShioriCmd() *cobra.Command {\n\trootCmd := &cobra.Command{\n\t\tUse:   \"shiori\",\n\t\tShort: \"Simple command-line bookmark manager built with Go\",\n\t}\n\n\trootCmd.PersistentFlags().Bool(\"portable\", false, \"run shiori in portable mode\")\n\trootCmd.PersistentFlags().String(\"storage-directory\", \"\", \"path to store shiori data\")\n\trootCmd.MarkFlagsMutuallyExclusive(\"portable\", \"storage-directory\")\n\n\trootCmd.PersistentFlags().String(\"log-level\", logrus.InfoLevel.String(), \"set logrus loglevel\")\n\trootCmd.PersistentFlags().Bool(\"log-caller\", false, \"logrus report caller or not\")\n\n\trootCmd.AddCommand(\n\t\taddCmd(),\n\t\tprintCmd(),\n\t\tupdateCmd(),\n\t\tdeleteCmd(),\n\t\topenCmd(),\n\t\timportCmd(),\n\t\texportCmd(),\n\t\tpocketCmd(),\n\t\tserveCmd(),\n\t\tcheckCmd(),\n\t\tnewVersionCommand(),\n\t\tnewServerCommand(),\n\t)\n\n\treturn rootCmd\n}\n\nfunc initShiori(ctx context.Context, cmd *cobra.Command) (*config.Config, *dependencies.Dependencies) {\n\tlogger := logrus.New()\n\n\tportableMode, _ := cmd.Flags().GetBool(\"portable\")\n\tlogLevel, _ := cmd.Flags().GetString(\"log-level\")\n\tlogCaller, _ := cmd.Flags().GetBool(\"log-caller\")\n\tstorageDirectory, _ := cmd.Flags().GetString(\"storage-directory\")\n\n\tlogger.SetReportCaller(logCaller)\n\tlogger.SetFormatter(&logrus.TextFormatter{\n\t\tFullTimestamp:    true,\n\t\tTimestampFormat:  time.RFC3339,\n\t\tCallerPrettyfier: SFCallerPrettyfier,\n\t})\n\n\tif lvl, err := logrus.ParseLevel(logLevel); err != nil {\n\t\tlogger.WithError(err).Panic(\"failed to set log level\")\n\t} else {\n\t\tlogger.SetLevel(lvl)\n\t}\n\n\tcfg := config.ParseServerConfiguration(ctx, logger)\n\tcfg.LogLevel = logger.Level.String()\n\n\tif storageDirectory != \"\" {\n\t\tlogger.Warn(\"--storage-directory is set, overriding SHIORI_DIR.\")\n\t\tcfg.Storage.DataDir = storageDirectory\n\t}\n\n\tcfg.SetDefaults(logger, portableMode)\n\n\tif err := cfg.IsValid(); err != nil {\n\t\tlogger.WithError(err).Fatal(\"invalid configuration detected\")\n\t}\n\n\terr := os.MkdirAll(cfg.Storage.DataDir, model.DataDirPerm)\n\tif err != nil {\n\t\tlogger.WithError(err).Fatal(\"error creating data directory\")\n\t}\n\n\tdb, err := openDatabase(logger, ctx, cfg)\n\tif err != nil {\n\t\tlogger.WithError(err).Fatal(\"error opening database\")\n\t}\n\n\t// Migrate\n\tif err := db.Migrate(ctx); err != nil {\n\t\tlogger.WithError(err).Fatalf(\"Error running migration\")\n\t}\n\n\tif cfg.Development {\n\t\tlogger.Warn(\"Development mode is ENABLED, this will enable some helpers for local development, unsuitable for production environments\")\n\t}\n\n\tdependencies := dependencies.NewDependencies(logger, db, cfg)\n\tdependencies.Domains().SetAuth(domains.NewAuthDomain(dependencies))\n\tdependencies.Domains().SetAccounts(domains.NewAccountsDomain(dependencies))\n\tdependencies.Domains().SetArchiver(domains.NewArchiverDomain(dependencies))\n\tdependencies.Domains().SetBookmarks(domains.NewBookmarksDomain(dependencies))\n\tdependencies.Domains().SetStorage(domains.NewStorageDomain(dependencies, afero.NewBasePathFs(afero.NewOsFs(), cfg.Storage.DataDir)))\n\tdependencies.Domains().SetTags(domains.NewTagsDomain(dependencies))\n\n\t// Workaround: Get accounts to make sure at least one is present in the database.\n\t// If there's no accounts in the database, create the shiori/gopher account the legacy api\n\t// hardcoded in the login handler.\n\taccounts, err := dependencies.Domains().Accounts().ListAccounts(cmd.Context())\n\tif err != nil {\n\t\tcError.Printf(\"Failed to get owner account: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\tif len(accounts) == 0 {\n\t\taccount := model.AccountDTO{\n\t\t\tUsername: \"shiori\",\n\t\t\tPassword: \"gopher\",\n\t\t\tOwner:    model.Ptr(true),\n\t\t}\n\n\t\tif _, err := dependencies.Domains().Accounts().CreateAccount(cmd.Context(), account); err != nil {\n\t\t\tlogger.WithError(err).Fatal(\"error ensuring owner account\")\n\t\t}\n\t}\n\n\tcfg.DebugConfiguration(logger)\n\n\treturn cfg, dependencies\n}\n\nfunc openDatabase(logger *logrus.Logger, ctx context.Context, cfg *config.Config) (model.DB, error) {\n\tif cfg.Database.URL != \"\" {\n\t\treturn database.Connect(ctx, cfg.Database.URL)\n\t}\n\n\tif cfg.Database.DBMS != \"\" {\n\t\tlogger.Warnf(\"The use of SHIORI_DBMS is deprecated and will be removed in the future. Please migrate to SHIORI_DATABASE_URL instead.\")\n\t}\n\n\t// TODO remove this the moment DBMS is deprecated\n\tif cfg.Database.DBMS == \"mysql\" {\n\t\treturn openMySQLDatabase(ctx)\n\t}\n\tif cfg.Database.DBMS == \"postgresql\" {\n\t\treturn openPostgreSQLDatabase(ctx)\n\t}\n\n\treturn database.OpenSQLiteDatabase(ctx, fp.Join(cfg.Storage.DataDir, \"shiori.db\"))\n}\n\nfunc openMySQLDatabase(ctx context.Context) (model.DB, error) {\n\tuser, _ := os.LookupEnv(\"SHIORI_MYSQL_USER\")\n\tpassword, _ := os.LookupEnv(\"SHIORI_MYSQL_PASS\")\n\tdbName, _ := os.LookupEnv(\"SHIORI_MYSQL_NAME\")\n\tdbAddress, _ := os.LookupEnv(\"SHIORI_MYSQL_ADDRESS\")\n\n\tconnString := fmt.Sprintf(\"%s:%s@%s/%s?charset=utf8mb4\", user, password, dbAddress, dbName)\n\treturn database.OpenMySQLDatabase(ctx, connString)\n}\n\nfunc openPostgreSQLDatabase(ctx context.Context) (model.DB, error) {\n\thost, _ := os.LookupEnv(\"SHIORI_PG_HOST\")\n\tport, _ := os.LookupEnv(\"SHIORI_PG_PORT\")\n\tuser, _ := os.LookupEnv(\"SHIORI_PG_USER\")\n\tpassword, _ := os.LookupEnv(\"SHIORI_PG_PASS\")\n\tdbName, _ := os.LookupEnv(\"SHIORI_PG_NAME\")\n\tsslmode, _ := os.LookupEnv(\"SHIORI_PG_SSLMODE\")\n\tif sslmode == \"\" {\n\t\tsslmode = \"disable\"\n\t}\n\n\tconnString := fmt.Sprintf(\"host=%s port=%s user=%s password=%s dbname=%s sslmode=%s\",\n\t\thost, port, user, password, dbName, sslmode)\n\treturn database.OpenPGDatabase(ctx, connString)\n}\n"
  },
  {
    "path": "internal/cmd/serve.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/spf13/cobra\"\n)\n\nfunc serveCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"serve\",\n\t\tShort: \"Serve web interface for managing bookmarks\",\n\t\tLong: \"Run a simple and performant web server which \" +\n\t\t\t\"serves the site for managing bookmarks. If --port \" +\n\t\t\t\"flag is not used, it will use port 8080 by default.\",\n\t\tDeprecated: \"use server instead\",\n\t\tRun:        newServerCommandHandler(),\n\t}\n\n\tcmd.Flags().IntP(\"port\", \"p\", 8080, \"Port used by the server\")\n\tcmd.Flags().StringP(\"address\", \"a\", \"\", \"Address the server listens to\")\n\tcmd.Flags().StringP(\"webroot\", \"r\", \"/\", \"Root path that used by server\")\n\tcmd.Flags().Bool(\"log\", true, \"Print out a non-standard access log\")\n\tcmd.Flags().Bool(\"serve-web-ui\", true, \"Serve static files from the webroot path\")\n\n\treturn cmd\n}\n"
  },
  {
    "path": "internal/cmd/server.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"strings\"\n\n\t\"github.com/go-shiori/shiori/internal/config\"\n\t\"github.com/go-shiori/shiori/internal/http\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/pflag\"\n)\n\nfunc newServerCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"server\",\n\t\tShort: \"Starts the Shiori webserver\",\n\t\tLong:  \"Serves the Shiori web interface and API.\",\n\t\tRun:   newServerCommandHandler(),\n\t}\n\n\tcmd.Flags().IntP(\"port\", \"p\", 8080, \"Port used by the server\")\n\tcmd.Flags().StringP(\"address\", \"a\", \"\", \"Address the server listens to\")\n\tcmd.Flags().StringP(\"webroot\", \"r\", \"/\", \"Root path that used by server\")\n\tcmd.Flags().Bool(\"access-log\", false, \"Print out a non-standard access log\")\n\tcmd.Flags().Bool(\"serve-web-ui\", true, \"Serve static files from the webroot path\")\n\tcmd.Flags().Bool(\"experimental-serve-web-ui-v2\", false, \"Serve static files from the webapp path\")\n\tcmd.Flags().String(\"secret-key\", \"\", \"Secret key used for encrypting session data\")\n\n\treturn cmd\n}\n\nfunc setIfFlagChanged(flagName string, flags *pflag.FlagSet, cfg *config.Config, fn func(cfg *config.Config)) {\n\tif flags.Changed(flagName) {\n\t\tfn(cfg)\n\t}\n}\n\nfunc newServerCommandHandler() func(cmd *cobra.Command, args []string) {\n\treturn func(cmd *cobra.Command, args []string) {\n\t\tctx := context.Background()\n\n\t\t// Get flags values\n\t\tport, _ := cmd.Flags().GetInt(\"port\")\n\t\taddress, _ := cmd.Flags().GetString(\"address\")\n\t\trootPath, _ := cmd.Flags().GetString(\"webroot\")\n\t\taccessLog, _ := cmd.Flags().GetBool(\"access-log\")\n\t\tserveWebUI, _ := cmd.Flags().GetBool(\"serve-web-ui\")\n\t\tserveWebUIV2, _ := cmd.Flags().GetBool(\"experimental-serve-web-ui-v2\")\n\t\tsecretKey, _ := cmd.Flags().GetBytesHex(\"secret-key\")\n\n\t\tcfg, dependencies := initShiori(ctx, cmd)\n\n\t\t// Validate root path\n\t\tif rootPath == \"\" {\n\t\t\trootPath = \"/\"\n\t\t}\n\n\t\tif !strings.HasPrefix(rootPath, \"/\") {\n\t\t\trootPath = \"/\" + rootPath\n\t\t}\n\n\t\tif !strings.HasSuffix(rootPath, \"/\") {\n\t\t\trootPath += \"/\"\n\t\t}\n\n\t\t// Override configuration from flags if needed\n\t\tsetIfFlagChanged(\"port\", cmd.Flags(), cfg, func(cfg *config.Config) {\n\t\t\tcfg.Http.Port = port\n\t\t})\n\t\tsetIfFlagChanged(\"address\", cmd.Flags(), cfg, func(cfg *config.Config) {\n\t\t\tcfg.Http.Address = address + \":\"\n\t\t})\n\t\tsetIfFlagChanged(\"webroot\", cmd.Flags(), cfg, func(cfg *config.Config) {\n\t\t\tcfg.Http.RootPath = rootPath\n\t\t})\n\t\tsetIfFlagChanged(\"access-log\", cmd.Flags(), cfg, func(cfg *config.Config) {\n\t\t\tcfg.Http.AccessLog = accessLog\n\t\t})\n\t\tsetIfFlagChanged(\"serve-web-ui\", cmd.Flags(), cfg, func(cfg *config.Config) {\n\t\t\tcfg.Http.ServeWebUI = serveWebUI\n\t\t})\n\t\tsetIfFlagChanged(\"secret-key\", cmd.Flags(), cfg, func(cfg *config.Config) {\n\t\t\tcfg.Http.SecretKey = secretKey\n\t\t})\n\t\tsetIfFlagChanged(\"experimental-serve-web-ui-v2\", cmd.Flags(), cfg, func(cfg *config.Config) {\n\t\t\tcfg.Http.ServeWebUIV2 = serveWebUIV2\n\t\t})\n\n\t\tdependencies.Logger().Infof(\"Starting Shiori v%s\", model.BuildVersion)\n\n\t\tserver, err := http.NewHttpServer(dependencies.Logger()).Setup(cfg, dependencies)\n\t\tif err != nil {\n\t\t\tdependencies.Logger().WithError(err).Fatal(\"error setting up server\")\n\t\t}\n\n\t\tif err := server.Start(ctx); err != nil {\n\t\t\tdependencies.Logger().WithError(err).Fatal(\"error starting server\")\n\t\t}\n\t\tdependencies.Logger().Debug(\"started http server\")\n\n\t\tserver.WaitStop(ctx)\n\t}\n}\n"
  },
  {
    "path": "internal/cmd/server_test.go",
    "content": "package cmd\n\nimport (\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/config\"\n\t\"github.com/spf13/pflag\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc Test_setIfFlagChanged(t *testing.T) {\n\ttype args struct {\n\t\tflagName string\n\t\tflags    func() *pflag.FlagSet\n\t\tcfg      *config.Config\n\t\tfn       func(cfg *config.Config)\n\t}\n\ttests := []struct {\n\t\tname     string\n\t\targs     args\n\t\tassertFn func(t *testing.T, cfg *config.Config)\n\t}{\n\t\t{\n\t\t\tname: \"Flag didn't change\",\n\t\t\targs: args{\n\t\t\t\tflagName: \"port\",\n\t\t\t\tflags: func() *pflag.FlagSet {\n\t\t\t\t\treturn &pflag.FlagSet{}\n\t\t\t\t},\n\t\t\t\tcfg: &config.Config{\n\t\t\t\t\tHttp: &config.HttpConfig{\n\t\t\t\t\t\tPort: 8080,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tfn: func(cfg *config.Config) {\n\t\t\t\t\tcfg.Http.Port = 9999\n\t\t\t\t},\n\t\t\t},\n\t\t\tassertFn: func(t *testing.T, cfg *config.Config) {\n\t\t\t\trequire.Equal(t, cfg.Http.Port, 8080)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Flag changed\",\n\t\t\targs: args{\n\t\t\t\tflagName: \"port\",\n\t\t\t\tflags: func() *pflag.FlagSet {\n\t\t\t\t\tpf := &pflag.FlagSet{}\n\t\t\t\t\tpf.IntP(\"port\", \"p\", 8080, \"Port used by the server\")\n\t\t\t\t\tpf.Set(\"port\", \"9999\")\n\t\t\t\t\treturn pf\n\t\t\t\t},\n\t\t\t\tcfg: &config.Config{\n\t\t\t\t\tHttp: &config.HttpConfig{\n\t\t\t\t\t\tPort: 8080,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tfn: func(cfg *config.Config) {\n\t\t\t\t\tcfg.Http.Port = 9999\n\t\t\t\t},\n\t\t\t},\n\t\t\tassertFn: func(t *testing.T, cfg *config.Config) {\n\t\t\t\trequire.Equal(t, cfg.Http.Port, 9999)\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsetIfFlagChanged(tt.args.flagName, tt.args.flags(), tt.args.cfg, tt.args.fn)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/cmd/update.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/go-shiori/shiori/internal/core\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc updateCmd() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"update [indices]\",\n\t\tShort: \"Update the saved bookmarks\",\n\t\tLong: \"Update fields and archive of an existing bookmark. \" +\n\t\t\t\"Accepts space-separated list of indices (e.g. 5 6 23 4 110 45), \" +\n\t\t\t\"hyphenated range (e.g. 100-200) or both (e.g. 1-3 7 9). \" +\n\t\t\t\"If no arguments, ALL bookmarks will be updated. Update works differently depending on the flags:\\n\" +\n\t\t\t\"- If indices are passed without any flags (--url, --title, --tag and --excerpt), read the URLs from database and update titles from web.\\n\" +\n\t\t\t\"- If --url is passed (and --title is omitted), update the title from web using the URL. While using this flag, update only accept EXACTLY one index.\\n\" +\n\t\t\t\"While updating bookmark's tags, you can use - to remove tag (e.g. -nature to remove nature tag from this bookmark).\",\n\t\tRun: updateHandler,\n\t}\n\n\tcmd.Flags().StringP(\"url\", \"u\", \"\", \"New URL for this bookmark\")\n\tcmd.Flags().StringP(\"title\", \"i\", \"\", \"New title for this bookmark\")\n\tcmd.Flags().StringP(\"excerpt\", \"e\", \"\", \"New excerpt for this bookmark\")\n\tcmd.Flags().StringSliceP(\"tags\", \"t\", []string{}, \"Comma-separated tags for this bookmark\")\n\tcmd.Flags().BoolP(\"offline\", \"o\", false, \"Update bookmark without fetching data from internet\")\n\tcmd.Flags().BoolP(\"yes\", \"y\", false, \"Skip confirmation prompt and update ALL bookmarks\")\n\tcmd.Flags().Bool(\"keep-metadata\", false, \"Keep existing metadata. Useful when only want to update bookmark's content\")\n\tcmd.Flags().BoolP(\"no-archival\", \"a\", false, \"Update bookmark without updating offline archive\")\n\tcmd.Flags().Bool(\"log-archival\", false, \"Log the archival process\")\n\n\treturn cmd\n}\n\nfunc updateHandler(cmd *cobra.Command, args []string) {\n\tcfg, deps := initShiori(cmd.Context(), cmd)\n\n\t// Parse flags\n\turl, _ := cmd.Flags().GetString(\"url\")\n\ttitle, _ := cmd.Flags().GetString(\"title\")\n\texcerpt, _ := cmd.Flags().GetString(\"excerpt\")\n\ttags, _ := cmd.Flags().GetStringSlice(\"tags\")\n\toffline, _ := cmd.Flags().GetBool(\"offline\")\n\tskipConfirm, _ := cmd.Flags().GetBool(\"yes\")\n\tnoArchival, _ := cmd.Flags().GetBool(\"no-archival\")\n\tlogArchival, _ := cmd.Flags().GetBool(\"log-archival\")\n\tkeep_metadata := cmd.Flags().Changed(\"keep-metadata\")\n\n\t// If no arguments (i.e all bookmarks going to be updated), confirm to user\n\tif len(args) == 0 && !skipConfirm {\n\t\tconfirmUpdate := \"\"\n\t\tfmt.Print(\"Update ALL bookmarks? (y/N): \")\n\t\tfmt.Scanln(&confirmUpdate)\n\n\t\tif confirmUpdate != \"y\" {\n\t\t\tfmt.Println(\"No bookmarks updated\")\n\t\t\treturn\n\t\t}\n\t}\n\n\t// Convert args to ids\n\tids, err := parseStrIndices(args)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to parse args: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\t// Clean up new parameter from flags\n\ttitle = validateTitle(title, \"\")\n\texcerpt = normalizeSpace(excerpt)\n\n\tif cmd.Flags().Changed(\"url\") {\n\t\t// Clean up bookmark URL\n\t\turl, err = core.RemoveUTMParams(url)\n\t\tif err != nil {\n\t\t\tpanic(fmt.Errorf(\"failed to clean URL: %v\", err))\n\t\t}\n\n\t\t// Since user uses custom URL, make sure there is only one ID to update\n\t\tif len(ids) != 1 {\n\t\t\tcError.Println(\"Update only accepts one index while using --url flag\")\n\t\t\tos.Exit(1)\n\t\t}\n\t}\n\n\t// Fetch bookmarks from database\n\tfilterOptions := model.DBGetBookmarksOptions{\n\t\tIDs: ids,\n\t}\n\n\tbookmarks, err := deps.Database().GetBookmarks(cmd.Context(), filterOptions)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to get bookmarks: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\tif len(bookmarks) == 0 {\n\t\tcError.Println(\"No matching index found\")\n\t\tos.Exit(1)\n\t}\n\n\t// Check if user really want to batch update archive\n\tif nBook := len(bookmarks); nBook > 5 && !offline && !noArchival && !skipConfirm {\n\t\tfmt.Printf(\"This update will generate offline archive for %d bookmark(s).\\n\", nBook)\n\t\tfmt.Println(\"This might take a long time and uses lot of your network bandwidth.\")\n\n\t\tconfirmUpdate := \"\"\n\t\tfmt.Printf(\"Continue update and archival process ? (y/N): \")\n\t\tfmt.Scanln(&confirmUpdate)\n\n\t\tif confirmUpdate != \"y\" {\n\t\t\tfmt.Println(\"No bookmarks updated\")\n\t\t\treturn\n\n\t\t}\n\t}\n\n\t// If it's not offline mode, fetch data from internet\n\tidWithProblems := []int{}\n\n\tif !offline {\n\t\tmx := sync.RWMutex{}\n\t\twg := sync.WaitGroup{}\n\t\tchDone := make(chan struct{})\n\t\tchProblem := make(chan int, 10)\n\t\tchMessage := make(chan interface{}, 10)\n\t\tsemaphore := make(chan struct{}, 10)\n\n\t\tcInfo.Println(\"Downloading article(s)...\")\n\n\t\tfor i, book := range bookmarks {\n\t\t\twg.Add(1)\n\n\t\t\t// Mark whether book will be archived\n\t\t\tbook.CreateArchive = !noArchival\n\n\t\t\t// If used, use submitted URL\n\t\t\tif url != \"\" {\n\t\t\t\tbook.URL = url\n\t\t\t}\n\n\t\t\tgo func(i int, book model.BookmarkDTO) {\n\t\t\t\t// Make sure to finish the WG\n\t\t\t\tdefer wg.Done()\n\n\t\t\t\t// Register goroutine to semaphore\n\t\t\t\tsemaphore <- struct{}{}\n\t\t\t\tdefer func() {\n\t\t\t\t\t<-semaphore\n\t\t\t\t}()\n\n\t\t\t\t// Download data from internet\n\t\t\t\tcontent, contentType, err := core.DownloadBookmark(book.URL)\n\t\t\t\tif err != nil {\n\t\t\t\t\tchProblem <- book.ID\n\t\t\t\t\tchMessage <- fmt.Errorf(\"failed to download %s: %v\", book.URL, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\trequest := core.ProcessRequest{\n\t\t\t\t\tDataDir:     cfg.Storage.DataDir,\n\t\t\t\t\tBookmark:    book,\n\t\t\t\t\tContent:     content,\n\t\t\t\t\tContentType: contentType,\n\t\t\t\t\tKeepTitle:   keep_metadata,\n\t\t\t\t\tKeepExcerpt: keep_metadata,\n\t\t\t\t\tLogArchival: logArchival,\n\t\t\t\t}\n\n\t\t\t\tbook, _, err = core.ProcessBookmark(deps, request)\n\t\t\t\tcontent.Close()\n\n\t\t\t\tif err != nil {\n\t\t\t\t\tchProblem <- book.ID\n\t\t\t\t\tchMessage <- fmt.Errorf(\"failed to process %s: %v\", book.URL, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// Send success message\n\t\t\t\tchMessage <- fmt.Sprintf(\"Downloaded %s\", book.URL)\n\n\t\t\t\t// Save parse result to bookmark\n\t\t\t\tmx.Lock()\n\t\t\t\tbookmarks[i] = book\n\t\t\t\tmx.Unlock()\n\t\t\t}(i, book)\n\t\t}\n\n\t\t// Print log message\n\t\tgo func(nBookmark int) {\n\t\t\tlogIndex := 0\n\n\t\t\tfor {\n\t\t\t\tselect {\n\t\t\t\tcase <-chDone:\n\t\t\t\t\tcInfo.Println(\"Download finished\")\n\t\t\t\t\treturn\n\t\t\t\tcase id := <-chProblem:\n\t\t\t\t\tidWithProblems = append(idWithProblems, id)\n\t\t\t\tcase msg := <-chMessage:\n\t\t\t\t\tlogIndex++\n\n\t\t\t\t\tswitch msg.(type) {\n\t\t\t\t\tcase error:\n\t\t\t\t\t\tcError.Printf(\"[%d/%d] %v\\n\", logIndex, nBookmark, msg)\n\t\t\t\t\tcase string:\n\t\t\t\t\t\tcInfo.Printf(\"[%d/%d] %s\\n\", logIndex, nBookmark, msg)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}(len(bookmarks))\n\n\t\t// Wait until all download finished\n\t\twg.Wait()\n\t\tclose(chDone)\n\t}\n\n\t// Map which tags is new or deleted from flag --tags\n\taddedTags := make(map[string]struct{})\n\tdeletedTags := make(map[string]struct{})\n\tfor _, tag := range tags {\n\t\ttagName := strings.ToLower(tag)\n\t\ttagName = strings.TrimSpace(tagName)\n\n\t\tif strings.HasPrefix(tagName, \"-\") {\n\t\t\ttagName = strings.TrimPrefix(tagName, \"-\")\n\t\t\tdeletedTags[tagName] = struct{}{}\n\t\t} else {\n\t\t\taddedTags[tagName] = struct{}{}\n\t\t}\n\t}\n\n\t// Attach user submitted value to the bookmarks\n\tfor i, book := range bookmarks {\n\t\t// If user submit his own title or excerpt, use it\n\t\tif title != \"\" {\n\t\t\tbook.Title = title\n\t\t}\n\n\t\tif excerpt != \"\" {\n\t\t\tbook.Excerpt = excerpt\n\t\t}\n\n\t\t// If user submits url, use it\n\t\tif url != \"\" {\n\t\t\tbook.URL = url\n\t\t}\n\n\t\t// Make sure title is valid and not empty\n\t\tbook.Title = validateTitle(book.Title, book.URL)\n\n\t\t// Generate new tags\n\t\ttmpAddedTags := make(map[string]struct{})\n\t\tfor key, value := range addedTags {\n\t\t\ttmpAddedTags[key] = value\n\t\t}\n\n\t\tnewTags := []model.TagDTO{}\n\t\tfor _, tag := range book.Tags {\n\t\t\tif _, isDeleted := deletedTags[tag.Name]; isDeleted {\n\t\t\t\ttag.Deleted = true\n\t\t\t}\n\n\t\t\tif _, alreadyExist := addedTags[tag.Name]; alreadyExist {\n\t\t\t\tdelete(tmpAddedTags, tag.Name)\n\t\t\t}\n\n\t\t\tnewTags = append(newTags, tag)\n\t\t}\n\n\t\tfor tag := range tmpAddedTags {\n\t\t\tnewTags = append(newTags, model.TagDTO{\n\t\t\t\tTag: model.Tag{Name: tag},\n\t\t\t})\n\t\t}\n\n\t\tbook.Tags = newTags\n\n\t\t// Set bookmark's new data\n\t\tbookmarks[i] = book\n\t}\n\n\t// Save bookmarks to database\n\tbookmarks, err = deps.Database().SaveBookmarks(cmd.Context(), false, bookmarks...)\n\tif err != nil {\n\t\tcError.Printf(\"Failed to save bookmark: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\t// Print updated bookmarks\n\tfmt.Println()\n\tprintBookmarks(bookmarks...)\n\n\tvar code int\n\tif len(idWithProblems) > 0 {\n\t\tcode = 1\n\t\tsort.Ints(idWithProblems)\n\n\t\tcError.Println(\"Encountered error while downloading some bookmark(s):\")\n\t\tfor _, id := range idWithProblems {\n\t\t\tcError.Printf(\"%d \", id)\n\t\t}\n\t\tfmt.Println()\n\t}\n\tos.Exit(code)\n}\n"
  },
  {
    "path": "internal/cmd/utils.go",
    "content": "package cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\tnurl \"net/url\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\t\"unicode/utf8\"\n\n\t\"github.com/fatih/color\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"golang.org/x/term\"\n)\n\nvar (\n\tcIndex   = color.New(color.FgHiCyan)\n\tcSymbol  = color.New(color.FgHiMagenta)\n\tcTitle   = color.New(color.FgHiGreen).Add(color.Bold)\n\tcURL     = color.New(color.FgHiYellow)\n\tcExcerpt = color.New(color.FgHiWhite)\n\tcTag     = color.New(color.FgHiBlue)\n\n\tcInfo  = color.New(color.FgHiCyan)\n\tcError = color.New(color.FgHiRed)\n\n\terrInvalidIndex = errors.New(\"index is not valid\")\n)\n\nfunc normalizeSpace(str string) string {\n\tstr = strings.TrimSpace(str)\n\treturn strings.Join(strings.Fields(str), \" \")\n}\n\nfunc isURLValid(s string) bool {\n\ttmp, err := nurl.Parse(s)\n\treturn err == nil && tmp.Scheme != \"\" && tmp.Hostname() != \"\"\n}\n\nfunc printBookmarks(bookmarks ...model.BookmarkDTO) {\n\tfor _, bookmark := range bookmarks {\n\t\t// Create bookmark index\n\t\tstrBookmarkIndex := fmt.Sprintf(\"%d. \", bookmark.ID)\n\t\tstrSpace := strings.Repeat(\" \", len(strBookmarkIndex))\n\n\t\t// Print bookmark title\n\t\tcIndex.Print(strBookmarkIndex)\n\t\tcTitle.Println(bookmark.Title)\n\n\t\t// Print bookmark URL\n\t\tcSymbol.Print(strSpace + \"> \")\n\t\tcURL.Println(bookmark.URL)\n\n\t\t// Print bookmark excerpt\n\t\tif bookmark.Excerpt != \"\" {\n\t\t\tcSymbol.Print(strSpace + \"+ \")\n\t\t\tcExcerpt.Println(bookmark.Excerpt)\n\t\t}\n\n\t\t// Print bookmark tags\n\t\tif len(bookmark.Tags) > 0 {\n\t\t\tcSymbol.Print(strSpace + \"# \")\n\t\t\tfor i, tag := range bookmark.Tags {\n\t\t\t\tif i == len(bookmark.Tags)-1 {\n\t\t\t\t\tcTag.Println(tag.Name)\n\t\t\t\t} else {\n\t\t\t\t\tcTag.Print(tag.Name + \", \")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Append new line\n\t\tfmt.Println()\n\t}\n}\n\n// parseStrIndices converts a list of indices to their integer values\nfunc parseStrIndices(indices []string) ([]int, error) {\n\tvar listIndex []int\n\tfor _, strIndex := range indices {\n\t\tif !strings.Contains(strIndex, \"-\") {\n\t\t\tindex, err := strconv.Atoi(strIndex)\n\t\t\tif err != nil || index < 1 {\n\t\t\t\treturn nil, errInvalidIndex\n\t\t\t}\n\n\t\t\tlistIndex = append(listIndex, index)\n\t\t\tcontinue\n\t\t}\n\n\t\tparts := strings.Split(strIndex, \"-\")\n\t\tif len(parts) != 2 {\n\t\t\treturn nil, errInvalidIndex\n\t\t}\n\n\t\tminIndex, errMin := strconv.Atoi(parts[0])\n\t\tmaxIndex, errMax := strconv.Atoi(parts[1])\n\t\tif errMin != nil || errMax != nil || minIndex < 1 || minIndex > maxIndex {\n\t\t\treturn nil, errInvalidIndex\n\t\t}\n\n\t\tfor i := minIndex; i <= maxIndex; i++ {\n\t\t\tlistIndex = append(listIndex, i)\n\t\t}\n\t}\n\n\treturn listIndex, nil\n}\n\n// openBrowser tries to open the URL in a browser,\n// and returns any error if it happened.\nfunc openBrowser(url string) error {\n\tvar args []string\n\tswitch runtime.GOOS {\n\tcase \"darwin\":\n\t\targs = []string{\"open\"}\n\tcase \"windows\":\n\t\targs = []string{\"cmd\", \"/c\", \"start\"}\n\tdefault:\n\t\targs = []string{\"xdg-open\"}\n\t}\n\n\tcmd := exec.Command(args[0], append(args[1:], url)...)\n\treturn cmd.Run()\n}\n\nfunc getTerminalWidth() int {\n\twidth, _, _ := term.GetSize(int(os.Stdin.Fd()))\n\treturn width\n}\n\nfunc validateTitle(title, fallback string) string {\n\t// Normalize spaces before we begin\n\ttitle = normalizeSpace(title)\n\ttitle = strings.TrimSpace(title)\n\n\t// If at this point title already empty, just uses fallback\n\tif title == \"\" {\n\t\treturn fallback\n\t}\n\n\t// Check if it's already valid UTF-8 string\n\tif valid := utf8.ValidString(title); valid {\n\t\treturn title\n\t}\n\n\t// Remove invalid runes to get the valid UTF-8 title\n\tfixUtf := func(r rune) rune {\n\t\tif r == utf8.RuneError {\n\t\t\treturn -1\n\t\t}\n\t\treturn r\n\t}\n\tvalidUtf := strings.Map(fixUtf, title)\n\n\t// If it's empty use fallback string\n\tvalidUtf = strings.TrimSpace(validUtf)\n\tif validUtf == \"\" {\n\t\treturn fallback\n\t}\n\n\treturn validUtf\n}\n\nfunc SFCallerPrettyfier(frame *runtime.Frame) (string, string) {\n\treturn \"\", fmt.Sprintf(\"%s:%d\", path.Base(frame.File), frame.Line)\n}\n"
  },
  {
    "path": "internal/cmd/utils_test.go",
    "content": "package cmd\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc Test_normalizeSpace(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\targs string\n\t\twant string\n\t}{{\n\t\tname: \"normal sentence\",\n\t\targs: \"What a perfect, beautiful sentence\",\n\t\twant: \"What a perfect, beautiful sentence\",\n\t}, {\n\t\tname: \"has unnecessary space before and after sentence\",\n\t\targs: \"    I'm surrounded with spaces    \",\n\t\twant: \"I'm surrounded with spaces\",\n\t}, {\n\t\tname: \"has unnecessary spaces in middle of sentence\",\n\t\targs: \"I'm hollow         inside\",\n\t\twant: \"I'm hollow inside\",\n\t}, {\n\t\tname: \"has unnecessary new line in middle of sentence\",\n\t\targs: \"I'm broken \\n\\n\\ninside\",\n\t\twant: \"I'm broken inside\",\n\t}, {\n\t\tname: \"has unnecessary new line and spaces everywhere\",\n\t\targs: \"    I'm hollow     broken\\n\\n\\n\\nand surrounded by spaces    \",\n\t\twant: \"I'm hollow broken and surrounded by spaces\",\n\t}}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := normalizeSpace(tt.args); got != tt.want {\n\t\t\t\tt.Errorf(\"normalizeSpace() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_isURLValid(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\targs string\n\t\twant bool\n\t}{{\n\t\tname: \"valid URL\",\n\t\targs: \"https://www.google.com\",\n\t\twant: true,\n\t}, {\n\t\tname: \"valid localhost URL\",\n\t\targs: \"http://localhost:8080\",\n\t\twant: true,\n\t}, {\n\t\tname: \"valid non-HTTP URL\",\n\t\targs: \"ftp://www.example.com/storage\",\n\t\twant: true,\n\t}, {\n\t\tname: \"invalid URL\",\n\t\targs: \"https:/www.google.com\",\n\t\twant: false,\n\t}, {\n\t\tname: \"hash URL\",\n\t\targs: \"#some-awesome-heading\",\n\t\twant: false,\n\t}, {\n\t\tname: \"relative URL\",\n\t\targs: \"/page/contact\",\n\t\twant: false,\n\t}}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := isURLValid(tt.args); got != tt.want {\n\t\t\t\tt.Errorf(\"isURLValid() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_parseStrIndices(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\targs    []string\n\t\twant    []int\n\t\twantErr bool\n\t}{{\n\t\tname:    \"single number\",\n\t\targs:    []string{\"1\"},\n\t\twant:    []int{1},\n\t\twantErr: false,\n\t}, {\n\t\tname:    \"multiple number\",\n\t\targs:    []string{\"1\", \"2\", \"3\"},\n\t\twant:    []int{1, 2, 3},\n\t\twantErr: false,\n\t}, {\n\t\tname:    \"single ranged number\",\n\t\targs:    []string{\"1-5\"},\n\t\twant:    []int{1, 2, 3, 4, 5},\n\t\twantErr: false,\n\t}, {\n\t\tname:    \"multiple ranged number\",\n\t\targs:    []string{\"1-5\", \"8-9\"},\n\t\twant:    []int{1, 2, 3, 4, 5, 8, 9},\n\t\twantErr: false,\n\t}, {\n\t\tname:    \"mixed single and ranged number\",\n\t\targs:    []string{\"1-5\", \"8-9\", \"11\", \"12\"},\n\t\twant:    []int{1, 2, 3, 4, 5, 8, 9, 11, 12},\n\t\twantErr: false,\n\t}, {\n\t\tname:    \"invalid number\",\n\t\targs:    []string{\"AAA\"},\n\t\twant:    nil,\n\t\twantErr: true,\n\t}, {\n\t\tname:    \"mixed number and string\",\n\t\targs:    []string{\"1\", \"2\", \"A\"},\n\t\twant:    nil,\n\t\twantErr: true,\n\t}, {\n\t\tname:    \"reversed ranged number\",\n\t\targs:    []string{\"5-1\"},\n\t\twant:    nil,\n\t\twantErr: true,\n\t}}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := parseStrIndices(tt.args)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"parseStrIndices() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"parseStrIndices() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/cmd/version.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/spf13/cobra\"\n)\n\nfunc newVersionCommand() *cobra.Command {\n\tcmd := &cobra.Command{\n\t\tUse:   \"version\",\n\t\tShort: \"Output the shiori version\",\n\t\tRun:   newVersionCommandHandler(),\n\t}\n\n\treturn cmd\n}\n\nfunc newVersionCommandHandler() func(cmd *cobra.Command, args []string) {\n\treturn func(cmd *cobra.Command, args []string) {\n\t\tcmd.Printf(\"Shiori version %s (build %s) at %s\\n\", model.BuildVersion, model.BuildCommit, model.BuildDate)\n\t}\n}\n"
  },
  {
    "path": "internal/config/config.go",
    "content": "package config\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gofrs/uuid/v5\"\n\t\"github.com/sethvargo/go-envconfig\"\n\t\"github.com/sirupsen/logrus\"\n)\n\n// readDotEnv reads the configuration from variables in a .env file (only for contributing)\nfunc readDotEnv(logger *logrus.Logger) map[string]string {\n\tresult := make(map[string]string)\n\n\tfile, err := os.Open(\".env\")\n\tif err != nil {\n\t\treturn result\n\t}\n\tdefer file.Close()\n\n\tscanner := bufio.NewScanner(file)\n\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\tif strings.HasPrefix(line, \"#\") {\n\t\t\tcontinue\n\t\t}\n\n\t\tkeyval := strings.SplitN(line, \"=\", 2)\n\t\tif len(keyval) != 2 {\n\t\t\tlogger.WithField(\"line\", line).Warn(\"invalid line in .env file\")\n\t\t\tcontinue\n\t\t}\n\n\t\tresult[keyval[0]] = keyval[1]\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tlogger.WithError(err).Fatal(\"error reading dotenv\")\n\t}\n\n\treturn result\n}\n\ntype HttpConfig struct {\n\tEnabled      bool   `env:\"HTTP_ENABLED,default=True\"`\n\tPort         int    `env:\"HTTP_PORT,default=8080\"`\n\tAddress      string `env:\"HTTP_ADDRESS,default=:\"`\n\tRootPath     string `env:\"HTTP_ROOT_PATH,default=/\"`\n\tAccessLog    bool   `env:\"HTTP_ACCESS_LOG,default=True\"`\n\tServeWebUI   bool   `env:\"HTTP_SERVE_WEB_UI,default=True\"`\n\tServeWebUIV2 bool   `env:\"HTTP_SERVE_WEB_UI_V2,default=False\"`\n\tServeSwagger bool   `env:\"HTTP_SERVE_SWAGGER,default=False\"`\n\tSecretKey    []byte `env:\"HTTP_SECRET_KEY\"`\n\t// Fiber Specific\n\tBodyLimit                    int           `env:\"HTTP_BODY_LIMIT,default=1024\"`\n\tReadTimeout                  time.Duration `env:\"HTTP_READ_TIMEOUT,default=10s\"`\n\tWriteTimeout                 time.Duration `env:\"HTTP_WRITE_TIMEOUT,default=10s\"`\n\tIDLETimeout                  time.Duration `env:\"HTTP_IDLE_TIMEOUT,default=10s\"`\n\tDisableKeepAlive             bool          `env:\"HTTP_DISABLE_KEEP_ALIVE,default=true\"`\n\tDisablePreParseMultipartForm bool          `env:\"HTTP_DISABLE_PARSE_MULTIPART_FORM,default=true\"`\n\n\tSSOProxyAuth           bool     `env:\"SSO_PROXY_AUTH_ENABLED,default=false\"`\n\tSSOProxyAuthHeaderName string   `env:\"SSO_PROXY_AUTH_HEADER_NAME,default=Remote-User\"`\n\tSSOProxyAuthTrusted    []string `env:\"SSO_PROXY_AUTH_TRUSTED,default=10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7\"`\n}\n\n// SetDefaults sets the default values for the configuration\nfunc (c *HttpConfig) SetDefaults(logger *logrus.Logger) {\n\t// Set a random secret key if not set\n\tif len(c.SecretKey) == 0 {\n\t\tlogger.Warn(\"SHIORI_HTTP_SECRET_KEY is not set, using random value. This means that all sessions will be invalidated on server restart.\")\n\t\trandomUUID, err := uuid.NewV4()\n\t\tif err != nil {\n\t\t\tlogger.WithError(err).Fatal(\"couldn't generate a random UUID\")\n\t\t}\n\t\tc.SecretKey = []byte(randomUUID.String())\n\t}\n}\n\nfunc (c *HttpConfig) IsValid() error {\n\tif !strings.HasSuffix(c.RootPath, \"/\") {\n\t\treturn fmt.Errorf(\"root path should end with a slash\")\n\t}\n\n\tif c.ServeWebUIV2 && !c.ServeWebUI {\n\t\treturn fmt.Errorf(\"you need to enable serving the Web UI to use the experimental Web UI v2\")\n\t}\n\n\treturn nil\n}\n\ntype DatabaseConfig struct {\n\tDBMS string `env:\"DBMS\"` // Deprecated\n\t// DBMS requires more environment variables. Check the database package for more information.\n\tURL string `env:\"DATABASE_URL\"`\n}\n\ntype StorageConfig struct {\n\tDataDir string `env:\"DIR\"` // Using DIR to be backwards compatible with the old config\n}\n\ntype Config struct {\n\tHostname    string `env:\"HOSTNAME,required\"`\n\tDevelopment bool   `env:\"DEVELOPMENT,default=False\"`\n\tLogLevel    string // Set only from the CLI flag\n\tDatabase    *DatabaseConfig\n\tStorage     *StorageConfig\n\tHttp        *HttpConfig\n}\n\n// SetDefaults sets the default values for the configuration\nfunc (c Config) SetDefaults(logger *logrus.Logger, portableMode bool) {\n\t// Set the default storage directory if not set, setting also the database url for\n\t// sqlite3 if that engine is used\n\tif c.Storage.DataDir == \"\" {\n\t\tvar err error\n\t\tc.Storage.DataDir, err = getStorageDirectory(portableMode)\n\t\tif err != nil {\n\t\t\tlogger.WithError(err).Fatal(\"couldn't determine the data directory\")\n\t\t}\n\t}\n\n\t// Set default database url if not set\n\tif c.Database.DBMS == \"\" && c.Database.URL == \"\" {\n\t\tc.Database.URL = fmt.Sprintf(\"sqlite:///%s?_txlock=immediate\", filepath.Join(c.Storage.DataDir, \"shiori.db\"))\n\t}\n\n\tc.Http.SetDefaults(logger)\n}\n\nfunc (c *Config) DebugConfiguration(logger *logrus.Logger) {\n\tlogger.Debug(\"Configuration:\")\n\tlogger.Debugf(\" SHIORI_HOSTNAME: %s\", c.Hostname)\n\tlogger.Debugf(\" SHIORI_DEVELOPMENT: %t\", c.Development)\n\tlogger.Debugf(\" SHIORI_DATABASE_URL: %s\", c.Database.URL)\n\tlogger.Debugf(\" SHIORI_DBMS: %s\", c.Database.DBMS)\n\tlogger.Debugf(\" SHIORI_DIR: %s\", c.Storage.DataDir)\n\tlogger.Debugf(\" SHIORI_HTTP_ENABLED: %t\", c.Http.Enabled)\n\tlogger.Debugf(\" SHIORI_HTTP_PORT: %d\", c.Http.Port)\n\tlogger.Debugf(\" SHIORI_HTTP_ADDRESS: %s\", c.Http.Address)\n\tlogger.Debugf(\" SHIORI_HTTP_ROOT_PATH: %s\", c.Http.RootPath)\n\tlogger.Debugf(\" SHIORI_HTTP_ACCESS_LOG: %t\", c.Http.AccessLog)\n\tlogger.Debugf(\" SHIORI_HTTP_SERVE_WEB_UI: %t\", c.Http.ServeWebUI)\n\tlogger.Debugf(\" SHIORI_HTTP_SERVE_WEB_UI_V2: %t\", c.Http.ServeWebUIV2)\n\tlogger.Debugf(\" SHIORI_HTTP_SECRET_KEY: %d characters\", len(c.Http.SecretKey))\n\tlogger.Debugf(\" SHIORI_HTTP_BODY_LIMIT: %d\", c.Http.BodyLimit)\n\tlogger.Debugf(\" SHIORI_HTTP_READ_TIMEOUT: %s\", c.Http.ReadTimeout)\n\tlogger.Debugf(\" SHIORI_HTTP_WRITE_TIMEOUT: %s\", c.Http.WriteTimeout)\n\tlogger.Debugf(\" SHIORI_HTTP_IDLE_TIMEOUT: %s\", c.Http.IDLETimeout)\n\tlogger.Debugf(\" SHIORI_HTTP_DISABLE_KEEP_ALIVE: %t\", c.Http.DisableKeepAlive)\n\tlogger.Debugf(\" SHIORI_HTTP_DISABLE_PARSE_MULTIPART_FORM: %t\", c.Http.DisablePreParseMultipartForm)\n\tlogger.Debugf(\" SHIORI_SSO_PROXY_AUTH_ENABLED: %t\", c.Http.SSOProxyAuth)\n\tlogger.Debugf(\" SHIORI_SSO_PROXY_AUTH_HEADER_NAME: %s\", c.Http.SSOProxyAuthHeaderName)\n\tlogger.Debugf(\" SHIORI_SSO_PROXY_AUTH_TRUSTED: %v\", c.Http.SSOProxyAuthTrusted)\n}\n\nfunc (c *Config) IsValid() error {\n\tif err := c.Http.IsValid(); err != nil {\n\t\treturn fmt.Errorf(\"http configuration is invalid: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// ParseServerConfiguration parses the configuration from the enabled lookupers\nfunc ParseServerConfiguration(ctx context.Context, logger *logrus.Logger) *Config {\n\tvar cfg Config\n\n\tlookupers := envconfig.MultiLookuper(\n\t\tenvconfig.MapLookuper(map[string]string{\"HOSTNAME\": os.Getenv(\"HOSTNAME\")}),\n\t\tenvconfig.MapLookuper(readDotEnv(logger)),\n\t\tenvconfig.PrefixLookuper(\"SHIORI_\", envconfig.OsLookuper()),\n\t)\n\n\tif err := envconfig.ProcessWith(ctx, &envconfig.Config{\n\t\tTarget:   &cfg,\n\t\tLookuper: lookupers,\n\t}); err != nil {\n\t\tlogger.WithError(err).Fatal(\"Error parsing configuration\")\n\t}\n\n\treturn &cfg\n}\n"
  },
  {
    "path": "internal/config/config_test.go",
    "content": "package config\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestHostnameVariable(t *testing.T) {\n\tos.Setenv(\"HOSTNAME\", \"test_hostname\")\n\tdefer os.Unsetenv(\"HOSTNAME\")\n\n\tlog := logrus.New()\n\tcfg := ParseServerConfiguration(context.TODO(), log)\n\n\trequire.Equal(t, \"test_hostname\", cfg.Hostname)\n}\n\n// TestBackwardsCompatibility tests that the old environment variables changed from 1.5.5 onwards\n// are still supported and working with the new configuration system.\nfunc TestBackwardsCompatibility(t *testing.T) {\n\tfor _, env := range []struct {\n\t\tenv  string\n\t\twant string\n\t\teval func(t *testing.T, cfg *Config)\n\t}{\n\t\t{\"HOSTNAME\", \"test_hostname\", func(t *testing.T, cfg *Config) {\n\t\t\trequire.Equal(t, \"test_hostname\", cfg.Hostname)\n\t\t}},\n\t\t{\"SHIORI_DIR\", \"test\", func(t *testing.T, cfg *Config) {\n\t\t\trequire.Equal(t, \"test\", cfg.Storage.DataDir)\n\t\t}},\n\t\t{\"SHIORI_DBMS\", \"test\", func(t *testing.T, cfg *Config) {\n\t\t\trequire.Equal(t, \"test\", cfg.Database.DBMS)\n\t\t}},\n\t} {\n\t\tt.Run(env.env, func(t *testing.T) {\n\t\t\tos.Setenv(env.env, env.want)\n\t\t\tt.Cleanup(func() {\n\t\t\t\tos.Unsetenv(env.env)\n\t\t\t})\n\n\t\t\tlog := logrus.New()\n\t\t\tcfg := ParseServerConfiguration(context.Background(), log)\n\t\t\tenv.eval(t, cfg)\n\t\t})\n\t}\n}\n\nfunc TestReadDotEnv(t *testing.T) {\n\tlog := logrus.New()\n\n\tfor _, testCase := range []struct {\n\t\tname string\n\t\tline string\n\t\tenv  map[string]string\n\t}{\n\t\t{\"empty\", \"\", map[string]string{}},\n\t\t{\"comment\", \"# comment\", map[string]string{}},\n\t\t{\"ignore invalid lines\", \"invalid line\", map[string]string{}},\n\t\t{\"single variable\", \"SHIORI_HTTP_PORT=9999\", map[string]string{\"SHIORI_HTTP_PORT\": \"9999\"}},\n\t\t{\"multiple variable\", \"SHIORI_HTTP_PORT=9999\\nSHIORI_HTTP_SECRET_KEY=123123\", map[string]string{\"SHIORI_HTTP_PORT\": \"9999\", \"SHIORI_HTTP_SECRET_KEY\": \"123123\"}},\n\t} {\n\t\tt.Run(testCase.name, func(t *testing.T) {\n\t\t\ttmpDir, err := os.MkdirTemp(\"\", \"\")\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.NoError(t, os.Chdir(tmpDir))\n\n\t\t\t// Write the .env file in the temporary directory\n\t\t\thandler, err := os.OpenFile(\".env\", os.O_CREATE|os.O_WRONLY, 0655)\n\t\t\trequire.NoError(t, err)\n\t\t\thandler.Write([]byte(testCase.line + \"\\n\"))\n\t\t\thandler.Close()\n\n\t\t\te := readDotEnv(log)\n\n\t\t\trequire.Equal(t, testCase.env, e)\n\t\t})\n\t}\n\n\tt.Run(\"no file\", func(t *testing.T) {\n\t\ttmpDir, err := os.MkdirTemp(\"\", \"\")\n\t\trequire.NoError(t, err)\n\t\trequire.NoError(t, os.Chdir(tmpDir))\n\n\t\te := readDotEnv(log)\n\n\t\trequire.Equal(t, map[string]string{}, e)\n\t})\n}\n\nfunc TestConfigSetDefaults(t *testing.T) {\n\tlog := logrus.New()\n\tcfg := ParseServerConfiguration(context.TODO(), log)\n\tcfg.SetDefaults(log, false)\n\n\trequire.NotEmpty(t, cfg.Http.SecretKey)\n\trequire.NotEmpty(t, cfg.Storage.DataDir)\n\trequire.NotEmpty(t, cfg.Database.URL)\n}\n\nfunc TestConfigIsValid(t *testing.T) {\n\tlog := logrus.New()\n\n\tt.Run(\"valid configuration\", func(t *testing.T) {\n\t\tcfg := ParseServerConfiguration(context.TODO(), log)\n\t\tcfg.SetDefaults(log, false)\n\t\trequire.NoError(t, cfg.IsValid())\n\t})\n\n\tt.Run(\"invalid http root path\", func(t *testing.T) {\n\t\tcfg := ParseServerConfiguration(context.TODO(), log)\n\t\tcfg.Http.RootPath = \"/invalid\"\n\t\trequire.Error(t, cfg.IsValid())\n\t})\n}\n"
  },
  {
    "path": "internal/config/storage.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\tgap \"github.com/muesli/go-app-paths\"\n)\n\nfunc getStorageDirectory(portableMode bool) (string, error) {\n\t// If in portable mode, uses directory of executable\n\tif portableMode {\n\t\texePath, err := os.Executable()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\n\t\texeDir := filepath.Dir(exePath)\n\t\treturn filepath.Join(exeDir, \"shiori-data\"), nil\n\t}\n\n\t// Try to use platform specific app path\n\tuserScope := gap.NewScope(gap.User, \"shiori\")\n\tdataDir, err := userScope.DataPath(\"\")\n\tif err == nil {\n\t\treturn dataDir, nil\n\t}\n\n\treturn \"\", fmt.Errorf(\"couldn't determine the data directory\")\n}\n"
  },
  {
    "path": "internal/core/core.go",
    "content": "package core\n\nimport \"github.com/go-shiori/shiori/internal/model\"\n\nvar userAgent = \"Shiori/\" + model.BuildVersion + \" (\" + model.BuildCommit + \") (+https://github.com/go-shiori/shiori)\"\n"
  },
  {
    "path": "internal/core/download.go",
    "content": "package core\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n)\n\nvar httpClient = &http.Client{Timeout: time.Minute}\n\n// DownloadBookmark downloads bookmarked page from specified URL.\n// Return response body, make sure to close it later.\nfunc DownloadBookmark(url string) (io.ReadCloser, string, error) {\n\t// Prepare download request\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\t// Send download request\n\treq.Header.Set(\"User-Agent\", userAgent)\n\tresp, err := httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\t// Get content type\n\tcontentType := resp.Header.Get(\"Content-Type\")\n\n\treturn resp.Body, contentType, nil\n}\n"
  },
  {
    "path": "internal/core/ebook.go",
    "content": "package core\n\nimport (\n\t\"os\"\n\tfp \"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\n\tepub \"github.com/go-shiori/go-epub\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/pkg/errors\"\n)\n\n// GenerateEbook receives a `ProcessRequest` and generates an ebook file in the destination path specified.\n// The destination path `dstPath` should include file name with \".epub\" extension\n// The bookmark model will be used to update the UI based on whether this function is successful or not.\nfunc GenerateEbook(deps model.Dependencies, req ProcessRequest, dstPath string) (book model.BookmarkDTO, err error) {\n\tbook = req.Bookmark\n\n\t// Make sure bookmark ID is defined\n\tif book.ID == 0 {\n\t\treturn book, errors.New(\"bookmark ID is not valid\")\n\t}\n\n\t// Get current state of bookmark cheak archive and thumb\n\tstrID := strconv.Itoa(book.ID)\n\n\tbookmarkThumbnailPath := model.GetThumbnailPath(&book)\n\tbookmarkArchivePath := model.GetArchivePath(&book)\n\n\tif deps.Domains().Storage().FileExists(bookmarkThumbnailPath) {\n\t\tbook.ImageURL = fp.Join(\"/\", \"bookmark\", strID, \"thumb\")\n\t}\n\n\tif deps.Domains().Storage().FileExists(bookmarkArchivePath) {\n\t\tbook.HasArchive = true\n\t}\n\n\t// This function create ebook from reader mode of bookmark so\n\t// we can't create ebook from PDF so we return error here if bookmark is a pdf\n\tcontentType := req.ContentType\n\tif strings.Contains(contentType, \"application/pdf\") {\n\t\treturn book, errors.New(\"can't create ebook for pdf\")\n\t}\n\n\t// Create temporary epub file\n\ttmpFile, err := os.CreateTemp(\"\", \"ebook\")\n\tif err != nil {\n\t\treturn book, errors.Wrap(err, \"can't create temporary EPUB file\")\n\t}\n\tdefer os.Remove(tmpFile.Name())\n\n\t// Create last line of ebook\n\tlastline := `<hr/><p style=\"text-align:center\">Generated By <a href=\"https://github.com/go-shiori/shiori\">Shiori</a> From <a href=\"` + book.URL + `\">This Page</a></p>`\n\n\t// Create ebook\n\tebook, err := epub.NewEpub(book.Title)\n\tif err != nil {\n\t\treturn book, errors.Wrap(err, \"can't create EPUB\")\n\t}\n\n\tebook.SetTitle(book.Title)\n\tebook.SetAuthor(book.Author)\n\tif deps.Domains().Storage().FileExists(bookmarkThumbnailPath) {\n\t\t// TODO: Use `deps.Domains.Storage` to retrieve the file.\n\t\tabsoluteCoverPath := fp.Join(deps.Config().Storage.DataDir, bookmarkThumbnailPath)\n\t\tcoverPath, _ := ebook.AddImage(absoluteCoverPath, \"cover.jpg\")\n\t\tebook.SetCover(coverPath, \"\")\n\t}\n\tebook.SetDescription(book.Excerpt)\n\t_, err = ebook.AddSection(`<h1 style=\"text-align:center\"> `+book.Title+` </h1>`+book.HTML+lastline, book.Title, \"\", \"\")\n\tif err != nil {\n\t\treturn book, errors.Wrap(err, \"can't add ebook Section\")\n\t}\n\tebook.EmbedImages()\n\terr = ebook.Write(tmpFile.Name())\n\tif err != nil {\n\t\treturn book, errors.Wrap(err, \"can't create ebook file\")\n\t}\n\n\tdefer tmpFile.Close()\n\n\t// If everything go well we move ebook to dstPath\n\terr = deps.Domains().Storage().WriteFile(dstPath, tmpFile)\n\tif err != nil {\n\t\treturn book, errors.Wrap(err, \"failed move ebook to destination\")\n\t}\n\n\tbook.HasEbook = true\n\treturn book, nil\n}\n"
  },
  {
    "path": "internal/core/ebook_test.go",
    "content": "package core_test\n\nimport (\n\t\"context\"\n\t\"os\"\n\tfp \"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/core\"\n\t\"github.com/go-shiori/shiori/internal/domains\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/spf13/afero\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestGenerateEbook(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tt.Run(\"Successful ebook generate\", func(t *testing.T) {\n\t\tt.Run(\"valid bookmarkId that return HasEbook true\", func(t *testing.T) {\n\t\t\tdstFile := \"/ebook/1.epub\"\n\t\t\ttmpDir := t.TempDir()\n\n\t\t\tdeps.Domains().SetStorage(domains.NewStorageDomain(deps, afero.NewBasePathFs(afero.NewOsFs(), tmpDir)))\n\n\t\t\tmockRequest := core.ProcessRequest{\n\t\t\t\tBookmark: model.BookmarkDTO{\n\t\t\t\t\tID:       1,\n\t\t\t\t\tTitle:    \"Example Bookmark\",\n\t\t\t\t\tHTML:     \"<html><body>Example HTML</body></html>\",\n\t\t\t\t\tHasEbook: false,\n\t\t\t\t},\n\t\t\t\tDataDir:     tmpDir,\n\t\t\t\tContentType: \"text/html\",\n\t\t\t}\n\t\t\tbookmark, err := core.GenerateEbook(deps, mockRequest, dstFile)\n\n\t\t\tassert.True(t, bookmark.HasEbook)\n\t\t\tassert.NoError(t, err)\n\t\t})\n\t\tt.Run(\"ebook generate with valid BookmarkID EbookExist ImagePathExist ReturnWithHasEbookTrue\", func(t *testing.T) {\n\t\t\tdstFile := \"/ebook/2.epub\"\n\t\t\ttmpDir := t.TempDir()\n\n\t\t\tdeps.Domains().SetStorage(domains.NewStorageDomain(deps, afero.NewBasePathFs(afero.NewOsFs(), tmpDir)))\n\n\t\t\tbookmark := model.BookmarkDTO{\n\t\t\t\tID:       2,\n\t\t\t\tHasEbook: false,\n\t\t\t}\n\t\t\tmockRequest := core.ProcessRequest{\n\t\t\t\tBookmark:    bookmark,\n\t\t\t\tDataDir:     tmpDir,\n\t\t\t\tContentType: \"text/html\",\n\t\t\t}\n\t\t\t// Create the thumbnail file\n\t\t\timagePath := model.GetThumbnailPath(&bookmark)\n\t\t\timagedirPath := fp.Dir(imagePath)\n\t\t\tdeps.Domains().Storage().FS().MkdirAll(imagedirPath, os.ModePerm)\n\t\t\tfile, err := deps.Domains().Storage().FS().Create(imagePath)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\n\t\t\tbookmark, err = core.GenerateEbook(deps, mockRequest, dstFile)\n\t\t\texpectedImagePath := string(fp.Separator) + fp.Join(\"bookmark\", \"2\", \"thumb\")\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.True(t, bookmark.HasEbook)\n\t\t\tassert.Equalf(t, expectedImagePath, bookmark.ImageURL, \"Expected imageURL %s, but got %s\", expectedImagePath, bookmark.ImageURL)\n\t\t})\n\t\tt.Run(\"generate ebook valid BookmarkID EbookExist ReturnHasArchiveTrue\", func(t *testing.T) {\n\t\t\tdstFile := \"/ebook/3.epub\"\n\t\t\ttmpDir := t.TempDir()\n\n\t\t\tdeps.Domains().SetStorage(domains.NewStorageDomain(deps, afero.NewBasePathFs(afero.NewOsFs(), tmpDir)))\n\n\t\t\tbookmark := model.BookmarkDTO{\n\t\t\t\tID:       3,\n\t\t\t\tHasEbook: false,\n\t\t\t}\n\t\t\tmockRequest := core.ProcessRequest{\n\t\t\t\tBookmark:    bookmark,\n\t\t\t\tDataDir:     tmpDir,\n\t\t\t\tContentType: \"text/html\",\n\t\t\t}\n\t\t\t// Create the archive file\n\t\t\tarchivePath := model.GetArchivePath(&bookmark)\n\t\t\tarchiveDirPath := fp.Dir(archivePath)\n\t\t\tdeps.Domains().Storage().FS().MkdirAll(archiveDirPath, os.ModePerm)\n\t\t\tfile, err := deps.Domains().Storage().FS().Create(archivePath)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\n\t\t\tbookmark, err = core.GenerateEbook(deps, mockRequest, fp.Join(dstFile, \"1\"))\n\t\t\tassert.True(t, bookmark.HasArchive)\n\t\t\tassert.NoError(t, err)\n\t\t})\n\t})\n\tt.Run(\"specific ebook generate case\", func(t *testing.T) {\n\t\tt.Run(\"invalid bookmarkId that return Error\", func(t *testing.T) {\n\t\t\tdstFile := \"/ebook/0.epub\"\n\t\t\ttmpDir := t.TempDir()\n\t\t\tmockRequest := core.ProcessRequest{\n\t\t\t\tBookmark: model.BookmarkDTO{\n\t\t\t\t\tID:       0,\n\t\t\t\t\tHasEbook: false,\n\t\t\t\t},\n\t\t\t\tDataDir:     tmpDir,\n\t\t\t\tContentType: \"text/html\",\n\t\t\t}\n\n\t\t\tbookmark, err := core.GenerateEbook(deps, mockRequest, dstFile)\n\n\t\t\tassert.Equal(t, model.BookmarkDTO{\n\t\t\t\tID:       0,\n\t\t\t\tHasEbook: false,\n\t\t\t}, bookmark)\n\t\t\tassert.EqualError(t, err, \"bookmark ID is not valid\")\n\t\t})\n\t\tt.Run(\"ebook exist return HasEbook true\", func(t *testing.T) {\n\t\t\tdstFile := \"/ebook/1.epub\"\n\t\t\ttmpDir := t.TempDir()\n\n\t\t\tdeps.Domains().SetStorage(domains.NewStorageDomain(deps, afero.NewBasePathFs(afero.NewOsFs(), tmpDir)))\n\n\t\t\tbookmark := model.BookmarkDTO{\n\t\t\t\tID:       1,\n\t\t\t\tHasEbook: false,\n\t\t\t}\n\t\t\tmockRequest := core.ProcessRequest{\n\t\t\t\tBookmark:    bookmark,\n\t\t\t\tDataDir:     tmpDir,\n\t\t\t\tContentType: \"text/html\",\n\t\t\t}\n\t\t\t// Create the ebook file\n\t\t\tebookPath := model.GetEbookPath(&bookmark)\n\t\t\tebookDirPath := fp.Dir(ebookPath)\n\t\t\tdeps.Domains().Storage().FS().MkdirAll(ebookDirPath, os.ModePerm)\n\t\t\tfile, err := deps.Domains().Storage().FS().Create(ebookPath)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\n\t\t\tbookmark, err = core.GenerateEbook(deps, mockRequest, dstFile)\n\n\t\t\tassert.True(t, bookmark.HasEbook)\n\t\t\tassert.NoError(t, err)\n\t\t})\n\t\tt.Run(\"generate ebook valid BookmarkID RetuenError for PDF file\", func(t *testing.T) {\n\t\t\tdstFile := \"/ebook/1.epub\"\n\t\t\ttmpDir := t.TempDir()\n\n\t\t\tmockRequest := core.ProcessRequest{\n\t\t\t\tBookmark: model.BookmarkDTO{\n\t\t\t\t\tID:       1,\n\t\t\t\t\tHasEbook: false,\n\t\t\t\t},\n\t\t\t\tDataDir:     tmpDir,\n\t\t\t\tContentType: \"application/pdf\",\n\t\t\t}\n\n\t\t\tbookmark, err := core.GenerateEbook(deps, mockRequest, dstFile)\n\n\t\t\tassert.False(t, bookmark.HasEbook)\n\t\t\tassert.Error(t, err)\n\t\t\tassert.EqualError(t, err, \"can't create ebook for pdf\")\n\t\t})\n\t})\n}\n"
  },
  {
    "path": "internal/core/processing.go",
    "content": "package core\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"image\"\n\t\"image/color\"\n\t\"image/draw\"\n\t\"image/jpeg\"\n\t\"io\"\n\t\"log\"\n\t\"math\"\n\t\"net/url\"\n\t\"os\"\n\tfp \"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/disintegration/imaging\"\n\t\"github.com/go-shiori/go-readability\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/warc\"\n\t\"github.com/pkg/errors\"\n\t_ \"golang.org/x/image/webp\"\n\n\t// Add support for png\n\t_ \"image/png\"\n)\n\n// ProcessRequest is the request for processing bookmark.\ntype ProcessRequest struct {\n\tDataDir     string\n\tBookmark    model.BookmarkDTO\n\tContent     io.Reader\n\tContentType string\n\tKeepTitle   bool\n\tKeepExcerpt bool\n\tLogArchival bool\n}\n\nvar ErrNoSupportedImageType = errors.New(\"unsupported image type\")\n\n// ProcessBookmark process the bookmark and archive it if needed.\n// Return three values, is error fatal, and error value.\nfunc ProcessBookmark(deps model.Dependencies, req ProcessRequest) (book model.BookmarkDTO, isFatalErr bool, err error) {\n\tbook = req.Bookmark\n\tcontentType := req.ContentType\n\n\t// Make sure bookmark ID is defined\n\tif book.ID == 0 {\n\t\treturn book, true, fmt.Errorf(\"bookmark ID is not valid\")\n\t}\n\n\t// Split bookmark content so it can be processed several times\n\tarchivalInput := bytes.NewBuffer(nil)\n\treadabilityInput := bytes.NewBuffer(nil)\n\treadabilityCheckInput := bytes.NewBuffer(nil)\n\n\tvar multiWriter io.Writer\n\tif !strings.Contains(contentType, \"text/html\") {\n\t\tmultiWriter = io.MultiWriter(archivalInput)\n\t} else {\n\t\tmultiWriter = io.MultiWriter(archivalInput, readabilityInput, readabilityCheckInput)\n\t}\n\n\t_, err = io.Copy(multiWriter, req.Content)\n\tif err != nil {\n\t\treturn book, false, fmt.Errorf(\"failed to process article: %v\", err)\n\t}\n\n\t// If this is HTML, parse for readable content\n\tstrID := strconv.Itoa(book.ID)\n\timgPath := model.GetThumbnailPath(&book)\n\tvar imageURLs []string\n\tif strings.Contains(contentType, \"text/html\") {\n\t\tisReadable := readability.Check(readabilityCheckInput)\n\n\t\tnurl, err := url.Parse(book.URL)\n\t\tif err != nil {\n\t\t\treturn book, true, fmt.Errorf(\"failed to parse url: %v\", err)\n\t\t}\n\n\t\tarticle, err := readability.FromReader(readabilityInput, nurl)\n\t\tif err != nil {\n\t\t\treturn book, false, fmt.Errorf(\"failed to parse article: %v\", err)\n\t\t}\n\n\t\tbook.Author = article.Byline\n\t\tbook.Content = article.TextContent\n\t\tbook.HTML = article.Content\n\n\t\t// If title and excerpt doesnt have submitted value, use from article\n\t\tif !req.KeepTitle || book.Title == \"\" {\n\t\t\tbook.Title = article.Title\n\t\t}\n\n\t\tif !req.KeepExcerpt || book.Excerpt == \"\" {\n\t\t\tbook.Excerpt = article.Excerpt\n\t\t}\n\n\t\t// Sometimes article doesn't have any title, so make sure it is not empty\n\t\tif book.Title == \"\" {\n\t\t\tbook.Title = book.URL\n\t\t}\n\n\t\t// Get image URL\n\t\tif article.Image != \"\" {\n\t\t\timageURLs = append(imageURLs, article.Image)\n\t\t} else {\n\t\t\tdeps.Domains().Storage().FS().Remove(imgPath)\n\t\t}\n\n\t\tif article.Favicon != \"\" {\n\t\t\timageURLs = append(imageURLs, article.Favicon)\n\t\t}\n\n\t\tif !isReadable {\n\t\t\tbook.Content = \"\"\n\t\t}\n\n\t\tbook.HasContent = book.Content != \"\"\n\t\tbook.ModifiedAt = \"\"\n\t}\n\n\t// Save article image to local disk\n\tfor i, imageURL := range imageURLs {\n\t\terr = DownloadBookImage(deps, imageURL, imgPath)\n\t\tif err != nil && errors.Is(err, ErrNoSupportedImageType) {\n\t\t\tlog.Printf(\"%s: %s\", err, imageURL)\n\t\t\tif i == len(imageURLs)-1 {\n\t\t\t\tdeps.Domains().Storage().FS().Remove(imgPath)\n\t\t\t}\n\t\t}\n\t\tif err != nil {\n\t\t\tlog.Printf(\"File download not successful for image URL: %s\", imageURL)\n\t\t\tcontinue\n\t\t}\n\t\tif err == nil {\n\t\t\tbook.ImageURL = fp.Join(\"/\", \"bookmark\", strID, \"thumb\")\n\t\t\tbook.ModifiedAt = \"\"\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// If needed, create ebook as well\n\tif book.CreateEbook {\n\t\tebookPath := model.GetEbookPath(&book)\n\t\treq.Bookmark = book\n\n\t\tif strings.Contains(contentType, \"application/pdf\") {\n\t\t\treturn book, false, errors.Wrap(err, \"can't create ebook from pdf\")\n\t\t} else {\n\t\t\t_, err = GenerateEbook(deps, req, ebookPath)\n\t\t\tif err != nil {\n\t\t\t\treturn book, true, errors.Wrap(err, \"failed to create ebook\")\n\t\t\t}\n\t\t\tbook.HasEbook = true\n\t\t\tbook.ModifiedAt = \"\"\n\t\t}\n\t}\n\n\t// If needed, create offline archive as well\n\tif book.CreateArchive {\n\t\ttmpFile, err := os.CreateTemp(\"\", \"archive\")\n\t\tif err != nil {\n\t\t\treturn book, false, fmt.Errorf(\"failed to create temp archive: %v\", err)\n\t\t}\n\t\tdefer os.Remove(tmpFile.Name())\n\n\t\tarchivalRequest := warc.ArchivalRequest{\n\t\t\tURL:         book.URL,\n\t\t\tReader:      archivalInput,\n\t\t\tContentType: contentType,\n\t\t\tUserAgent:   userAgent,\n\t\t\tLogEnabled:  req.LogArchival,\n\t\t}\n\n\t\terr = warc.NewArchive(archivalRequest, tmpFile.Name())\n\t\tif err != nil {\n\t\t\treturn book, false, fmt.Errorf(\"failed to create archive: %v\", err)\n\t\t}\n\n\t\tdstPath := model.GetArchivePath(&book)\n\t\terr = deps.Domains().Storage().WriteFile(dstPath, tmpFile)\n\t\tif err != nil {\n\t\t\treturn book, false, fmt.Errorf(\"failed move archive to destination `: %v\", err)\n\t\t}\n\n\t\tbook.HasArchive = true\n\t\tbook.ModifiedAt = \"\"\n\t}\n\n\treturn book, false, nil\n}\n\nfunc DownloadBookImage(deps model.Dependencies, url, dstPath string) error {\n\t// Fetch data from URL\n\tresp, err := httpClient.Get(url)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\t// Make sure it's JPG or PNG image\n\tcp := resp.Header.Get(\"Content-Type\")\n\tif !strings.Contains(cp, \"image/jpeg\") &&\n\t\t!strings.Contains(cp, \"image/pjpeg\") &&\n\t\t!strings.Contains(cp, \"image/jpg\") &&\n\t\t!strings.Contains(cp, \"image/webp\") &&\n\t\t!strings.Contains(cp, \"image/png\") {\n\t\treturn ErrNoSupportedImageType\n\t}\n\n\t// At this point, the download has finished successfully.\n\t// Create tmpFile\n\ttmpFile, err := os.CreateTemp(\"\", \"image\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create temporary image file: %v\", err)\n\t}\n\tdefer os.Remove(tmpFile.Name())\n\n\t// Parse image and process it.\n\t// If image is smaller than 600x400 or its ratio is less than 4:3, resize.\n\t// Else, save it as it is.\n\timg, _, err := image.Decode(resp.Body)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse image %s: %v\", url, err)\n\t}\n\n\timgRect := img.Bounds()\n\timgWidth := imgRect.Dx()\n\timgHeight := imgRect.Dy()\n\timgRatio := float64(imgWidth) / float64(imgHeight)\n\n\tif imgWidth >= 600 && imgHeight >= 400 && imgRatio > 1.3 {\n\t\terr = jpeg.Encode(tmpFile, img, nil)\n\t} else {\n\t\t// Create background\n\t\tbg := image.NewNRGBA(imgRect)\n\t\tdraw.Draw(bg, imgRect, image.NewUniform(color.White), image.Point{}, draw.Src)\n\t\tdraw.Draw(bg, imgRect, img, image.Point{}, draw.Over)\n\n\t\tbg = imaging.Fill(bg, 600, 400, imaging.Center, imaging.Lanczos)\n\t\tbg = imaging.Blur(bg, 150)\n\t\tbg = imaging.AdjustBrightness(bg, 30)\n\n\t\t// Create foreground\n\t\tfg := imaging.Fit(img, 600, 400, imaging.Lanczos)\n\n\t\t// Merge foreground and background\n\t\tbgRect := bg.Bounds()\n\t\tfgRect := fg.Bounds()\n\t\tfgPosition := image.Point{\n\t\t\tX: bgRect.Min.X - int(math.Round(float64(bgRect.Dx()-fgRect.Dx())/2)),\n\t\t\tY: bgRect.Min.Y - int(math.Round(float64(bgRect.Dy()-fgRect.Dy())/2)),\n\t\t}\n\n\t\tdraw.Draw(bg, bgRect, fg, fgPosition, draw.Over)\n\n\t\t// Save to file\n\t\terr = jpeg.Encode(tmpFile, bg, nil)\n\t}\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to save image %s: %v\", url, err)\n\t}\n\n\terr = deps.Domains().Storage().WriteFile(dstPath, tmpFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/core/processing_test.go",
    "content": "package core_test\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"os\"\n\tfp \"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/core\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestDownloadBookImage(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tt.Run(\"Download Images\", func(t *testing.T) {\n\t\tt.Run(\"fails\", func(t *testing.T) {\n\t\t\t// images is too small with unsupported format with a valid URL\n\t\t\timageURL := \"https://github.com/go-shiori/shiori/blob/master/internal/view/assets/res/apple-touch-icon-152x152.png\"\n\t\t\ttmpDir, err := os.MkdirTemp(\"\", \"\")\n\t\t\trequire.NoError(t, err)\n\t\t\tdstFile := fp.Join(tmpDir, \"image.png\")\n\n\t\t\t// Act\n\t\t\terr = core.DownloadBookImage(deps, imageURL, dstFile)\n\n\t\t\t// Assert\n\t\t\tassert.EqualError(t, err, \"unsupported image type\")\n\t\t\tassert.False(t, deps.Domains().Storage().FileExists(dstFile))\n\t\t})\n\t\tt.Run(\"successful download image\", func(t *testing.T) {\n\t\t\ttmpDir, err := os.MkdirTemp(\"\", \"\")\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.NoError(t, os.Chdir(tmpDir))\n\t\t\t// Arrange\n\t\t\timageURL := \"https://raw.githubusercontent.com/go-shiori/shiori/master/docs/assets/screenshots/cover.png\"\n\t\t\tdstFile := \".\" + string(fp.Separator) + \"cover.png\"\n\n\t\t\t// Act\n\t\t\terr = core.DownloadBookImage(deps, imageURL, dstFile)\n\n\t\t\t// Assert\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.True(t, deps.Domains().Storage().FileExists(dstFile))\n\t\t})\n\t\tt.Run(\"successful download medium size image\", func(t *testing.T) {\n\t\t\ttmpDir, err := os.MkdirTemp(\"\", \"\")\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.NoError(t, os.Chdir(tmpDir))\n\n\t\t\t// Arrange\n\t\t\timageURL := \"https://raw.githubusercontent.com/go-shiori/shiori/master/testdata/medium_image.png\"\n\t\t\tdstFile := \".\" + string(fp.Separator) + \"medium_image.png\"\n\n\t\t\t// Act\n\t\t\terr = core.DownloadBookImage(deps, imageURL, dstFile)\n\n\t\t\t// Assert\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.True(t, deps.Domains().Storage().FileExists(dstFile))\n\t\t})\n\t})\n}\n\nfunc TestProcessBookmark(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tt.Run(\"ProcessRequest with sucssesful result\", func(t *testing.T) {\n\t\ttmpDir := t.TempDir()\n\t\tt.Run(\"Normal without image\", func(t *testing.T) {\n\t\t\tbookmark := model.BookmarkDTO{\n\t\t\t\tID:            1,\n\t\t\t\tURL:           \"https://example.com\",\n\t\t\t\tTitle:         \"Example\",\n\t\t\t\tExcerpt:       \"This is an example article\",\n\t\t\t\tCreateEbook:   true,\n\t\t\t\tCreateArchive: true,\n\t\t\t}\n\t\t\tcontent := bytes.NewBufferString(\"<html><head></head><body><p>This is an example article</p></body></html>\")\n\t\t\trequest := core.ProcessRequest{\n\t\t\t\tBookmark:    bookmark,\n\t\t\t\tContent:     content,\n\t\t\t\tContentType: \"text/html\",\n\t\t\t\tDataDir:     tmpDir,\n\t\t\t\tKeepTitle:   true,\n\t\t\t\tKeepExcerpt: true,\n\t\t\t}\n\t\t\texpected, _, _ := core.ProcessBookmark(deps, request)\n\n\t\t\tif expected.ID != bookmark.ID {\n\t\t\t\tt.Errorf(\"Unexpected ID: got %v, want %v\", expected.ID, bookmark.ID)\n\t\t\t}\n\t\t\tif expected.URL != bookmark.URL {\n\t\t\t\tt.Errorf(\"Unexpected URL: got %v, want %v\", expected.URL, bookmark.URL)\n\t\t\t}\n\t\t\tif expected.Title != bookmark.Title {\n\t\t\t\tt.Errorf(\"Unexpected Title: got %v, want %v\", expected.Title, bookmark.Title)\n\t\t\t}\n\t\t\tif expected.Excerpt != bookmark.Excerpt {\n\t\t\t\tt.Errorf(\"Unexpected Excerpt: got %v, want %v\", expected.Excerpt, bookmark.Excerpt)\n\t\t\t}\n\t\t})\n\t\tt.Run(\"Normal with multipleimage\", func(t *testing.T) {\n\t\t\ttmpDir := t.TempDir()\n\t\t\thtml := `html<html>\n\t\t  <head>\n\t\t    <meta property=\"og:image\" content=\"http://example.com/image1.jpg\">\n\t\t    <meta property=\"og:image\" content=\"http://example.com/image2.jpg\">\n\t\t    <link rel=\"icon\" type=\"image/png\" href=\"http://example.com/favicon.png\">\n\t\t  </head>\n\t\t  <body>\n\t\t    <p>This is an example article</p>\n\t\t  </body>\n\t\t</html>`\n\t\t\tbookmark := model.BookmarkDTO{\n\t\t\t\tID:            1,\n\t\t\t\tURL:           \"https://example.com\",\n\t\t\t\tTitle:         \"Example\",\n\t\t\t\tExcerpt:       \"This is an example article\",\n\t\t\t\tCreateEbook:   true,\n\t\t\t\tCreateArchive: true,\n\t\t\t}\n\t\t\tcontent := bytes.NewBufferString(html)\n\t\t\trequest := core.ProcessRequest{\n\t\t\t\tBookmark:    bookmark,\n\t\t\t\tContent:     content,\n\t\t\t\tContentType: \"text/html\",\n\t\t\t\tDataDir:     tmpDir,\n\t\t\t\tKeepTitle:   true,\n\t\t\t\tKeepExcerpt: true,\n\t\t\t}\n\t\t\texpected, _, _ := core.ProcessBookmark(deps, request)\n\n\t\t\tif expected.ID != bookmark.ID {\n\t\t\t\tt.Errorf(\"Unexpected ID: got %v, want %v\", expected.ID, bookmark.ID)\n\t\t\t}\n\t\t\tif expected.URL != bookmark.URL {\n\t\t\t\tt.Errorf(\"Unexpected URL: got %v, want %v\", expected.URL, bookmark.URL)\n\t\t\t}\n\t\t\tif expected.Title != bookmark.Title {\n\t\t\t\tt.Errorf(\"Unexpected Title: got %v, want %v\", expected.Title, bookmark.Title)\n\t\t\t}\n\t\t\tif expected.Excerpt != bookmark.Excerpt {\n\t\t\t\tt.Errorf(\"Unexpected Excerpt: got %v, want %v\", expected.Excerpt, bookmark.Excerpt)\n\t\t\t}\n\t\t})\n\t\tt.Run(\"ProcessRequest sucssesful with multipleimage included favicon and Thumbnail \", func(t *testing.T) {\n\t\t\ttmpDir := t.TempDir()\n\t\t\thtml := `html<html>\n  \t\t\t<head>\n    \t\t<meta property=\"og:image\" content=\"http://example.com/image1.jpg\">\n    \t\t<meta property=\"og:image\" content=\"https://raw.githubusercontent.com/go-shiori/shiori/master/testdata/big_image.png\">\n    \t\t<link rel=\"icon\" type=\"image/svg\" href=\"https://raw.githubusercontent.com/go-shiori/shiori/master/testdata/favicon.svg\">\n  \t\t\t</head>\n  \t\t\t<body>\n    \t\t\t<p>This is an example article</p>\n  \t\t\t</body>\n\t\t\t</html>`\n\t\t\tbookmark := model.BookmarkDTO{\n\t\t\t\tID:            1,\n\t\t\t\tURL:           \"https://example.com\",\n\t\t\t\tTitle:         \"Example\",\n\t\t\t\tExcerpt:       \"This is an example article\",\n\t\t\t\tCreateEbook:   true,\n\t\t\t\tCreateArchive: true,\n\t\t\t}\n\t\t\tcontent := bytes.NewBufferString(html)\n\t\t\trequest := core.ProcessRequest{\n\t\t\t\tBookmark:    bookmark,\n\t\t\t\tContent:     content,\n\t\t\t\tContentType: \"text/html\",\n\t\t\t\tDataDir:     tmpDir,\n\t\t\t\tKeepTitle:   true,\n\t\t\t\tKeepExcerpt: true,\n\t\t\t}\n\t\t\texpected, _, _ := core.ProcessBookmark(deps, request)\n\t\t\tassert.True(t, deps.Domains().Storage().FileExists(fp.Join(\"thumb\", \"1\")))\n\t\t\tif expected.ID != bookmark.ID {\n\t\t\t\tt.Errorf(\"Unexpected ID: got %v, want %v\", expected.ID, bookmark.ID)\n\t\t\t}\n\t\t\tif expected.URL != bookmark.URL {\n\t\t\t\tt.Errorf(\"Unexpected URL: got %v, want %v\", expected.URL, bookmark.URL)\n\t\t\t}\n\t\t\tif expected.Title != bookmark.Title {\n\t\t\t\tt.Errorf(\"Unexpected Title: got %v, want %v\", expected.Title, bookmark.Title)\n\t\t\t}\n\t\t\tif expected.Excerpt != bookmark.Excerpt {\n\t\t\t\tt.Errorf(\"Unexpected Excerpt: got %v, want %v\", expected.Excerpt, bookmark.Excerpt)\n\t\t\t}\n\t\t})\n\t\tt.Run(\"ProcessRequest sucssesful with empty title \", func(t *testing.T) {\n\t\t\ttmpDir := t.TempDir()\n\t\t\tbookmark := model.BookmarkDTO{\n\t\t\t\tID:            1,\n\t\t\t\tURL:           \"https://example.com\",\n\t\t\t\tTitle:         \"\",\n\t\t\t\tExcerpt:       \"This is an example article\",\n\t\t\t\tCreateEbook:   true,\n\t\t\t\tCreateArchive: true,\n\t\t\t}\n\t\t\tcontent := bytes.NewBufferString(\"<html><head></head><body><p>This is an example article</p></body></html>\")\n\t\t\trequest := core.ProcessRequest{\n\t\t\t\tBookmark:    bookmark,\n\t\t\t\tContent:     content,\n\t\t\t\tContentType: \"text/html\",\n\t\t\t\tDataDir:     tmpDir,\n\t\t\t\tKeepTitle:   true,\n\t\t\t\tKeepExcerpt: true,\n\t\t\t}\n\t\t\texpected, _, _ := core.ProcessBookmark(deps, request)\n\n\t\t\tif expected.ID != bookmark.ID {\n\t\t\t\tt.Errorf(\"Unexpected ID: got %v, want %v\", expected.ID, bookmark.ID)\n\t\t\t}\n\t\t\tif expected.URL != bookmark.URL {\n\t\t\t\tt.Errorf(\"Unexpected URL: got %v, want %v\", expected.URL, bookmark.URL)\n\t\t\t}\n\t\t\tif expected.Title != bookmark.URL {\n\t\t\t\tt.Errorf(\"Unexpected Title: got %v, want %v\", expected.Title, bookmark.Title)\n\t\t\t}\n\t\t\tif expected.Excerpt != bookmark.Excerpt {\n\t\t\t\tt.Errorf(\"Unexpected Excerpt: got %v, want %v\", expected.Excerpt, bookmark.Excerpt)\n\t\t\t}\n\t\t})\n\t\tt.Run(\"ProcessRequest sucssesful with empty Excerpt\", func(t *testing.T) {\n\t\t\ttmpDir := t.TempDir()\n\t\t\tbookmark := model.BookmarkDTO{\n\t\t\t\tID:            1,\n\t\t\t\tURL:           \"https://example.com\",\n\t\t\t\tTitle:         \"\",\n\t\t\t\tExcerpt:       \"This is an example article\",\n\t\t\t\tCreateEbook:   true,\n\t\t\t\tCreateArchive: true,\n\t\t\t}\n\t\t\tcontent := bytes.NewBufferString(\"<html><head></head><body><p>This is an example article</p></body></html>\")\n\t\t\trequest := core.ProcessRequest{\n\t\t\t\tBookmark:    bookmark,\n\t\t\t\tContent:     content,\n\t\t\t\tContentType: \"text/html\",\n\t\t\t\tDataDir:     tmpDir,\n\t\t\t\tKeepTitle:   true,\n\t\t\t\tKeepExcerpt: false,\n\t\t\t}\n\t\t\texpected, _, _ := core.ProcessBookmark(deps, request)\n\n\t\t\tif expected.ID != bookmark.ID {\n\t\t\t\tt.Errorf(\"Unexpected ID: got %v, want %v\", expected.ID, bookmark.ID)\n\t\t\t}\n\t\t\tif expected.URL != bookmark.URL {\n\t\t\t\tt.Errorf(\"Unexpected URL: got %v, want %v\", expected.URL, bookmark.URL)\n\t\t\t}\n\t\t\tif expected.Title != bookmark.URL {\n\t\t\t\tt.Errorf(\"Unexpected Title: got %v, want %v\", expected.Title, bookmark.Title)\n\t\t\t}\n\t\t\tif expected.Excerpt != bookmark.Excerpt {\n\t\t\t\tt.Errorf(\"Unexpected Excerpt: got %v, want %v\", expected.Excerpt, bookmark.Excerpt)\n\t\t\t}\n\t\t})\n\t\tt.Run(\"Specific case\", func(t *testing.T) {\n\t\t\ttmpDir := t.TempDir()\n\t\t\tt.Run(\"ProcessRequest with ID zero\", func(t *testing.T) {\n\n\t\t\t\tbookmark := model.BookmarkDTO{\n\t\t\t\t\tID:            0,\n\t\t\t\t\tURL:           \"https://example.com\",\n\t\t\t\t\tTitle:         \"Example\",\n\t\t\t\t\tExcerpt:       \"This is an example article\",\n\t\t\t\t\tCreateEbook:   true,\n\t\t\t\t\tCreateArchive: true,\n\t\t\t\t}\n\t\t\t\tcontent := bytes.NewBufferString(\"<html><head></head><body><p>This is an example article</p></body></html>\")\n\t\t\t\trequest := core.ProcessRequest{\n\t\t\t\t\tBookmark:    bookmark,\n\t\t\t\t\tContent:     content,\n\t\t\t\t\tContentType: \"text/html\",\n\t\t\t\t\tDataDir:     tmpDir,\n\t\t\t\t\tKeepTitle:   true,\n\t\t\t\t\tKeepExcerpt: true,\n\t\t\t\t}\n\t\t\t\t_, isFatal, err := core.ProcessBookmark(deps, request)\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Contains(t, err.Error(), \"bookmark ID is not valid\")\n\t\t\t\tassert.True(t, isFatal)\n\t\t\t})\n\n\t\t\tt.Run(\"ProcessRequest that content type not zero\", func(t *testing.T) {\n\t\t\t\ttmpDir := t.TempDir()\n\t\t\t\tbookmark := model.BookmarkDTO{\n\t\t\t\t\tID:            1,\n\t\t\t\t\tURL:           \"https://example.com\",\n\t\t\t\t\tTitle:         \"Example\",\n\t\t\t\t\tExcerpt:       \"This is an example article\",\n\t\t\t\t\tCreateEbook:   true,\n\t\t\t\t\tCreateArchive: true,\n\t\t\t\t}\n\t\t\t\tcontent := bytes.NewBufferString(\"<html><head></head><body><p>This is an example article</p></body></html>\")\n\t\t\t\trequest := core.ProcessRequest{\n\t\t\t\t\tBookmark:    bookmark,\n\t\t\t\t\tContent:     content,\n\t\t\t\t\tContentType: \"application/pdf\",\n\t\t\t\t\tDataDir:     tmpDir,\n\t\t\t\t\tKeepTitle:   true,\n\t\t\t\t\tKeepExcerpt: true,\n\t\t\t\t}\n\t\t\t\t_, _, err := core.ProcessBookmark(deps, request)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t})\n\t\t})\n\t})\n}\n"
  },
  {
    "path": "internal/core/url.go",
    "content": "package core\n\nimport (\n\t\"fmt\"\n\tnurl \"net/url\"\n\t\"sort\"\n\t\"strings\"\n)\n\n// queryEncodeWithoutEmptyValues is a copy of `values.Encode` but checking if the queryparam\n// value is empty to prevent sending the = symbol empty which breaks in some servers.\nfunc queryEncodeWithoutEmptyValues(v nurl.Values) string {\n\tif v == nil {\n\t\treturn \"\"\n\t}\n\tvar buf strings.Builder\n\tkeys := make([]string, 0, len(v))\n\tfor k := range v {\n\t\tkeys = append(keys, k)\n\t}\n\tsort.Strings(keys)\n\tfor _, k := range keys {\n\t\tvs := v[k]\n\t\tkeyEscaped := nurl.QueryEscape(k)\n\t\tfor _, v := range vs {\n\t\t\tif buf.Len() > 0 {\n\t\t\t\tbuf.WriteByte('&')\n\t\t\t}\n\t\t\tbuf.WriteString(keyEscaped)\n\t\t\tif v != \"\" {\n\t\t\t\tbuf.WriteByte('=')\n\t\t\t\tbuf.WriteString(nurl.QueryEscape(v))\n\t\t\t}\n\t\t}\n\t}\n\treturn buf.String()\n}\n\n// RemoveUTMParams removes the UTM parameters from URL.\nfunc RemoveUTMParams(url string) (string, error) {\n\t// Parse string URL\n\ttmp, err := nurl.Parse(url)\n\tif err != nil || tmp.Scheme == \"\" || tmp.Hostname() == \"\" {\n\t\treturn url, fmt.Errorf(\"URL is not valid\")\n\t}\n\n\t// Remove UTM queries\n\tqueries := tmp.Query()\n\tfor key := range queries {\n\t\tif strings.HasPrefix(key, \"utm_\") {\n\t\t\tqueries.Del(key)\n\t\t}\n\t}\n\n\ttmp.RawQuery = queryEncodeWithoutEmptyValues(queries)\n\treturn tmp.String(), nil\n}\n"
  },
  {
    "path": "internal/database/database.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/huandu/go-sqlbuilder\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/pkg/errors\"\n)\n\n// ErrNotFound is error returned when record is not found in database.\nvar ErrNotFound = errors.New(\"not found\")\n\n// ErrAlreadyExists is error returned when record already exists in database.\nvar ErrAlreadyExists = errors.New(\"already exists\")\n\n// Connect connects to database based on submitted database URL.\nfunc Connect(ctx context.Context, dbURL string) (model.DB, error) {\n\tdbU, err := url.Parse(dbURL)\n\tif err != nil {\n\t\treturn nil, errors.Wrap(err, \"failed to parse database URL\")\n\t}\n\n\tswitch dbU.Scheme {\n\tcase \"mysql\":\n\t\turlNoSchema := strings.Split(dbURL, \"://\")[1]\n\t\treturn OpenMySQLDatabase(ctx, urlNoSchema)\n\tcase \"postgres\":\n\t\treturn OpenPGDatabase(ctx, dbURL)\n\tcase \"sqlite\":\n\t\treturn OpenSQLiteDatabase(ctx, dbU.Path[1:])\n\t}\n\n\treturn nil, fmt.Errorf(\"unsupported database scheme: %s\", dbU.Scheme)\n}\n\ntype dbbase struct {\n\tflavor sqlbuilder.Flavor\n\treader *sqlx.DB\n\twriter *sqlx.DB\n}\n\nfunc (db *dbbase) Flavor() sqlbuilder.Flavor {\n\treturn db.flavor\n}\n\nfunc (db *dbbase) ReaderDB() *sqlx.DB {\n\treturn db.reader\n}\n\nfunc (db *dbbase) WriterDB() *sqlx.DB {\n\treturn db.writer\n}\n\nfunc (db *dbbase) withTx(ctx context.Context, fn func(tx *sqlx.Tx) error) error {\n\ttx, err := db.writer.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn errors.WithStack(err)\n\t}\n\n\tdefer func() {\n\t\tif err := tx.Commit(); err != nil {\n\t\t\tlog.Printf(\"error during commit: %s\", err)\n\t\t}\n\t}()\n\n\terr = fn(tx)\n\tif err != nil {\n\t\tif err := tx.Rollback(); err != nil {\n\t\t\tlog.Printf(\"error during rollback: %s\", err)\n\t\t}\n\t\treturn errors.WithStack(err)\n\t}\n\n\treturn err\n}\n\nfunc (db *dbbase) GetContext(ctx context.Context, dest any, query string, args ...any) error {\n\treturn db.reader.GetContext(ctx, dest, query, args...)\n}\n\n// Deprecated: Use SelectContext instead.\nfunc (db *dbbase) Select(dest any, query string, args ...any) error {\n\treturn db.reader.Select(dest, query, args...)\n}\n\nfunc (db *dbbase) SelectContext(ctx context.Context, dest any, query string, args ...any) error {\n\treturn db.reader.SelectContext(ctx, dest, query, args...)\n}\n\nfunc (db *dbbase) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {\n\treturn db.writer.ExecContext(ctx, query, args...)\n}\n\nfunc (db *dbbase) MustBegin() *sqlx.Tx {\n\treturn db.writer.MustBegin()\n}\n\nfunc NewDBBase(reader, writer *sqlx.DB, flavor sqlbuilder.Flavor) dbbase {\n\treturn dbbase{\n\t\treader: reader,\n\t\twriter: writer,\n\t\tflavor: flavor,\n\t}\n}\n"
  },
  {
    "path": "internal/database/database_tags.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/huandu/go-sqlbuilder\"\n\t\"github.com/jmoiron/sqlx\"\n)\n\n// GetTags returns a list of tags from the database.\n// If opts.WithBookmarkCount is true, the result will include the number of bookmarks for each tag.\n// If opts.BookmarkID is not 0, the result will include only the tags for the specified bookmark.\n// If opts.OrderBy is set, the result will be ordered by the specified column.\nfunc (db *dbbase) GetTags(ctx context.Context, opts model.DBListTagsOptions) ([]model.TagDTO, error) {\n\tsb := db.Flavor().NewSelectBuilder()\n\n\tsb.Select(\"t.id\", \"t.name\")\n\tsb.From(\"tag t\")\n\n\t// Treat the case where we want the bookmark count and filter by bookmark ID as a special case:\n\t// If we only want one of them, we can use a JOIN and GROUP BY.\n\t// If we want both, we need to use a subquery to get the count of bookmarks for each tag filtered\n\t// by bookmark ID.\n\tif opts.WithBookmarkCount && opts.BookmarkID == 0 {\n\t\t// Join with bookmark_tag and group by tag ID to get the count of bookmarks for each tag\n\t\tsb.JoinWithOption(sqlbuilder.LeftJoin, \"bookmark_tag bt\", \"bt.tag_id = t.id\")\n\t\tsb.SelectMore(\"COUNT(bt.tag_id) AS bookmark_count\")\n\t\tsb.GroupBy(\"t.id\")\n\t} else if opts.BookmarkID > 0 {\n\t\t// If we want the bookmark count, we need to use a subquery to get the count of bookmarks for each tag\n\t\tif opts.WithBookmarkCount {\n\t\t\tsb.SelectMore(\n\t\t\t\tsb.BuilderAs(\n\t\t\t\t\tdb.Flavor().NewSelectBuilder().Select(\"COUNT(bt2.tag_id)\").From(\"bookmark_tag bt2\").Where(\"bt2.tag_id = t.id\"),\n\t\t\t\t\t\"bookmark_count\",\n\t\t\t\t),\n\t\t\t)\n\t\t}\n\n\t\t// Join with bookmark_tag and filter by bookmark ID to get the tags for a specific bookmark\n\t\tsb.JoinWithOption(sqlbuilder.RightJoin, \"bookmark_tag bt\",\n\t\t\tsb.And(\n\t\t\t\t\"bt.tag_id = t.id\",\n\t\t\t\tsb.Equal(\"bt.bookmark_id\", opts.BookmarkID),\n\t\t\t),\n\t\t)\n\t\tsb.Where(sb.IsNotNull(\"t.id\"))\n\t}\n\n\t// Add search condition if search term is provided\n\tif opts.Search != \"\" {\n\t\t// Note: Search and BookmarkID filtering are mutually exclusive as per requirements\n\t\tsb.Where(sb.Like(\"t.name\", \"%\"+opts.Search+\"%\"))\n\t}\n\n\tif opts.OrderBy == model.DBTagOrderByTagName {\n\t\tsb.OrderBy(\"t.name\")\n\t}\n\n\tquery, args := sb.Build()\n\tquery = db.ReaderDB().Rebind(query)\n\n\ttags := []model.TagDTO{}\n\terr := db.ReaderDB().SelectContext(ctx, &tags, query, args...)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn nil, fmt.Errorf(\"failed to get tags: %w\", err)\n\t}\n\n\treturn tags, nil\n}\n\n// AddTagToBookmark adds a tag to a bookmark\nfunc (db *dbbase) AddTagToBookmark(ctx context.Context, bookmarkID int, tagID int) error {\n\t// Insert the bookmark-tag association\n\tinsertSb := db.Flavor().NewInsertBuilder()\n\tinsertSb.InsertInto(\"bookmark_tag\")\n\tinsertSb.Cols(\"bookmark_id\", \"tag_id\")\n\tinsertSb.Values(bookmarkID, tagID)\n\n\tinsertQuery, insertArgs := insertSb.Build()\n\tinsertQuery = db.WriterDB().Rebind(insertQuery)\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// First check if the association already exists using sqlbuilder\n\t\tselectSb := db.Flavor().NewSelectBuilder()\n\t\tselectSb.Select(\"1\")\n\t\tselectSb.From(\"bookmark_tag\")\n\t\tselectSb.Where(\n\t\t\tselectSb.And(\n\t\t\t\tselectSb.Equal(\"bookmark_id\", bookmarkID),\n\t\t\t\tselectSb.Equal(\"tag_id\", tagID),\n\t\t\t),\n\t\t)\n\n\t\tselectQuery, selectArgs := selectSb.Build()\n\t\tselectQuery = db.ReaderDB().Rebind(selectQuery)\n\n\t\tvar exists int\n\t\terr := tx.QueryRowContext(ctx, selectQuery, selectArgs...).Scan(&exists)\n\n\t\tif err != nil && err != sql.ErrNoRows {\n\t\t\treturn fmt.Errorf(\"failed to check if tag is already associated: %w\", err)\n\t\t}\n\n\t\t// If it doesn't exist, insert it\n\t\tif err == sql.ErrNoRows {\n\t\t\t_, err = tx.ExecContext(ctx, insertQuery, insertArgs...)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to add tag to bookmark: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// RemoveTagFromBookmark removes a tag from a bookmark\nfunc (db *dbbase) RemoveTagFromBookmark(ctx context.Context, bookmarkID int, tagID int) error {\n\t// Delete the bookmark-tag association\n\tdeleteSb := db.Flavor().NewDeleteBuilder()\n\tdeleteSb.DeleteFrom(\"bookmark_tag\")\n\tdeleteSb.Where(\n\t\tdeleteSb.And(\n\t\t\tdeleteSb.Equal(\"bookmark_id\", bookmarkID),\n\t\t\tdeleteSb.Equal(\"tag_id\", tagID),\n\t\t),\n\t)\n\n\tquery, args := deleteSb.Build()\n\tquery = db.WriterDB().Rebind(query)\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t_, err := tx.ExecContext(ctx, query, args...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to remove tag from bookmark: %w\", err)\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// TagExists checks if a tag with the given ID exists in the database\nfunc (db *dbbase) TagExists(ctx context.Context, tagID int) (bool, error) {\n\tsb := db.Flavor().NewSelectBuilder()\n\tsb.Select(\"1\")\n\tsb.From(\"tag\")\n\tsb.Where(sb.Equal(\"id\", tagID))\n\tsb.Limit(1)\n\n\tquery, args := sb.Build()\n\tquery = db.ReaderDB().Rebind(query)\n\n\tvar exists int\n\terr := db.ReaderDB().QueryRowContext(ctx, query, args...).Scan(&exists)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, fmt.Errorf(\"failed to check if tag exists: %w\", err)\n\t}\n\n\treturn true, nil\n}\n\n// BookmarkExists checks if a bookmark with the given ID exists in the database\nfunc (db *dbbase) BookmarkExists(ctx context.Context, bookmarkID int) (bool, error) {\n\tsb := db.Flavor().NewSelectBuilder()\n\tsb.Select(\"1\")\n\tsb.From(\"bookmark\")\n\tsb.Where(sb.Equal(\"id\", bookmarkID))\n\tsb.Limit(1)\n\n\tquery, args := sb.Build()\n\tquery = db.ReaderDB().Rebind(query)\n\n\tvar exists int\n\terr := db.ReaderDB().QueryRowContext(ctx, query, args...).Scan(&exists)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, fmt.Errorf(\"failed to check if bookmark exists: %w\", err)\n\t}\n\n\treturn true, nil\n}\n"
  },
  {
    "path": "internal/database/database_tags_test.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\n// testGetTagsFunction tests the GetTags function with various options\nfunc testGetTagsFunction(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Create test tags\n\ttags := []model.Tag{\n\t\t{Name: \"golang\"},\n\t\t{Name: \"database\"},\n\t\t{Name: \"testing\"},\n\t\t{Name: \"web\"},\n\t}\n\tcreatedTags, err := db.CreateTags(ctx, tags...)\n\trequire.NoError(t, err)\n\trequire.Len(t, createdTags, 4)\n\n\t// Map tag names to IDs for easier reference\n\ttagIDsByName := make(map[string]int)\n\tfor _, tag := range createdTags {\n\t\ttagIDsByName[tag.Name] = tag.ID\n\t}\n\n\t// Create bookmarks with different tag combinations\n\tbookmarks := []model.BookmarkDTO{\n\t\t{\n\t\t\tURL:   \"https://golang.org\",\n\t\t\tTitle: \"Go Language\",\n\t\t\tTags: []model.TagDTO{\n\t\t\t\t{Tag: model.Tag{Name: \"golang\"}},\n\t\t\t\t{Tag: model.Tag{Name: \"web\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tURL:   \"https://postgresql.org\",\n\t\t\tTitle: \"PostgreSQL\",\n\t\t\tTags: []model.TagDTO{\n\t\t\t\t{Tag: model.Tag{Name: \"database\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tURL:   \"https://sqlite.org\",\n\t\t\tTitle: \"SQLite\",\n\t\t\tTags: []model.TagDTO{\n\t\t\t\t{Tag: model.Tag{Name: \"database\"}},\n\t\t\t\t{Tag: model.Tag{Name: \"testing\"}},\n\t\t\t},\n\t\t},\n\t}\n\n\t// Save bookmarks\n\tvar savedBookmarks []model.BookmarkDTO\n\tfor _, bookmark := range bookmarks {\n\t\tresult, err := db.SaveBookmarks(ctx, true, bookmark)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, result, 1)\n\t\tsavedBookmarks = append(savedBookmarks, result[0])\n\t}\n\n\t// Verify test data setup\n\tt.Run(\"VerifyTestData\", func(t *testing.T) {\n\t\t// Check that all bookmarks were saved with their tags\n\t\tfor i, bookmark := range savedBookmarks {\n\t\t\tassert.NotZero(t, bookmark.ID)\n\t\t\tassert.Len(t, bookmark.Tags, len(bookmarks[i].Tags))\n\t\t}\n\n\t\t// Verify that the first bookmark has golang and web tags\n\t\tassert.Len(t, savedBookmarks[0].Tags, 2)\n\t\ttagNames := []string{savedBookmarks[0].Tags[0].Name, savedBookmarks[0].Tags[1].Name}\n\t\tassert.Contains(t, tagNames, \"golang\")\n\t\tassert.Contains(t, tagNames, \"web\")\n\t})\n\n\t// Test 1: Get all tags without any options\n\tt.Run(\"GetAllTags\", func(t *testing.T) {\n\t\tfetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{})\n\t\trequire.NoError(t, err)\n\n\t\t// Should return all 4 tags\n\t\tassert.Len(t, fetchedTags, 4)\n\n\t\t// Verify all tag names are present\n\t\ttagNames := make(map[string]bool)\n\t\tfor _, tag := range fetchedTags {\n\t\t\ttagNames[tag.Name] = true\n\t\t}\n\n\t\tfor _, expectedTag := range tags {\n\t\t\tassert.True(t, tagNames[expectedTag.Name], \"Tag %s should be present\", expectedTag.Name)\n\t\t}\n\t})\n\n\t// Test 2: Get tags with bookmark count\n\tt.Run(\"GetTagsWithBookmarkCount\", func(t *testing.T) {\n\t\tfetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{\n\t\t\tWithBookmarkCount: true,\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\t// Should return all 4 tags\n\t\tassert.Len(t, fetchedTags, 4)\n\n\t\t// Create a map of tag name to bookmark count\n\t\ttagCounts := make(map[string]int64)\n\t\tfor _, tag := range fetchedTags {\n\t\t\ttagCounts[tag.Name] = tag.BookmarkCount\n\t\t}\n\n\t\t// Verify counts\n\t\tassert.Equal(t, int64(1), tagCounts[\"golang\"])\n\t\tassert.Equal(t, int64(2), tagCounts[\"database\"])\n\t\tassert.Equal(t, int64(1), tagCounts[\"testing\"])\n\t\tassert.Equal(t, int64(1), tagCounts[\"web\"])\n\t})\n\n\t// Test 3: Get tags for a specific bookmark\n\tt.Run(\"GetTagsForBookmark\", func(t *testing.T) {\n\t\t// Get tags for the first bookmark (Go Language with golang and web tags)\n\t\tfetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{\n\t\t\tBookmarkID: savedBookmarks[0].ID,\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\t// Should return 2 tags\n\t\tassert.Len(t, fetchedTags, 2)\n\n\t\t// Verify tag names\n\t\ttagNames := make(map[string]bool)\n\t\tfor _, tag := range fetchedTags {\n\t\t\ttagNames[tag.Name] = true\n\t\t}\n\n\t\tassert.True(t, tagNames[\"golang\"], \"Tag 'golang' should be present\")\n\t\tassert.True(t, tagNames[\"web\"], \"Tag 'web' should be present\")\n\t})\n\n\t// Test 4: Get tags for a specific bookmark with bookmark count\n\tt.Run(\"GetTagsForBookmarkWithCount\", func(t *testing.T) {\n\t\t// Get tags for the third bookmark (SQLite with database and testing tags)\n\t\tfetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{\n\t\t\tBookmarkID:        savedBookmarks[2].ID,\n\t\t\tWithBookmarkCount: true,\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\t// Should return 2 tags\n\t\tassert.Len(t, fetchedTags, 2)\n\n\t\t// Create a map of tag name to bookmark count\n\t\ttagCounts := make(map[string]int64)\n\t\tfor _, tag := range fetchedTags {\n\t\t\ttagCounts[tag.Name] = tag.BookmarkCount\n\t\t}\n\n\t\t// Verify counts - database should have 2 bookmarks, testing should have 1\n\t\tassert.Equal(t, int64(2), tagCounts[\"database\"])\n\t\tassert.Equal(t, int64(1), tagCounts[\"testing\"])\n\t})\n\n\t// Test 5: Get tags ordered by name\n\tt.Run(\"GetTagsOrderedByName\", func(t *testing.T) {\n\t\tfetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{\n\t\t\tOrderBy: model.DBTagOrderByTagName,\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\t// Should return all 4 tags in alphabetical order\n\t\tassert.Len(t, fetchedTags, 4)\n\n\t\t// Verify order\n\t\tassert.Equal(t, \"database\", fetchedTags[0].Name)\n\t\tassert.Equal(t, \"golang\", fetchedTags[1].Name)\n\t\tassert.Equal(t, \"testing\", fetchedTags[2].Name)\n\t\tassert.Equal(t, \"web\", fetchedTags[3].Name)\n\t})\n\n\t// Test 6: Get tags with search term\n\tt.Run(\"GetTagsWithSearch\", func(t *testing.T) {\n\t\t// Search for tags containing \"go\"\n\t\tfetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{\n\t\t\tSearch: \"go\",\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\t// Should return only the golang tag\n\t\tassert.Len(t, fetchedTags, 1)\n\t\tassert.Equal(t, \"golang\", fetchedTags[0].Name)\n\n\t\t// Search for tags containing \"a\"\n\t\tfetchedTags, err = db.GetTags(ctx, model.DBListTagsOptions{\n\t\t\tSearch: \"a\",\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\t// Should return database and possibly other tags containing \"a\"\n\t\tassert.GreaterOrEqual(t, len(fetchedTags), 1)\n\n\t\t// Create a map of tag names for easier checking\n\t\ttagNames := make(map[string]bool)\n\t\tfor _, tag := range fetchedTags {\n\t\t\ttagNames[tag.Name] = true\n\t\t}\n\n\t\t// Verify database is in the results\n\t\tassert.True(t, tagNames[\"database\"], \"Tag 'database' should be present\")\n\n\t\t// Search for non-existent tag\n\t\tfetchedTags, err = db.GetTags(ctx, model.DBListTagsOptions{\n\t\t\tSearch: \"nonexistent\",\n\t\t})\n\t\trequire.NoError(t, err)\n\t\tassert.Len(t, fetchedTags, 0)\n\t})\n\n\t// Test 7: Search and bookmark ID are mutually exclusive\n\tt.Run(\"SearchAndBookmarkIDMutuallyExclusive\", func(t *testing.T) {\n\t\t// This test is just to document the behavior, as the validation happens at the model level\n\t\t// The database layer will prioritize the bookmark ID filter if both are provided\n\t\tfetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{\n\t\t\tSearch:     \"go\",\n\t\t\tBookmarkID: savedBookmarks[0].ID,\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\t// Should return tags for the bookmark, not the search\n\t\t// The number of tags may vary depending on the database implementation\n\t\tassert.NotEmpty(t, fetchedTags, \"Should return at least one tag for the bookmark\")\n\n\t\t// Create a map of tag names for easier checking\n\t\ttagNames := make(map[string]bool)\n\t\tfor _, tag := range fetchedTags {\n\t\t\ttagNames[tag.Name] = true\n\t\t}\n\n\t\t// Verify golang is in the results (it's associated with the first bookmark)\n\t\tassert.True(t, tagNames[\"golang\"], \"Tag 'golang' should be present\")\n\t})\n\n\t// Test 8: Get tags for a non-existent bookmark\n\tt.Run(\"GetTagsForNonExistentBookmark\", func(t *testing.T) {\n\t\tfetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{\n\t\t\tBookmarkID: 9999, // Non-existent ID\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\t// Should return empty result\n\t\tassert.Empty(t, fetchedTags)\n\t})\n\n\t// Test 9: Get tags for a bookmark with no tags\n\tt.Run(\"GetTagsForBookmarkWithNoTags\", func(t *testing.T) {\n\t\t// Create a bookmark with no tags\n\t\tbookmarkWithNoTags := model.BookmarkDTO{\n\t\t\tURL:   \"https://example.com\",\n\t\t\tTitle: \"Example with no tags\",\n\t\t}\n\n\t\tresult, err := db.SaveBookmarks(ctx, true, bookmarkWithNoTags)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, result, 1)\n\n\t\t// Get tags for this bookmark\n\t\tfetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{\n\t\t\tBookmarkID: result[0].ID,\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\t// Should return empty result\n\t\tassert.Empty(t, fetchedTags)\n\t})\n\n\t// Test 10: Get tags with combined options (order + count)\n\tt.Run(\"GetTagsWithCombinedOptions\", func(t *testing.T) {\n\t\tfetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{\n\t\t\tWithBookmarkCount: true,\n\t\t\tOrderBy:           model.DBTagOrderByTagName,\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\t// Should return all 4 tags in alphabetical order with counts\n\t\tassert.Len(t, fetchedTags, 4)\n\n\t\t// Verify order and counts\n\t\tassert.Equal(t, \"database\", fetchedTags[0].Name)\n\t\tassert.Equal(t, int64(2), fetchedTags[0].BookmarkCount)\n\n\t\tassert.Equal(t, \"golang\", fetchedTags[1].Name)\n\t\tassert.Equal(t, int64(1), fetchedTags[1].BookmarkCount)\n\n\t\tassert.Equal(t, \"testing\", fetchedTags[2].Name)\n\t\tassert.Equal(t, int64(1), fetchedTags[2].BookmarkCount)\n\n\t\tassert.Equal(t, \"web\", fetchedTags[3].Name)\n\t\tassert.Equal(t, int64(1), fetchedTags[3].BookmarkCount)\n\t})\n}\n\n// testTagBookmarkOperations tests the tag-bookmark relationship operations\nfunc testTagBookmarkOperations(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Create test data\n\t// 1. Create a test bookmark\n\tbookmark := model.BookmarkDTO{\n\t\tURL:   \"https://example.com/tag-operations-test\",\n\t\tTitle: \"Tag Operations Test\",\n\t}\n\tsavedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark)\n\trequire.NoError(t, err)\n\trequire.Len(t, savedBookmarks, 1)\n\tbookmarkID := savedBookmarks[0].ID\n\n\t// 2. Create a test tag\n\ttag := model.Tag{\n\t\tName: \"tag-operations-test\",\n\t}\n\tcreatedTags, err := db.CreateTags(ctx, tag)\n\trequire.NoError(t, err)\n\trequire.Len(t, createdTags, 1)\n\ttagID := createdTags[0].ID\n\n\t// Test BookmarkExists function\n\tt.Run(\"BookmarkExists\", func(t *testing.T) {\n\t\t// Test with existing bookmark\n\t\texists, err := db.BookmarkExists(ctx, bookmarkID)\n\t\trequire.NoError(t, err)\n\t\tassert.True(t, exists, \"Bookmark should exist\")\n\n\t\t// Test with non-existent bookmark\n\t\texists, err = db.BookmarkExists(ctx, 9999)\n\t\trequire.NoError(t, err)\n\t\tassert.False(t, exists, \"Non-existent bookmark should return false\")\n\t})\n\n\t// Test TagExists function\n\tt.Run(\"TagExists\", func(t *testing.T) {\n\t\t// Test with existing tag\n\t\texists, err := db.TagExists(ctx, tagID)\n\t\trequire.NoError(t, err)\n\t\tassert.True(t, exists, \"Tag should exist\")\n\n\t\t// Test with non-existent tag\n\t\texists, err = db.TagExists(ctx, 9999)\n\t\trequire.NoError(t, err)\n\t\tassert.False(t, exists, \"Non-existent tag should return false\")\n\t})\n\n\t// Test AddTagToBookmark function\n\tt.Run(\"AddTagToBookmark\", func(t *testing.T) {\n\t\t// Add tag to bookmark\n\t\terr := db.AddTagToBookmark(ctx, bookmarkID, tagID)\n\t\trequire.NoError(t, err)\n\n\t\t// Verify tag was added by fetching tags for the bookmark\n\t\ttags, err := db.GetTags(ctx, model.DBListTagsOptions{\n\t\t\tBookmarkID: bookmarkID,\n\t\t})\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, tags, 1)\n\t\tassert.Equal(t, tagID, tags[0].ID)\n\t\tassert.Equal(t, \"tag-operations-test\", tags[0].Name)\n\n\t\t// Test adding the same tag again (should not error)\n\t\terr = db.AddTagToBookmark(ctx, bookmarkID, tagID)\n\t\trequire.NoError(t, err)\n\n\t\t// Verify no duplicate was created\n\t\ttags, err = db.GetTags(ctx, model.DBListTagsOptions{\n\t\t\tBookmarkID: bookmarkID,\n\t\t})\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, tags, 1)\n\t})\n\n\t// Test RemoveTagFromBookmark function\n\tt.Run(\"RemoveTagFromBookmark\", func(t *testing.T) {\n\t\t// First ensure the tag is associated with the bookmark\n\t\ttags, err := db.GetTags(ctx, model.DBListTagsOptions{\n\t\t\tBookmarkID: bookmarkID,\n\t\t})\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, tags, 1, \"Tag should be associated with bookmark before removal test\")\n\n\t\t// Remove tag from bookmark\n\t\terr = db.RemoveTagFromBookmark(ctx, bookmarkID, tagID)\n\t\trequire.NoError(t, err)\n\n\t\t// Verify tag was removed\n\t\ttags, err = db.GetTags(ctx, model.DBListTagsOptions{\n\t\t\tBookmarkID: bookmarkID,\n\t\t})\n\t\trequire.NoError(t, err)\n\t\tassert.Len(t, tags, 0, \"Tag should be removed from bookmark\")\n\n\t\t// Test removing a tag that's not associated (should not error)\n\t\terr = db.RemoveTagFromBookmark(ctx, bookmarkID, tagID)\n\t\trequire.NoError(t, err)\n\n\t\t// Test removing a tag from a non-existent bookmark (should not error)\n\t\terr = db.RemoveTagFromBookmark(ctx, 9999, tagID)\n\t\trequire.NoError(t, err)\n\n\t\t// Test removing a non-existent tag from a bookmark (should not error)\n\t\terr = db.RemoveTagFromBookmark(ctx, bookmarkID, 9999)\n\t\trequire.NoError(t, err)\n\t})\n\n\t// Test edge cases\n\tt.Run(\"EdgeCases\", func(t *testing.T) {\n\t\t// Test adding a tag to a non-existent bookmark\n\t\t// This should not error at the database layer since we're not checking existence there\n\t\terr := db.AddTagToBookmark(ctx, 9999, tagID)\n\t\t// The test might fail depending on foreign key constraints in the database\n\t\t// If it fails, that's acceptable behavior, but we're not explicitly testing for it\n\t\tif err != nil {\n\t\t\tt.Logf(\"Adding tag to non-existent bookmark failed as expected: %v\", err)\n\t\t}\n\n\t\t// Test adding a non-existent tag to a bookmark\n\t\t// This should not error at the database layer since we're not checking existence there\n\t\terr = db.AddTagToBookmark(ctx, bookmarkID, 9999)\n\t\t// The test might fail depending on foreign key constraints in the database\n\t\t// If it fails, that's acceptable behavior, but we're not explicitly testing for it\n\t\tif err != nil {\n\t\t\tt.Logf(\"Adding non-existent tag to bookmark failed as expected: %v\", err)\n\t\t}\n\t})\n}\n\n// testTagExists tests the TagExists function\nfunc testTagExists(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Create a test tag\n\ttag := model.Tag{\n\t\tName: \"tag-exists-test\",\n\t}\n\tcreatedTags, err := db.CreateTags(ctx, tag)\n\trequire.NoError(t, err)\n\trequire.Len(t, createdTags, 1)\n\ttagID := createdTags[0].ID\n\n\t// Test with existing tag\n\texists, err := db.TagExists(ctx, tagID)\n\trequire.NoError(t, err)\n\tassert.True(t, exists, \"Tag should exist\")\n\n\t// Test with non-existent tag\n\texists, err = db.TagExists(ctx, 9999)\n\trequire.NoError(t, err)\n\tassert.False(t, exists, \"Non-existent tag should return false\")\n}\n\n// testBookmarkExists tests the BookmarkExists function\nfunc testBookmarkExists(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Create a test bookmark\n\tbookmark := model.BookmarkDTO{\n\t\tURL:   \"https://example.com/bookmark-exists-test\",\n\t\tTitle: \"Bookmark Exists Test\",\n\t}\n\tsavedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark)\n\trequire.NoError(t, err)\n\trequire.Len(t, savedBookmarks, 1)\n\tbookmarkID := savedBookmarks[0].ID\n\n\t// Test with existing bookmark\n\texists, err := db.BookmarkExists(ctx, bookmarkID)\n\trequire.NoError(t, err)\n\tassert.True(t, exists, \"Bookmark should exist\")\n\n\t// Test with non-existent bookmark\n\texists, err = db.BookmarkExists(ctx, 9999)\n\trequire.NoError(t, err)\n\tassert.False(t, exists, \"Non-existent bookmark should return false\")\n}\n\n// testAddTagToBookmark tests the AddTagToBookmark function\nfunc testAddTagToBookmark(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Create test data\n\tbookmark := model.BookmarkDTO{\n\t\tURL:   \"https://example.com/add-tag-test\",\n\t\tTitle: \"Add Tag Test\",\n\t}\n\tsavedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark)\n\trequire.NoError(t, err)\n\trequire.Len(t, savedBookmarks, 1)\n\tbookmarkID := savedBookmarks[0].ID\n\n\ttag := model.Tag{\n\t\tName: \"add-tag-test\",\n\t}\n\tcreatedTags, err := db.CreateTags(ctx, tag)\n\trequire.NoError(t, err)\n\trequire.Len(t, createdTags, 1)\n\ttagID := createdTags[0].ID\n\n\t// Add tag to bookmark\n\terr = db.AddTagToBookmark(ctx, bookmarkID, tagID)\n\trequire.NoError(t, err)\n\n\t// Verify tag was added by fetching tags for the bookmark\n\ttags, err := db.GetTags(ctx, model.DBListTagsOptions{\n\t\tBookmarkID: bookmarkID,\n\t})\n\trequire.NoError(t, err)\n\trequire.Len(t, tags, 1)\n\tassert.Equal(t, tagID, tags[0].ID)\n\tassert.Equal(t, \"add-tag-test\", tags[0].Name)\n\n\t// Test adding the same tag again (should not error)\n\terr = db.AddTagToBookmark(ctx, bookmarkID, tagID)\n\trequire.NoError(t, err)\n\n\t// Verify no duplicate was created\n\ttags, err = db.GetTags(ctx, model.DBListTagsOptions{\n\t\tBookmarkID: bookmarkID,\n\t})\n\trequire.NoError(t, err)\n\trequire.Len(t, tags, 1)\n}\n\n// testRemoveTagFromBookmark tests the RemoveTagFromBookmark function\nfunc testRemoveTagFromBookmark(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Create test data\n\tbookmark := model.BookmarkDTO{\n\t\tURL:   \"https://example.com/remove-tag-test\",\n\t\tTitle: \"Remove Tag Test\",\n\t}\n\tsavedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark)\n\trequire.NoError(t, err)\n\trequire.Len(t, savedBookmarks, 1)\n\tbookmarkID := savedBookmarks[0].ID\n\n\ttag := model.Tag{\n\t\tName: \"remove-tag-test\",\n\t}\n\tcreatedTags, err := db.CreateTags(ctx, tag)\n\trequire.NoError(t, err)\n\trequire.Len(t, createdTags, 1)\n\ttagID := createdTags[0].ID\n\n\t// Add tag to bookmark first\n\terr = db.AddTagToBookmark(ctx, bookmarkID, tagID)\n\trequire.NoError(t, err)\n\n\t// Verify tag was added\n\ttags, err := db.GetTags(ctx, model.DBListTagsOptions{\n\t\tBookmarkID: bookmarkID,\n\t})\n\trequire.NoError(t, err)\n\trequire.Len(t, tags, 1, \"Tag should be associated with bookmark before removal test\")\n\n\t// Remove tag from bookmark\n\terr = db.RemoveTagFromBookmark(ctx, bookmarkID, tagID)\n\trequire.NoError(t, err)\n\n\t// Verify tag was removed\n\ttags, err = db.GetTags(ctx, model.DBListTagsOptions{\n\t\tBookmarkID: bookmarkID,\n\t})\n\trequire.NoError(t, err)\n\tassert.Len(t, tags, 0, \"Tag should be removed from bookmark\")\n\n\t// Test removing a tag that's not associated (should not error)\n\terr = db.RemoveTagFromBookmark(ctx, bookmarkID, tagID)\n\trequire.NoError(t, err)\n\n\t// Test removing a tag from a non-existent bookmark (should not error)\n\terr = db.RemoveTagFromBookmark(ctx, 9999, tagID)\n\trequire.NoError(t, err)\n\n\t// Test removing a non-existent tag from a bookmark (should not error)\n\terr = db.RemoveTagFromBookmark(ctx, bookmarkID, 9999)\n\trequire.NoError(t, err)\n}\n\n// testTagBookmarkEdgeCases tests edge cases for tag-bookmark operations\nfunc testTagBookmarkEdgeCases(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Create test data\n\tbookmark := model.BookmarkDTO{\n\t\tURL:   \"https://example.com/edge-cases-test\",\n\t\tTitle: \"Edge Cases Test\",\n\t}\n\tsavedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark)\n\trequire.NoError(t, err)\n\trequire.Len(t, savedBookmarks, 1)\n\tbookmarkID := savedBookmarks[0].ID\n\n\ttag := model.Tag{\n\t\tName: \"edge-cases-test\",\n\t}\n\tcreatedTags, err := db.CreateTags(ctx, tag)\n\trequire.NoError(t, err)\n\trequire.Len(t, createdTags, 1)\n\ttagID := createdTags[0].ID\n\n\t// Test adding a tag to a non-existent bookmark\n\t// This should not error at the database layer since we're not checking existence there\n\terr = db.AddTagToBookmark(ctx, 9999, tagID)\n\t// The test might fail depending on foreign key constraints in the database\n\t// If it fails, that's acceptable behavior, but we're not explicitly testing for it\n\tif err != nil {\n\t\tt.Logf(\"Adding tag to non-existent bookmark failed as expected: %v\", err)\n\t}\n\n\t// Test adding a non-existent tag to a bookmark\n\t// This should not error at the database layer since we're not checking existence there\n\terr = db.AddTagToBookmark(ctx, bookmarkID, 9999)\n\t// The test might fail depending on foreign key constraints in the database\n\t// If it fails, that's acceptable behavior, but we're not explicitly testing for it\n\tif err != nil {\n\t\tt.Logf(\"Adding non-existent tag to bookmark failed as expected: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "internal/database/database_test.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype databaseTestCase func(t *testing.T, db model.DB)\ntype testDatabaseFactory func(t *testing.T, ctx context.Context) (model.DB, error)\n\nfunc testDatabase(t *testing.T, dbFactory testDatabaseFactory) {\n\ttests := map[string]databaseTestCase{\n\t\t// Bookmarks\n\t\t\"testBookmarkAutoIncrement\":             testBookmarkAutoIncrement,\n\t\t\"testCreateBookmark\":                    testCreateBookmark,\n\t\t\"testCreateBookmarkWithContent\":         testCreateBookmarkWithContent,\n\t\t\"testCreateBookmarkTwice\":               testCreateBookmarkTwice,\n\t\t\"testCreateBookmarkWithTag\":             testCreateBookmarkWithTag,\n\t\t\"testCreateTwoDifferentBookmarks\":       testCreateTwoDifferentBookmarks,\n\t\t\"testUpdateBookmark\":                    testUpdateBookmark,\n\t\t\"testUpdateBookmarkUpdatesModifiedTime\": testUpdateBookmarkUpdatesModifiedTime,\n\t\t\"testGetBoomarksWithTimeFilters\":        testGetBoomarksWithTimeFilters,\n\t\t\"testUpdateBookmarkWithContent\":         testUpdateBookmarkWithContent,\n\t\t\"testGetBookmark\":                       testGetBookmark,\n\t\t\"testGetBookmarkNotExistent\":            testGetBookmarkNotExistent,\n\t\t\"testGetBookmarks\":                      testGetBookmarks,\n\t\t\"testGetBookmarksWithTags\":              testGetBookmarksWithTags,\n\t\t\"testGetBookmarksWithSQLCharacters\":     testGetBookmarksWithSQLCharacters,\n\t\t\"testGetBookmarksCount\":                 testGetBookmarksCount,\n\t\t\"testSaveBookmark\":                      testSaveBookmark,\n\t\t\"testBulkUpdateBookmarkTags\":            testBulkUpdateBookmarkTags,\n\t\t\"testBookmarkExists\":                    testBookmarkExists,\n\t\t// Tags\n\t\t\"testCreateTag\":             testCreateTag,\n\t\t\"testCreateTags\":            testCreateTags,\n\t\t\"testTagExists\":             testTagExists,\n\t\t\"testGetTags\":               testGetTags,\n\t\t\"testGetTagsFunction\":       testGetTagsFunction,\n\t\t\"testGetTag\":                testGetTag,\n\t\t\"testGetTagNotExistent\":     testGetTagNotExistent,\n\t\t\"testUpdateTag\":             testUpdateTag,\n\t\t\"testRenameTag\":             testRenameTag,\n\t\t\"testDeleteTag\":             testDeleteTag,\n\t\t\"testDeleteTagNotExistent\":  testDeleteTagNotExistent,\n\t\t\"testAddTagToBookmark\":      testAddTagToBookmark,\n\t\t\"testRemoveTagFromBookmark\": testRemoveTagFromBookmark,\n\t\t\"testTagBookmarkEdgeCases\":  testTagBookmarkEdgeCases,\n\t\t\"testTagBookmarkOperations\": testTagBookmarkOperations,\n\t\t// Accounts\n\t\t\"testCreateAccount\":              testCreateAccount,\n\t\t\"testCreateDuplicateAccount\":     testCreateDuplicateAccount,\n\t\t\"testDeleteAccount\":              testDeleteAccount,\n\t\t\"testDeleteNonExistantAccount\":   testDeleteNonExistantAccount,\n\t\t\"testUpdateAccount\":              testUpdateAccount,\n\t\t\"testUpdateAccountDuplicateUser\": testUpdateAccountDuplicateUser,\n\t\t\"testGetAccount\":                 testGetAccount,\n\t\t\"testListAccounts\":               testListAccounts,\n\t\t\"testListAccountsWithPassword\":   testListAccountsWithPassword,\n\t}\n\n\tfor testName, testCase := range tests {\n\t\tt.Run(testName, func(tInner *testing.T) {\n\t\t\tctx := context.TODO()\n\t\t\tdb, err := dbFactory(t, ctx)\n\t\t\trequire.NoError(tInner, err, \"Error recreating database\")\n\t\t\ttestCase(tInner, db)\n\t\t})\n\t}\n}\n\nfunc testBookmarkAutoIncrement(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tbook := model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/shiori\",\n\t\tTitle: \"shiori\",\n\t}\n\n\tresult, err := db.SaveBookmarks(ctx, true, book)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\tassert.Equal(t, 1, result[0].ID, \"Saved bookmark must have ID %d\", 1)\n\n\tbook = model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/obelisk\",\n\t\tTitle: \"obelisk\",\n\t}\n\n\tresult, err = db.SaveBookmarks(ctx, true, book)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\tassert.Equal(t, 2, result[0].ID, \"Saved bookmark must have ID %d\", 2)\n}\n\nfunc testCreateBookmark(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tbook := model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/obelisk\",\n\t\tTitle: \"shiori\",\n\t}\n\n\tresult, err := db.SaveBookmarks(ctx, true, book)\n\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\tassert.Equal(t, 1, result[0].ID, \"Saved bookmark must have an ID set\")\n}\n\nfunc testCreateBookmarkWithContent(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tbook := model.BookmarkDTO{\n\t\tURL:     \"https://github.com/go-shiori/obelisk\",\n\t\tTitle:   \"shiori\",\n\t\tContent: \"Some content\",\n\t\tHTML:    \"Some HTML content\",\n\t}\n\n\tresult, err := db.SaveBookmarks(ctx, true, book)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\n\tbooks, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{\n\t\tIDs:         []int{result[0].ID},\n\t\tWithContent: true,\n\t})\n\tassert.NoError(t, err, \"Get bookmarks must not fail\")\n\tassert.Len(t, books, 1)\n\n\tassert.Equal(t, 1, books[0].ID, \"Saved bookmark must have an ID set\")\n\tassert.Equal(t, book.Content, books[0].Content, \"Saved bookmark must have content\")\n\tassert.Equal(t, book.HTML, books[0].HTML, \"Saved bookmark must have HTML\")\n}\n\nfunc testCreateBookmarkWithTag(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tbook := model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/obelisk\",\n\t\tTitle: \"shiori\",\n\t\tTags: []model.TagDTO{\n\t\t\t{\n\t\t\t\tTag: model.Tag{\n\t\t\t\t\tName: \"test-tag\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tresult, err := db.SaveBookmarks(ctx, true, book)\n\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\tassert.Equal(t, book.URL, result[0].URL)\n\tassert.Equal(t, book.Tags[0].Name, result[0].Tags[0].Name)\n}\n\nfunc testCreateBookmarkTwice(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tbook := model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/shiori\",\n\t\tTitle: \"shiori\",\n\t}\n\n\tresult, err := db.SaveBookmarks(ctx, true, book)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\n\tsavedBookmark := result[0]\n\tsavedBookmark.Title = \"modified\"\n\n\t_, err = db.SaveBookmarks(ctx, true, savedBookmark)\n\tassert.Error(t, err, \"Save bookmarks must fail\")\n}\n\nfunc testCreateTwoDifferentBookmarks(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tbook := model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/shiori\",\n\t\tTitle: \"shiori\",\n\t}\n\n\t_, err := db.SaveBookmarks(ctx, true, book)\n\tassert.NoError(t, err, \"Save first bookmark must not fail\")\n\n\tbook = model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/go-readability\",\n\t\tTitle: \"go-readability\",\n\t}\n\t_, err = db.SaveBookmarks(ctx, true, book)\n\tassert.NoError(t, err, \"Save second bookmark must not fail\")\n}\n\nfunc testUpdateBookmark(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tbook := model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/shiori\",\n\t\tTitle: \"shiori\",\n\t}\n\n\tresult, err := db.SaveBookmarks(ctx, true, book)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\n\tsavedBookmark := result[0]\n\tsavedBookmark.Title = \"modified\"\n\n\tresult, err = db.SaveBookmarks(ctx, false, savedBookmark)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\n\tassert.Equal(t, \"modified\", result[0].Title)\n\tassert.Equal(t, savedBookmark.ID, result[0].ID)\n}\n\nfunc testUpdateBookmarkWithContent(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tbook := model.BookmarkDTO{\n\t\tURL:     \"https://github.com/go-shiori/obelisk\",\n\t\tTitle:   \"shiori\",\n\t\tContent: \"Some content\",\n\t\tHTML:    \"Some HTML content\",\n\t}\n\n\tresult, err := db.SaveBookmarks(ctx, true, book)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\n\tupdatedBook := result[0]\n\tupdatedBook.Content = \"Some updated content\"\n\tupdatedBook.HTML = \"Some updated HTML content\"\n\n\t_, err = db.SaveBookmarks(ctx, false, updatedBook)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\n\tbooks, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{\n\t\tIDs:         []int{result[0].ID},\n\t\tWithContent: true,\n\t})\n\tassert.NoError(t, err, \"Get bookmarks must not fail\")\n\tassert.Len(t, books, 1)\n\n\tassert.Equal(t, 1, books[0].ID, \"Saved bookmark must have an ID set\")\n\tassert.Equal(t, updatedBook.Content, books[0].Content, \"Saved bookmark must have updated content\")\n\tassert.Equal(t, updatedBook.HTML, books[0].HTML, \"Saved bookmark must have updated HTML\")\n}\n\nfunc testGetBookmark(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tbook := model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/shiori\",\n\t\tTitle: \"shiori\",\n\t}\n\n\tresult, err := db.SaveBookmarks(ctx, true, book)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\n\tsavedBookmark, exists, err := db.GetBookmark(ctx, result[0].ID, \"\")\n\tassert.NoError(t, err, \"Get bookmark should not fail\")\n\tassert.True(t, exists, \"Bookmark should exist\")\n\tassert.Equal(t, result[0].ID, savedBookmark.ID, \"Retrieved bookmark should be the same\")\n\tassert.Equal(t, book.URL, savedBookmark.URL, \"Retrieved bookmark should be the same\")\n}\n\nfunc testGetBookmarkNotExistent(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tsavedBookmark, exists, err := db.GetBookmark(ctx, 1, \"\")\n\tassert.NoError(t, err, \"Get bookmark should not fail\")\n\tassert.False(t, exists, \"Bookmark should not exist\")\n\tassert.Equal(t, model.BookmarkDTO{}, savedBookmark)\n}\n\nfunc testGetBookmarks(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tbook := model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/shiori\",\n\t\tTitle: \"shiori\",\n\t}\n\n\tbookmarks, err := db.SaveBookmarks(ctx, true, book)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\n\tsavedBookmark := bookmarks[0]\n\n\tresults, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{\n\t\tKeyword: \"go-shiori\",\n\t})\n\n\tassert.NoError(t, err, \"Get bookmarks should not fail\")\n\tassert.Len(t, results, 1, \"results should contain one item\")\n\tassert.Equal(t, savedBookmark.ID, results[0].ID, \"bookmark should be the one saved\")\n}\n\nfunc testGetBookmarksWithSQLCharacters(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// _ := 0\n\tbook := model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/shiori\",\n\t\tTitle: \"shiori\",\n\t}\n\t_, err := db.SaveBookmarks(ctx, true, book)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\n\tcharacters := []string{\";\", \"%\", \"_\", \"\\\\\", \"\\\"\", \":\"}\n\n\tfor _, char := range characters {\n\t\tt.Run(\"GetBookmarks/\"+char, func(t *testing.T) {\n\t\t\t_, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{\n\t\t\t\tKeyword: char,\n\t\t\t})\n\t\t\tassert.NoError(t, err, \"Get bookmarks should not fail\")\n\t\t})\n\n\t\tt.Run(\"GetBookmarksCount/\"+char, func(t *testing.T) {\n\t\t\t_, err := db.GetBookmarksCount(ctx, model.DBGetBookmarksOptions{\n\t\t\t\tKeyword: char,\n\t\t\t})\n\t\t\tassert.NoError(t, err, \"Get bookmarks count should not fail\")\n\t\t})\n\t}\n}\n\nfunc testGetBookmarksWithTags(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Create test tags\n\ttags := []model.Tag{\n\t\t{Name: \"programming\"},\n\t\t{Name: \"golang\"},\n\t\t{Name: \"database\"},\n\t\t{Name: \"testing\"},\n\t}\n\tcreatedTags, err := db.CreateTags(ctx, tags...)\n\trequire.NoError(t, err)\n\trequire.Len(t, createdTags, 4)\n\n\t// Create bookmarks with different tag combinations\n\tbookmarks := []model.BookmarkDTO{\n\t\t{\n\t\t\tURL:   \"https://golang.org\",\n\t\t\tTitle: \"Go Language\",\n\t\t\tTags: []model.TagDTO{\n\t\t\t\t{Tag: model.Tag{Name: \"programming\"}},\n\t\t\t\t{Tag: model.Tag{Name: \"golang\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tURL:   \"https://postgresql.org\",\n\t\t\tTitle: \"PostgreSQL\",\n\t\t\tTags: []model.TagDTO{\n\t\t\t\t{Tag: model.Tag{Name: \"programming\"}},\n\t\t\t\t{Tag: model.Tag{Name: \"database\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tURL:   \"https://sqlite.org\",\n\t\t\tTitle: \"SQLite\",\n\t\t\tTags: []model.TagDTO{\n\t\t\t\t{Tag: model.Tag{Name: \"database\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tURL:   \"https://example.com\",\n\t\t\tTitle: \"No Tags Example\",\n\t\t},\n\t}\n\n\t// Save all bookmarks\n\tfor _, bookmark := range bookmarks {\n\t\tresults, err := db.SaveBookmarks(ctx, true, bookmark)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, results, 1)\n\t}\n\n\ttests := []struct {\n\t\tname           string\n\t\topts           model.DBGetBookmarksOptions\n\t\texpectedCount  int\n\t\texpectedTitles []string\n\t}{\n\t\t{\n\t\t\tname: \"single tag - programming\",\n\t\t\topts: model.DBGetBookmarksOptions{\n\t\t\t\tTags: []string{\"programming\"},\n\t\t\t},\n\t\t\texpectedCount:  2,\n\t\t\texpectedTitles: []string{\"Go Language\", \"PostgreSQL\"},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple tags - programming AND golang\",\n\t\t\topts: model.DBGetBookmarksOptions{\n\t\t\t\tTags: []string{\"programming\", \"golang\"},\n\t\t\t},\n\t\t\texpectedCount:  1,\n\t\t\texpectedTitles: []string{\"Go Language\"},\n\t\t},\n\t\t{\n\t\t\tname: \"all tags using *\",\n\t\t\topts: model.DBGetBookmarksOptions{\n\t\t\t\tTags: []string{\"*\"},\n\t\t\t},\n\t\t\texpectedCount:  3,\n\t\t\texpectedTitles: []string{\"Go Language\", \"PostgreSQL\", \"SQLite\"},\n\t\t},\n\t\t{\n\t\t\tname: \"exclude database tag\",\n\t\t\topts: model.DBGetBookmarksOptions{\n\t\t\t\tExcludedTags: []string{\"database\"},\n\t\t\t},\n\t\t\texpectedCount:  2,\n\t\t\texpectedTitles: []string{\"Go Language\", \"No Tags Example\"},\n\t\t},\n\t\t{\n\t\t\tname: \"no tags only\",\n\t\t\topts: model.DBGetBookmarksOptions{\n\t\t\t\tExcludedTags: []string{\"*\"},\n\t\t\t},\n\t\t\texpectedCount:  1,\n\t\t\texpectedTitles: []string{\"No Tags Example\"},\n\t\t},\n\t\t{\n\t\t\tname: \"non-existent tag\",\n\t\t\topts: model.DBGetBookmarksOptions{\n\t\t\t\tTags: []string{\"nonexistent\"},\n\t\t\t},\n\t\t\texpectedCount:  0,\n\t\t\texpectedTitles: []string{},\n\t\t},\n\t}\n\n\tt.Run(\"ensure tags are present\", func(t *testing.T) {\n\t\ttags, err := db.GetTags(ctx, model.DBListTagsOptions{})\n\t\trequire.NoError(t, err)\n\t\tassert.Len(t, tags, 4)\n\t})\n\n\tt.Run(\"ensure test data is correct\", func(t *testing.T) {\n\t\tresults, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{})\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, results, 4)\n\t\tfor _, book := range results {\n\t\t\tif book.Title == \"No Tags Example\" {\n\t\t\t\tassert.Empty(t, book.Tags)\n\t\t\t} else {\n\t\t\t\tassert.NotEmpty(t, book.Tags)\n\t\t\t}\n\n\t\t\t// Ensure tags contain their ID and name\n\t\t\tfor _, tag := range book.Tags {\n\t\t\t\tassert.NotZero(t, tag.ID)\n\t\t\t\tassert.NotEmpty(t, tag.Name)\n\t\t\t}\n\t\t}\n\t})\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresults, err := db.GetBookmarks(ctx, tt.opts)\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Len(t, results, tt.expectedCount)\n\n\t\t\t// Check if all expected titles are present\n\t\t\ttitles := make([]string, len(results))\n\t\t\tfor i, result := range results {\n\t\t\t\ttitles[i] = result.Title\n\t\t\t}\n\t\t\tassert.ElementsMatch(t, tt.expectedTitles, titles)\n\t\t})\n\t}\n}\n\nfunc testGetBookmarksCount(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\texpectedCount := 1\n\tbook := model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/shiori\",\n\t\tTitle: \"shiori\",\n\t}\n\n\t_, err := db.SaveBookmarks(ctx, true, book)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\n\tcount, err := db.GetBookmarksCount(ctx, model.DBGetBookmarksOptions{\n\t\tKeyword: \"go-shiori\",\n\t})\n\tassert.NoError(t, err, \"Get bookmarks count should not fail\")\n\tassert.Equal(t, count, expectedCount, \"count should be %d\", expectedCount)\n}\n\nfunc testCreateTag(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\ttag := model.Tag{Name: \"shiori\"}\n\tcreatedTags, err := db.CreateTags(ctx, tag)\n\tassert.NoError(t, err, \"Save tag must not fail\")\n\tassert.Len(t, createdTags, 1, \"Should return one created tag\")\n\tassert.Greater(t, createdTags[0].ID, 0, \"Created tag should have a valid ID\")\n\tassert.Equal(t, \"shiori\", createdTags[0].Name, \"Created tag should have the correct name\")\n}\n\nfunc testCreateTags(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\tcreatedTags, err := db.CreateTags(ctx, model.Tag{Name: \"shiori\"}, model.Tag{Name: \"shiori2\"})\n\tassert.NoError(t, err, \"Save tag must not fail\")\n\tassert.Len(t, createdTags, 2, \"Should return two created tags\")\n\tassert.Greater(t, createdTags[0].ID, 0, \"First created tag should have a valid ID\")\n\tassert.Greater(t, createdTags[1].ID, 0, \"Second created tag should have a valid ID\")\n\tassert.Equal(t, \"shiori\", createdTags[0].Name, \"First created tag should have the correct name\")\n\tassert.Equal(t, \"shiori2\", createdTags[1].Name, \"Second created tag should have the correct name\")\n}\n\n// ----------------- ACCOUNTS -----------------\nfunc testCreateAccount(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tacc := model.Account{\n\t\tUsername: \"testuser\",\n\t\tPassword: \"testpass\",\n\t\tOwner:    true,\n\t}\n\tinsertedAccount, err := db.CreateAccount(ctx, acc)\n\tassert.NoError(t, err, \"Save account must not fail\")\n\tassert.Equal(t, acc.Username, insertedAccount.Username, \"Saved account must have an username set\")\n\tassert.Equal(t, acc.Password, insertedAccount.Password, \"Saved account must have a password set\")\n\tassert.Equal(t, acc.Owner, insertedAccount.Owner, \"Saved account must have an owner set\")\n\tassert.NotEmpty(t, insertedAccount.ID, \"Saved account must have an ID set\")\n}\n\nfunc testDeleteAccount(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tacc := model.Account{\n\t\tUsername: \"testuser\",\n\t\tPassword: \"testpass\",\n\t\tOwner:    true,\n\t}\n\tstoredAccount, err := db.CreateAccount(ctx, acc)\n\tassert.NoError(t, err, \"Save account must not fail\")\n\n\terr = db.DeleteAccount(ctx, storedAccount.ID)\n\tassert.NoError(t, err, \"Delete account must not fail\")\n\n\t_, exists, err := db.GetAccount(ctx, storedAccount.ID)\n\tassert.False(t, exists, \"Account must not exist\")\n\tassert.ErrorIs(t, err, ErrNotFound, \"Get account must return not found error\")\n}\n\nfunc testDeleteNonExistantAccount(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\terr := db.DeleteAccount(ctx, model.DBID(99))\n\tassert.ErrorIs(t, err, ErrNotFound, \"Delete account must fail\")\n}\n\nfunc testUpdateAccount(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tacc := model.Account{\n\t\tUsername: \"testuser\",\n\t\tPassword: \"testpass\",\n\t\tOwner:    true,\n\t\tConfig: model.UserConfig{\n\t\t\tShowId: true,\n\t\t},\n\t}\n\n\taccount, err := db.CreateAccount(ctx, acc)\n\trequire.Nil(t, err)\n\trequire.NotNil(t, account)\n\trequire.NotEmpty(t, account.ID)\n\n\taccount, _, err = db.GetAccount(ctx, account.ID)\n\trequire.Nil(t, err)\n\n\tt.Run(\"update\", func(t *testing.T) {\n\t\tacc := model.Account{\n\t\t\tID:       account.ID,\n\t\t\tUsername: \"asdlasd\",\n\t\t\tOwner:    false,\n\t\t\tPassword: \"another\",\n\t\t\tConfig: model.UserConfig{\n\t\t\t\tShowId: false,\n\t\t\t},\n\t\t}\n\n\t\terr := db.UpdateAccount(ctx, acc)\n\t\trequire.Nil(t, err)\n\n\t\tupdatedAccount, exists, err := db.GetAccount(ctx, account.ID)\n\t\trequire.NoError(t, err)\n\t\trequire.True(t, exists)\n\t\trequire.Equal(t, acc.Username, updatedAccount.Username)\n\t\trequire.Equal(t, acc.Owner, updatedAccount.Owner)\n\t\trequire.Equal(t, acc.Config, updatedAccount.Config)\n\t\trequire.NotEqual(t, acc.Password, account.Password)\n\t})\n}\n\nfunc testGetAccount(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Insert test accounts\n\ttestAccounts := []model.Account{\n\t\t{Username: \"foo\", Password: \"bar\", Owner: false},\n\t\t{Username: \"hello\", Password: \"world\", Owner: false},\n\t\t{Username: \"foo_bar\", Password: \"foobar\", Owner: true},\n\t}\n\n\tfor _, acc := range testAccounts {\n\t\tstoredAcc, err := db.CreateAccount(ctx, acc)\n\t\tassert.Nil(t, err)\n\n\t\t// Successful case\n\t\taccount, exists, err := db.GetAccount(ctx, storedAcc.ID)\n\t\tassert.Nil(t, err)\n\t\tassert.True(t, exists, \"Expected account to exist\")\n\t\tassert.Equal(t, storedAcc.Username, account.Username)\n\t}\n\n\t// Failed case\n\taccount, exists, err := db.GetAccount(ctx, 99)\n\tassert.ErrorIs(t, err, ErrNotFound)\n\tassert.False(t, exists, \"Expected account to exist\")\n\tassert.Empty(t, account.Username)\n}\n\nfunc testListAccounts(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// prepare database\n\ttestAccounts := []model.Account{\n\t\t{Username: \"foo\", Password: \"bar\", Owner: false},\n\t\t{Username: \"hello\", Password: \"world\", Owner: false},\n\t\t{Username: \"foo_bar\", Password: \"foobar\", Owner: true},\n\t}\n\tfor _, acc := range testAccounts {\n\t\t_, err := db.CreateAccount(ctx, acc)\n\t\tassert.Nil(t, err)\n\t}\n\n\ttests := []struct {\n\t\tname     string\n\t\toptions  model.DBListAccountsOptions\n\t\texpected int\n\t}{\n\t\t{\"default\", model.DBListAccountsOptions{}, 3},\n\t\t{\"with owner\", model.DBListAccountsOptions{Owner: true}, 1},\n\t\t{\"with keyword\", model.DBListAccountsOptions{Keyword: \"foo\"}, 2},\n\t\t{\"with keyword and owner\", model.DBListAccountsOptions{Keyword: \"hello\", Owner: false}, 1},\n\t\t{\"with no result\", model.DBListAccountsOptions{Keyword: \"shiori\"}, 0},\n\t\t{\"with username\", model.DBListAccountsOptions{Username: \"foo\"}, 1},\n\t\t{\"with non-existent username\", model.DBListAccountsOptions{Username: \"non-existant\"}, 0},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\taccounts, err := db.ListAccounts(ctx, tt.options)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expected, len(accounts))\n\t\t})\n\t}\n}\n\nfunc testCreateDuplicateAccount(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tacc := model.Account{\n\t\tUsername: \"testuser\",\n\t\tPassword: \"testpass\",\n\t\tOwner:    false,\n\t}\n\n\t// Create first account\n\t_, err := db.CreateAccount(ctx, acc)\n\tassert.NoError(t, err, \"First account creation must not fail\")\n\n\t// Try to create account with same username\n\t_, err = db.CreateAccount(ctx, acc)\n\tassert.ErrorIs(t, err, ErrAlreadyExists, \"Creating duplicate account must return ErrAlreadyExists\")\n}\n\nfunc testUpdateAccountDuplicateUser(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Create first account\n\tacc1 := model.Account{\n\t\tUsername: \"testuser1\",\n\t\tPassword: \"testpass\",\n\t\tOwner:    false,\n\t}\n\tstoredAcc1, err := db.CreateAccount(ctx, acc1)\n\tassert.NoError(t, err, \"First account creation must not fail\")\n\n\t// Create second account\n\tacc2 := model.Account{\n\t\tUsername: \"testuser2\",\n\t\tPassword: \"testpass\",\n\t\tOwner:    false,\n\t}\n\tstoredAcc2, err := db.CreateAccount(ctx, acc2)\n\tassert.NoError(t, err, \"Second account creation must not fail\")\n\n\t// Try to update second account to have same username as first\n\tstoredAcc2.Username = storedAcc1.Username\n\terr = db.UpdateAccount(ctx, *storedAcc2)\n\tassert.ErrorIs(t, err, ErrAlreadyExists, \"Updating to duplicate username must return ErrAlreadyExists\")\n}\n\nfunc testListAccountsWithPassword(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\t_, err := db.CreateAccount(ctx, model.Account{\n\t\tUsername: \"gopher\",\n\t\tPassword: \"shiori\",\n\t})\n\tassert.Nil(t, err)\n\n\tstoredAccounts, err := db.ListAccounts(ctx, model.DBListAccountsOptions{\n\t\tWithPassword: true,\n\t})\n\trequire.NoError(t, err)\n\tfor _, acc := range storedAccounts {\n\t\trequire.NotEmpty(t, acc.Password)\n\t}\n}\n\n// TODO: Consider using `t.Parallel()` once we have automated database tests spawning databases using testcontainers.\nfunc testUpdateBookmarkUpdatesModifiedTime(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tbook := model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/shiori\",\n\t\tTitle: \"shiori\",\n\t}\n\n\tresultBook, err := db.SaveBookmarks(ctx, true, book)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\n\tupdatedBook := resultBook[0]\n\tupdatedBook.Title = \"modified\"\n\tupdatedBook.ModifiedAt = \"\"\n\n\ttime.Sleep(1 * time.Second)\n\tresultUpdatedBooks, err := db.SaveBookmarks(ctx, false, updatedBook)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\n\tassert.NotEqual(t, resultBook[0].ModifiedAt, resultUpdatedBooks[0].ModifiedAt)\n\tassert.Equal(t, resultBook[0].CreatedAt, resultUpdatedBooks[0].CreatedAt)\n\tassert.Equal(t, resultBook[0].CreatedAt, resultBook[0].ModifiedAt)\n\tassert.NoError(t, err, \"Get bookmarks must not fail\")\n\n\tassert.Equal(t, updatedBook.Title, resultUpdatedBooks[0].Title, \"Saved bookmark must have updated Title\")\n}\n\n// TODO: Consider using `t.Parallel()` once we have automated database tests spawning databases using testcontainers.\nfunc testGetBoomarksWithTimeFilters(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tbook1 := model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/shiori/one\",\n\t\tTitle: \"Added First but Modified Last\",\n\t}\n\tbook2 := model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/shiori/second\",\n\t\tTitle: \"Added Last but Modified First\",\n\t}\n\n\t// create two new bookmark\n\tresultBook1, err := db.SaveBookmarks(ctx, true, book1)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\ttime.Sleep(1 * time.Second)\n\tresultBook2, err := db.SaveBookmarks(ctx, true, book2)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\n\t// update those bookmarks\n\tupdatedBook1 := resultBook1[0]\n\tupdatedBook1.Title = \"Added First but Modified Last Updated Title\"\n\tupdatedBook1.ModifiedAt = \"\"\n\n\tupdatedBook2 := resultBook2[0]\n\tupdatedBook2.Title = \"Last Added but modified First Updated Title\"\n\tupdatedBook2.ModifiedAt = \"\"\n\n\t// modified bookmark2 first after one second modified bookmark1\n\tresultUpdatedBook2, err := db.SaveBookmarks(ctx, false, updatedBook2)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\ttime.Sleep(1 * time.Second)\n\tresultUpdatedBook1, err := db.SaveBookmarks(ctx, false, updatedBook1)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\n\t// get diffrent filteter combination\n\tbooksOrderByLastAdded, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{\n\t\tIDs:         []int{resultUpdatedBook1[0].ID, resultUpdatedBook2[0].ID},\n\t\tOrderMethod: 1,\n\t})\n\tassert.NoError(t, err, \"Get bookmarks must not fail\")\n\tbooksOrderByLastModified, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{\n\t\tIDs:         []int{resultUpdatedBook1[0].ID, resultUpdatedBook2[0].ID},\n\t\tOrderMethod: 2,\n\t})\n\tassert.NoError(t, err, \"Get bookmarks must not fail\")\n\tbooksOrderById, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{\n\t\tIDs:         []int{resultUpdatedBook1[0].ID, resultUpdatedBook2[0].ID},\n\t\tOrderMethod: 0,\n\t})\n\tassert.NoError(t, err, \"Get bookmarks must not fail\")\n\n\t// Check Last Added\n\tassert.Equal(t, booksOrderByLastAdded[0].Title, updatedBook2.Title)\n\t// Check Last Modified\n\tassert.Equal(t, booksOrderByLastModified[0].Title, updatedBook1.Title)\n\t// Second id should be 2 if order them by id\n\tassert.Equal(t, booksOrderById[1].ID, 2)\n}\n\n// Additional tag test functions\n\nfunc testGetTags(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Create initial tag to ensure there's at least one tag\n\tinitialTag := model.Tag{Name: \"initial-test-tag\"}\n\t_, err := db.CreateTags(ctx, initialTag)\n\trequire.NoError(t, err)\n\n\t// Create additional tags\n\ttags := []model.Tag{\n\t\t{Name: \"tag1\"},\n\t\t{Name: \"tag2\"},\n\t\t{Name: \"tag3\"},\n\t}\n\tcreatedTags, err := db.CreateTags(ctx, tags...)\n\trequire.NoError(t, err)\n\trequire.Len(t, createdTags, 3)\n\n\t// Fetch all tags\n\tfetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{})\n\trequire.NoError(t, err)\n\trequire.GreaterOrEqual(t, len(fetchedTags), 4) // At least 3 new tags + 1 initial tag\n\n\t// Check that all expected tags are present\n\ttagNames := make(map[string]bool)\n\tfor _, tag := range fetchedTags {\n\t\ttagNames[tag.Name] = true\n\t}\n\n\tassert.True(t, tagNames[\"tag1\"], \"Tag 'tag1' should be present\")\n\tassert.True(t, tagNames[\"tag2\"], \"Tag 'tag2' should be present\")\n\tassert.True(t, tagNames[\"tag3\"], \"Tag 'tag3' should be present\")\n\tassert.True(t, tagNames[\"initial-test-tag\"], \"Tag 'initial-test-tag' should be present\")\n}\n\nfunc testGetTag(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Create a tag\n\ttag := model.Tag{Name: \"get-tag-test\"}\n\tcreatedTags, err := db.CreateTags(ctx, tag)\n\trequire.NoError(t, err)\n\trequire.Len(t, createdTags, 1)\n\ttagID := createdTags[0].ID\n\n\t// Get the tag\n\tfetchedTag, exists, err := db.GetTag(ctx, tagID)\n\trequire.NoError(t, err)\n\trequire.True(t, exists)\n\tassert.Equal(t, tagID, fetchedTag.ID)\n\tassert.Equal(t, tag.Name, fetchedTag.Name)\n}\n\nfunc testGetTagNotExistent(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Test non-existent tag\n\tnonExistentTag, exists, err := db.GetTag(ctx, 9999)\n\trequire.NoError(t, err)\n\trequire.False(t, exists)\n\tassert.Empty(t, nonExistentTag.Name)\n}\n\nfunc testUpdateTag(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Create a tag\n\ttag := model.Tag{Name: \"update-tag-test\"}\n\tcreatedTags, err := db.CreateTags(ctx, tag)\n\trequire.NoError(t, err)\n\trequire.Len(t, createdTags, 1)\n\n\t// Update the tag\n\ttagToUpdate := model.Tag{\n\t\tID:   createdTags[0].ID,\n\t\tName: \"updated-tag\",\n\t}\n\terr = db.UpdateTag(ctx, tagToUpdate)\n\trequire.NoError(t, err)\n\n\t// Verify the tag was updated\n\tupdatedTag, exists, err := db.GetTag(ctx, tagToUpdate.ID)\n\trequire.NoError(t, err)\n\trequire.True(t, exists)\n\tassert.Equal(t, \"updated-tag\", updatedTag.Name)\n}\n\nfunc testRenameTag(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Create a tag\n\ttag := model.Tag{Name: \"rename-tag-test\"}\n\tcreatedTags, err := db.CreateTags(ctx, tag)\n\trequire.NoError(t, err)\n\trequire.Len(t, createdTags, 1)\n\ttagID := createdTags[0].ID\n\n\t// Rename the tag\n\terr = db.RenameTag(ctx, tagID, \"renamed-tag\")\n\trequire.NoError(t, err)\n\n\t// Verify the tag was renamed\n\trenamedTag, exists, err := db.GetTag(ctx, tagID)\n\trequire.NoError(t, err)\n\trequire.True(t, exists)\n\tassert.Equal(t, \"renamed-tag\", renamedTag.Name)\n}\n\nfunc testDeleteTag(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Create a tag\n\ttag := model.Tag{Name: \"delete-tag-test\"}\n\tcreatedTags, err := db.CreateTags(ctx, tag)\n\trequire.NoError(t, err)\n\trequire.Len(t, createdTags, 1)\n\ttagID := createdTags[0].ID\n\n\t// Delete the tag\n\terr = db.DeleteTag(ctx, tagID)\n\trequire.NoError(t, err)\n\n\t// Verify the tag was deleted\n\t_, exists, err := db.GetTag(ctx, tagID)\n\trequire.NoError(t, err)\n\trequire.False(t, exists)\n}\n\nfunc testDeleteTagNotExistent(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Test deleting a non-existent tag\n\terr := db.DeleteTag(ctx, 9999)\n\trequire.Error(t, err)\n\tassert.ErrorIs(t, err, ErrNotFound, \"Error should be ErrNotFound\")\n}\n\nfunc testSaveBookmark(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\tt.Run(\"invalid_bookmark_id\", func(t *testing.T) {\n\t\tbookmark := model.Bookmark{\n\t\t\tID:    0, // Invalid ID\n\t\t\tURL:   \"https://example.com\",\n\t\t\tTitle: \"Example\",\n\t\t}\n\t\terr := db.SaveBookmark(ctx, bookmark)\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"bookmark ID must be greater than 0\")\n\t})\n\n\tt.Run(\"empty_url\", func(t *testing.T) {\n\t\tbookmark := model.Bookmark{\n\t\t\tID:    1,\n\t\t\tURL:   \"\", // Empty URL\n\t\t\tTitle: \"Example\",\n\t\t}\n\t\terr := db.SaveBookmark(ctx, bookmark)\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"URL must not be empty\")\n\t})\n\n\tt.Run(\"empty_title\", func(t *testing.T) {\n\t\tbookmark := model.Bookmark{\n\t\t\tID:    1,\n\t\t\tURL:   \"https://example.com\",\n\t\t\tTitle: \"\", // Empty title\n\t\t}\n\t\terr := db.SaveBookmark(ctx, bookmark)\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"title must not be empty\")\n\t})\n\n\tt.Run(\"successful_update\", func(t *testing.T) {\n\t\t// First create a bookmark\n\t\tbookmark := model.BookmarkDTO{\n\t\t\tURL:   \"https://example.com\",\n\t\t\tTitle: \"Example\",\n\t\t}\n\t\tresults, err := db.SaveBookmarks(ctx, true, bookmark)\n\t\trequire.NoError(t, err)\n\t\tbookmarkID := results[0].ID\n\n\t\t// Now update it\n\t\tupdatedBookmark := model.Bookmark{\n\t\t\tID:      bookmarkID,\n\t\t\tURL:     \"https://updated-example.com\",\n\t\t\tTitle:   \"Updated Example\",\n\t\t\tExcerpt: \"Updated excerpt\",\n\t\t\tAuthor:  \"Updated Author\",\n\t\t\tPublic:  1, // Use 1 for SQLite, should work for other DBs too\n\t\t}\n\n\t\terr = db.SaveBookmark(ctx, updatedBookmark)\n\t\trequire.NoError(t, err)\n\n\t\t// Verify the bookmark was updated\n\t\tretrievedBookmark, exists, err := db.GetBookmark(ctx, bookmarkID, \"\")\n\t\trequire.NoError(t, err)\n\t\trequire.True(t, exists)\n\t\tassert.Equal(t, updatedBookmark.URL, retrievedBookmark.URL)\n\t\tassert.Equal(t, updatedBookmark.Title, retrievedBookmark.Title)\n\t\tassert.Equal(t, updatedBookmark.Excerpt, retrievedBookmark.Excerpt)\n\t\tassert.Equal(t, updatedBookmark.Author, retrievedBookmark.Author)\n\t\tassert.Equal(t, updatedBookmark.Public, retrievedBookmark.Public)\n\t})\n}\n\nfunc testBulkUpdateBookmarkTags(t *testing.T, db model.DB) {\n\tctx := context.TODO()\n\n\t// Create test bookmarks\n\tbookmark1 := model.BookmarkDTO{\n\t\tURL:   \"https://example1.com\",\n\t\tTitle: \"Example 1\",\n\t}\n\tbookmark2 := model.BookmarkDTO{\n\t\tURL:   \"https://example2.com\",\n\t\tTitle: \"Example 2\",\n\t}\n\tbookmark3 := model.BookmarkDTO{\n\t\tURL:   \"https://example3.com\",\n\t\tTitle: \"Example 3\",\n\t}\n\n\tresults1, err := db.SaveBookmarks(ctx, true, bookmark1)\n\trequire.NoError(t, err)\n\tbookmark1ID := results1[0].ID\n\n\tresults2, err := db.SaveBookmarks(ctx, true, bookmark2)\n\trequire.NoError(t, err)\n\tbookmark2ID := results2[0].ID\n\n\tresults3, err := db.SaveBookmarks(ctx, true, bookmark3)\n\trequire.NoError(t, err)\n\tbookmark3ID := results3[0].ID\n\n\t// Create test tags\n\ttag1 := model.Tag{Name: \"tag1-bulk-test\"}\n\ttag2 := model.Tag{Name: \"tag2-bulk-test\"}\n\ttag3 := model.Tag{Name: \"tag3-bulk-test\"}\n\ttag4 := model.Tag{Name: \"tag4-bulk-test\"}\n\n\tcreatedTags, err := db.CreateTags(ctx, tag1, tag2, tag3, tag4)\n\trequire.NoError(t, err)\n\trequire.Len(t, createdTags, 4)\n\n\ttag1ID := createdTags[0].ID\n\ttag2ID := createdTags[1].ID\n\ttag3ID := createdTags[2].ID\n\ttag4ID := createdTags[3].ID\n\n\tt.Run(\"empty_bookmark_ids\", func(t *testing.T) {\n\t\terr := db.BulkUpdateBookmarkTags(ctx, []int{}, []int{tag1ID, tag2ID})\n\t\trequire.NoError(t, err, \"Empty bookmark IDs should not cause an error\")\n\t})\n\n\tt.Run(\"empty_tag_ids\", func(t *testing.T) {\n\t\terr := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID, bookmark2ID}, []int{})\n\t\trequire.NoError(t, err, \"Empty tag IDs should not cause an error\")\n\n\t\t// Verify tags were removed\n\t\tbookmark, exists, err := db.GetBookmark(ctx, bookmark1ID, \"\")\n\t\trequire.NoError(t, err)\n\t\trequire.True(t, exists)\n\t\tassert.Empty(t, bookmark.Tags, \"Tags should be empty after update with empty tag IDs\")\n\t})\n\n\tt.Run(\"non_existent_bookmark\", func(t *testing.T) {\n\t\tnonExistentID := 9999\n\t\terr := db.BulkUpdateBookmarkTags(ctx, []int{nonExistentID}, []int{tag1ID})\n\t\trequire.Error(t, err, \"Non-existent bookmark ID should cause an error\")\n\t\tassert.Contains(t, err.Error(), \"some bookmarks do not exist\")\n\t})\n\n\tt.Run(\"non_existent_tag\", func(t *testing.T) {\n\t\tnonExistentID := 9999\n\t\terr := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID}, []int{nonExistentID})\n\t\trequire.Error(t, err, \"Non-existent tag ID should cause an error\")\n\t\tassert.Contains(t, err.Error(), \"some tags do not exist\")\n\t})\n\n\tt.Run(\"multiple_non_existent_bookmarks\", func(t *testing.T) {\n\t\terr := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID, 9998, 9999}, []int{tag1ID})\n\t\trequire.Error(t, err, \"Multiple non-existent bookmark IDs should cause an error\")\n\t\tassert.Contains(t, err.Error(), \"some bookmarks do not exist\")\n\t})\n\n\tt.Run(\"multiple_non_existent_tags\", func(t *testing.T) {\n\t\terr := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID}, []int{tag1ID, 9998, 9999})\n\t\trequire.Error(t, err, \"Multiple non-existent tag IDs should cause an error\")\n\t\tassert.Contains(t, err.Error(), \"some tags do not exist\")\n\t})\n\n\tt.Run(\"successful_update\", func(t *testing.T) {\n\t\t// Update both bookmarks with both tags\n\t\terr := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID, bookmark2ID}, []int{tag1ID, tag2ID})\n\t\trequire.NoError(t, err, \"Bulk update should succeed\")\n\n\t\t// Verify bookmark1 has both tags\n\t\tbookmark1, exists, err := db.GetBookmark(ctx, bookmark1ID, \"\")\n\t\trequire.NoError(t, err)\n\t\trequire.True(t, exists)\n\t\tassert.Len(t, bookmark1.Tags, 2, \"Bookmark 1 should have 2 tags\")\n\n\t\t// Verify bookmark2 has both tags\n\t\tbookmark2, exists, err := db.GetBookmark(ctx, bookmark2ID, \"\")\n\t\trequire.NoError(t, err)\n\t\trequire.True(t, exists)\n\t\tassert.Len(t, bookmark2.Tags, 2, \"Bookmark 2 should have 2 tags\")\n\n\t\t// Verify tag names\n\t\ttagNames := make(map[string]bool)\n\t\tfor _, tag := range bookmark1.Tags {\n\t\t\ttagNames[tag.Name] = true\n\t\t}\n\t\tassert.True(t, tagNames[tag1.Name], \"Bookmark 1 should have tag1\")\n\t\tassert.True(t, tagNames[tag2.Name], \"Bookmark 1 should have tag2\")\n\n\t\t// Update with a single tag\n\t\terr = db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID}, []int{tag1ID})\n\t\trequire.NoError(t, err, \"Update with single tag should succeed\")\n\n\t\t// Verify bookmark1 now has only one tag\n\t\tbookmark1, exists, err = db.GetBookmark(ctx, bookmark1ID, \"\")\n\t\trequire.NoError(t, err)\n\t\trequire.True(t, exists)\n\t\tassert.Len(t, bookmark1.Tags, 1, \"Bookmark 1 should have 1 tag after update\")\n\t\tassert.Equal(t, tag1.Name, bookmark1.Tags[0].Name, \"Bookmark 1 should have tag1\")\n\n\t\t// Verify bookmark2 still has both tags\n\t\tbookmark2, exists, err = db.GetBookmark(ctx, bookmark2ID, \"\")\n\t\trequire.NoError(t, err)\n\t\trequire.True(t, exists)\n\t\tassert.Len(t, bookmark2.Tags, 2, \"Bookmark 2 should still have 2 tags\")\n\t})\n\n\tt.Run(\"multiple_updates\", func(t *testing.T) {\n\t\t// First update\n\t\terr := db.BulkUpdateBookmarkTags(ctx, []int{bookmark3ID}, []int{tag1ID, tag2ID})\n\t\trequire.NoError(t, err, \"First update should succeed\")\n\n\t\t// Verify bookmark3 has both tags\n\t\tbookmark3, exists, err := db.GetBookmark(ctx, bookmark3ID, \"\")\n\t\trequire.NoError(t, err)\n\t\trequire.True(t, exists)\n\t\tassert.Len(t, bookmark3.Tags, 2, \"Bookmark 3 should have 2 tags after first update\")\n\n\t\t// Second update with different tags\n\t\terr = db.BulkUpdateBookmarkTags(ctx, []int{bookmark3ID}, []int{tag3ID, tag4ID})\n\t\trequire.NoError(t, err, \"Second update should succeed\")\n\n\t\t// Verify bookmark3 now has the new tags and not the old ones\n\t\tbookmark3, exists, err = db.GetBookmark(ctx, bookmark3ID, \"\")\n\t\trequire.NoError(t, err)\n\t\trequire.True(t, exists)\n\t\tassert.Len(t, bookmark3.Tags, 2, \"Bookmark 3 should have 2 tags after second update\")\n\n\t\t// Check tag names\n\t\ttagNames := make(map[string]bool)\n\t\tfor _, tag := range bookmark3.Tags {\n\t\t\ttagNames[tag.Name] = true\n\t\t}\n\t\tassert.False(t, tagNames[tag1.Name], \"Bookmark 3 should not have tag1 after second update\")\n\t\tassert.False(t, tagNames[tag2.Name], \"Bookmark 3 should not have tag2 after second update\")\n\t\tassert.True(t, tagNames[tag3.Name], \"Bookmark 3 should have tag3 after second update\")\n\t\tassert.True(t, tagNames[tag4.Name], \"Bookmark 3 should have tag4 after second update\")\n\t})\n\n\tt.Run(\"update_multiple_bookmarks_with_different_initial_tags\", func(t *testing.T) {\n\t\t// Setup: bookmark1 has tag1, bookmark2 has tag1 and tag2\n\t\terr := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID}, []int{tag1ID})\n\t\trequire.NoError(t, err)\n\n\t\terr = db.BulkUpdateBookmarkTags(ctx, []int{bookmark2ID}, []int{tag1ID, tag2ID})\n\t\trequire.NoError(t, err)\n\n\t\t// Verify initial state\n\t\tbookmark1, exists, err := db.GetBookmark(ctx, bookmark1ID, \"\")\n\t\trequire.NoError(t, err)\n\t\trequire.True(t, exists)\n\t\tassert.Len(t, bookmark1.Tags, 1, \"Bookmark 1 should have 1 tag initially\")\n\n\t\tbookmark2, exists, err := db.GetBookmark(ctx, bookmark2ID, \"\")\n\t\trequire.NoError(t, err)\n\t\trequire.True(t, exists)\n\t\tassert.Len(t, bookmark2.Tags, 2, \"Bookmark 2 should have 2 tags initially\")\n\n\t\t// Update both bookmarks with tag3 and tag4\n\t\terr = db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID, bookmark2ID}, []int{tag3ID, tag4ID})\n\t\trequire.NoError(t, err, \"Bulk update should succeed\")\n\n\t\t// Verify both bookmarks now have tag3 and tag4 only\n\t\tbookmark1, exists, err = db.GetBookmark(ctx, bookmark1ID, \"\")\n\t\trequire.NoError(t, err)\n\t\trequire.True(t, exists)\n\t\tassert.Len(t, bookmark1.Tags, 2, \"Bookmark 1 should have 2 tags after update\")\n\n\t\tbookmark2, exists, err = db.GetBookmark(ctx, bookmark2ID, \"\")\n\t\trequire.NoError(t, err)\n\t\trequire.True(t, exists)\n\t\tassert.Len(t, bookmark2.Tags, 2, \"Bookmark 2 should have 2 tags after update\")\n\n\t\t// Check tag names for bookmark1\n\t\ttagNames1 := make(map[string]bool)\n\t\tfor _, tag := range bookmark1.Tags {\n\t\t\ttagNames1[tag.Name] = true\n\t\t}\n\t\tassert.False(t, tagNames1[tag1.Name], \"Bookmark 1 should not have tag1 after update\")\n\t\tassert.False(t, tagNames1[tag2.Name], \"Bookmark 1 should not have tag2 after update\")\n\t\tassert.True(t, tagNames1[tag3.Name], \"Bookmark 1 should have tag3 after update\")\n\t\tassert.True(t, tagNames1[tag4.Name], \"Bookmark 1 should have tag4 after update\")\n\n\t\t// Check tag names for bookmark2\n\t\ttagNames2 := make(map[string]bool)\n\t\tfor _, tag := range bookmark2.Tags {\n\t\t\ttagNames2[tag.Name] = true\n\t\t}\n\t\tassert.False(t, tagNames2[tag1.Name], \"Bookmark 2 should not have tag1 after update\")\n\t\tassert.False(t, tagNames2[tag2.Name], \"Bookmark 2 should not have tag2 after update\")\n\t\tassert.True(t, tagNames2[tag3.Name], \"Bookmark 2 should have tag3 after update\")\n\t\tassert.True(t, tagNames2[tag4.Name], \"Bookmark 2 should have tag4 after update\")\n\t})\n}\n"
  },
  {
    "path": "internal/database/migrations/mysql/0000_system_create.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS shiori_system(\n    database_schema_version VARCHAR(12) NOT NULL DEFAULT '0.0.0'\n);\n"
  },
  {
    "path": "internal/database/migrations/mysql/0000_system_insert.up.sql",
    "content": "INSERT INTO shiori_system(database_schema_version) VALUES('0.0.0');\n"
  },
  {
    "path": "internal/database/migrations/mysql/0001_initial_account.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS account(\n\t\tid       INT(11)        NOT NULL AUTO_INCREMENT,\n\t\tusername VARCHAR(250)   NOT NULL,\n\t\tpassword BINARY(80)     NOT NULL,\n\t\towner    TINYINT(1)     NOT NULL DEFAULT '0',\n\t\tconfig   JSON           NOT NULL DEFAULT ('{}'),\n\t\tPRIMARY KEY (id),\n\t\tUNIQUE KEY account_username_UNIQUE (username))\n\t\tCHARACTER SET utf8mb4;\n"
  },
  {
    "path": "internal/database/migrations/mysql/0002_initial_bookmark.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS bookmark(\n\t\tid          INT(11)    NOT NULL AUTO_INCREMENT,\n\t\turl         TEXT       NOT NULL,\n\t\ttitle       TEXT       NOT NULL,\n\t\texcerpt     TEXT       NOT NULL DEFAULT (''),\n\t\tauthor      TEXT       NOT NULL DEFAULT (''),\n\t\tpublic      BOOLEAN    NOT NULL DEFAULT 0,\n\t\tcontent     MEDIUMTEXT NOT NULL DEFAULT (''),\n\t\thtml        MEDIUMTEXT NOT NULL DEFAULT (''),\n\t\tmodified    TIMESTAMP  NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n\t\thas_content BOOLEAN    NOT NULL DEFAULT 0,\n\t\tPRIMARY KEY(id),\n\t\tUNIQUE KEY bookmark_url_UNIQUE (url(255)),\n\t\tFULLTEXT (title, excerpt, content))\n\t\tCHARACTER SET utf8mb4;\n"
  },
  {
    "path": "internal/database/migrations/mysql/0003_initial_tag.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS tag(\n\t\tid   INT(11)      NOT NULL AUTO_INCREMENT,\n\t\tname VARCHAR(250) NOT NULL,\n\t\tPRIMARY KEY (id),\n\t\tUNIQUE KEY tag_name_UNIQUE (name))\n\t\tCHARACTER SET utf8mb4;\n"
  },
  {
    "path": "internal/database/migrations/mysql/0004_initial_bookmark_tag.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS bookmark_tag(\n\t\tbookmark_id INT(11)      NOT NULL,\n\t\ttag_id      INT(11)      NOT NULL,\n\t\tPRIMARY KEY(bookmark_id, tag_id),\n\t\tKEY bookmark_tag_bookmark_id_FK (bookmark_id),\n\t\tKEY bookmark_tag_tag_id_FK (tag_id),\n\t\tCONSTRAINT bookmark_tag_bookmark_id_FK FOREIGN KEY (bookmark_id) REFERENCES bookmark (id),\n\t\tCONSTRAINT bookmark_tag_tag_id_FK FOREIGN KEY (tag_id) REFERENCES tag (id))\n\t\tCHARACTER SET utf8mb4;\n"
  },
  {
    "path": "internal/database/migrations/mysql/0005_rename_to_created_at.up.sql",
    "content": "ALTER TABLE bookmark RENAME COLUMN modified to created_at;\n"
  },
  {
    "path": "internal/database/migrations/mysql/0006_change_created_at_settings.up.sql",
    "content": "ALTER TABLE bookmark\nMODIFY created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;\n"
  },
  {
    "path": "internal/database/migrations/mysql/0007_add_modified_at.up.sql",
    "content": "ALTER TABLE bookmark\nADD COLUMN modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;\n"
  },
  {
    "path": "internal/database/migrations/mysql/0008_set_modified_at_equal_created_at.up.sql",
    "content": "UPDATE bookmark\nSET modified_at = COALESCE(created_at, CURRENT_TIMESTAMP)\nWHERE created_at IS NOT NULL;\n"
  },
  {
    "path": "internal/database/migrations/mysql/0009_index_for_created_at.up.sql",
    "content": "CREATE INDEX idx_created_at ON bookmark (created_at);\n"
  },
  {
    "path": "internal/database/migrations/mysql/0010_index_for_modified_at.up.sql",
    "content": "CREATE INDEX idx_modified_at ON bookmark (modified_at);\n"
  },
  {
    "path": "internal/database/migrations/postgres/0000_system.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS shiori_system(\n    database_schema_version TEXT NOT NULL DEFAULT '0.0.0'\n);\n\nINSERT INTO shiori_system(database_schema_version) VALUES('0.0.0');\n"
  },
  {
    "path": "internal/database/migrations/postgres/0001_initial.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS account(\n\t\tid       SERIAL,\n\t\tusername VARCHAR(250) NOT NULL,\n\t\tpassword BYTEA        NOT NULL,\n\t\towner    BOOLEAN      NOT NULL DEFAULT FALSE,\n\t\tconfig   JSONB        NOT NULL DEFAULT '{}',\n\t\tPRIMARY KEY (id),\n\t\tCONSTRAINT account_username_UNIQUE UNIQUE (username));\n\nCREATE TABLE IF NOT EXISTS bookmark(\n\t\tid          SERIAL,\n\t\turl         TEXT       NOT NULL,\n\t\ttitle       TEXT       NOT NULL,\n\t\texcerpt     TEXT       NOT NULL DEFAULT '',\n\t\tauthor      TEXT       NOT NULL DEFAULT '',\n\t\tpublic      SMALLINT   NOT NULL DEFAULT 0,\n\t\tcontent     TEXT       NOT NULL DEFAULT '',\n\t\thtml        TEXT       NOT NULL DEFAULT '',\n\t\tmodified    TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\t\thas_content BOOLEAN NOT NULL DEFAULT FALSE,\n\t\tPRIMARY KEY(id),\n\t\tCONSTRAINT bookmark_url_UNIQUE UNIQUE (url));\n\nCREATE TABLE IF NOT EXISTS tag(\n\t\tid   SERIAL,\n\t\tname VARCHAR(250) NOT NULL,\n\t\tPRIMARY KEY (id),\n\t\tCONSTRAINT tag_name_UNIQUE UNIQUE (name));\n\nCREATE TABLE IF NOT EXISTS bookmark_tag(\n\t\tbookmark_id INT      NOT NULL,\n\t\ttag_id      INT      NOT NULL,\n\t\tPRIMARY KEY(bookmark_id, tag_id),\n\t\tCONSTRAINT bookmark_tag_bookmark_id_FK FOREIGN KEY (bookmark_id) REFERENCES bookmark (id),\n\t\tCONSTRAINT bookmark_tag_tag_id_FK FOREIGN KEY (tag_id) REFERENCES tag (id));\n\nCREATE INDEX IF NOT EXISTS bookmark_tag_bookmark_id_FK ON bookmark_tag (bookmark_id);\nCREATE INDEX IF NOT EXISTS bookmark_tag_tag_id_FK ON bookmark_tag (tag_id);\n"
  },
  {
    "path": "internal/database/migrations/postgres/0002_created_time.up.sql",
    "content": "-- Rename \"modified\" column to \"created_at\"\nALTER TABLE bookmark\nRENAME COLUMN modified to created_at;\n\n-- Add the \"modified_at\" column to the bookmark table\nALTER TABLE bookmark\nADD COLUMN modified_at TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP;\n\n-- Update the \"modified_at\" column with the value from the \"created_at\" column if it is not null\nUPDATE bookmark\nSET modified_at = COALESCE(created_at, CURRENT_TIMESTAMP)\nWHERE created_at IS NOT NULL;\n\n-- Index for \"created_at\" \"modified_at\"\"\nCREATE INDEX idx_created_at ON bookmark(created_at);\nCREATE INDEX idx_modified_at ON bookmark(modified_at);\n"
  },
  {
    "path": "internal/database/migrations/sqlite/0000_system.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS shiori_system(\n    database_schema_version TEXT NOT NULL DEFAULT '0.0.0'\n);\n\nINSERT INTO shiori_system(database_schema_version) VALUES('0.0.0');\n"
  },
  {
    "path": "internal/database/migrations/sqlite/0001_initial.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS account(\n    id INTEGER NOT NULL,\n    username TEXT NOT NULL,\n    password TEXT NOT NULL,\n    owner INTEGER NOT NULL DEFAULT 0,\n    config JSON NOT NULL DEFAULT '{}',\n    CONSTRAINT account_PK PRIMARY KEY(id),\n    CONSTRAINT account_username_UNIQUE UNIQUE(username)\n);\n\nCREATE TABLE IF NOT EXISTS bookmark(\n    id INTEGER NOT NULL,\n    url TEXT NOT NULL,\n    title TEXT NOT NULL,\n    excerpt TEXT NOT NULL DEFAULT \"\",\n    author TEXT NOT NULL DEFAULT \"\",\n    public INTEGER NOT NULL DEFAULT 0,\n    modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    has_content BOOLEAN DEFAULT FALSE NOT NULL,\n    CONSTRAINT bookmark_PK PRIMARY KEY(id),\n    CONSTRAINT bookmark_url_UNIQUE UNIQUE(url)\n);\n\nCREATE TABLE IF NOT EXISTS tag(\n    id INTEGER NOT NULL,\n    name TEXT NOT NULL,\n    CONSTRAINT tag_PK PRIMARY KEY(id),\n    CONSTRAINT tag_name_UNIQUE UNIQUE(name)\n);\n\nCREATE TABLE IF NOT EXISTS bookmark_tag(\n    bookmark_id INTEGER NOT NULL,\n    tag_id INTEGER NOT NULL,\n    CONSTRAINT bookmark_tag_PK PRIMARY KEY(bookmark_id, tag_id),\n    CONSTRAINT bookmark_id_FK FOREIGN KEY(bookmark_id) REFERENCES bookmark(id),\n    CONSTRAINT tag_id_FK FOREIGN KEY(tag_id) REFERENCES tag(id)\n);\n\nCREATE VIRTUAL TABLE IF NOT EXISTS bookmark_content\n    USING fts5(title, content, html, docid);\n"
  },
  {
    "path": "internal/database/migrations/sqlite/0002_denormalize_content.up.sql",
    "content": "UPDATE bookmark\nSET has_content = bc.has_content FROM (SELECT docid, content <> '' AS has_content FROM bookmark_content) AS bc\nWHERE bookmark.id = bc.docid;\n"
  },
  {
    "path": "internal/database/migrations/sqlite/0003_uniq_id.up.sql",
    "content": "-- Create a temporary table\nCREATE TABLE IF NOT EXISTS bookmark_temp(\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    url TEXT NOT NULL,\n    title TEXT NOT NULL,\n    excerpt TEXT NOT NULL DEFAULT \"\",\n    author TEXT NOT NULL DEFAULT \"\",\n    public INTEGER NOT NULL DEFAULT 0,\n    modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    has_content BOOLEAN DEFAULT FALSE NOT NULL,\n    CONSTRAINT bookmark_url_UNIQUE UNIQUE(url)\n);\n\n-- Copy data from the original table to the temporary table\nINSERT INTO bookmark_temp (id, url, title, excerpt, author, public, modified, has_content)\nSELECT id, url, title, excerpt, author, public, modified, has_content FROM bookmark;\n\n-- Drop the original table\nDROP TABLE bookmark;\n\n-- Rename the temporary table to the original table name\nALTER TABLE bookmark_temp RENAME TO bookmark;\n"
  },
  {
    "path": "internal/database/migrations/sqlite/0004_created_time.up.sql",
    "content": "ALTER TABLE bookmark\nRENAME COLUMN modified to created_at;\n\nALTER TABLE bookmark\nADD COLUMN modified_at TEXT NULL;\n\nUPDATE bookmark\nSET modified_at = bookmark.created_at\nWHERE created_at IS NOT NULL;\n\nCREATE INDEX idx_created_at ON bookmark(created_at);\nCREATE INDEX idx_modified_at ON bookmark(modified_at);\n"
  },
  {
    "path": "internal/database/migrations.go",
    "content": "// Package database implements database operations and migrations\npackage database\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"embed\"\n\t\"fmt\"\n\t\"path\"\n\n\t\"github.com/blang/semver\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\n//go:embed migrations/*\nvar migrationFiles embed.FS\n\n// migration represents a database schema migration\ntype migration struct {\n\tfromVersion   semver.Version\n\ttoVersion     semver.Version\n\tmigrationFunc func(db *sql.DB) error\n}\n\n// txFn is a function that runs in a transaction.\ntype txFn func(tx *sql.Tx) error\n\n// runInTransaction runs the given function in a transaction.\nfunc runInTransaction(db *sql.DB, fn txFn) error {\n\ttx, err := db.Begin()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to start transaction: %w\", err)\n\t}\n\tdefer tx.Rollback()\n\n\tif err := fn(tx); err != nil {\n\t\treturn fmt.Errorf(\"failed to run transaction: %w\", err)\n\t}\n\n\tif err := tx.Commit(); err != nil {\n\t\treturn fmt.Errorf(\"failed to commit transaction: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// newFuncMigration creates a new migration from a function.\nfunc newFuncMigration(fromVersion, toVersion string, migrationFunc func(db *sql.DB) error) migration {\n\treturn migration{\n\t\tfromVersion:   semver.MustParse(fromVersion),\n\t\ttoVersion:     semver.MustParse(toVersion),\n\t\tmigrationFunc: migrationFunc,\n\t}\n}\n\n// newFileMigration creates a new migration from a file.\nfunc newFileMigration(fromVersion, toVersion, filename string) migration {\n\treturn newFuncMigration(fromVersion, toVersion, func(db *sql.DB) error {\n\t\treturn runInTransaction(db, func(tx *sql.Tx) error {\n\t\t\tmigrationSQL, err := migrationFiles.ReadFile(path.Join(\"migrations\", filename+\".up.sql\"))\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to read migration file: %w\", err)\n\t\t\t}\n\n\t\t\tif _, err := tx.Exec(string(migrationSQL)); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to execute migration %s to %s: %w\", fromVersion, toVersion, err)\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t})\n}\n\n// runMigrations runs the given migrations.\nfunc runMigrations(ctx context.Context, db model.DB, migrations []migration) error {\n\tcurrentVersion := semver.Version{}\n\n\t// Get current database version\n\tdbVersion, err := db.GetDatabaseSchemaVersion(ctx)\n\tif err == nil && dbVersion != \"\" {\n\t\tcurrentVersion = semver.MustParse(dbVersion)\n\t}\n\n\tfor _, migration := range migrations {\n\t\tif !currentVersion.EQ(migration.fromVersion) {\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := migration.migrationFunc(db.WriterDB().DB); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to run migration from %s to %s: %w\", migration.fromVersion, migration.toVersion, err)\n\t\t}\n\n\t\tcurrentVersion = migration.toVersion\n\n\t\tif err := db.SetDatabaseSchemaVersion(ctx, currentVersion.String()); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to store database version %s from %s to %s: %w\", currentVersion.String(), migration.fromVersion, migration.toVersion, err)\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/database/mysql.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/huandu/go-sqlbuilder\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/pkg/errors\"\n\n\t_ \"github.com/go-sql-driver/mysql\"\n)\n\nvar mysqlMigrations = []migration{\n\tnewFileMigration(\"0.0.0\", \"0.1.0\", \"mysql/0000_system_create\"),\n\tnewFileMigration(\"0.1.0\", \"0.2.0\", \"mysql/0000_system_insert\"),\n\tnewFileMigration(\"0.2.0\", \"0.3.0\", \"mysql/0001_initial_account\"),\n\tnewFileMigration(\"0.3.0\", \"0.4.0\", \"mysql/0002_initial_bookmark\"),\n\tnewFileMigration(\"0.4.0\", \"0.5.0\", \"mysql/0003_initial_tag\"),\n\tnewFileMigration(\"0.5.0\", \"0.6.0\", \"mysql/0004_initial_bookmark_tag\"),\n\tnewFuncMigration(\"0.6.0\", \"0.7.0\", func(db *sql.DB) error {\n\t\t// Ensure that bookmark table has `has_content` column and account table has `config` column\n\t\t// for users upgrading from <1.5.4 directly into this version.\n\t\ttx, err := db.Begin()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to start transaction: %w\", err)\n\t\t}\n\t\tdefer tx.Rollback()\n\n\t\t_, err = tx.Exec(`ALTER TABLE bookmark ADD COLUMN has_content BOOLEAN DEFAULT 0`)\n\t\tif err != nil && strings.Contains(err.Error(), `Duplicate column name`) {\n\t\t\ttx.Rollback()\n\t\t} else if err != nil {\n\t\t\treturn fmt.Errorf(\"failed to add has_content column to bookmark table: %w\", err)\n\t\t} else if err == nil {\n\t\t\tif errCommit := tx.Commit(); errCommit != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to commit transaction: %w\", errCommit)\n\t\t\t}\n\t\t}\n\n\t\ttx, err = db.Begin()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to start transaction: %w\", err)\n\t\t}\n\t\tdefer tx.Rollback()\n\n\t\t_, err = tx.Exec(`ALTER TABLE account ADD COLUMN config JSON  NOT NULL DEFAULT ('{}')`)\n\t\tif err != nil && strings.Contains(err.Error(), `Duplicate column name`) {\n\t\t\ttx.Rollback()\n\t\t} else if err != nil {\n\t\t\treturn fmt.Errorf(\"failed to add config column to account table: %w\", err)\n\t\t} else if err == nil {\n\t\t\tif errCommit := tx.Commit(); errCommit != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to commit transaction: %w\", errCommit)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}),\n\tnewFileMigration(\"0.7.0\", \"0.8.0\", \"mysql/0005_rename_to_created_at\"),\n\tnewFileMigration(\"0.8.0\", \"0.8.1\", \"mysql/0006_change_created_at_settings\"),\n\tnewFileMigration(\"0.8.1\", \"0.8.2\", \"mysql/0007_add_modified_at\"),\n\tnewFileMigration(\"0.8.2\", \"0.8.3\", \"mysql/0008_set_modified_at_equal_created_at\"),\n\tnewFileMigration(\"0.8.3\", \"0.8.4\", \"mysql/0009_index_for_created_at\"),\n\tnewFileMigration(\"0.8.4\", \"0.8.5\", \"mysql/0010_index_for_modified_at\"),\n}\n\n// MySQLDatabase is implementation of Database interface\n// for connecting to MySQL or MariaDB database.\ntype MySQLDatabase struct {\n\tdbbase\n}\n\n// OpenMySQLDatabase creates and opens connection to a MySQL Database.\nfunc OpenMySQLDatabase(ctx context.Context, connString string) (mysqlDB *MySQLDatabase, err error) {\n\t// Open database and start transaction\n\tdb, err := sqlx.ConnectContext(ctx, \"mysql\", connString)\n\tif err != nil {\n\t\treturn nil, errors.WithStack(err)\n\t}\n\n\tdb.SetMaxOpenConns(100)\n\tdb.SetConnMaxLifetime(time.Second) // in case mysql client has longer timeout (driver issue #674)\n\n\tmysqlDB = &MySQLDatabase{dbbase: NewDBBase(db, db, sqlbuilder.MySQL)}\n\treturn mysqlDB, err\n}\n\n// Init initializes the database\nfunc (db *MySQLDatabase) Init(ctx context.Context) error {\n\treturn nil\n}\n\n// Migrate runs migrations for this database engine\nfunc (db *MySQLDatabase) Migrate(ctx context.Context) error {\n\tif err := runMigrations(ctx, db, mysqlMigrations); err != nil {\n\t\treturn errors.WithStack(err)\n\t}\n\n\treturn nil\n}\n\n// GetDatabaseSchemaVersion fetches the current migrations version of the database\nfunc (db *MySQLDatabase) GetDatabaseSchemaVersion(ctx context.Context) (string, error) {\n\tvar version string\n\n\terr := db.GetContext(ctx, &version, \"SELECT database_schema_version FROM shiori_system\")\n\tif err != nil {\n\t\treturn \"\", errors.WithStack(err)\n\t}\n\n\treturn version, nil\n}\n\n// SetDatabaseSchemaVersion sets the current migrations version of the database\nfunc (db *MySQLDatabase) SetDatabaseSchemaVersion(ctx context.Context, version string) error {\n\ttx := db.MustBegin()\n\tdefer tx.Rollback()\n\n\t_, err := tx.Exec(\"UPDATE shiori_system SET database_schema_version = ?\", version)\n\tif err != nil {\n\t\treturn errors.WithStack(err)\n\t}\n\n\treturn tx.Commit()\n}\n\n// SaveBookmarks saves new or updated bookmarks to database.\n// Returns the saved ID and error message if any happened.\nfunc (db *MySQLDatabase) SaveBookmarks(ctx context.Context, create bool, bookmarks ...model.BookmarkDTO) ([]model.BookmarkDTO, error) {\n\tvar result []model.BookmarkDTO\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Prepare statement\n\t\tstmtInsertBook, err := tx.Preparex(`INSERT INTO bookmark\n\t\t\t(url, title, excerpt, author, public, content, html, modified_at, created_at)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`)\n\t\tif err != nil {\n\t\t\treturn errors.WithStack(err)\n\t\t}\n\n\t\tstmtUpdateBook, err := tx.Preparex(`UPDATE bookmark\n\t\tSET url      = ?,\n\t\t\ttitle    = ?,\n\t\t\texcerpt  = ?,\n\t\t\tauthor   = ?,\n\t\t\tpublic   = ?,\n\t\t\tcontent  = ?,\n\t\t\thtml     = ?,\n\t\t\tmodified_at = ?\n\t\tWHERE id = ?`)\n\t\tif err != nil {\n\t\t\treturn errors.WithStack(err)\n\t\t}\n\n\t\tstmtGetTag, err := tx.Preparex(`SELECT id FROM tag WHERE name = ?`)\n\t\tif err != nil {\n\t\t\treturn errors.WithStack(err)\n\t\t}\n\n\t\tstmtInsertTag, err := tx.Preparex(`INSERT INTO tag (name) VALUES (?)`)\n\t\tif err != nil {\n\t\t\treturn errors.WithStack(err)\n\t\t}\n\n\t\tstmtInsertBookTag, err := tx.Preparex(`INSERT IGNORE INTO bookmark_tag\n\t\t\t(tag_id, bookmark_id) VALUES (?, ?)`)\n\t\tif err != nil {\n\t\t\treturn errors.WithStack(err)\n\t\t}\n\n\t\tstmtDeleteBookTag, err := tx.Preparex(`DELETE FROM bookmark_tag\n\t\t\tWHERE bookmark_id = ? AND tag_id = ?`)\n\t\tif err != nil {\n\t\t\treturn errors.WithStack(err)\n\t\t}\n\n\t\t// Prepare modified time\n\t\tmodifiedTime := time.Now().UTC().Format(model.DatabaseDateFormat)\n\n\t\t// Execute statements\n\n\t\tfor _, book := range bookmarks {\n\t\t\t// Check URL and title\n\t\t\tif book.URL == \"\" {\n\t\t\t\treturn errors.New(\"URL must not be empty\")\n\t\t\t}\n\n\t\t\tif book.Title == \"\" {\n\t\t\t\treturn errors.New(\"title must not be empty\")\n\t\t\t}\n\n\t\t\t// Set modified time\n\t\t\tif book.ModifiedAt == \"\" {\n\t\t\t\tbook.ModifiedAt = modifiedTime\n\t\t\t}\n\n\t\t\t// Save bookmark\n\t\t\tvar err error\n\t\t\tif create {\n\t\t\t\tbook.CreatedAt = modifiedTime\n\t\t\t\tvar res sql.Result\n\t\t\t\tres, err = stmtInsertBook.ExecContext(ctx,\n\t\t\t\t\tbook.URL, book.Title, book.Excerpt, book.Author,\n\t\t\t\t\tbook.Public, book.Content, book.HTML, book.ModifiedAt, book.CreatedAt)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn errors.WithStack(err)\n\t\t\t\t}\n\t\t\t\tbookID, err := res.LastInsertId()\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn errors.WithStack(err)\n\t\t\t\t}\n\t\t\t\tbook.ID = int(bookID)\n\t\t\t} else {\n\t\t\t\t_, err = stmtUpdateBook.ExecContext(ctx,\n\t\t\t\t\tbook.URL, book.Title, book.Excerpt, book.Author,\n\t\t\t\t\tbook.Public, book.Content, book.HTML, book.ModifiedAt, book.ID)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\treturn errors.WithStack(err)\n\t\t\t}\n\n\t\t\t// Save book tags\n\t\t\tnewTags := []model.TagDTO{}\n\t\t\tfor _, tag := range book.Tags {\n\t\t\t\tt := tag.ToDTO()\n\t\t\t\t// If it's deleted tag, delete and continue\n\t\t\t\tif t.Deleted {\n\t\t\t\t\t_, err = stmtDeleteBookTag.ExecContext(ctx, book.ID, t.ID)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn errors.WithStack(err)\n\t\t\t\t\t}\n\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// Normalize tag name\n\t\t\t\ttagName := strings.ToLower(tag.Name)\n\t\t\t\ttagName = strings.Join(strings.Fields(tagName), \" \")\n\n\t\t\t\t// If tag doesn't have any ID, fetch it from database\n\t\t\t\tif tag.ID == 0 {\n\t\t\t\t\tif err := stmtGetTag.GetContext(ctx, &tag.ID, tagName); err != nil && err != sql.ErrNoRows {\n\t\t\t\t\t\treturn errors.WithStack(err)\n\t\t\t\t\t}\n\n\t\t\t\t\t// If tag doesn't exist in database, save it\n\t\t\t\t\tif tag.ID == 0 {\n\t\t\t\t\t\tres, err := stmtInsertTag.ExecContext(ctx, tagName)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\treturn errors.WithStack(err)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\ttagID64, err := res.LastInsertId()\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\treturn errors.WithStack(err)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\ttag.ID = int(tagID64)\n\t\t\t\t\t\tt.ID = int(tagID64)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Always insert the tag-bookmark association\n\t\t\t\tif _, err := stmtInsertBookTag.ExecContext(ctx, tag.ID, book.ID); err != nil {\n\t\t\t\t\treturn errors.WithStack(err)\n\t\t\t\t}\n\n\t\t\t\tnewTags = append(newTags, t)\n\t\t\t}\n\n\t\t\tbook.Tags = newTags\n\t\t\tresult = append(result, book)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn result, errors.WithStack(err)\n\t}\n\n\treturn result, nil\n}\n\n// GetBookmarks fetch list of bookmarks based on submitted options.\nfunc (db *MySQLDatabase) GetBookmarks(ctx context.Context, opts model.DBGetBookmarksOptions) ([]model.BookmarkDTO, error) {\n\t// Create initial query\n\tcolumns := []string{\n\t\t`id`,\n\t\t`url`,\n\t\t`title`,\n\t\t`excerpt`,\n\t\t`author`,\n\t\t`public`,\n\t\t`created_at`,\n\t\t`modified_at`,\n\t\t`content <> \"\" as has_content`}\n\n\tif opts.WithContent {\n\t\tcolumns = append(columns, `content`, `html`)\n\t}\n\n\tquery := `SELECT ` + strings.Join(columns, \",\") + `\n\t\tFROM bookmark WHERE 1`\n\n\t// Add where clause\n\targs := []interface{}{}\n\n\t// Add where clause for IDs\n\tif len(opts.IDs) > 0 {\n\t\tquery += ` AND id IN (?)`\n\t\targs = append(args, opts.IDs)\n\t}\n\n\t// Add where clause for search keyword\n\tif opts.Keyword != \"\" {\n\t\tquery += ` AND (\n\t\t\turl LIKE ? OR\n\t\t\tMATCH(title, excerpt, content) AGAINST (? IN BOOLEAN MODE)\n\t\t)`\n\n\t\targs = append(args, \"%\"+opts.Keyword+\"%\", opts.Keyword)\n\t}\n\n\t// Add where clause for tags.\n\t// First we check for * in excluded and included tags,\n\t// which means all tags will be excluded and included, respectively.\n\texcludeAllTags := false\n\tif slices.Contains(opts.ExcludedTags, \"*\") {\n\t\texcludeAllTags = true\n\t\topts.ExcludedTags = []string{}\n\t}\n\n\tincludeAllTags := false\n\tif slices.Contains(opts.Tags, \"*\") {\n\t\tincludeAllTags = true\n\t\topts.Tags = []string{}\n\t}\n\n\t// If all tags excluded, we will only show bookmark without tags.\n\t// In other hand, if all tags included, we will only show bookmark with tags.\n\tif excludeAllTags {\n\t\tquery += ` AND id NOT IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`\n\t} else if includeAllTags {\n\t\tquery += ` AND id IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`\n\t}\n\n\t// Now we only need to find the normal tags\n\tif len(opts.Tags) > 0 {\n\t\tquery += ` AND id IN (\n\t\t\tSELECT bt.bookmark_id\n\t\t\tFROM bookmark_tag bt\n\t\t\tLEFT JOIN tag t ON bt.tag_id = t.id\n\t\t\tWHERE t.name IN(?)\n\t\t\tGROUP BY bt.bookmark_id\n\t\t\tHAVING COUNT(bt.bookmark_id) = ?)`\n\n\t\targs = append(args, opts.Tags, len(opts.Tags))\n\t}\n\n\tif len(opts.ExcludedTags) > 0 {\n\t\tquery += ` AND id NOT IN (\n\t\t\tSELECT DISTINCT bt.bookmark_id\n\t\t\tFROM bookmark_tag bt\n\t\t\tLEFT JOIN tag t ON bt.tag_id = t.id\n\t\t\tWHERE t.name IN(?))`\n\n\t\targs = append(args, opts.ExcludedTags)\n\t}\n\n\t// Add order clause\n\tswitch opts.OrderMethod {\n\tcase model.ByLastAdded:\n\t\tquery += ` ORDER BY id DESC`\n\tcase model.ByLastModified:\n\t\tquery += ` ORDER BY modified_at DESC`\n\tdefault:\n\t\tquery += ` ORDER BY id`\n\t}\n\n\tif opts.Limit > 0 && opts.Offset >= 0 {\n\t\tquery += ` LIMIT ? OFFSET ?`\n\t\targs = append(args, opts.Limit, opts.Offset)\n\t}\n\n\t// Expand query, because some of the args might be an array\n\tquery, args, err := sqlx.In(query, args...)\n\tif err != nil {\n\t\treturn nil, errors.WithStack(err)\n\t}\n\n\t// Fetch bookmarks\n\tbookmarks := []model.BookmarkDTO{}\n\terr = db.Select(&bookmarks, query, args...)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn nil, errors.WithStack(err)\n\t}\n\n\t// Fetch tags for each bookmark\n\tfor i, book := range bookmarks {\n\t\ttags, err := db.getTagsForBookmark(ctx, book.ID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to get tags: %w\", err)\n\t\t}\n\t\tbookmarks[i].Tags = tags\n\t}\n\n\treturn bookmarks, nil\n}\n\nfunc (db *MySQLDatabase) getTagsForBookmark(ctx context.Context, bookmarkID int) ([]model.TagDTO, error) {\n\tsb := sqlbuilder.MySQL.NewSelectBuilder()\n\tsb.Select(\"t.id\", \"t.name\")\n\tsb.From(\"bookmark_tag bt\")\n\tsb.JoinWithOption(sqlbuilder.LeftJoin, \"tag t\", \"bt.tag_id = t.id\")\n\tsb.Where(sb.Equal(\"bt.bookmark_id\", bookmarkID))\n\tsb.OrderBy(\"t.name\")\n\n\tquery, args := sb.Build()\n\tquery = db.ReaderDB().Rebind(query)\n\n\ttags := []model.TagDTO{}\n\terr := db.ReaderDB().SelectContext(ctx, &tags, query, args...)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn nil, fmt.Errorf(\"failed to get tags: %w\", err)\n\t}\n\n\treturn tags, nil\n}\n\n// GetBookmarksCount fetch count of bookmarks based on submitted options.\nfunc (db *MySQLDatabase) GetBookmarksCount(ctx context.Context, opts model.DBGetBookmarksOptions) (int, error) {\n\t// Create initial query\n\tquery := `SELECT COUNT(id) FROM bookmark WHERE 1`\n\n\t// Add where clause\n\targs := []interface{}{}\n\n\t// Add where clause for IDs\n\tif len(opts.IDs) > 0 {\n\t\tquery += ` AND id IN (?)`\n\t\targs = append(args, opts.IDs)\n\t}\n\n\t// Add where clause for search keyword\n\tif opts.Keyword != \"\" {\n\t\tquery += ` AND (\n\t\t\turl LIKE ? OR\n\t\t\tMATCH(title, excerpt, content) AGAINST (? IN BOOLEAN MODE)\n\t\t)`\n\n\t\targs = append(args,\n\t\t\t\"%\"+opts.Keyword+\"%\",\n\t\t\topts.Keyword)\n\t}\n\n\t// Add where clause for tags.\n\t// First we check for * in excluded and included tags,\n\t// which means all tags will be excluded and included, respectively.\n\texcludeAllTags := false\n\tif slices.Contains(opts.ExcludedTags, \"*\") {\n\t\texcludeAllTags = true\n\t\topts.ExcludedTags = []string{}\n\t}\n\n\tincludeAllTags := false\n\tif slices.Contains(opts.Tags, \"*\") {\n\t\tincludeAllTags = true\n\t\topts.Tags = []string{}\n\t}\n\n\t// If all tags excluded, we will only show bookmark without tags.\n\t// In other hand, if all tags included, we will only show bookmark with tags.\n\tif excludeAllTags {\n\t\tquery += ` AND id NOT IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`\n\t} else if includeAllTags {\n\t\tquery += ` AND id IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`\n\t}\n\n\t// Now we only need to find the normal tags\n\tif len(opts.Tags) > 0 {\n\t\tquery += ` AND id IN (\n\t\t\tSELECT bt.bookmark_id\n\t\t\tFROM bookmark_tag bt\n\t\t\tLEFT JOIN tag t ON bt.tag_id = t.id\n\t\t\tWHERE t.name IN(?)\n\t\t\tGROUP BY bt.bookmark_id\n\t\t\tHAVING COUNT(bt.bookmark_id) = ?)`\n\n\t\targs = append(args, opts.Tags, len(opts.Tags))\n\t}\n\n\tif len(opts.ExcludedTags) > 0 {\n\t\tquery += ` AND id NOT IN (\n\t\t\tSELECT DISTINCT bt.bookmark_id\n\t\t\tFROM bookmark_tag bt\n\t\t\tLEFT JOIN tag t ON bt.tag_id = t.id\n\t\t\tWHERE t.name IN(?))`\n\n\t\targs = append(args, opts.ExcludedTags)\n\t}\n\n\t// Expand query, because some of the args might be an array\n\tquery, args, err := sqlx.In(query, args...)\n\tif err != nil {\n\t\treturn 0, errors.WithStack(err)\n\t}\n\n\t// Fetch count\n\tvar nBookmarks int\n\terr = db.GetContext(ctx, &nBookmarks, query, args...)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn 0, errors.WithStack(err)\n\t}\n\n\treturn nBookmarks, nil\n}\n\n// DeleteBookmarks removes all record with matching ids from database.\nfunc (db *MySQLDatabase) DeleteBookmarks(ctx context.Context, ids ...int) (err error) {\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Prepare queries\n\t\tdelBookmark := `DELETE FROM bookmark`\n\t\tdelBookmarkTag := `DELETE FROM bookmark_tag`\n\n\t\t// Delete bookmark(s)\n\t\tif len(ids) == 0 {\n\t\t\t_, err := tx.ExecContext(ctx, delBookmarkTag)\n\t\t\tif err != nil {\n\t\t\t\treturn errors.WithStack(err)\n\t\t\t}\n\n\t\t\t_, err = tx.ExecContext(ctx, delBookmark)\n\t\t\tif err != nil {\n\t\t\t\treturn errors.WithStack(err)\n\t\t\t}\n\t\t} else {\n\t\t\tdelBookmark += ` WHERE id = ?`\n\t\t\tdelBookmarkTag += ` WHERE bookmark_id = ?`\n\n\t\t\tstmtDelBookmark, _ := tx.Preparex(delBookmark)\n\t\t\tstmtDelBookmarkTag, _ := tx.Preparex(delBookmarkTag)\n\n\t\t\tfor _, id := range ids {\n\t\t\t\t_, err := stmtDelBookmarkTag.ExecContext(ctx, id)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn errors.WithStack(err)\n\t\t\t\t}\n\n\t\t\t\t_, err = stmtDelBookmark.ExecContext(ctx, id)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn errors.WithStack(err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn errors.WithStack(err)\n\t}\n\n\treturn nil\n}\n\n// GetBookmark fetches bookmark based on its ID or URL.\n// Returns the bookmark and boolean whether it's exist or not.\nfunc (db *MySQLDatabase) GetBookmark(ctx context.Context, id int, url string) (model.BookmarkDTO, bool, error) {\n\t// Create the main query builder for bookmark data\n\tsb := sqlbuilder.NewSelectBuilder()\n\tsb.Select(\n\t\t\"id\", \"url\", \"title\", \"excerpt\", \"author\", `public`, \"modified_at\",\n\t\t\"content\", \"html\", \"created_at\", \"has_content\")\n\tsb.From(\"bookmark\")\n\n\t// Add conditions\n\tif id != 0 {\n\t\tsb.Where(sb.Equal(\"id\", id))\n\t} else if url != \"\" {\n\t\tsb.Where(sb.Equal(\"url\", url))\n\t} else {\n\t\treturn model.BookmarkDTO{}, false, fmt.Errorf(\"id or url is required\")\n\t}\n\n\t// Build the query\n\tquery, args := sb.Build()\n\tquery = db.ReaderDB().Rebind(query)\n\t// Execute the query\n\tbook := model.BookmarkDTO{}\n\terr := db.ReaderDB().GetContext(ctx, &book, query, args...)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn book, false, nil\n\t\t}\n\t\treturn book, false, fmt.Errorf(\"failed to get bookmark: %w\", err)\n\t}\n\n\t// If bookmark exists, fetch its tags\n\tif book.ID != 0 {\n\t\t// Create query builder for tags\n\t\ttagSb := sqlbuilder.NewSelectBuilder()\n\t\ttagSb.Select(\"t.id\", \"t.name\")\n\t\ttagSb.From(\"tag t\")\n\t\ttagSb.JoinWithOption(sqlbuilder.InnerJoin, \"bookmark_tag bt\", \"bt.tag_id = t.id\")\n\t\ttagSb.Where(tagSb.Equal(\"bt.bookmark_id\", book.ID))\n\n\t\t// Build the query\n\t\ttagQuery, tagArgs := tagSb.Build()\n\t\ttagQuery = db.ReaderDB().Rebind(tagQuery)\n\t\t// Execute the query\n\t\ttags := []model.TagDTO{}\n\t\tif err := db.ReaderDB().SelectContext(ctx, &tags, tagQuery, tagArgs...); err != nil && err != sql.ErrNoRows {\n\t\t\treturn book, false, fmt.Errorf(\"failed to get tags: %w\", err)\n\t\t}\n\n\t\tbook.Tags = tags\n\t}\n\n\treturn book, true, nil\n}\n\n// CreateAccount saves new account to database. Returns error if any happened.\nfunc (db *MySQLDatabase) CreateAccount(ctx context.Context, account model.Account) (*model.Account, error) {\n\tvar accountID int64\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Check for existing username\n\t\tvar exists bool\n\t\terr := tx.QueryRowContext(\n\t\t\tctx, \"SELECT EXISTS(SELECT 1 FROM account WHERE username = ?)\",\n\t\t\taccount.Username,\n\t\t).Scan(&exists)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error checking username: %w\", err)\n\t\t}\n\t\tif exists {\n\t\t\treturn ErrAlreadyExists\n\t\t}\n\n\t\t// Create the account\n\t\tresult, err := tx.ExecContext(ctx, `INSERT INTO account\n\t\t\t(username, password, owner, config) VALUES (?, ?, ?, ?)`,\n\t\t\taccount.Username, account.Password, account.Owner, account.Config)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error executing query: %w\", err)\n\t\t}\n\n\t\tid, err := result.LastInsertId()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error getting last insert id: %w\", err)\n\t\t}\n\t\taccountID = id\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"error running transaction: %w\", err)\n\t}\n\n\taccount.ID = model.DBID(accountID)\n\treturn &account, nil\n}\n\n// UpdateAccount update account in database\nfunc (db *MySQLDatabase) UpdateAccount(ctx context.Context, account model.Account) error {\n\tif account.ID == 0 {\n\t\treturn ErrNotFound\n\t}\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Check for existing username\n\t\tvar exists bool\n\t\terr := tx.QueryRowContext(ctx,\n\t\t\t\"SELECT EXISTS(SELECT 1 FROM account WHERE username = ? AND id != ?)\",\n\t\t\taccount.Username, account.ID).Scan(&exists)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error checking username: %w\", err)\n\t\t}\n\t\tif exists {\n\t\t\treturn ErrAlreadyExists\n\t\t}\n\n\t\tresult, err := tx.ExecContext(ctx, `UPDATE account\n\t\t\tSET username = ?, password = ?, owner = ?, config = ?\n\t\t\tWHERE id = ?`,\n\t\t\taccount.Username, account.Password, account.Owner, account.Config, account.ID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error updating account: %w\", err)\n\t\t}\n\n\t\trows, err := result.RowsAffected()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error getting rows affected: %w\", err)\n\t\t}\n\t\tif rows == 0 {\n\t\t\treturn ErrNotFound\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"error running transaction: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// ListAccounts fetch list of account (without its password) based on submitted options.\nfunc (db *MySQLDatabase) ListAccounts(ctx context.Context, opts model.DBListAccountsOptions) ([]model.Account, error) {\n\t// Create query\n\targs := []interface{}{}\n\tfields := []string{\"id\", \"username\", \"owner\", \"config\"}\n\tif opts.WithPassword {\n\t\tfields = append(fields, \"password\")\n\t}\n\n\tquery := fmt.Sprintf(`SELECT %s FROM account WHERE 1`, strings.Join(fields, \", \"))\n\n\tif opts.Keyword != \"\" {\n\t\tquery += \" AND username LIKE ?\"\n\t\targs = append(args, \"%\"+opts.Keyword+\"%\")\n\t}\n\n\tif opts.Username != \"\" {\n\t\tquery += \" AND username = ?\"\n\t\targs = append(args, opts.Username)\n\t}\n\n\tif opts.Owner {\n\t\tquery += \" AND owner = 1\"\n\t}\n\n\t// Fetch list account\n\taccounts := []model.Account{}\n\terr := db.SelectContext(ctx, &accounts, query, args...)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn nil, errors.WithStack(err)\n\t}\n\n\treturn accounts, nil\n}\n\n// GetAccount fetch account with matching ID.\n// Returns the account and boolean whether it's exist or not.\nfunc (db *MySQLDatabase) GetAccount(ctx context.Context, id model.DBID) (*model.Account, bool, error) {\n\taccount := model.Account{}\n\terr := db.GetContext(ctx, &account, `SELECT\n\t\tid, username, password, owner, config FROM account WHERE id = ?`,\n\t\tid,\n\t)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn &account, false, ErrNotFound\n\t\t}\n\t\treturn &account, false, fmt.Errorf(\"error getting account: %w\", err)\n\t}\n\n\treturn &account, true, nil\n}\n\n// DeleteAccount removes record with matching ID.\nfunc (db *MySQLDatabase) DeleteAccount(ctx context.Context, id model.DBID) error {\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\tresult, err := tx.ExecContext(ctx, `DELETE FROM account WHERE id = ?`, id)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error deleting account: %w\", err)\n\t\t}\n\n\t\trows, err := result.RowsAffected()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error getting rows affected: %w\", err)\n\t\t}\n\n\t\tif rows == 0 {\n\t\t\treturn ErrNotFound\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"error running transaction: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// CreateTags creates new tags from submitted objects.\nfunc (db *MySQLDatabase) CreateTags(ctx context.Context, tags ...model.Tag) ([]model.Tag, error) {\n\tif len(tags) == 0 {\n\t\treturn []model.Tag{}, nil\n\t}\n\n\t// Create a slice to hold the created tags\n\tcreatedTags := make([]model.Tag, len(tags))\n\tcopy(createdTags, tags)\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// For MySQL, we need to insert tags one by one to get their IDs\n\t\tstmtInsertTag, err := tx.PrepareContext(ctx, \"INSERT INTO tag (name) VALUES (?)\")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to prepare tag insertion statement: %w\", err)\n\t\t}\n\t\tdefer stmtInsertTag.Close()\n\n\t\t// Insert each tag and get its ID\n\t\tfor i, tag := range createdTags {\n\t\t\tresult, err := stmtInsertTag.ExecContext(ctx, tag.Name)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to insert tag: %w\", err)\n\t\t\t}\n\n\t\t\t// Get the last inserted ID\n\t\t\ttagID, err := result.LastInsertId()\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to get last insert ID: %w\", err)\n\t\t\t}\n\n\t\t\tcreatedTags[i].ID = int(tagID)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to run tag creation transaction: %w\", err)\n\t}\n\n\treturn createdTags, nil\n}\n\n// CreateTag creates a new tag in database.\nfunc (db *MySQLDatabase) CreateTag(ctx context.Context, tag model.Tag) (model.Tag, error) {\n\t// Use CreateTags to implement this method\n\tcreatedTags, err := db.CreateTags(ctx, tag)\n\tif err != nil {\n\t\treturn model.Tag{}, err\n\t}\n\n\tif len(createdTags) == 0 {\n\t\treturn model.Tag{}, fmt.Errorf(\"failed to create tag\")\n\t}\n\n\treturn createdTags[0], nil\n}\n\n// RenameTag change the name of a tag.\nfunc (db *MySQLDatabase) RenameTag(ctx context.Context, id int, newName string) error {\n\tsb := sqlbuilder.NewUpdateBuilder()\n\tsb.Update(\"tag\")\n\tsb.Set(sb.Assign(\"name\", newName))\n\tsb.Where(sb.Equal(\"id\", id))\n\n\tquery, args := sb.Build()\n\tquery = db.WriterDB().Rebind(query)\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t_, err := tx.ExecContext(ctx, query, args...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to rename tag: %w\", err)\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// GetTag fetch a tag by its ID.\nfunc (db *MySQLDatabase) GetTag(ctx context.Context, id int) (model.TagDTO, bool, error) {\n\tsb := sqlbuilder.MySQL.NewSelectBuilder()\n\tsb.Select(\"t.id\", \"t.name\", \"COUNT(bt.tag_id) bookmark_count\")\n\tsb.From(\"tag t\")\n\tsb.JoinWithOption(sqlbuilder.LeftJoin, \"bookmark_tag bt\", \"bt.tag_id = t.id\")\n\tsb.Where(sb.Equal(\"t.id\", id))\n\tsb.GroupBy(\"t.id\")\n\tsb.OrderBy(\"t.name\")\n\n\tquery, args := sb.Build()\n\tquery = db.ReaderDB().Rebind(query)\n\n\tvar tag model.TagDTO\n\terr := db.ReaderDB().GetContext(ctx, &tag, query, args...)\n\tif err == sql.ErrNoRows {\n\t\treturn model.TagDTO{}, false, nil\n\t}\n\tif err != nil {\n\t\treturn model.TagDTO{}, false, fmt.Errorf(\"failed to get tag: %w\", err)\n\t}\n\n\treturn tag, true, nil\n}\n\n// UpdateTag updates a tag in the database.\nfunc (db *MySQLDatabase) UpdateTag(ctx context.Context, tag model.Tag) error {\n\tsb := sqlbuilder.NewUpdateBuilder()\n\tsb.Update(\"tag\")\n\tsb.Set(sb.Assign(\"name\", tag.Name))\n\tsb.Where(sb.Equal(\"id\", tag.ID))\n\n\tquery, args := sb.Build()\n\tquery = db.WriterDB().Rebind(query)\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t_, err := tx.ExecContext(ctx, query, args...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to update tag: %w\", err)\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// DeleteTag removes a tag from the database.\nfunc (db *MySQLDatabase) DeleteTag(ctx context.Context, id int) error {\n\t// First, check if the tag exists\n\t_, exists, err := db.GetTag(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to check if tag exists: %w\", err)\n\t}\n\tif !exists {\n\t\treturn ErrNotFound\n\t}\n\n\t// Delete all bookmark_tag associations\n\tdeleteAssocSb := sqlbuilder.NewDeleteBuilder()\n\tdeleteAssocSb.DeleteFrom(\"bookmark_tag\")\n\tdeleteAssocSb.Where(deleteAssocSb.Equal(\"tag_id\", id))\n\n\tdeleteAssocQuery, deleteAssocArgs := deleteAssocSb.Build()\n\tdeleteAssocQuery = db.WriterDB().Rebind(deleteAssocQuery)\n\n\t// Then, delete the tag itself\n\tdeleteTagSb := sqlbuilder.NewDeleteBuilder()\n\tdeleteTagSb.DeleteFrom(\"tag\")\n\tdeleteTagSb.Where(deleteTagSb.Equal(\"id\", id))\n\n\tdeleteTagQuery, deleteTagArgs := deleteTagSb.Build()\n\tdeleteTagQuery = db.WriterDB().Rebind(deleteTagQuery)\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Delete bookmark_tag associations\n\t\t_, err := tx.ExecContext(ctx, deleteAssocQuery, deleteAssocArgs...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to delete tag associations: %w\", err)\n\t\t}\n\n\t\t// Delete the tag\n\t\t_, err = tx.ExecContext(ctx, deleteTagQuery, deleteTagArgs...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to delete tag: %w\", err)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// SaveBookmark saves a single bookmark to database without handling tags.\n// It only updates the bookmark data in the database.\nfunc (db *MySQLDatabase) SaveBookmark(ctx context.Context, bookmark model.Bookmark) error {\n\tif bookmark.ID <= 0 {\n\t\treturn fmt.Errorf(\"bookmark ID must be greater than 0\")\n\t}\n\n\t// Prepare modified time if not set\n\tif bookmark.ModifiedAt == \"\" {\n\t\tbookmark.ModifiedAt = time.Now().UTC().Format(model.DatabaseDateFormat)\n\t}\n\n\t// Check URL and title\n\tif bookmark.URL == \"\" {\n\t\treturn errors.New(\"URL must not be empty\")\n\t}\n\n\tif bookmark.Title == \"\" {\n\t\treturn errors.New(\"title must not be empty\")\n\t}\n\n\t// Use sqlbuilder to build the update query\n\tsb := sqlbuilder.NewUpdateBuilder()\n\tsb.Update(\"bookmark\")\n\tsb.Set(\n\t\tsb.Assign(\"url\", bookmark.URL),\n\t\tsb.Assign(\"title\", bookmark.Title),\n\t\tsb.Assign(\"excerpt\", bookmark.Excerpt),\n\t\tsb.Assign(\"author\", bookmark.Author),\n\t\tsb.Assign(\"public\", bookmark.Public),\n\t\tsb.Assign(\"modified_at\", bookmark.ModifiedAt),\n\t\tsb.Assign(\"has_content\", bookmark.HasContent),\n\t)\n\tsb.Where(sb.Equal(\"id\", bookmark.ID))\n\n\tquery, args := sb.Build()\n\tquery = db.WriterDB().Rebind(query)\n\n\treturn db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Update bookmark\n\t\t_, err := tx.ExecContext(ctx, query, args...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to update bookmark: %w\", err)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\nfunc (db *MySQLDatabase) SaveBookmarkTags(ctx context.Context, bookmarkID int, tagIDs []int) error {\n\treturn db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Prepare statements\n\t\tstmtDeleteAllBookmarkTags, err := tx.PreparexContext(ctx, `DELETE FROM bookmark_tag WHERE bookmark_id = ?`)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to prepare delete all bookmark tags statement: %w\", err)\n\t\t}\n\n\t\tstmtInsertBookTag, err := tx.PreparexContext(ctx, `INSERT IGNORE INTO bookmark_tag\n\t\t\t(tag_id, bookmark_id) VALUES (?, ?)`)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to prepare insert book tag statement: %w\", err)\n\t\t}\n\n\t\t// Delete all existing tags for this bookmark\n\t\t_, err = stmtDeleteAllBookmarkTags.ExecContext(ctx, bookmarkID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to delete existing bookmark tags: %w\", err)\n\t\t}\n\n\t\t// Insert new tags\n\t\tfor _, tagID := range tagIDs {\n\t\t\t_, err := stmtInsertBookTag.ExecContext(ctx, tagID, bookmarkID)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to insert bookmark tag: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\n// BulkUpdateBookmarkTags updates tags for multiple bookmarks.\n// It ensures that all bookmarks and tags exist before proceeding.\nfunc (db *MySQLDatabase) BulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error {\n\tif len(bookmarkIDs) == 0 || len(tagIDs) == 0 {\n\t\treturn nil\n\t}\n\n\t// Convert int slices to interface slices for sqlbuilder\n\tbookmarkIDsIface := make([]interface{}, len(bookmarkIDs))\n\tfor i, id := range bookmarkIDs {\n\t\tbookmarkIDsIface[i] = id\n\t}\n\n\ttagIDsIface := make([]interface{}, len(tagIDs))\n\tfor i, id := range tagIDs {\n\t\ttagIDsIface[i] = id\n\t}\n\n\t// Verify all bookmarks exist\n\tbookmarkSb := sqlbuilder.NewSelectBuilder()\n\tbookmarkSb.Select(\"id\")\n\tbookmarkSb.From(\"bookmark\")\n\tbookmarkSb.Where(bookmarkSb.In(\"id\", bookmarkIDsIface...))\n\n\tbookmarkQuery, bookmarkArgs := bookmarkSb.Build()\n\tbookmarkQuery = db.ReaderDB().Rebind(bookmarkQuery)\n\n\tvar existingBookmarkIDs []int\n\terr := db.ReaderDB().SelectContext(ctx, &existingBookmarkIDs, bookmarkQuery, bookmarkArgs...)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to check bookmarks: %w\", err)\n\t}\n\n\tif len(existingBookmarkIDs) != len(bookmarkIDs) {\n\t\t// Find which bookmarks don't exist\n\t\tmissingBookmarkIDs := model.SliceDifference(bookmarkIDs, existingBookmarkIDs)\n\t\treturn fmt.Errorf(\"some bookmarks do not exist: %v\", missingBookmarkIDs)\n\t}\n\n\t// Verify all tags exist\n\ttagSb := sqlbuilder.NewSelectBuilder()\n\ttagSb.Select(\"id\")\n\ttagSb.From(\"tag\")\n\ttagSb.Where(tagSb.In(\"id\", tagIDsIface...))\n\n\ttagQuery, tagArgs := tagSb.Build()\n\ttagQuery = db.ReaderDB().Rebind(tagQuery)\n\n\tvar existingTagIDs []int\n\terr = db.ReaderDB().SelectContext(ctx, &existingTagIDs, tagQuery, tagArgs...)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to check tags: %w\", err)\n\t}\n\n\tif len(existingTagIDs) != len(tagIDs) {\n\t\t// Find which tags don't exist\n\t\tmissingTagIDs := model.SliceDifference(tagIDs, existingTagIDs)\n\t\treturn fmt.Errorf(\"some tags do not exist: %v\", missingTagIDs)\n\t}\n\n\treturn db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Delete existing bookmark-tag associations\n\t\tdeleteSb := sqlbuilder.NewDeleteBuilder()\n\t\tdeleteSb.DeleteFrom(\"bookmark_tag\")\n\t\tdeleteSb.Where(deleteSb.In(\"bookmark_id\", bookmarkIDsIface...))\n\n\t\tdeleteQuery, deleteArgs := deleteSb.Build()\n\t\tdeleteQuery = tx.Rebind(deleteQuery)\n\n\t\t_, err := tx.ExecContext(ctx, deleteQuery, deleteArgs...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to delete existing bookmark tags: %w\", err)\n\t\t}\n\n\t\t// Insert new bookmark-tag associations\n\t\tif len(tagIDs) > 0 {\n\t\t\t// Build values for bulk insert\n\t\t\tinsertSb := sqlbuilder.NewInsertBuilder()\n\t\t\tinsertSb.InsertInto(\"bookmark_tag\")\n\t\t\t// Fix column order to match database schema\n\t\t\tinsertSb.Cols(\"bookmark_id\", \"tag_id\")\n\n\t\t\tfor _, bookmarkID := range bookmarkIDs {\n\t\t\t\tfor _, tagID := range tagIDs {\n\t\t\t\t\t// Match the column order in Values\n\t\t\t\t\tinsertSb.Values(bookmarkID, tagID)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tinsertQuery, insertArgs := insertSb.Build()\n\t\t\t// Add MySQL-specific INSERT IGNORE INTO syntax\n\t\t\tinsertQuery = strings.Replace(insertQuery, \"INSERT INTO\", \"INSERT IGNORE INTO\", 1)\n\t\t\tinsertQuery = tx.Rebind(insertQuery)\n\n\t\t\t_, err = tx.ExecContext(ctx, insertQuery, insertArgs...)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to insert bookmark tags: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n"
  },
  {
    "path": "internal/database/mysql_test.go",
    "content": "//go:build !test_sqlite_only\n// +build !test_sqlite_only\n\npackage database\n\nimport (\n\t\"context\"\n\t\"log\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/jmoiron/sqlx\"\n)\n\nfunc init() {\n\tconnString := os.Getenv(\"SHIORI_TEST_MYSQL_URL\")\n\tif connString == \"\" {\n\t\tlog.Fatal(\"mysql tests can't run without a MysQL database, set SHIORI_TEST_MYSQL_URL environment variable\")\n\t}\n\n\tconnStringMariaDB := os.Getenv(\"SHIORI_TEST_MARIADB_URL\")\n\tif connStringMariaDB == \"\" {\n\t\tlog.Fatal(\"mysql tests can't run without a MariaDB database, set SHIORI_TEST_MARIADB_URL environment variable\")\n\t}\n}\n\nfunc mysqlTestDatabaseFactory(envKey string) testDatabaseFactory {\n\treturn func(_ *testing.T, ctx context.Context) (model.DB, error) {\n\t\tconnString := os.Getenv(envKey)\n\t\tdb, err := OpenMySQLDatabase(ctx, connString)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tvar dbname string\n\t\terr = db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t\terr := tx.QueryRow(\"SELECT DATABASE()\").Scan(&dbname)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t_, err = tx.ExecContext(ctx, \"DROP DATABASE IF EXISTS \"+dbname)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t_, err = tx.ExecContext(ctx, \"CREATE DATABASE \"+dbname)\n\t\t\treturn err\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif _, err := db.ExecContext(ctx, \"USE \"+dbname); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif err = db.Migrate(context.TODO()); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\treturn db, err\n\t}\n}\n\nfunc TestMysqlsDatabase(t *testing.T) {\n\ttestDatabase(t, mysqlTestDatabaseFactory(\"SHIORI_TEST_MYSQL_URL\"))\n}\n\nfunc TestMariaDBDatabase(t *testing.T) {\n\ttestDatabase(t, mysqlTestDatabaseFactory(\"SHIORI_TEST_MARIADB_URL\"))\n}\n"
  },
  {
    "path": "internal/database/pg.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/huandu/go-sqlbuilder\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/pkg/errors\"\n\n\t\"github.com/lib/pq\"\n)\n\nvar postgresMigrations = []migration{\n\tnewFileMigration(\"0.0.0\", \"0.1.0\", \"postgres/0000_system\"),\n\tnewFileMigration(\"0.1.0\", \"0.2.0\", \"postgres/0001_initial\"),\n\tnewFuncMigration(\"0.2.0\", \"0.3.0\", func(db *sql.DB) error {\n\t\t// Ensure that bookmark table has `has_content` column and account table has `config` column\n\t\t// for users upgrading from <1.5.4 directly into this version.\n\t\ttx, err := db.Begin()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to start transaction: %w\", err)\n\t\t}\n\n\t\t_, err = tx.Exec(`ALTER TABLE bookmark ADD COLUMN has_content BOOLEAN DEFAULT FALSE NOT NULL`)\n\t\tif err != nil {\n\t\t\t// Check if this is a \"column already exists\" error (PostgreSQL error code 42701)\n\t\t\t// If it's not, return error.\n\t\t\t// This is needed for users upgrading from >1.5.4 directly into this version.\n\t\t\tpqErr, ok := err.(*pq.Error)\n\t\t\tif ok && pqErr.Code == \"42701\" {\n\t\t\t\ttx.Rollback()\n\t\t\t} else {\n\t\t\t\treturn fmt.Errorf(\"failed to add has_content column to bookmark table: %w\", err)\n\t\t\t}\n\t\t} else {\n\t\t\tif err := tx.Commit(); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to commit transaction: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\ttx, err = db.Begin()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to start transaction: %w\", err)\n\t\t}\n\n\t\t_, err = tx.Exec(`ALTER TABLE account ADD COLUMN config JSONB NOT NULL DEFAULT '{}'`)\n\t\tif err != nil {\n\t\t\t// Check if this is a \"column already exists\" error (PostgreSQL error code 42701)\n\t\t\t// If it's not, return error\n\t\t\t// This is needed for users upgrading from >1.5.4 directly into this version.\n\t\t\tpqErr, ok := err.(*pq.Error)\n\t\t\tif ok && pqErr.Code == \"42701\" {\n\t\t\t\ttx.Rollback()\n\t\t\t} else {\n\t\t\t\treturn fmt.Errorf(\"failed to add config column to account table: %w\", err)\n\t\t\t}\n\t\t} else {\n\t\t\tif err := tx.Commit(); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to commit transaction: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}),\n\tnewFileMigration(\"0.3.0\", \"0.4.0\", \"postgres/0002_created_time\"),\n}\n\n// PGDatabase is implementation of Database interface\n// for connecting to PostgreSQL database.\ntype PGDatabase struct {\n\tdbbase\n}\n\n// OpenPGDatabase creates and opens connection to a PostgreSQL Database.\nfunc OpenPGDatabase(ctx context.Context, connString string) (pgDB *PGDatabase, err error) {\n\t// Open database and start transaction\n\tdb, err := sqlx.ConnectContext(ctx, \"postgres\", connString)\n\tif err != nil {\n\t\treturn nil, errors.WithStack(err)\n\t}\n\n\tdb.SetMaxOpenConns(100)\n\tdb.SetConnMaxLifetime(time.Second)\n\n\tpgDB = &PGDatabase{dbbase: NewDBBase(db, db, sqlbuilder.PostgreSQL)}\n\treturn pgDB, err\n}\n\n// Init initializes the database\nfunc (db *PGDatabase) Init(ctx context.Context) error {\n\treturn nil\n}\n\n// Migrate runs migrations for this database engine\nfunc (db *PGDatabase) Migrate(ctx context.Context) error {\n\tif err := runMigrations(ctx, db, postgresMigrations); err != nil {\n\t\treturn errors.WithStack(err)\n\t}\n\n\treturn nil\n}\n\n// GetDatabaseSchemaVersion fetches the current migrations version of the database\nfunc (db *PGDatabase) GetDatabaseSchemaVersion(ctx context.Context) (string, error) {\n\tvar version string\n\n\terr := db.GetContext(ctx, &version, \"SELECT database_schema_version FROM shiori_system\")\n\tif err != nil {\n\t\treturn \"\", errors.WithStack(err)\n\t}\n\n\treturn version, nil\n}\n\n// SetDatabaseSchemaVersion sets the current migrations version of the database\nfunc (db *PGDatabase) SetDatabaseSchemaVersion(ctx context.Context, version string) error {\n\ttx := db.MustBegin()\n\tdefer tx.Rollback()\n\n\treturn db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t_, err := tx.Exec(\"UPDATE shiori_system SET database_schema_version = $1\", version)\n\t\tif err != nil {\n\t\t\treturn errors.WithStack(err)\n\t\t}\n\n\t\treturn tx.Commit()\n\t})\n}\n\n// SaveBookmarks saves new or updated bookmarks to database.\n// Returns the saved ID and error message if any happened.\nfunc (db *PGDatabase) SaveBookmarks(ctx context.Context, create bool, bookmarks ...model.BookmarkDTO) (result []model.BookmarkDTO, err error) {\n\tresult = []model.BookmarkDTO{}\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Prepare statement\n\t\tstmtInsertBook, err := tx.Preparex(`INSERT INTO bookmark\n\t\t\t(url, title, excerpt, author, public, content, html, modified_at, created_at)\n\t\t\tVALUES($1, $2, $3, $4, $5, $6, $7, $8, $9)\n\t\tRETURNING id`)\n\t\tif err != nil {\n\t\t\treturn errors.WithStack(err)\n\t\t}\n\n\t\tstmtUpdateBook, err := tx.Preparex(`UPDATE bookmark SET\n\t\t\turl      = $1,\n\t\t\ttitle    = $2,\n\t\t\texcerpt  = $3,\n\t\t\tauthor   = $4,\n\t\t\tpublic   = $5,\n\t\t\tcontent  = $6,\n\t\t\thtml     = $7,\n\t\t\tmodified_at = $8\n\t\t\tWHERE id = $9`)\n\t\tif err != nil {\n\t\t\treturn errors.WithStack(err)\n\t\t}\n\n\t\tstmtGetTag, err := tx.Preparex(`SELECT id FROM tag WHERE name = $1`)\n\t\tif err != nil {\n\t\t\treturn errors.WithStack(err)\n\t\t}\n\n\t\tstmtInsertTag, err := tx.Preparex(`INSERT INTO tag (name) VALUES ($1) RETURNING id`)\n\t\tif err != nil {\n\t\t\treturn errors.WithStack(err)\n\t\t}\n\n\t\tstmtInsertBookTag, err := tx.Preparex(`INSERT INTO bookmark_tag\n\t\t\t(tag_id, bookmark_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`)\n\t\tif err != nil {\n\t\t\treturn errors.WithStack(err)\n\t\t}\n\n\t\tstmtDeleteBookTag, err := tx.Preparex(`DELETE FROM bookmark_tag\n\t\t\tWHERE bookmark_id = $1 AND tag_id = $2`)\n\t\tif err != nil {\n\t\t\treturn errors.WithStack(err)\n\t\t}\n\n\t\t// Prepare modified time\n\t\tmodifiedTime := time.Now().UTC().Format(model.DatabaseDateFormat)\n\n\t\t// Execute statements\n\t\tresult = []model.BookmarkDTO{}\n\t\tfor _, book := range bookmarks {\n\t\t\t// URL and title\n\t\t\tif book.URL == \"\" {\n\t\t\t\treturn errors.New(\"URL must not be empty\")\n\t\t\t}\n\n\t\t\tif book.Title == \"\" {\n\t\t\t\treturn errors.New(\"title must not be empty\")\n\t\t\t}\n\n\t\t\t// Set modified time\n\t\t\tif book.ModifiedAt == \"\" {\n\t\t\t\tbook.ModifiedAt = modifiedTime\n\t\t\t}\n\n\t\t\t// Save bookmark\n\t\t\tvar err error\n\t\t\tif create {\n\t\t\t\tbook.CreatedAt = modifiedTime\n\t\t\t\terr = stmtInsertBook.QueryRowContext(ctx,\n\t\t\t\t\tbook.URL, book.Title, book.Excerpt, book.Author,\n\t\t\t\t\tbook.Public, book.Content, book.HTML, book.ModifiedAt, book.CreatedAt).Scan(&book.ID)\n\t\t\t} else {\n\t\t\t\t_, err = stmtUpdateBook.ExecContext(ctx,\n\t\t\t\t\tbook.URL, book.Title, book.Excerpt, book.Author,\n\t\t\t\t\tbook.Public, book.Content, book.HTML, book.ModifiedAt, book.ID)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\treturn errors.WithStack(err)\n\t\t\t}\n\n\t\t\t// Save book tags\n\t\t\tnewTags := []model.TagDTO{}\n\t\t\tfor _, tag := range book.Tags {\n\t\t\t\tt := tag.ToDTO()\n\t\t\t\t// If it's deleted tag, delete and continue\n\t\t\t\tif t.Deleted {\n\t\t\t\t\t_, err = stmtDeleteBookTag.ExecContext(ctx, book.ID, t.ID)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn errors.WithStack(err)\n\t\t\t\t\t}\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// Normalize tag name\n\t\t\t\ttagName := strings.ToLower(tag.Name)\n\t\t\t\ttagName = strings.Join(strings.Fields(tagName), \" \")\n\n\t\t\t\t// If tag doesn't have any ID, fetch it from database\n\t\t\t\tif tag.ID == 0 {\n\t\t\t\t\terr = stmtGetTag.GetContext(ctx, &tag.ID, tagName)\n\t\t\t\t\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\t\t\t\t\treturn errors.WithStack(err)\n\t\t\t\t\t}\n\n\t\t\t\t\t// If tag doesn't exist in database, save it\n\t\t\t\t\tif tag.ID == 0 {\n\t\t\t\t\t\tvar tagID64 int64\n\t\t\t\t\t\terr = stmtInsertTag.GetContext(ctx, &tagID64, tagName)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\treturn errors.WithStack(err)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\ttag.ID = int(tagID64)\n\t\t\t\t\t\tt.ID = int(tagID64)\n\t\t\t\t\t}\n\n\t\t\t\t\tif _, err := stmtInsertBookTag.ExecContext(ctx, tag.ID, book.ID); err != nil {\n\t\t\t\t\t\treturn errors.WithStack(err)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tnewTags = append(newTags, t)\n\t\t\t}\n\n\t\t\tbook.Tags = newTags\n\t\t\tresult = append(result, book)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, errors.WithStack(err)\n\t}\n\n\treturn result, nil\n}\n\n// GetBookmarks fetch list of bookmarks based on submitted options.\nfunc (db *PGDatabase) GetBookmarks(ctx context.Context, opts model.DBGetBookmarksOptions) ([]model.BookmarkDTO, error) {\n\t// Create initial query\n\tcolumns := []string{\n\t\t`id`,\n\t\t`url`,\n\t\t`title`,\n\t\t`excerpt`,\n\t\t`author`,\n\t\t`public`,\n\t\t`created_at`,\n\t\t`modified_at`,\n\t\t`content <> '' has_content`}\n\n\tif opts.WithContent {\n\t\tcolumns = append(columns, `content`, `html`)\n\t}\n\n\tquery := `SELECT ` + strings.Join(columns, \",\") + `\n\t\tFROM bookmark WHERE TRUE`\n\n\t// Add where clause\n\targ := map[string]interface{}{}\n\n\t// Add where clause for IDs\n\tif len(opts.IDs) > 0 {\n\t\tquery += ` AND id IN (:ids)`\n\t\targ[\"ids\"] = opts.IDs\n\t}\n\n\t// Add where clause for search keyword\n\tif opts.Keyword != \"\" {\n\t\tquery += ` AND (\n\t\t\turl LIKE '%' || :kw || '%' OR\n\t\t\ttitle LIKE '%' || :kw || '%' OR\n\t\t\texcerpt LIKE '%' || :kw || '%' OR\n\t\t\tcontent LIKE '%' || :kw || '%'\n\t\t)`\n\n\t\targ[\"kw\"] = opts.Keyword\n\t}\n\n\t// Add where clause for tags.\n\t// First we check for * in excluded and included tags,\n\t// which means all tags will be excluded and included, respectively.\n\texcludeAllTags := false\n\tif slices.Contains(opts.ExcludedTags, \"*\") {\n\t\texcludeAllTags = true\n\t\topts.ExcludedTags = []string{}\n\t}\n\n\tincludeAllTags := false\n\tif slices.Contains(opts.Tags, \"*\") {\n\t\tincludeAllTags = true\n\t\topts.Tags = []string{}\n\t}\n\n\t// If all tags excluded, we will only show bookmark without tags.\n\t// In other hand, if all tags included, we will only show bookmark with tags.\n\tif excludeAllTags {\n\t\tquery += ` AND id NOT IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`\n\t} else if includeAllTags {\n\t\tquery += ` AND id IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`\n\t}\n\n\t// Now we only need to find the normal tags\n\tif len(opts.Tags) > 0 {\n\t\tquery += ` AND id IN (\n\t\t\tSELECT bt.bookmark_id\n\t\t\tFROM bookmark_tag bt\n\t\t\tLEFT JOIN tag t ON bt.tag_id = t.id\n\t\t\tWHERE t.name IN(:tags)\n\t\t\tGROUP BY bt.bookmark_id\n\t\t\tHAVING COUNT(bt.bookmark_id) = :ltags)`\n\n\t\targ[\"tags\"] = opts.Tags\n\t\targ[\"ltags\"] = len(opts.Tags)\n\t}\n\n\tif len(opts.ExcludedTags) > 0 {\n\t\tquery += ` AND id NOT IN (\n\t\t\tSELECT DISTINCT bt.bookmark_id\n\t\t\tFROM bookmark_tag bt\n\t\t\tLEFT JOIN tag t ON bt.tag_id = t.id\n\t\t\tWHERE t.name IN(:extags))`\n\n\t\targ[\"extags\"] = opts.ExcludedTags\n\t}\n\n\t// Add order clause\n\tswitch opts.OrderMethod {\n\tcase model.ByLastAdded:\n\t\tquery += ` ORDER BY id DESC`\n\tcase model.ByLastModified:\n\t\tquery += ` ORDER BY modified_at DESC`\n\tdefault:\n\t\tquery += ` ORDER BY id`\n\t}\n\n\tif opts.Limit > 0 && opts.Offset >= 0 {\n\t\tquery += ` LIMIT :limit OFFSET :offset`\n\t\targ[\"limit\"] = opts.Limit\n\t\targ[\"offset\"] = opts.Offset\n\t}\n\n\t// Expand query, because some of the args might be an array\n\tvar err error\n\tquery, args, _ := sqlx.Named(query, arg)\n\tquery, args, err = sqlx.In(query, args...)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to expand query: %v\", err)\n\t}\n\tquery = db.ReaderDB().Rebind(query)\n\n\t// Fetch bookmarks\n\tbookmarks := []model.BookmarkDTO{}\n\terr = db.SelectContext(ctx, &bookmarks, query, args...)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn nil, fmt.Errorf(\"failed to fetch data: %v\", err)\n\t}\n\n\t// Fetch tags for each bookmarks\n\tstmtGetTags, err := db.ReaderDB().PreparexContext(ctx, `SELECT t.id, t.name\n\t\tFROM bookmark_tag bt\n\t\tLEFT JOIN tag t ON bt.tag_id = t.id\n\t\tWHERE bt.bookmark_id = $1\n\t\tORDER BY t.name`)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to prepare tag query: %v\", err)\n\t}\n\tdefer stmtGetTags.Close()\n\n\tfor i, book := range bookmarks {\n\t\tbook.Tags = []model.TagDTO{}\n\t\terr = stmtGetTags.SelectContext(ctx, &book.Tags, book.ID)\n\t\tif err != nil && err != sql.ErrNoRows {\n\t\t\treturn nil, fmt.Errorf(\"failed to fetch tags: %v\", err)\n\t\t}\n\n\t\tbookmarks[i] = book\n\t}\n\n\treturn bookmarks, nil\n}\n\n// GetBookmarksCount fetch count of bookmarks based on submitted options.\nfunc (db *PGDatabase) GetBookmarksCount(ctx context.Context, opts model.DBGetBookmarksOptions) (int, error) {\n\t// Create initial query\n\tquery := `SELECT COUNT(id) FROM bookmark WHERE TRUE`\n\n\targ := map[string]interface{}{}\n\n\t// Add where clause for IDs\n\tif len(opts.IDs) > 0 {\n\t\tquery += ` AND id IN (:ids)`\n\t\targ[\"ids\"] = opts.IDs\n\t}\n\n\t// Add where clause for search keyword\n\tif opts.Keyword != \"\" {\n\t\tquery += ` AND (\n\t\t\turl LIKE '%' || :kw || '%' OR\n\t\t\ttitle LIKE '%' || :kw || '%' OR\n\t\t\texcerpt LIKE '%' || :kw || '%' OR\n\t\t\tcontent LIKE '%' || :kw || '%'\n\t\t)`\n\n\t\targ[\"lurl\"] = \"%\" + opts.Keyword + \"%\"\n\t\targ[\"kw\"] = opts.Keyword\n\t}\n\n\t// Add where clause for tags.\n\t// First we check for * in excluded and included tags,\n\t// which means all tags will be excluded and included, respectively.\n\texcludeAllTags := false\n\tfor _, excludedTag := range opts.ExcludedTags {\n\t\tif excludedTag == \"*\" {\n\t\t\texcludeAllTags = true\n\t\t\topts.ExcludedTags = []string{}\n\t\t\tbreak\n\t\t}\n\t}\n\n\tincludeAllTags := false\n\tfor _, includedTag := range opts.Tags {\n\t\tif includedTag == \"*\" {\n\t\t\tincludeAllTags = true\n\t\t\topts.Tags = []string{}\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// If all tags excluded, we will only show bookmark without tags.\n\t// In other hand, if all tags included, we will only show bookmark with tags.\n\tif excludeAllTags {\n\t\tquery += ` AND id NOT IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`\n\t} else if includeAllTags {\n\t\tquery += ` AND id IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`\n\t}\n\n\t// Now we only need to find the normal tags\n\tif len(opts.Tags) > 0 {\n\t\tquery += ` AND id IN (\n\t\t\tSELECT bt.bookmark_id\n\t\t\tFROM bookmark_tag bt\n\t\t\tLEFT JOIN tag t ON bt.tag_id = t.id\n\t\t\tWHERE t.name IN(:tags)\n\t\t\tGROUP BY bt.bookmark_id\n\t\t\tHAVING COUNT(bt.bookmark_id) = :ltags)`\n\n\t\targ[\"tags\"] = opts.Tags\n\t\targ[\"ltags\"] = len(opts.Tags)\n\t}\n\n\tif len(opts.ExcludedTags) > 0 {\n\t\tquery += ` AND id NOT IN (\n\t\t\tSELECT DISTINCT bt.bookmark_id\n\t\t\tFROM bookmark_tag bt\n\t\t\tLEFT JOIN tag t ON bt.tag_id = t.id\n\t\t\tWHERE t.name IN(:etags))`\n\n\t\targ[\"etags\"] = opts.ExcludedTags\n\t}\n\n\t// Expand query, because some of the args might be an array\n\tvar err error\n\tquery, args, err := sqlx.Named(query, arg)\n\tif err != nil {\n\t\treturn 0, errors.WithStack(err)\n\t}\n\n\tquery, args, err = sqlx.In(query, args...)\n\tif err != nil {\n\t\treturn 0, errors.WithStack(err)\n\t}\n\tquery = db.ReaderDB().Rebind(query)\n\n\t// Fetch count\n\tvar nBookmarks int\n\terr = db.GetContext(ctx, &nBookmarks, query, args...)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn 0, errors.WithStack(err)\n\t}\n\n\treturn nBookmarks, nil\n}\n\n// DeleteBookmarks removes all record with matching ids from database.\nfunc (db *PGDatabase) DeleteBookmarks(ctx context.Context, ids ...int) (err error) {\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Prepare queries\n\t\tdelBookmark := `DELETE FROM bookmark`\n\t\tdelBookmarkTag := `DELETE FROM bookmark_tag`\n\n\t\t// Delete bookmark(s)\n\t\tif len(ids) == 0 {\n\t\t\t_, err := tx.ExecContext(ctx, delBookmarkTag)\n\t\t\tif err != nil {\n\t\t\t\treturn errors.WithStack(err)\n\t\t\t}\n\n\t\t\t_, err = tx.ExecContext(ctx, delBookmark)\n\t\t\tif err != nil {\n\t\t\t\treturn errors.WithStack(err)\n\t\t\t}\n\t\t} else {\n\t\t\tdelBookmark += ` WHERE id = $1`\n\t\t\tdelBookmarkTag += ` WHERE bookmark_id = $1`\n\n\t\t\tstmtDelBookmark, err := tx.Preparex(delBookmark)\n\t\t\tif err != nil {\n\t\t\t\treturn errors.WithStack(err)\n\t\t\t}\n\t\t\tstmtDelBookmarkTag, err := tx.Preparex(delBookmarkTag)\n\t\t\tif err != nil {\n\t\t\t\treturn errors.WithStack(err)\n\t\t\t}\n\n\t\t\tfor _, id := range ids {\n\t\t\t\t_, err = stmtDelBookmarkTag.ExecContext(ctx, id)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn errors.WithStack(err)\n\t\t\t\t}\n\n\t\t\t\t_, err = stmtDelBookmark.ExecContext(ctx, id)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn errors.WithStack(err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn errors.WithStack(err)\n\t}\n\n\treturn nil\n}\n\n// GetBookmark fetches bookmark based on its ID or URL.\n// Returns the bookmark and boolean whether it's exist or not.\nfunc (db *PGDatabase) GetBookmark(ctx context.Context, id int, url string) (model.BookmarkDTO, bool, error) {\n\t// Create the main query builder for bookmark data\n\tsb := sqlbuilder.PostgreSQL.NewSelectBuilder()\n\tsb.Select(\n\t\t\"id\", \"url\", \"title\", \"excerpt\", \"author\", `\"public\"`, \"modified_at\",\n\t\t\"content\", \"html\", \"created_at\", \"has_content\")\n\tsb.From(\"bookmark\")\n\n\t// Add conditions\n\tif id != 0 {\n\t\tsb.Where(sb.Equal(\"id\", id))\n\t} else if url != \"\" {\n\t\tsb.Where(sb.Equal(\"url\", url))\n\t} else {\n\t\treturn model.BookmarkDTO{}, false, fmt.Errorf(\"id or url is required\")\n\t}\n\n\t// Build the query\n\tquery, args := sb.Build()\n\n\t// Execute the query\n\tbook := model.BookmarkDTO{}\n\n\tquery = db.ReaderDB().Rebind(query)\n\terr := db.ReaderDB().GetContext(ctx, &book, query, args...)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn book, false, nil\n\t\t}\n\t\treturn book, false, fmt.Errorf(\"failed to get bookmark: %w\", err)\n\t}\n\n\t// If bookmark exists, fetch its tags\n\tif book.ID != 0 {\n\t\t// Create query builder for tags\n\t\ttagSb := sqlbuilder.PostgreSQL.NewSelectBuilder()\n\t\ttagSb.Select(\"t.id\", \"t.name\")\n\t\ttagSb.From(\"tag t\")\n\t\ttagSb.JoinWithOption(sqlbuilder.InnerJoin, \"bookmark_tag bt\", \"bt.tag_id = t.id\")\n\t\ttagSb.Where(tagSb.Equal(\"bt.bookmark_id\", book.ID))\n\n\t\t// Build the query\n\t\ttagQuery, tagArgs := tagSb.Build()\n\t\ttagQuery = db.ReaderDB().Rebind(tagQuery)\n\n\t\t// Execute the query\n\t\ttags := []model.TagDTO{}\n\t\tif err := db.ReaderDB().SelectContext(ctx, &tags, tagQuery, tagArgs...); err != nil && err != sql.ErrNoRows {\n\t\t\treturn book, false, fmt.Errorf(\"failed to get tags: %w\", err)\n\t\t}\n\n\t\tbook.Tags = tags\n\t}\n\n\treturn book, true, nil\n}\n\n// CreateAccount saves new account to database. Returns error if any happened.\nfunc (db *PGDatabase) CreateAccount(ctx context.Context, account model.Account) (*model.Account, error) {\n\tvar accountID int64\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Check for existing username\n\t\tvar exists bool\n\t\terr := tx.QueryRowContext(ctx,\n\t\t\t\"SELECT EXISTS(SELECT 1 FROM account WHERE username = $1)\",\n\t\t\taccount.Username).Scan(&exists)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error checking username: %w\", err)\n\t\t}\n\t\tif exists {\n\t\t\treturn ErrAlreadyExists\n\t\t}\n\n\t\t// Create the account\n\t\tquery, err := tx.PrepareContext(ctx, `INSERT INTO account\n\t\t\t(username, password, owner, config) VALUES ($1, $2, $3, $4)\n\t\t\tRETURNING id`)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error preparing query: %w\", err)\n\t\t}\n\n\t\terr = query.QueryRowContext(ctx,\n\t\t\taccount.Username, account.Password, account.Owner, account.Config).Scan(&accountID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error executing query: %w\", err)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"error running transaction: %w\", err)\n\t}\n\n\taccount.ID = model.DBID(accountID)\n\treturn &account, nil\n}\n\n// UpdateAccount updates account in database.\nfunc (db *PGDatabase) UpdateAccount(ctx context.Context, account model.Account) error {\n\tif account.ID == 0 {\n\t\treturn ErrNotFound\n\t}\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Check for existing username\n\t\tvar exists bool\n\t\terr := tx.QueryRowContext(ctx,\n\t\t\t\"SELECT EXISTS(SELECT 1 FROM account WHERE username = $1 AND id != $2)\",\n\t\t\taccount.Username, account.ID).Scan(&exists)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error checking username: %w\", err)\n\t\t}\n\t\tif exists {\n\t\t\treturn ErrAlreadyExists\n\t\t}\n\n\t\tresult, err := tx.ExecContext(ctx, `UPDATE account\n\t\t\tSET username = $1, password = $2, owner = $3, config = $4\n\t\t\tWHERE id = $5`,\n\t\t\taccount.Username, account.Password, account.Owner, account.Config, account.ID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error updating account: %w\", err)\n\t\t}\n\n\t\trows, err := result.RowsAffected()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error getting rows affected: %w\", err)\n\t\t}\n\t\tif rows == 0 {\n\t\t\treturn ErrNotFound\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"error running transaction: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// ListAccounts fetch list of account (without its password) based on submitted options.\nfunc (db *PGDatabase) ListAccounts(ctx context.Context, opts model.DBListAccountsOptions) ([]model.Account, error) {\n\t// Create query\n\targs := []interface{}{}\n\tfields := []string{\"id\", \"username\", \"owner\", \"config\"}\n\tif opts.WithPassword {\n\t\tfields = append(fields, \"password\")\n\t}\n\n\tquery := fmt.Sprintf(`SELECT %s FROM account WHERE TRUE`, strings.Join(fields, \", \"))\n\n\tif opts.Keyword != \"\" {\n\t\tquery += \" AND username LIKE $\" + strconv.Itoa(len(args)+1)\n\t\targs = append(args, \"%\"+opts.Keyword+\"%\")\n\t}\n\n\tif opts.Username != \"\" {\n\t\tquery += \" AND username = $\" + strconv.Itoa(len(args)+1)\n\t\targs = append(args, opts.Username)\n\t}\n\n\tif opts.Owner {\n\t\tquery += \" AND owner = TRUE\"\n\t}\n\n\t// Fetch list account\n\taccounts := []model.Account{}\n\terr := db.SelectContext(ctx, &accounts, query, args...)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn nil, errors.WithStack(err)\n\t}\n\n\treturn accounts, nil\n}\n\n// GetAccount fetch account with matching ID.\n// Returns the account and boolean whether it's exist or not.\nfunc (db *PGDatabase) GetAccount(ctx context.Context, id model.DBID) (*model.Account, bool, error) {\n\taccount := model.Account{}\n\terr := db.GetContext(ctx, &account, `SELECT\n\t\tid, username, password, owner, config FROM account WHERE id = $1`,\n\t\tid,\n\t)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn &account, false, ErrNotFound\n\t\t}\n\t\treturn &account, false, fmt.Errorf(\"error getting account: %w\", err)\n\t}\n\n\treturn &account, true, nil\n}\n\n// DeleteAccount removes record with matching ID.\nfunc (db *PGDatabase) DeleteAccount(ctx context.Context, id model.DBID) error {\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\tresult, err := tx.ExecContext(ctx, `DELETE FROM account WHERE id = $1`, id)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error deleting account: %w\", err)\n\t\t}\n\n\t\trows, err := result.RowsAffected()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error getting rows affected: %w\", err)\n\t\t}\n\n\t\tif rows == 0 {\n\t\t\treturn ErrNotFound\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"error running transaction: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// CreateTags creates new tags from submitted objects.\nfunc (db *PGDatabase) CreateTags(ctx context.Context, tags ...model.Tag) ([]model.Tag, error) {\n\tif len(tags) == 0 {\n\t\treturn []model.Tag{}, nil\n\t}\n\n\t// Create insert builder with RETURNING clause\n\tsb := sqlbuilder.NewInsertBuilder()\n\tsb.InsertInto(\"tag\")\n\tsb.Cols(\"name\")\n\n\t// Add values for each tag\n\tfor _, tag := range tags {\n\t\tsb.Values(tag.Name)\n\t}\n\n\t// Build query with RETURNING id\n\tquery, args := sb.Build()\n\tquery = query + \" RETURNING id\"\n\tquery = db.WriterDB().Rebind(query)\n\n\t// Create a slice to hold the created tags\n\tcreatedTags := make([]model.Tag, len(tags))\n\tcopy(createdTags, tags)\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Execute the query and scan the returned IDs\n\t\trows, err := tx.QueryContext(ctx, query, args...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to execute tag creation query: %w\", err)\n\t\t}\n\t\tdefer rows.Close()\n\n\t\t// Scan the returned IDs into the tags\n\t\ti := 0\n\t\tfor rows.Next() {\n\t\t\tif i >= len(createdTags) {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif err := rows.Scan(&createdTags[i].ID); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to scan tag ID: %w\", err)\n\t\t\t}\n\t\t\ti++\n\t\t}\n\n\t\tif err := rows.Err(); err != nil {\n\t\t\treturn fmt.Errorf(\"error iterating over result rows: %w\", err)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to run tag creation transaction: %w\", err)\n\t}\n\n\treturn createdTags, nil\n}\n\n// CreateTag creates a new tag in database.\nfunc (db *PGDatabase) CreateTag(ctx context.Context, tag model.Tag) (model.Tag, error) {\n\t// Use CreateTags to implement this method\n\tcreatedTags, err := db.CreateTags(ctx, tag)\n\tif err != nil {\n\t\treturn model.Tag{}, err\n\t}\n\n\tif len(createdTags) == 0 {\n\t\treturn model.Tag{}, fmt.Errorf(\"failed to create tag\")\n\t}\n\n\treturn createdTags[0], nil\n}\n\n// RenameTag change the name of a tag.\nfunc (db *PGDatabase) RenameTag(ctx context.Context, id int, newName string) error {\n\tsb := sqlbuilder.NewUpdateBuilder()\n\tsb.Update(\"tag\")\n\tsb.Set(sb.Assign(\"name\", newName))\n\tsb.Where(sb.Equal(\"id\", id))\n\n\tquery, args := sb.Build()\n\tquery = db.WriterDB().Rebind(query)\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t_, err := tx.ExecContext(ctx, query, args...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to rename tag: %w\", err)\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// GetTag fetch a tag by its ID.\nfunc (db *PGDatabase) GetTag(ctx context.Context, id int) (model.TagDTO, bool, error) {\n\tsb := sqlbuilder.NewSelectBuilder()\n\tsb.Select(\"t.id\", \"t.name\", \"COUNT(bt.tag_id) bookmark_count\")\n\tsb.From(\"tag t\")\n\tsb.JoinWithOption(sqlbuilder.LeftJoin, \"bookmark_tag bt\", \"bt.tag_id = t.id\")\n\tsb.Where(sb.Equal(\"t.id\", id))\n\tsb.GroupBy(\"t.id\")\n\tsb.OrderBy(\"t.name\")\n\n\tquery, args := sb.Build()\n\tquery = db.ReaderDB().Rebind(query)\n\n\tvar tag model.TagDTO\n\terr := db.ReaderDB().GetContext(ctx, &tag, query, args...)\n\tif err == sql.ErrNoRows {\n\t\treturn model.TagDTO{}, false, nil\n\t}\n\tif err != nil {\n\t\treturn model.TagDTO{}, false, fmt.Errorf(\"failed to get tag: %w\", err)\n\t}\n\n\treturn tag, true, nil\n}\n\n// UpdateTag updates a tag in the database.\nfunc (db *PGDatabase) UpdateTag(ctx context.Context, tag model.Tag) error {\n\tsb := sqlbuilder.NewUpdateBuilder()\n\tsb.Update(\"tag\")\n\tsb.Set(sb.Assign(\"name\", tag.Name))\n\tsb.Where(sb.Equal(\"id\", tag.ID))\n\n\tquery, args := sb.Build()\n\tquery = db.WriterDB().Rebind(query)\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t_, err := tx.ExecContext(ctx, query, args...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to update tag: %w\", err)\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// DeleteTag removes a tag from the database.\nfunc (db *PGDatabase) DeleteTag(ctx context.Context, id int) error {\n\t// First, check if the tag exists\n\t_, exists, err := db.GetTag(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to check if tag exists: %w\", err)\n\t}\n\tif !exists {\n\t\treturn ErrNotFound\n\t}\n\n\t// Delete all bookmark_tag associations\n\tdeleteAssocSb := sqlbuilder.NewDeleteBuilder()\n\tdeleteAssocSb.DeleteFrom(\"bookmark_tag\")\n\tdeleteAssocSb.Where(deleteAssocSb.Equal(\"tag_id\", id))\n\n\tdeleteAssocQuery, deleteAssocArgs := deleteAssocSb.Build()\n\tdeleteAssocQuery = db.WriterDB().Rebind(deleteAssocQuery)\n\n\t// Then, delete the tag itself\n\tdeleteTagSb := sqlbuilder.NewDeleteBuilder()\n\tdeleteTagSb.DeleteFrom(\"tag\")\n\tdeleteTagSb.Where(deleteTagSb.Equal(\"id\", id))\n\n\tdeleteTagQuery, deleteTagArgs := deleteTagSb.Build()\n\tdeleteTagQuery = db.WriterDB().Rebind(deleteTagQuery)\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Delete bookmark_tag associations\n\t\t_, err := tx.ExecContext(ctx, deleteAssocQuery, deleteAssocArgs...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to delete tag associations: %w\", err)\n\t\t}\n\n\t\t// Delete the tag\n\t\t_, err = tx.ExecContext(ctx, deleteTagQuery, deleteTagArgs...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to delete tag: %w\", err)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// SaveBookmark saves a single bookmark to database without handling tags.\n// It only updates the bookmark data in the database.\nfunc (db *PGDatabase) SaveBookmark(ctx context.Context, bookmark model.Bookmark) error {\n\tif bookmark.ID <= 0 {\n\t\treturn fmt.Errorf(\"bookmark ID must be greater than 0\")\n\t}\n\n\tbookmark.ModifiedAt = time.Now().UTC().Format(model.DatabaseDateFormat)\n\n\t// Check URL and title\n\tif bookmark.URL == \"\" {\n\t\treturn errors.New(\"URL must not be empty\")\n\t}\n\n\tif bookmark.Title == \"\" {\n\t\treturn errors.New(\"title must not be empty\")\n\t}\n\n\t// Use sqlbuilder to build the update query\n\tsb := sqlbuilder.NewUpdateBuilder()\n\tsb.Update(\"bookmark\")\n\tsb.Set(\n\t\tsb.Assign(\"url\", bookmark.URL),\n\t\tsb.Assign(\"title\", bookmark.Title),\n\t\tsb.Assign(\"excerpt\", bookmark.Excerpt),\n\t\tsb.Assign(\"author\", bookmark.Author),\n\t\tsb.Assign(\"public\", bookmark.Public),\n\t\tsb.Assign(\"modified_at\", bookmark.ModifiedAt),\n\t\tsb.Assign(\"has_content\", bookmark.HasContent),\n\t)\n\tsb.Where(sb.Equal(\"id\", bookmark.ID))\n\n\tquery, args := sb.Build()\n\tquery = db.WriterDB().Rebind(query)\n\n\treturn db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Update bookmark\n\t\t_, err := tx.ExecContext(ctx, query, args...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to update bookmark: %w\", err)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\n// BulkUpdateBookmarkTags updates tags for multiple bookmarks.\n// It ensures that all bookmarks and tags exist before proceeding.\nfunc (db *PGDatabase) BulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error {\n\tif len(bookmarkIDs) == 0 || len(tagIDs) == 0 {\n\t\treturn nil\n\t}\n\n\t// Convert int slices to interface slices for sqlbuilder\n\tbookmarkIDsIface := make([]interface{}, len(bookmarkIDs))\n\tfor i, id := range bookmarkIDs {\n\t\tbookmarkIDsIface[i] = id\n\t}\n\n\ttagIDsIface := make([]interface{}, len(tagIDs))\n\tfor i, id := range tagIDs {\n\t\ttagIDsIface[i] = id\n\t}\n\n\t// Verify all bookmarks exist\n\tbookmarkSb := sqlbuilder.NewSelectBuilder()\n\tbookmarkSb.Select(\"id\")\n\tbookmarkSb.From(\"bookmark\")\n\tbookmarkSb.Where(bookmarkSb.In(\"id\", bookmarkIDsIface...))\n\n\tbookmarkQuery, bookmarkArgs := bookmarkSb.Build()\n\tbookmarkQuery = db.ReaderDB().Rebind(bookmarkQuery)\n\n\tvar existingBookmarkIDs []int\n\terr := db.ReaderDB().SelectContext(ctx, &existingBookmarkIDs, bookmarkQuery, bookmarkArgs...)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to check bookmarks: %w\", err)\n\t}\n\n\tif len(existingBookmarkIDs) != len(bookmarkIDs) {\n\t\t// Find which bookmarks don't exist\n\t\tmissingBookmarkIDs := model.SliceDifference(bookmarkIDs, existingBookmarkIDs)\n\t\treturn fmt.Errorf(\"some bookmarks do not exist: %v\", missingBookmarkIDs)\n\t}\n\n\t// Verify all tags exist\n\ttagSb := sqlbuilder.NewSelectBuilder()\n\ttagSb.Select(\"id\")\n\ttagSb.From(\"tag\")\n\ttagSb.Where(tagSb.In(\"id\", tagIDsIface...))\n\n\ttagQuery, tagArgs := tagSb.Build()\n\ttagQuery = db.ReaderDB().Rebind(tagQuery)\n\n\tvar existingTagIDs []int\n\terr = db.ReaderDB().SelectContext(ctx, &existingTagIDs, tagQuery, tagArgs...)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to check tags: %w\", err)\n\t}\n\n\tif len(existingTagIDs) != len(tagIDs) {\n\t\t// Find which tags don't exist\n\t\tmissingTagIDs := model.SliceDifference(tagIDs, existingTagIDs)\n\t\treturn fmt.Errorf(\"some tags do not exist: %v\", missingTagIDs)\n\t}\n\n\treturn db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Delete existing bookmark-tag associations\n\t\tdeleteSb := sqlbuilder.NewDeleteBuilder()\n\t\tdeleteSb.DeleteFrom(\"bookmark_tag\")\n\t\tdeleteSb.Where(deleteSb.In(\"bookmark_id\", bookmarkIDsIface...))\n\n\t\tdeleteQuery, deleteArgs := deleteSb.Build()\n\t\tdeleteQuery = tx.Rebind(deleteQuery)\n\n\t\t_, err := tx.ExecContext(ctx, deleteQuery, deleteArgs...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to delete existing bookmark tags: %w\", err)\n\t\t}\n\n\t\t// Insert new bookmark-tag associations\n\t\tif len(tagIDs) > 0 {\n\t\t\t// Build values for bulk insert\n\t\t\tinsertSb := sqlbuilder.NewInsertBuilder()\n\t\t\tinsertSb.InsertInto(\"bookmark_tag\")\n\t\t\tinsertSb.Cols(\"bookmark_id\", \"tag_id\")\n\n\t\t\tfor _, bookmarkID := range bookmarkIDs {\n\t\t\t\tfor _, tagID := range tagIDs {\n\t\t\t\t\tinsertSb.Values(bookmarkID, tagID)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tinsertQuery, insertArgs := insertSb.Build()\n\t\t\tinsertQuery = tx.Rebind(insertQuery)\n\n\t\t\t_, err = tx.ExecContext(ctx, insertQuery, insertArgs...)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to insert bookmark tags: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n"
  },
  {
    "path": "internal/database/pg_test.go",
    "content": "//go:build !test_sqlite_only\n// +build !test_sqlite_only\n\npackage database\n\nimport (\n\t\"context\"\n\t\"log\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\nfunc init() {\n\tconnString := os.Getenv(\"SHIORI_TEST_PG_URL\")\n\tif connString == \"\" {\n\t\tlog.Fatal(\"psql tests can't run without a PSQL database, set SHIORI_TEST_PG_URL environment variable\")\n\t}\n}\n\nfunc postgresqlTestDatabaseFactory(_ *testing.T, ctx context.Context) (model.DB, error) {\n\tdb, err := OpenPGDatabase(ctx, os.Getenv(\"SHIORI_TEST_PG_URL\"))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t_, err = db.ExecContext(ctx, \"DROP SCHEMA public CASCADE; CREATE SCHEMA public;\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := db.Migrate(context.TODO()); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn db, nil\n}\n\nfunc TestPostgresDatabase(t *testing.T) {\n\ttestDatabase(t, postgresqlTestDatabaseFactory)\n}\n"
  },
  {
    "path": "internal/database/sqlite.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"log\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/huandu/go-sqlbuilder\"\n\t\"github.com/jmoiron/sqlx\"\n\t\"github.com/pkg/errors\"\n\n\t\"slices\"\n\n\t_ \"modernc.org/sqlite\"\n)\n\nvar sqliteMigrations = []migration{\n\tnewFileMigration(\"0.0.0\", \"0.1.0\", \"sqlite/0000_system\"),\n\tnewFileMigration(\"0.1.0\", \"0.2.0\", \"sqlite/0001_initial\"),\n\tnewFuncMigration(\"0.2.0\", \"0.3.0\", func(db *sql.DB) error {\n\t\t// Ensure that bookmark table has `has_content` column and account table has `config` column\n\t\t// for users upgrading from <1.5.4 directly into this version.\n\t\ttx, err := db.Begin()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to start transaction: %w\", err)\n\t\t}\n\t\tdefer tx.Rollback()\n\n\t\t_, err = tx.Exec(`ALTER TABLE bookmark ADD COLUMN has_content BOOLEAN DEFAULT FALSE NOT NULL`)\n\t\tif err != nil && strings.Contains(err.Error(), `duplicate column name`) {\n\t\t\ttx.Rollback()\n\t\t} else if err != nil {\n\t\t\treturn fmt.Errorf(\"failed to add has_content column to bookmark table: %w\", err)\n\t\t} else if err == nil {\n\t\t\tif errCommit := tx.Commit(); errCommit != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to commit transaction: %w\", errCommit)\n\t\t\t}\n\t\t}\n\n\t\ttx, err = db.Begin()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to start transaction: %w\", err)\n\t\t}\n\t\tdefer tx.Rollback()\n\n\t\t_, err = tx.Exec(`ALTER TABLE account ADD COLUMN config JSON NOT NULL DEFAULT '{}'`)\n\t\tif err != nil && strings.Contains(err.Error(), `duplicate column name`) {\n\t\t\ttx.Rollback()\n\t\t} else if err != nil {\n\t\t\treturn fmt.Errorf(\"failed to add config column to account table: %w\", err)\n\t\t} else if err == nil {\n\t\t\tif errCommit := tx.Commit(); errCommit != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to commit transaction: %w\", errCommit)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}),\n\tnewFileMigration(\"0.3.0\", \"0.4.0\", \"sqlite/0002_denormalize_content\"),\n\tnewFileMigration(\"0.4.0\", \"0.5.0\", \"sqlite/0003_uniq_id\"),\n\tnewFileMigration(\"0.5.0\", \"0.6.0\", \"sqlite/0004_created_time\"),\n}\n\n// SQLiteDatabase is implementation of Database interface\n// for connecting to SQLite3 database.\ntype SQLiteDatabase struct {\n\tdbbase\n}\n\n// withTx executes the given function within a transaction.\n// If the function returns an error, the transaction is rolled back.\n// Otherwise, the transaction is committed.\nfunc (db *SQLiteDatabase) withTx(ctx context.Context, fn func(tx *sqlx.Tx) error) error {\n\ttx, err := db.writer.BeginTxx(ctx, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to begin transaction: %w\", err)\n\t}\n\tdefer tx.Rollback() // Will be a no-op if tx.Commit() is called\n\n\tif err := fn(tx); err != nil {\n\t\t// Return the error directly without wrapping\n\t\treturn fmt.Errorf(\"transaction failed: %w\", err)\n\t}\n\n\tif err := tx.Commit(); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// withTxRetry executes the given function within a transaction with retry logic.\n// It will retry up to 3 times if the database is locked, with exponential backoff.\n// For other errors, it returns immediately.\nfunc (db *SQLiteDatabase) withTxRetry(ctx context.Context, fn func(tx *sqlx.Tx) error) error {\n\tmaxRetries := 3\n\tvar lastErr error\n\n\tfor i := 0; i < maxRetries; i++ {\n\t\terr := db.withTx(ctx, fn)\n\t\tif err == nil {\n\t\t\treturn nil\n\t\t}\n\n\t\tif strings.Contains(err.Error(), \"database is locked\") {\n\t\t\tlastErr = err\n\t\t\ttime.Sleep(time.Duration(i+1) * 100 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\n\t\treturn fmt.Errorf(\"transaction failed after retry: %w\", err)\n\t}\n\n\treturn fmt.Errorf(\"transaction failed after max retries, last error: %w\", lastErr)\n}\n\n// Init sets up the SQLite database with optimal settings for both reader and writer connections\nfunc (db *SQLiteDatabase) Init(ctx context.Context) error {\n\t// Initialize both connections with appropriate settings\n\tfor _, conn := range []*sqlx.DB{db.WriterDB(), db.ReaderDB()} {\n\t\t// Reuse connections for up to one hour\n\t\tconn.SetConnMaxLifetime(time.Hour)\n\n\t\t// Enable WAL mode for better concurrency\n\t\tif _, err := conn.ExecContext(ctx, `PRAGMA journal_mode=WAL`); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to set journal mode: %w\", err)\n\t\t}\n\n\t\t// Set busy timeout to avoid \"database is locked\" errors\n\t\tif _, err := conn.ExecContext(ctx, `PRAGMA busy_timeout=5000`); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to set busy timeout: %w\", err)\n\t\t}\n\n\t\t// Other performance and reliability settings\n\t\tpragmas := []string{\n\t\t\t`PRAGMA synchronous=NORMAL`,\n\t\t\t`PRAGMA cache_size=-2000`, // Use 2MB of memory for cache\n\t\t\t`PRAGMA foreign_keys=ON`,\n\t\t}\n\n\t\tfor _, pragma := range pragmas {\n\t\t\tif _, err := conn.ExecContext(ctx, pragma); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to set pragma %s: %w\", pragma, err)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Use a single connection on the writer to avoid database is locked errors\n\tdb.writer.SetMaxOpenConns(1)\n\n\t// Set maximum idle connections for the reader to number of CPUs (maxing at 4)\n\tdb.reader.SetMaxIdleConns(max(4, runtime.NumCPU()))\n\n\treturn nil\n}\n\ntype bookmarkContent struct {\n\tID      int    `db:\"docid\"`\n\tContent string `db:\"content\"`\n\tHTML    string `db:\"html\"`\n}\n\n// DBX returns the underlying sqlx.DB object for writes\nfunc (db *SQLiteDatabase) WriterDB() *sqlx.DB {\n\treturn db.dbbase.WriterDB()\n}\n\n// ReaderDBx returns the underlying sqlx.DB object for reading\nfunc (db *SQLiteDatabase) ReaderDB() *sqlx.DB {\n\treturn db.dbbase.ReaderDB()\n}\n\n// Migrate runs migrations for this database engine\nfunc (db *SQLiteDatabase) Migrate(ctx context.Context) error {\n\tif err := runMigrations(ctx, db, sqliteMigrations); err != nil {\n\t\treturn fmt.Errorf(\"failed to run migrations: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// GetDatabaseSchemaVersion fetches the current migrations version of the database\nfunc (db *SQLiteDatabase) GetDatabaseSchemaVersion(ctx context.Context) (string, error) {\n\tvar version string\n\n\terr := db.reader.GetContext(ctx, &version, \"SELECT database_schema_version FROM shiori_system\")\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to get database schema version: %w\", err)\n\t}\n\n\treturn version, nil\n}\n\n// SetDatabaseSchemaVersion sets the current migrations version of the database\nfunc (db *SQLiteDatabase) SetDatabaseSchemaVersion(ctx context.Context, version string) error {\n\tif err := db.withTxRetry(ctx, func(tx *sqlx.Tx) error {\n\t\t_, err := tx.ExecContext(ctx, \"UPDATE shiori_system SET database_schema_version = ?\", version)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to set database schema version: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// SaveBookmarks saves new or updated bookmarks to database.\n// Returns the saved ID and error message if any happened.\nfunc (db *SQLiteDatabase) SaveBookmarks(ctx context.Context, create bool, bookmarks ...model.BookmarkDTO) ([]model.BookmarkDTO, error) {\n\tvar result []model.BookmarkDTO\n\n\tif err := db.withTxRetry(ctx, func(tx *sqlx.Tx) error {\n\t\t// Prepare statement\n\n\t\tstmtInsertBook, err := tx.PreparexContext(ctx, `INSERT INTO bookmark\n\t\t\t(url, title, excerpt, author, public, modified_at, has_content, created_at)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to prepare insert book statement: %w\", err)\n\t\t}\n\n\t\tstmtUpdateBook, err := tx.PreparexContext(ctx, `UPDATE bookmark SET\n\t\t\turl = ?, title = ?,\texcerpt = ?, author = ?,\n\t\t\tpublic = ?, modified_at = ?, has_content = ?\n\t\t\tWHERE id = ?`)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to prepare update book statement: %w\", err)\n\t\t}\n\n\t\tstmtInsertBookContent, err := tx.PreparexContext(ctx, `INSERT OR REPLACE INTO bookmark_content\n\t\t\t(docid, title, content, html)\n\t\t\tVALUES (?, ?, ?, ?)`)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to prepare insert book content statement: %w\", err)\n\t\t}\n\n\t\tstmtUpdateBookContent, err := tx.PreparexContext(ctx, `UPDATE bookmark_content SET\n\t\t\ttitle = ?, content = ?, html = ?\n\t\t\tWHERE docid = ?`)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to prepare update book content statement: %w\", err)\n\t\t}\n\n\t\tstmtGetTag, err := tx.PreparexContext(ctx, `SELECT id FROM tag WHERE name = ?`)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to prepare get tag statement: %w\", err)\n\t\t}\n\n\t\tstmtInsertTag, err := tx.PreparexContext(ctx, `INSERT INTO tag (name) VALUES (?)`)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to prepare insert tag statement: %w\", err)\n\t\t}\n\n\t\tstmtInsertBookTag, err := tx.PreparexContext(ctx, `INSERT OR IGNORE INTO bookmark_tag\n\t\t\t(tag_id, bookmark_id) VALUES (?, ?)`)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to prepare insert book tag statement: %w\", err)\n\t\t}\n\n\t\tstmtDeleteBookTag, err := tx.PreparexContext(ctx, `DELETE FROM bookmark_tag\n\t\t\tWHERE bookmark_id = ? AND tag_id = ?`)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to execute delete statement: %w\", err)\n\t\t}\n\n\t\t// Prepare modified time\n\t\tmodifiedTime := time.Now().UTC().Format(model.DatabaseDateFormat)\n\n\t\t// Execute statements\n\n\t\tfor _, book := range bookmarks {\n\t\t\t// Check URL and title\n\t\t\tif book.URL == \"\" {\n\t\t\t\treturn errors.New(\"URL must not be empty\")\n\t\t\t}\n\n\t\t\tif book.Title == \"\" {\n\t\t\t\treturn errors.New(\"title must not be empty\")\n\t\t\t}\n\n\t\t\t// Set modified time\n\t\t\tif book.ModifiedAt == \"\" {\n\t\t\t\tbook.ModifiedAt = modifiedTime\n\t\t\t}\n\n\t\t\thasContent := book.Content != \"\"\n\n\t\t\t// Create or update bookmark\n\t\t\tvar err error\n\t\t\tif create {\n\t\t\t\tbook.CreatedAt = modifiedTime\n\t\t\t\terr = stmtInsertBook.QueryRowContext(ctx,\n\t\t\t\t\tbook.URL, book.Title, book.Excerpt, book.Author, book.Public, book.ModifiedAt, hasContent, book.CreatedAt).Scan(&book.ID)\n\t\t\t} else {\n\t\t\t\t_, err = stmtUpdateBook.ExecContext(ctx,\n\t\t\t\t\tbook.URL, book.Title, book.Excerpt, book.Author, book.Public, book.ModifiedAt, hasContent, book.ID)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to delete bookmark content: %w\", err)\n\t\t\t}\n\n\t\t\t// Try to update it first to check for existence, we can't do an UPSERT here because\n\t\t\t// bookmant_content is a virtual table\n\t\t\tres, err := stmtUpdateBookContent.ExecContext(ctx, book.Title, book.Content, book.HTML, book.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to delete bookmark tag: %w\", err)\n\t\t\t}\n\n\t\t\trows, err := res.RowsAffected()\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to delete bookmark: %w\", err)\n\t\t\t}\n\n\t\t\tif rows == 0 {\n\t\t\t\t_, err = stmtInsertBookContent.ExecContext(ctx, book.ID, book.Title, book.Content, book.HTML)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to execute delete bookmark tag statement: %w\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Save book tags\n\t\t\tnewTags := []model.TagDTO{}\n\t\t\tfor _, tag := range book.Tags {\n\t\t\t\tt := tag.ToDTO()\n\t\t\t\t// If it's deleted tag, delete and continue\n\t\t\t\tif t.Deleted {\n\t\t\t\t\t_, err = stmtDeleteBookTag.ExecContext(ctx, book.ID, tag.ID)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"failed to execute delete bookmark statement: %w\", err)\n\t\t\t\t\t}\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// Normalize tag name\n\t\t\t\ttagName := strings.ToLower(tag.Name)\n\t\t\t\ttagName = strings.Join(strings.Fields(tagName), \" \")\n\n\t\t\t\t// If tag doesn't have any ID, fetch it from database\n\t\t\t\tif tag.ID == 0 {\n\t\t\t\t\tif err := stmtGetTag.GetContext(ctx, &tag.ID, tagName); err != nil && err != sql.ErrNoRows {\n\t\t\t\t\t\treturn fmt.Errorf(\"failed to get tag ID: %w\", err)\n\t\t\t\t\t}\n\n\t\t\t\t\t// If tag doesn't exist in database, save it\n\t\t\t\t\tif tag.ID == 0 {\n\t\t\t\t\t\tres, err := stmtInsertTag.ExecContext(ctx, tagName)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\treturn fmt.Errorf(\"failed to get last insert ID for tag: %w\", err)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\ttagID64, err := res.LastInsertId()\n\t\t\t\t\t\tif err != nil && err != sql.ErrNoRows {\n\t\t\t\t\t\t\treturn fmt.Errorf(\"failed to insert bookmark tag: %w\", err)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\ttag.ID = int(tagID64)\n\t\t\t\t\t\tt.ID = int(tagID64)\n\t\t\t\t\t}\n\n\t\t\t\t\tif _, err := stmtInsertBookTag.ExecContext(ctx, tag.ID, book.ID); err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"failed to execute bookmark tag statement: %w\", err)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tnewTags = append(newTags, t)\n\t\t\t}\n\n\t\t\tbook.Tags = newTags\n\t\t\tresult = append(result, book)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to execute select query for bookmark content: %w\", err)\n\t}\n\n\treturn result, nil\n}\n\n// GetBookmarks fetch list of bookmarks based on submitted options.\nfunc (db *SQLiteDatabase) GetBookmarks(ctx context.Context, opts model.DBGetBookmarksOptions) ([]model.BookmarkDTO, error) {\n\t// Create initial query\n\tquery := `SELECT\n\t\tb.id,\n\t\tb.url,\n\t\tb.title,\n\t\tb.excerpt,\n\t\tb.author,\n\t\tb.public,\n\t\tb.created_at,\n\t\tb.modified_at,\n\t\tb.has_content\n\t\tFROM bookmark b\n\t\tWHERE 1`\n\n\t// Add where clause\n\targs := []interface{}{}\n\n\t// Add where clause for IDs\n\tif len(opts.IDs) > 0 {\n\t\tquery += ` AND b.id IN (?)`\n\t\targs = append(args, opts.IDs)\n\t}\n\n\t// Add where clause for search keyword\n\tif opts.Keyword != \"\" {\n\t\tquery += ` AND (b.url LIKE '%' || ? || '%' OR b.excerpt LIKE '%' || ? || '%' OR b.id IN (\n\t\t\tSELECT docid id\n\t\t\tFROM bookmark_content\n\t\t\tWHERE title MATCH ? OR content MATCH ?))`\n\n\t\targs = append(args, opts.Keyword, opts.Keyword)\n\n\t\t// Replace dash with spaces since FTS5 uses `-name` as column identifier and double quote\n\t\t// since FTS5 uses double quote as string identifier\n\t\t// Reference: https://sqlite.org/fts5.html#fts5_strings\n\t\tftsKeyword := strings.ReplaceAll(opts.Keyword, \"-\", \" \")\n\n\t\t// Properly set double quotes for string literals in sqlite's fts\n\t\tftsKeyword = strings.ReplaceAll(ftsKeyword, \"\\\"\", \"\\\"\\\"\")\n\n\t\targs = append(args, \"\\\"\"+ftsKeyword+\"\\\"\", \"\\\"\"+ftsKeyword+\"\\\"\")\n\t}\n\n\t// Add where clause for tags.\n\t// First we check for * in excluded and included tags,\n\t// which means all tags will be excluded and included, respectively.\n\texcludeAllTags := false\n\tif slices.Contains(opts.ExcludedTags, \"*\") {\n\t\texcludeAllTags = true\n\t\topts.ExcludedTags = []string{}\n\t}\n\n\tincludeAllTags := false\n\tif slices.Contains(opts.Tags, \"*\") {\n\t\tincludeAllTags = true\n\t\topts.Tags = []string{}\n\t}\n\n\t// If all tags excluded, we will only show bookmark without tags.\n\t// In other hand, if all tags included, we will only show bookmark with tags.\n\tif excludeAllTags {\n\t\tquery += ` AND b.id NOT IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`\n\t} else if includeAllTags {\n\t\tquery += ` AND b.id IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`\n\t}\n\n\t// Now we only need to find the normal tags\n\tif len(opts.Tags) > 0 {\n\t\tquery += ` AND b.id IN (\n\t\t\tSELECT bt.bookmark_id\n\t\t\tFROM bookmark_tag bt\n\t\t\tLEFT JOIN tag t ON bt.tag_id = t.id\n\t\t\tWHERE t.name IN(?)\n\t\t\tGROUP BY bt.bookmark_id\n\t\t\tHAVING COUNT(bt.bookmark_id) = ?)`\n\n\t\targs = append(args, opts.Tags, len(opts.Tags))\n\t}\n\n\tif len(opts.ExcludedTags) > 0 {\n\t\tquery += ` AND b.id NOT IN (\n\t\t\tSELECT DISTINCT bt.bookmark_id\n\t\t\tFROM bookmark_tag bt\n\t\t\tLEFT JOIN tag t ON bt.tag_id = t.id\n\t\t\tWHERE t.name IN(?))`\n\n\t\targs = append(args, opts.ExcludedTags)\n\t}\n\n\t// Add order clause\n\tswitch opts.OrderMethod {\n\tcase model.ByLastAdded:\n\t\tquery += ` ORDER BY b.id DESC`\n\tcase model.ByLastModified:\n\t\tquery += ` ORDER BY b.modified_at DESC`\n\tdefault:\n\t\tquery += ` ORDER BY b.id`\n\t}\n\n\tif opts.Limit > 0 && opts.Offset >= 0 {\n\t\tquery += ` LIMIT ? OFFSET ?`\n\t\targs = append(args, opts.Limit, opts.Offset)\n\t}\n\n\t// Expand query, because some of the args might be an array\n\tquery, args, err := sqlx.In(query, args...)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to execute select query for tags: %w\", err)\n\t}\n\n\t// Fetch bookmarks\n\tbookmarks := []model.BookmarkDTO{}\n\terr = db.reader.SelectContext(ctx, &bookmarks, query, args...)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn nil, fmt.Errorf(\"failed to fetch accounts: %w\", err)\n\t}\n\n\t// store bookmark IDs for further enrichment\n\tvar bookmarkIds = make([]int, 0, len(bookmarks))\n\tfor _, book := range bookmarks {\n\t\tbookmarkIds = append(bookmarkIds, book.ID)\n\t}\n\n\tif len(bookmarkIds) == 0 {\n\t\treturn bookmarks, nil\n\t}\n\n\t// If content needed, fetch it separately\n\t// It's faster than join with virtual table\n\tif opts.WithContent {\n\t\tcontents := make([]bookmarkContent, 0, len(bookmarks))\n\t\tcontentMap := make(map[int]bookmarkContent, len(bookmarks))\n\n\t\tcontentQuery, args, err := sqlx.In(`SELECT docid, content, html FROM bookmark_content WHERE docid IN (?)`, bookmarkIds)\n\t\tcontentQuery = db.reader.Rebind(contentQuery)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to expand tags query with IN clause: %w\", err)\n\t\t}\n\n\t\terr = db.reader.Select(&contents, contentQuery, args...)\n\t\tif err != nil && err != sql.ErrNoRows {\n\t\t\treturn nil, fmt.Errorf(\"failed to get tags: %w\", err)\n\t\t}\n\t\tfor _, content := range contents {\n\t\t\tcontentMap[content.ID] = content\n\t\t}\n\t\tfor i := range bookmarks[:] {\n\t\t\tbook := &bookmarks[i]\n\t\t\tif bookmarkContent, found := contentMap[book.ID]; found {\n\t\t\t\tbook.Content = bookmarkContent.Content\n\t\t\t\tbook.HTML = bookmarkContent.HTML\n\t\t\t} else {\n\t\t\t\tlog.Printf(\"not found content for bookmark %d, but it should be; check DB consistency\", book.ID)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Fetch tags for each bookmark\n\tfor i, book := range bookmarks {\n\t\ttags, err := db.getTagsForBookmark(ctx, book.ID)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to get tags: %w\", err)\n\t\t}\n\t\tbookmarks[i].Tags = tags\n\t}\n\n\treturn bookmarks, nil\n}\n\nfunc (db *SQLiteDatabase) getTagsForBookmark(ctx context.Context, bookmarkID int) ([]model.TagDTO, error) {\n\tsb := sqlbuilder.SQLite.NewSelectBuilder()\n\tsb.Select(\"t.id\", \"t.name\")\n\tsb.From(\"bookmark_tag bt\")\n\tsb.JoinWithOption(sqlbuilder.LeftJoin, \"tag t\", \"bt.tag_id = t.id\")\n\tsb.Where(sb.Equal(\"bt.bookmark_id\", bookmarkID))\n\tsb.OrderBy(\"t.name\")\n\n\tquery, args := sb.Build()\n\tquery = db.ReaderDB().Rebind(query)\n\n\ttags := []model.TagDTO{}\n\terr := db.ReaderDB().SelectContext(ctx, &tags, query, args...)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn nil, fmt.Errorf(\"failed to get tags: %w\", err)\n\t}\n\n\treturn tags, nil\n}\n\n// GetBookmarksCount fetch count of bookmarks based on submitted options.\nfunc (db *SQLiteDatabase) GetBookmarksCount(ctx context.Context, opts model.DBGetBookmarksOptions) (int, error) {\n\t// Create initial query\n\tquery := `SELECT COUNT(b.id)\n\t\tFROM bookmark b\n\t\tWHERE 1`\n\n\t// Add where clause\n\targs := []interface{}{}\n\n\t// Add where clause for IDs\n\tif len(opts.IDs) > 0 {\n\t\tquery += ` AND b.id IN (?)`\n\t\targs = append(args, opts.IDs)\n\t}\n\n\t// Add where clause for search keyword\n\tif opts.Keyword != \"\" {\n\t\tquery += ` AND (b.url LIKE '%' || ? || '%' OR b.excerpt LIKE '%' || ? || '%' OR b.id IN (\n\t\t\tSELECT docid id\n\t\t\tFROM bookmark_content\n\t\t\tWHERE title MATCH ? OR content MATCH ?))`\n\n\t\targs = append(args, opts.Keyword, opts.Keyword)\n\n\t\t// Replace dash with spaces since FTS5 uses `-name` as column identifier and double quote\n\t\t// since FTS5 uses double quote as string identifier\n\t\t// Reference: https://sqlite.org/fts5.html#fts5_strings\n\t\tftsKeyword := strings.ReplaceAll(opts.Keyword, \"-\", \" \")\n\n\t\t// Properly set double quotes for string literals in sqlite's fts\n\t\tftsKeyword = strings.ReplaceAll(ftsKeyword, \"\\\"\", \"\\\"\\\"\")\n\n\t\targs = append(args, \"\\\"\"+ftsKeyword+\"\\\"\", \"\\\"\"+ftsKeyword+\"\\\"\")\n\t}\n\n\t// Add where clause for tags.\n\t// First we check for * in excluded and included tags,\n\t// which means all tags will be excluded and included, respectively.\n\texcludeAllTags := false\n\tfor _, excludedTag := range opts.ExcludedTags {\n\t\tif excludedTag == \"*\" {\n\t\t\texcludeAllTags = true\n\t\t\topts.ExcludedTags = []string{}\n\t\t\tbreak\n\t\t}\n\t}\n\n\tincludeAllTags := false\n\tfor _, includedTag := range opts.Tags {\n\t\tif includedTag == \"*\" {\n\t\t\tincludeAllTags = true\n\t\t\topts.Tags = []string{}\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// If all tags excluded, we will only show bookmark without tags.\n\t// In other hand, if all tags included, we will only show bookmark with tags.\n\tif excludeAllTags {\n\t\tquery += ` AND b.id NOT IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`\n\t} else if includeAllTags {\n\t\tquery += ` AND b.id IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`\n\t}\n\n\t// Now we only need to find the normal tags\n\tif len(opts.Tags) > 0 {\n\t\tquery += ` AND b.id IN (\n\t\t\tSELECT bt.bookmark_id\n\t\t\tFROM bookmark_tag bt\n\t\t\tLEFT JOIN tag t ON bt.tag_id = t.id\n\t\t\tWHERE t.name IN(?)\n\t\t\tGROUP BY bt.bookmark_id\n\t\t\tHAVING COUNT(bt.bookmark_id) = ?)`\n\n\t\targs = append(args, opts.Tags, len(opts.Tags))\n\t}\n\n\tif len(opts.ExcludedTags) > 0 {\n\t\tquery += ` AND b.id NOT IN (\n\t\t\tSELECT DISTINCT bt.bookmark_id\n\t\t\tFROM bookmark_tag bt\n\t\t\tLEFT JOIN tag t ON bt.tag_id = t.id\n\t\t\tWHERE t.name IN(?))`\n\n\t\targs = append(args, opts.ExcludedTags)\n\t}\n\n\t// Expand query, because some of the args might be an array\n\tquery, args, err := sqlx.In(query, args...)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to expand query with IN clause: %w\", err)\n\t}\n\n\t// Fetch count\n\tvar nBookmarks int\n\terr = db.reader.GetContext(ctx, &nBookmarks, query, args...)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn 0, fmt.Errorf(\"failed to get bookmark count: %w\", err)\n\t}\n\n\treturn nBookmarks, nil\n}\n\n// DeleteBookmarks removes all record with matching ids from database.\nfunc (db *SQLiteDatabase) DeleteBookmarks(ctx context.Context, ids ...int) error {\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Prepare queries\n\t\tdelBookmark := `DELETE FROM bookmark`\n\t\tdelBookmarkTag := `DELETE FROM bookmark_tag`\n\t\tdelBookmarkContent := `DELETE FROM bookmark_content`\n\n\t\t// Delete bookmark(s)\n\t\tif len(ids) == 0 {\n\t\t\t_, err := tx.ExecContext(ctx, delBookmarkContent)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to prepare delete statement: %w\", err)\n\t\t\t}\n\n\t\t\t_, err = tx.ExecContext(ctx, delBookmarkTag)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to execute delete account statement: %w\", err)\n\t\t\t}\n\n\t\t\t_, err = tx.ExecContext(ctx, delBookmark)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to execute delete bookmark statement: %w\", err)\n\t\t\t}\n\t\t} else {\n\t\t\tdelBookmark += ` WHERE id = ?`\n\t\t\tdelBookmarkTag += ` WHERE bookmark_id = ?`\n\t\t\tdelBookmarkContent += ` WHERE docid = ?`\n\n\t\t\tstmtDelBookmark, err := tx.Preparex(delBookmark)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to get bookmark: %w\", err)\n\t\t\t}\n\n\t\t\tstmtDelBookmarkTag, err := tx.Preparex(delBookmarkTag)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to expand query with IN clause: %w\", err)\n\t\t\t}\n\n\t\t\tstmtDelBookmarkContent, err := tx.Preparex(delBookmarkContent)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to delete bookmark content: %w\", err)\n\t\t\t}\n\n\t\t\tfor _, id := range ids {\n\t\t\t\t_, err = stmtDelBookmarkContent.ExecContext(ctx, id)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to delete bookmark: %w\", err)\n\t\t\t\t}\n\n\t\t\t\t_, err = stmtDelBookmarkTag.ExecContext(ctx, id)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to delete bookmark tag: %w\", err)\n\t\t\t\t}\n\n\t\t\t\t_, err = stmtDelBookmark.ExecContext(ctx, id)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to delete bookmark: %w\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"failed to update database schema version: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// GetBookmark fetches bookmark based on its ID or URL.\n// Returns the bookmark and boolean whether it's exist or not.\nfunc (db *SQLiteDatabase) GetBookmark(ctx context.Context, id int, url string) (model.BookmarkDTO, bool, error) {\n\t// Create the main query builder for bookmark data\n\tsb := sqlbuilder.NewSelectBuilder()\n\tsb.Select(\n\t\t\"b.id\", \"b.url\", \"b.title\", \"b.excerpt\", \"b.author\", \"b.public\", \"b.modified_at\",\n\t\t\"bc.content\", \"bc.html\", \"b.has_content\", \"b.created_at\")\n\tsb.From(\"bookmark b\")\n\tsb.JoinWithOption(sqlbuilder.LeftJoin, \"bookmark_content bc\", \"bc.docid = b.id\")\n\n\t// Add conditions\n\tif id != 0 {\n\t\tsb.Where(sb.Equal(\"b.id\", id))\n\t} else if url != \"\" {\n\t\tsb.Where(sb.Equal(\"b.url\", url))\n\t} else {\n\t\treturn model.BookmarkDTO{}, false, fmt.Errorf(\"id or url is required\")\n\t}\n\n\t// Build the query\n\tquery, args := sb.Build()\n\tquery = db.ReaderDB().Rebind(query)\n\n\t// Execute the query\n\tbook := model.BookmarkDTO{}\n\terr := db.ReaderDB().GetContext(ctx, &book, query, args...)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn book, false, nil\n\t\t}\n\t\treturn book, false, fmt.Errorf(\"failed to get bookmark: %w\", err)\n\t}\n\n\t// If bookmark exists, fetch its tags\n\tif book.ID != 0 {\n\t\t// Create query builder for tags\n\t\ttagSb := sqlbuilder.NewSelectBuilder()\n\t\ttagSb.Select(\"t.id\", \"t.name\")\n\t\ttagSb.From(\"tag t\")\n\t\ttagSb.JoinWithOption(sqlbuilder.InnerJoin, \"bookmark_tag bt\", \"bt.tag_id = t.id\")\n\t\ttagSb.Where(tagSb.Equal(\"bt.bookmark_id\", book.ID))\n\n\t\t// Build the query\n\t\ttagQuery, tagArgs := tagSb.Build()\n\t\ttagQuery = db.ReaderDB().Rebind(tagQuery)\n\n\t\t// Execute the query\n\t\ttags := []model.TagDTO{}\n\t\terr = db.ReaderDB().SelectContext(ctx, &tags, tagQuery, tagArgs...)\n\t\tif err != nil && err != sql.ErrNoRows {\n\t\t\treturn book, false, fmt.Errorf(\"failed to get bookmark tags: %w\", err)\n\t\t}\n\n\t\tbook.Tags = tags\n\t}\n\n\treturn book, true, nil\n}\n\n// CreateAccount saves new account to database. Returns error if any happened.\nfunc (db *SQLiteDatabase) CreateAccount(ctx context.Context, account model.Account) (*model.Account, error) {\n\tvar accountID int64\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Check if username already exists\n\t\tvar exists bool\n\t\terr := tx.GetContext(ctx, &exists,\n\t\t\t\"SELECT EXISTS(SELECT 1 FROM account WHERE username = ?)\",\n\t\t\taccount.Username)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error checking username existence: %w\", err)\n\t\t}\n\t\tif exists {\n\t\t\treturn ErrAlreadyExists\n\t\t}\n\n\t\t// Insert new account\n\t\tquery, err := tx.PrepareContext(ctx, `INSERT INTO account\n\t\t\t(username, password, owner, config) VALUES (?, ?, ?, ?)\n\t\t\tRETURNING id`)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error preparing query: %w\", err)\n\t\t}\n\n\t\terr = query.QueryRowContext(ctx,\n\t\t\taccount.Username, account.Password, account.Owner, account.Config).Scan(&accountID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error executing query: %w\", err)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"error running transaction: %w\", err)\n\t}\n\n\taccount.ID = model.DBID(accountID)\n\treturn &account, nil\n}\n\n// UpdateAccount updates account in database.\nfunc (db *SQLiteDatabase) UpdateAccount(ctx context.Context, account model.Account) error {\n\tif account.ID == 0 {\n\t\treturn ErrNotFound\n\t}\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Check if username already exists\n\t\tvar exists bool\n\t\terr := tx.GetContext(ctx, &exists,\n\t\t\t\"SELECT EXISTS(SELECT 1 FROM account WHERE username = ? AND id != ?)\",\n\t\t\taccount.Username, account.ID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error checking username existence: %w\", err)\n\t\t}\n\t\tif exists {\n\t\t\treturn ErrAlreadyExists\n\t\t}\n\n\t\tresult, err := tx.ExecContext(ctx, `UPDATE account\n\t\t\tSET username = ?, password = ?, owner = ?, config = ?\n\t\t\tWHERE id = ?`,\n\t\t\taccount.Username, account.Password, account.Owner, account.Config, account.ID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error updating account: %w\", err)\n\t\t}\n\n\t\trows, err := result.RowsAffected()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error getting rows affected: %w\", err)\n\t\t}\n\t\tif rows == 0 {\n\t\t\treturn ErrNotFound\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"error running transaction: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// ListAccounts fetch list of account (without its password) based on submitted options.\nfunc (db *SQLiteDatabase) ListAccounts(ctx context.Context, opts model.DBListAccountsOptions) ([]model.Account, error) {\n\t// Create query\n\targs := []interface{}{}\n\tfields := []string{\"id\", \"username\", \"owner\", \"config\"}\n\tif opts.WithPassword {\n\t\tfields = append(fields, \"password\")\n\t}\n\n\tquery := fmt.Sprintf(`SELECT %s FROM account WHERE 1`, strings.Join(fields, \", \"))\n\n\tif opts.Keyword != \"\" {\n\t\tquery += \" AND username LIKE ?\"\n\t\targs = append(args, \"%\"+opts.Keyword+\"%\")\n\t}\n\n\tif opts.Username != \"\" {\n\t\tquery += \" AND username = ?\"\n\t\targs = append(args, opts.Username)\n\t}\n\n\tif opts.Owner {\n\t\tquery += \" AND owner = 1\"\n\t}\n\n\t// Fetch list account\n\taccounts := []model.Account{}\n\terr := db.reader.SelectContext(ctx, &accounts, query, args...)\n\tif err != nil && err != sql.ErrNoRows {\n\t\treturn nil, fmt.Errorf(\"failed to execute select query: %w\", err)\n\t}\n\n\treturn accounts, nil\n}\n\n// GetAccount fetch account with matching ID.\n// Returns the account and boolean whether it's exist or not.\nfunc (db *SQLiteDatabase) GetAccount(ctx context.Context, id model.DBID) (*model.Account, bool, error) {\n\taccount := model.Account{}\n\terr := db.ReaderDB().GetContext(ctx, &account, `SELECT\n\t\tid, username, password, owner, config FROM account WHERE id = ?`,\n\t\tid,\n\t)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn &account, false, ErrNotFound\n\t\t}\n\t\treturn &account, false, fmt.Errorf(\"error getting account: %w\", err)\n\t}\n\n\treturn &account, true, nil\n}\n\n// DeleteAccount removes record with matching ID.\nfunc (db *SQLiteDatabase) DeleteAccount(ctx context.Context, id model.DBID) error {\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\tresult, err := tx.ExecContext(ctx, `DELETE FROM account WHERE id = ?`, id)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error deleting account: %w\", err)\n\t\t}\n\n\t\trows, err := result.RowsAffected()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error getting rows affected: %w\", err)\n\t\t}\n\n\t\tif rows == 0 {\n\t\t\treturn ErrNotFound\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"error running transaction: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// CreateTags creates new tags from submitted objects.\nfunc (db *SQLiteDatabase) CreateTags(ctx context.Context, tags ...model.Tag) ([]model.Tag, error) {\n\tif len(tags) == 0 {\n\t\treturn []model.Tag{}, nil\n\t}\n\n\t// Create a slice to hold the created tags\n\tcreatedTags := make([]model.Tag, len(tags))\n\tcopy(createdTags, tags)\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// For SQLite, we need to insert tags one by one to get their IDs\n\t\tstmtInsertTag, err := tx.PrepareContext(ctx, \"INSERT INTO tag (name) VALUES (?)\")\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to prepare tag insertion statement: %w\", err)\n\t\t}\n\t\tdefer stmtInsertTag.Close()\n\n\t\t// Insert each tag and get its ID\n\t\tfor i, tag := range createdTags {\n\t\t\tresult, err := stmtInsertTag.ExecContext(ctx, tag.Name)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to insert tag: %w\", err)\n\t\t\t}\n\n\t\t\t// Get the last inserted ID\n\t\t\ttagID, err := result.LastInsertId()\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to get last insert ID: %w\", err)\n\t\t\t}\n\n\t\t\tcreatedTags[i].ID = int(tagID)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to run tag creation transaction: %w\", err)\n\t}\n\n\treturn createdTags, nil\n}\n\n// CreateTag creates a new tag in database.\nfunc (db *SQLiteDatabase) CreateTag(ctx context.Context, tag model.Tag) (model.Tag, error) {\n\t// Use CreateTags to implement this method\n\tcreatedTags, err := db.CreateTags(ctx, tag)\n\tif err != nil {\n\t\treturn model.Tag{}, err\n\t}\n\n\tif len(createdTags) == 0 {\n\t\treturn model.Tag{}, fmt.Errorf(\"failed to create tag\")\n\t}\n\n\treturn createdTags[0], nil\n}\n\n// RenameTag change the name of a tag.\nfunc (db *SQLiteDatabase) RenameTag(ctx context.Context, id int, newName string) error {\n\tsb := sqlbuilder.NewUpdateBuilder()\n\tsb.Update(\"tag\")\n\tsb.Set(sb.Assign(\"name\", newName))\n\tsb.Where(sb.Equal(\"id\", id))\n\n\tquery, args := sb.Build()\n\tquery = db.WriterDB().Rebind(query)\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t_, err := tx.ExecContext(ctx, query, args...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to rename tag: %w\", err)\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// GetTag fetch a tag by its ID.\nfunc (db *SQLiteDatabase) GetTag(ctx context.Context, id int) (model.TagDTO, bool, error) {\n\tsb := sqlbuilder.SQLite.NewSelectBuilder()\n\tsb.Select(\"t.id\", \"t.name\", \"COUNT(bt.tag_id) bookmark_count\")\n\tsb.From(\"tag t\")\n\tsb.JoinWithOption(sqlbuilder.LeftJoin, \"bookmark_tag bt\", \"bt.tag_id = t.id\")\n\tsb.Where(sb.Equal(\"t.id\", id))\n\tsb.GroupBy(\"t.id\")\n\tsb.OrderBy(\"t.name\")\n\n\tquery, args := sb.Build()\n\tquery = db.ReaderDB().Rebind(query)\n\n\tvar tag model.TagDTO\n\terr := db.ReaderDB().GetContext(ctx, &tag, query, args...)\n\tif err == sql.ErrNoRows {\n\t\treturn model.TagDTO{}, false, nil\n\t}\n\tif err != nil {\n\t\treturn model.TagDTO{}, false, fmt.Errorf(\"failed to get tag: %w\", err)\n\t}\n\n\treturn tag, true, nil\n}\n\n// UpdateTag updates a tag in the database.\nfunc (db *SQLiteDatabase) UpdateTag(ctx context.Context, tag model.Tag) error {\n\tsb := sqlbuilder.NewUpdateBuilder()\n\tsb.Update(\"tag\")\n\tsb.Set(sb.Assign(\"name\", tag.Name))\n\tsb.Where(sb.Equal(\"id\", tag.ID))\n\n\tquery, args := sb.Build()\n\tquery = db.WriterDB().Rebind(query)\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t_, err := tx.ExecContext(ctx, query, args...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to update tag: %w\", err)\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// DeleteTag removes a tag from the database.\nfunc (db *SQLiteDatabase) DeleteTag(ctx context.Context, id int) error {\n\t// First, check if the tag exists\n\t_, exists, err := db.GetTag(ctx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to check if tag exists: %w\", err)\n\t}\n\tif !exists {\n\t\treturn ErrNotFound\n\t}\n\n\t// Delete all bookmark_tag associations\n\tdeleteAssocSb := sqlbuilder.NewDeleteBuilder()\n\tdeleteAssocSb.DeleteFrom(\"bookmark_tag\")\n\tdeleteAssocSb.Where(deleteAssocSb.Equal(\"tag_id\", id))\n\n\tdeleteAssocQuery, deleteAssocArgs := deleteAssocSb.Build()\n\tdeleteAssocQuery = db.WriterDB().Rebind(deleteAssocQuery)\n\n\t// Then, delete the tag itself\n\tdeleteTagSb := sqlbuilder.NewDeleteBuilder()\n\tdeleteTagSb.DeleteFrom(\"tag\")\n\tdeleteTagSb.Where(deleteTagSb.Equal(\"id\", id))\n\n\tdeleteTagQuery, deleteTagArgs := deleteTagSb.Build()\n\tdeleteTagQuery = db.WriterDB().Rebind(deleteTagQuery)\n\n\tif err := db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Delete bookmark_tag associations\n\t\t_, err := tx.ExecContext(ctx, deleteAssocQuery, deleteAssocArgs...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to delete tag associations: %w\", err)\n\t\t}\n\n\t\t// Delete the tag\n\t\t_, err = tx.ExecContext(ctx, deleteTagQuery, deleteTagArgs...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to delete tag: %w\", err)\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// SaveBookmark saves a single bookmark to database without handling tags.\n// It only updates the bookmark data in the database.\nfunc (db *SQLiteDatabase) SaveBookmark(ctx context.Context, bookmark model.Bookmark) error {\n\tif bookmark.ID <= 0 {\n\t\treturn fmt.Errorf(\"bookmark ID must be greater than 0\")\n\t}\n\n\tbookmark.ModifiedAt = time.Now().UTC().Format(model.DatabaseDateFormat)\n\n\t// Check URL and title\n\tif bookmark.URL == \"\" {\n\t\treturn errors.New(\"URL must not be empty\")\n\t}\n\n\tif bookmark.Title == \"\" {\n\t\treturn errors.New(\"title must not be empty\")\n\t}\n\n\t// Use sqlbuilder to build the update query\n\tsb := sqlbuilder.NewUpdateBuilder()\n\tsb.Update(\"bookmark\")\n\tsb.Set(\n\t\tsb.Assign(\"url\", bookmark.URL),\n\t\tsb.Assign(\"title\", bookmark.Title),\n\t\tsb.Assign(\"excerpt\", bookmark.Excerpt),\n\t\tsb.Assign(\"author\", bookmark.Author),\n\t\tsb.Assign(\"public\", bookmark.Public),\n\t\tsb.Assign(\"modified_at\", bookmark.ModifiedAt),\n\t\tsb.Assign(\"has_content\", bookmark.HasContent),\n\t)\n\tsb.Where(sb.Equal(\"id\", bookmark.ID))\n\n\tquery, args := sb.Build()\n\tquery = db.WriterDB().Rebind(query)\n\n\treturn db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Update bookmark\n\t\t_, err := tx.ExecContext(ctx, query, args...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to update bookmark: %w\", err)\n\t\t}\n\n\t\treturn nil\n\t})\n}\n\n// BulkUpdateBookmarkTags updates tags for multiple bookmarks.\n// It ensures that all bookmarks and tags exist before proceeding.\nfunc (db *SQLiteDatabase) BulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error {\n\tif len(bookmarkIDs) == 0 || len(tagIDs) == 0 {\n\t\treturn nil\n\t}\n\n\t// Convert int slices to any slices for sqlbuilder\n\tbookmarkIDsIface := make([]any, len(bookmarkIDs))\n\tfor i, id := range bookmarkIDs {\n\t\tbookmarkIDsIface[i] = id\n\t}\n\n\t// Verify all bookmarks exist\n\tbookmarkSb := sqlbuilder.NewSelectBuilder()\n\tbookmarkSb.Select(\"id\")\n\tbookmarkSb.From(\"bookmark\")\n\tbookmarkSb.Where(bookmarkSb.In(\"id\", bookmarkIDsIface...))\n\n\tbookmarkQuery, bookmarkArgs := bookmarkSb.Build()\n\tbookmarkQuery = db.ReaderDB().Rebind(bookmarkQuery)\n\n\tvar existingBookmarkIDs []int\n\terr := db.ReaderDB().SelectContext(ctx, &existingBookmarkIDs, bookmarkQuery, bookmarkArgs...)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to check bookmarks: %w\", err)\n\t}\n\n\tif len(existingBookmarkIDs) != len(bookmarkIDs) {\n\t\t// Find which bookmarks don't exist\n\t\tmissingBookmarkIDs := model.SliceDifference(bookmarkIDs, existingBookmarkIDs)\n\t\treturn fmt.Errorf(\"some bookmarks do not exist: %v\", missingBookmarkIDs)\n\t}\n\n\ttagIDsIface := make([]any, len(tagIDs))\n\tfor i, id := range tagIDs {\n\t\ttagIDsIface[i] = id\n\t}\n\n\t// Verify all tags exist\n\ttagSb := sqlbuilder.NewSelectBuilder()\n\ttagSb.Select(\"id\")\n\ttagSb.From(\"tag\")\n\ttagSb.Where(tagSb.In(\"id\", tagIDsIface...))\n\n\ttagQuery, tagArgs := tagSb.Build()\n\ttagQuery = db.ReaderDB().Rebind(tagQuery)\n\n\tvar existingTagIDs []int\n\terr = db.ReaderDB().SelectContext(ctx, &existingTagIDs, tagQuery, tagArgs...)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to check tags: %w\", err)\n\t}\n\n\tif len(existingTagIDs) != len(tagIDs) {\n\t\t// Find which tags don't exist\n\t\tmissingTagIDs := model.SliceDifference(tagIDs, existingTagIDs)\n\t\treturn fmt.Errorf(\"some tags do not exist: %v\", missingTagIDs)\n\t}\n\n\treturn db.withTx(ctx, func(tx *sqlx.Tx) error {\n\t\t// Delete existing bookmark-tag associations\n\t\tdeleteSb := sqlbuilder.NewDeleteBuilder()\n\t\tdeleteSb.DeleteFrom(\"bookmark_tag\")\n\t\tdeleteSb.Where(deleteSb.In(\"bookmark_id\", bookmarkIDsIface...))\n\n\t\tdeleteQuery, deleteArgs := deleteSb.Build()\n\t\tdeleteQuery = tx.Rebind(deleteQuery)\n\n\t\t_, err := tx.ExecContext(ctx, deleteQuery, deleteArgs...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to delete existing bookmark tags: %w\", err)\n\t\t}\n\n\t\t// Insert new bookmark-tag associations\n\t\tif len(tagIDs) > 0 {\n\t\t\t// Build insert statement for bookmark tags\n\t\t\tinsertSb := sqlbuilder.NewInsertBuilder()\n\t\t\t// SQLite syntax for INSERT OR IGNORE\n\t\t\tinsertSb.SQL(\"INSERT OR IGNORE INTO\")\n\t\t\tinsertSb.SQL(\"bookmark_tag\")\n\t\t\tinsertSb.SQL(\"(bookmark_id, tag_id)\")\n\t\t\tinsertSb.SQL(\"VALUES (?, ?)\")\n\n\t\t\tinsertQuery := insertSb.String()\n\t\t\tinsertQuery = tx.Rebind(insertQuery)\n\n\t\t\tstmtInsertBookTag, err := tx.PreparexContext(ctx, insertQuery)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to prepare insert book tag statement: %w\", err)\n\t\t\t}\n\t\t\tdefer stmtInsertBookTag.Close()\n\n\t\t\t// Insert new tags\n\t\t\tfor _, bookmarkID := range bookmarkIDs {\n\t\t\t\tfor _, tagID := range tagIDs {\n\t\t\t\t\t_, err := stmtInsertBookTag.ExecContext(ctx, bookmarkID, tagID)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"failed to insert bookmark tag: %w\", err)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n}\n"
  },
  {
    "path": "internal/database/sqlite_noncgo.go",
    "content": "//go:build linux || windows || darwin || freebsd\n// +build linux windows darwin freebsd\n\npackage database\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/huandu/go-sqlbuilder\"\n\t\"github.com/jmoiron/sqlx\"\n\n\t_ \"modernc.org/sqlite\"\n)\n\n// OpenSQLiteDatabase creates and open connection to new SQLite3 database.\nfunc OpenSQLiteDatabase(ctx context.Context, databasePath string) (sqliteDB *SQLiteDatabase, err error) {\n\t// Open database\n\trwDB, err := sqlx.ConnectContext(ctx, \"sqlite\", databasePath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error opening writer database: %w\", err)\n\t}\n\n\trDB, err := sqlx.ConnectContext(ctx, \"sqlite\", databasePath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error opening reader database: %w\", err)\n\t}\n\n\tsqliteDB = &SQLiteDatabase{\n\t\tdbbase: dbbase{\n\t\t\twriter: rwDB,\n\t\t\treader: rDB,\n\t\t\tflavor: sqlbuilder.SQLite,\n\t\t},\n\t}\n\n\tif err := sqliteDB.Init(ctx); err != nil {\n\t\treturn nil, fmt.Errorf(\"error initializing database: %w\", err)\n\t}\n\n\treturn sqliteDB, nil\n}\n"
  },
  {
    "path": "internal/database/sqlite_openbsd.go",
    "content": "//go:build openbsd\n// +build openbsd\n\npackage database\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/huandu/go-sqlbuilder\"\n\t\"github.com/jmoiron/sqlx\"\n\n\t_ \"git.sr.ht/~emersion/go-sqlite3-fts5\"\n\t_ \"github.com/mattn/go-sqlite3\"\n)\n\n// OpenSQLiteDatabase creates and open connection to new SQLite3 database.\nfunc OpenSQLiteDatabase(ctx context.Context, databasePath string) (sqliteDB *SQLiteDatabase, err error) {\n\t// Open database\n\trwDB, err := sqlx.ConnectContext(ctx, \"sqlite\", databasePath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error opening writer database: %w\", err)\n\t}\n\n\trDB, err := sqlx.ConnectContext(ctx, \"sqlite\", databasePath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error opening reader database: %w\", err)\n\t}\n\n\tsqliteDB = &SQLiteDatabase{\n\t\tdbbase: dbbase{\n\t\t\twriter: rwDB,\n\t\t\treader: rDB,\n\t\t\tflavor: sqlbuilder.SQLite,\n\t\t},\n\t}\n\n\tif err := sqliteDB.Init(ctx); err != nil {\n\t\treturn nil, fmt.Errorf(\"error initializing database: %w\", err)\n\t}\n\n\treturn sqliteDB, nil\n}\n"
  },
  {
    "path": "internal/database/sqlite_test.go",
    "content": "package database\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc sqliteTestDatabaseFactory(t *testing.T, ctx context.Context) (model.DB, error) {\n\ttmpDir, err := os.MkdirTemp(\"\", \"\")\n\trequire.NoError(t, err)\n\n\tdb, err := OpenSQLiteDatabase(ctx, filepath.Join(tmpDir, \"shiori.db\"))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := db.Migrate(context.TODO()); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn db, nil\n}\n\nfunc TestSqliteDatabase(t *testing.T) {\n\ttestDatabase(t, sqliteTestDatabaseFactory)\n\ttestSqliteGetBookmarksWithDash(t)\n}\n\n// testSqliteGetBookmarksWithDash ad-hoc test for SQLite that checks that a match search against\n// the FTS5 engine does not fail by using dashes, making sqlite think that we are trying to avoid\n// matching a column name. This works in a fun way and it seems that it depends on the tokens\n// already scanned by the database, since trying to match for `go-shiori` with no bookmarks or only\n// the shiori bookmark does not fail, but it fails if we add any other bookmark to the database, hence\n// this test.\nfunc testSqliteGetBookmarksWithDash(t *testing.T) {\n\tctx := context.TODO()\n\n\tdb, err := sqliteTestDatabaseFactory(t, ctx)\n\tassert.NoError(t, err)\n\n\tbook := model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/shiori\",\n\t\tTitle: \"shiori\",\n\t}\n\n\t_, err = db.SaveBookmarks(ctx, true, book)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\n\tbook = model.BookmarkDTO{\n\t\tURL:   \"https://github.com/jamiehannaford/what-happens-when-k8s\",\n\t\tTitle: \"what-happens-when-k8s\",\n\t}\n\n\tresult, err := db.SaveBookmarks(ctx, true, book)\n\tassert.NoError(t, err, \"Save bookmarks must not fail\")\n\tsavedBookmark := result[0]\n\n\tresults, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{\n\t\tKeyword: \"what-happens-when\",\n\t})\n\n\tassert.NoError(t, err, \"Get bookmarks should not fail\")\n\tassert.Len(t, results, 1, \"results should contain one item\")\n\tassert.Equal(t, savedBookmark.ID, results[0].ID, \"bookmark should be the one saved\")\n}\n"
  },
  {
    "path": "internal/dependencies/dependencies.go",
    "content": "package dependencies\n\nimport (\n\t\"github.com/go-shiori/shiori/internal/config\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/sirupsen/logrus\"\n)\n\ntype Dependencies struct {\n\tlog      *logrus.Logger\n\tdomains  *domains\n\tconfig   *config.Config\n\tdatabase model.DB\n}\n\nfunc (d *Dependencies) Logger() *logrus.Logger {\n\treturn d.log\n}\n\nfunc (d *Dependencies) Domains() model.DomainDependencies {\n\treturn d.domains\n}\n\nfunc (d *Dependencies) Config() *config.Config {\n\treturn d.config\n}\n\nfunc (d *Dependencies) Database() model.DB {\n\treturn d.database\n}\n\ntype domains struct {\n\tauth      model.AuthDomain\n\taccounts  model.AccountsDomain\n\tbookmarks model.BookmarksDomain\n\tarchiver  model.ArchiverDomain\n\tstorage   model.StorageDomain\n\ttags      model.TagsDomain\n}\n\nfunc (d *domains) Auth() model.AuthDomain                       { return d.auth }\nfunc (d *domains) SetAuth(auth model.AuthDomain)                { d.auth = auth }\nfunc (d *domains) Accounts() model.AccountsDomain               { return d.accounts }\nfunc (d *domains) SetAccounts(accounts model.AccountsDomain)    { d.accounts = accounts }\nfunc (d *domains) Bookmarks() model.BookmarksDomain             { return d.bookmarks }\nfunc (d *domains) SetBookmarks(bookmarks model.BookmarksDomain) { d.bookmarks = bookmarks }\nfunc (d *domains) Archiver() model.ArchiverDomain               { return d.archiver }\nfunc (d *domains) SetArchiver(archiver model.ArchiverDomain)    { d.archiver = archiver }\nfunc (d *domains) Storage() model.StorageDomain                 { return d.storage }\nfunc (d *domains) SetStorage(storage model.StorageDomain)       { d.storage = storage }\nfunc (d *domains) Tags() model.TagsDomain                       { return d.tags }\nfunc (d *domains) SetTags(tags model.TagsDomain)                { d.tags = tags }\n\nvar _ model.DomainDependencies = (*domains)(nil)\n\nfunc NewDependencies(log *logrus.Logger, db model.DB, cfg *config.Config) *Dependencies {\n\treturn &Dependencies{\n\t\tlog:      log,\n\t\tconfig:   cfg,\n\t\tdatabase: db,\n\t\tdomains:  &domains{},\n\t}\n}\n"
  },
  {
    "path": "internal/domains/accounts.go",
    "content": "package domains\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/go-shiori/shiori/internal/database\"\n\t\"github.com/go-shiori/shiori/internal/dependencies\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\ntype AccountsDomain struct {\n\tdeps *dependencies.Dependencies\n}\n\nfunc (d *AccountsDomain) ListAccounts(ctx context.Context) ([]model.AccountDTO, error) {\n\taccounts, err := d.deps.Database().ListAccounts(ctx, model.DBListAccountsOptions{})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting accounts: %v\", err)\n\t}\n\n\taccountDTOs := []model.AccountDTO{}\n\tfor _, account := range accounts {\n\t\taccountDTOs = append(accountDTOs, account.ToDTO())\n\t}\n\n\treturn accountDTOs, nil\n}\n\nfunc (d *AccountsDomain) GetAccountByUsername(ctx context.Context, username string) (*model.AccountDTO, error) {\n\tif username == \"\" {\n\t\treturn nil, errors.New(\"empty username\")\n\t}\n\n\taccounts, err := d.deps.Database().ListAccounts(ctx, model.DBListAccountsOptions{\n\t\tUsername: username,\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting accounts: %v\", err)\n\t}\n\tif len(accounts) != 1 {\n\t\treturn nil, fmt.Errorf(\"got none or more than one account by username: %s\", username)\n\t}\n\n\treturn model.Ptr(accounts[0].ToDTO()), nil\n}\n\nfunc (d *AccountsDomain) CreateAccount(ctx context.Context, account model.AccountDTO) (*model.AccountDTO, error) {\n\tif err := account.IsValidCreate(); err != nil {\n\t\treturn nil, err\n\t}\n\n\thashedPassword, err := bcrypt.GenerateFromPassword([]byte(account.Password), bcrypt.DefaultCost)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error hashing provided password: %w\", err)\n\t}\n\n\tacc := model.Account{\n\t\tUsername: account.Username,\n\t\tPassword: string(hashedPassword),\n\t}\n\tif account.Owner != nil {\n\t\tacc.Owner = *account.Owner\n\t}\n\tif account.Config != nil {\n\t\tacc.Config = *account.Config\n\t}\n\n\tstoredAccount, err := d.deps.Database().CreateAccount(ctx, acc)\n\tif errors.Is(err, database.ErrAlreadyExists) {\n\t\treturn nil, model.ErrAlreadyExists\n\t}\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating account: %v\", err)\n\t}\n\n\tresult := storedAccount.ToDTO()\n\n\treturn &result, nil\n}\n\nfunc (d *AccountsDomain) DeleteAccount(ctx context.Context, id int) error {\n\terr := d.deps.Database().DeleteAccount(ctx, model.DBID(id))\n\tif errors.Is(err, database.ErrNotFound) {\n\t\treturn model.ErrNotFound\n\t}\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error deleting account: %v\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (d *AccountsDomain) UpdateAccount(ctx context.Context, account model.AccountDTO) (*model.AccountDTO, error) {\n\tif err := account.IsValidUpdate(); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Get account from database\n\tstoredAccount, _, err := d.deps.Database().GetAccount(ctx, account.ID)\n\tif errors.Is(err, database.ErrNotFound) {\n\t\treturn nil, model.ErrNotFound\n\t}\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting account for update: %w\", err)\n\t}\n\n\tif account.Password != \"\" {\n\t\t// Hash password with bcrypt\n\t\thashedPassword, err := bcrypt.GenerateFromPassword([]byte(account.Password), 10)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error hashing provided password: %w\", err)\n\t\t}\n\t\tstoredAccount.Password = string(hashedPassword)\n\t}\n\n\tif account.Username != \"\" {\n\t\tstoredAccount.Username = account.Username\n\t}\n\n\tif account.Owner != nil {\n\t\tstoredAccount.Owner = *account.Owner\n\t}\n\n\tif account.Config != nil {\n\t\tstoredAccount.Config = *account.Config\n\t}\n\n\t// Save updated account\n\terr = d.deps.Database().UpdateAccount(ctx, *storedAccount)\n\tif errors.Is(err, database.ErrAlreadyExists) {\n\t\treturn nil, model.ErrAlreadyExists\n\t}\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error updating account: %w\", err)\n\t}\n\n\t// Get updated account from database\n\tupdatedAccount, _, err := d.deps.Database().GetAccount(ctx, account.ID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error getting updated account: %w\", err)\n\t}\n\n\taccount = updatedAccount.ToDTO()\n\n\treturn &account, nil\n}\n\nfunc NewAccountsDomain(deps *dependencies.Dependencies) model.AccountsDomain {\n\treturn &AccountsDomain{\n\t\tdeps: deps,\n\t}\n}\n"
  },
  {
    "path": "internal/domains/accounts_test.go",
    "content": "package domains_test\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestAccountDomainsListAccounts(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tt.Run(\"empty\", func(t *testing.T) {\n\t\taccounts, err := deps.Domains().Accounts().ListAccounts(context.Background())\n\t\trequire.NoError(t, err)\n\t\trequire.Empty(t, accounts)\n\t})\n\n\tt.Run(\"some accounts\", func(t *testing.T) {\n\t\tfor i := 0; i < 3; i++ {\n\t\t\t_, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{\n\t\t\t\tUsername: fmt.Sprintf(\"user%d\", i),\n\t\t\t\tPassword: fmt.Sprintf(\"password%d\", i),\n\t\t\t})\n\t\t\trequire.NoError(t, err)\n\t\t}\n\n\t\taccounts, err := deps.Domains().Accounts().ListAccounts(context.Background())\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, accounts, 3)\n\t\trequire.Equal(t, \"\", accounts[0].Password)\n\t})\n}\n\nfunc TestAccountDomainsGetAccountByUsername(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tt.Run(\"empty\", func(t *testing.T) {\n\t\taccount, err := deps.Domains().Accounts().GetAccountByUsername(context.Background(), \"\")\n\t\trequire.Error(t, err)\n\t\trequire.Nil(t, account)\n\t})\n\n\tt.Run(\"account found\", func(t *testing.T) {\n\t\t_, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{\n\t\t\tUsername: \"user1\",\n\t\t\tPassword: \"password1\",\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\taccount, err := deps.Domains().Accounts().GetAccountByUsername(context.Background(), \"user1\")\n\t\trequire.NoError(t, err)\n\t\trequire.NotNil(t, account)\n\t\trequire.Equal(t, \"user1\", account.Username)\n\t})\n}\n\nfunc TestAccountDomainCreateAccount(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tt.Run(\"create account\", func(t *testing.T) {\n\t\tacc, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{\n\t\t\tUsername: \"user\",\n\t\t\tPassword: \"password\",\n\t\t\tOwner:    model.Ptr(true),\n\t\t\tConfig: &model.UserConfig{\n\t\t\t\tTheme: \"dark\",\n\t\t\t},\n\t\t})\n\t\trequire.NoError(t, err)\n\t\trequire.NotZero(t, acc.ID)\n\t\trequire.Equal(t, \"user\", acc.Username)\n\t\trequire.Equal(t, \"dark\", acc.Config.Theme)\n\t})\n\n\tt.Run(\"create account with empty username\", func(t *testing.T) {\n\t\t_, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{\n\t\t\tUsername: \"\",\n\t\t\tPassword: \"password\",\n\t\t})\n\t\trequire.Error(t, err)\n\t\t_, isValidationErr := err.(model.ValidationError)\n\t\trequire.True(t, isValidationErr)\n\t})\n\n\tt.Run(\"create account with empty password\", func(t *testing.T) {\n\t\t_, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{\n\t\t\tUsername: \"user\",\n\t\t\tPassword: \"\",\n\t\t})\n\t\trequire.Error(t, err)\n\t\t_, isValidationErr := err.(model.ValidationError)\n\t\trequire.True(t, isValidationErr)\n\t})\n}\n\nfunc TestAccountDomainUpdateAccount(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tt.Run(\"update account\", func(t *testing.T) {\n\t\tacc, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{\n\t\t\tUsername: \"user\",\n\t\t\tPassword: \"password\",\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\tacc, err = deps.Domains().Accounts().UpdateAccount(context.TODO(), model.AccountDTO{\n\t\t\tID:       acc.ID,\n\t\t\tUsername: \"user2\",\n\t\t\tPassword: \"password2\",\n\t\t\tOwner:    model.Ptr(true),\n\t\t\tConfig: &model.UserConfig{\n\t\t\t\tTheme: \"light\",\n\t\t\t},\n\t\t})\n\t\trequire.NoError(t, err)\n\t\trequire.Equal(t, \"user2\", acc.Username)\n\t\trequire.Equal(t, \"light\", acc.Config.Theme)\n\t})\n\n\tt.Run(\"update non-existing account\", func(t *testing.T) {\n\t\t_, err := deps.Domains().Accounts().UpdateAccount(context.TODO(), model.AccountDTO{\n\t\t\tID:       999,\n\t\t\tUsername: \"user\",\n\t\t\tPassword: \"password\",\n\t\t})\n\t\trequire.Error(t, err)\n\t\trequire.ErrorIs(t, err, model.ErrNotFound)\n\t})\n\n\tt.Run(\"try to update with no changes\", func(t *testing.T) {\n\t\tacc, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{\n\t\t\tUsername: \"user\",\n\t\t\tPassword: \"password\",\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\t_, err = deps.Domains().Accounts().UpdateAccount(context.TODO(), model.AccountDTO{\n\t\t\tID: acc.ID,\n\t\t})\n\t\trequire.Error(t, err)\n\t\t_, isValidationErr := err.(model.ValidationError)\n\t\trequire.True(t, isValidationErr)\n\t})\n}\n\nfunc TestAccountDomainDeleteAccount(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tt.Run(\"delete account\", func(t *testing.T) {\n\t\tacc, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{\n\t\t\tUsername: \"user\",\n\t\t\tPassword: \"password\",\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\terr = deps.Domains().Accounts().DeleteAccount(context.TODO(), int(acc.ID))\n\t\trequire.NoError(t, err)\n\n\t\taccounts, err := deps.Domains().Accounts().ListAccounts(context.Background())\n\t\trequire.NoError(t, err)\n\t\trequire.Empty(t, accounts)\n\t})\n\n\tt.Run(\"delete non-existing account\", func(t *testing.T) {\n\t\terr := deps.Domains().Accounts().DeleteAccount(context.TODO(), 999)\n\t\trequire.Error(t, err)\n\t\trequire.ErrorIs(t, err, model.ErrNotFound)\n\t})\n\n\tt.Run(\"valid account\", func(t *testing.T) {\n\t\taccount := testutil.GetValidAccount().ToDTO()\n\t\ttoken, err := deps.Domains().Auth().CreateTokenForAccount(\n\t\t\t&account,\n\t\t\ttime.Now().Add(time.Hour*1),\n\t\t)\n\t\trequire.NoError(t, err)\n\t\trequire.NotEmpty(t, token)\n\t})\n\n\tt.Run(\"nil account\", func(t *testing.T) {\n\t\ttoken, err := deps.Domains().Auth().CreateTokenForAccount(\n\t\t\tnil,\n\t\t\ttime.Now().Add(time.Hour*1),\n\t\t)\n\t\trequire.Error(t, err)\n\t\trequire.Empty(t, token)\n\t})\n\n\tt.Run(\"token expiration is valid\", func(t *testing.T) {\n\t\tctx := context.TODO()\n\t\taccount := testutil.GetValidAccount().ToDTO()\n\t\texpiration := time.Now().Add(time.Hour * 9)\n\t\ttoken, err := deps.Domains().Auth().CreateTokenForAccount(\n\t\t\t&account,\n\t\t\texpiration,\n\t\t)\n\t\trequire.NoError(t, err)\n\t\trequire.NotEmpty(t, token)\n\t\ttokenAccount, err := deps.Domains().Auth().CheckToken(ctx, token)\n\t\trequire.NoError(t, err)\n\t\trequire.NotNil(t, tokenAccount)\n\t})\n}\n"
  },
  {
    "path": "internal/domains/archiver.go",
    "content": "package domains\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\n\t\"github.com/go-shiori/shiori/internal/core\"\n\t\"github.com/go-shiori/shiori/internal/dependencies\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/warc\"\n)\n\ntype ArchiverDomain struct {\n\tdeps *dependencies.Dependencies\n}\n\nfunc (d *ArchiverDomain) DownloadBookmarkArchive(book model.BookmarkDTO) (*model.BookmarkDTO, error) {\n\tcontent, contentType, err := core.DownloadBookmark(book.URL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error downloading url: %s\", err)\n\t}\n\n\tprocessRequest := core.ProcessRequest{\n\t\tDataDir:     d.deps.Config().Storage.DataDir,\n\t\tBookmark:    book,\n\t\tContent:     content,\n\t\tContentType: contentType,\n\t}\n\n\tresult, isFatalErr, err := core.ProcessBookmark(d.deps, processRequest)\n\tcontent.Close()\n\n\tif err != nil && isFatalErr {\n\t\treturn nil, fmt.Errorf(\"failed to process: %v\", err)\n\t}\n\n\treturn &result, nil\n}\n\nfunc (d *ArchiverDomain) GetBookmarkArchive(book *model.BookmarkDTO) (*warc.Archive, error) {\n\tarchivePath := model.GetArchivePath(book)\n\n\tif !d.deps.Domains().Storage().FileExists(archivePath) {\n\t\treturn nil, fmt.Errorf(\"archive for bookmark %d doesn't exist\", book.ID)\n\t}\n\n\t// FIXME: This only works in local filesystem\n\treturn warc.Open(filepath.Join(d.deps.Config().Storage.DataDir, archivePath))\n}\n\nfunc NewArchiverDomain(deps *dependencies.Dependencies) *ArchiverDomain {\n\treturn &ArchiverDomain{\n\t\tdeps: deps,\n\t}\n}\n"
  },
  {
    "path": "internal/domains/auth.go",
    "content": "package domains\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/dependencies\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\ntype AuthDomain struct {\n\tdeps *dependencies.Dependencies\n}\n\ntype JWTClaim struct {\n\tjwt.RegisteredClaims\n\n\tAccount *model.AccountDTO\n}\n\nfunc (d *AuthDomain) CheckToken(ctx context.Context, userJWT string) (*model.AccountDTO, error) {\n\ttoken, err := jwt.ParseWithClaims(userJWT, &JWTClaim{}, func(token *jwt.Token) (interface{}, error) {\n\t\t// Validate algorithm\n\t\tif _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {\n\t\t\treturn nil, fmt.Errorf(\"unexpected signing method: %v\", token.Header[\"alg\"])\n\t\t}\n\n\t\treturn d.deps.Config().Http.SecretKey, nil\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error parsing token: %w\", err)\n\t}\n\n\tif claims, ok := token.Claims.(*JWTClaim); ok && token.Valid {\n\t\tif claims.Account.ID > 0 {\n\t\t\treturn claims.Account, nil\n\t\t}\n\n\t\treturn claims.Account, nil\n\t}\n\treturn nil, fmt.Errorf(\"error obtaining user from JWT claims\")\n}\n\nfunc (d *AuthDomain) GetAccountFromCredentials(ctx context.Context, username, password string) (*model.AccountDTO, error) {\n\taccounts, err := d.deps.Database().ListAccounts(ctx, model.DBListAccountsOptions{\n\t\tUsername:     username,\n\t\tWithPassword: true,\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"username or password do not match\")\n\t}\n\n\tif len(accounts) != 1 {\n\t\treturn nil, fmt.Errorf(\"username or password do not match\")\n\t}\n\n\taccount := accounts[0]\n\n\tif err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)); err != nil {\n\t\treturn nil, fmt.Errorf(\"username or password do not match\")\n\t}\n\n\treturn model.Ptr(account.ToDTO()), nil\n}\n\nfunc (d *AuthDomain) CreateTokenForAccount(account *model.AccountDTO, expiration time.Time) (string, error) {\n\tif account == nil {\n\t\treturn \"\", fmt.Errorf(\"account is nil\")\n\t}\n\n\tclaims := jwt.MapClaims{\n\t\t\"account\": account,\n\t\t\"exp\":     expiration.UTC().Unix(),\n\t}\n\n\ttoken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)\n\n\tt, err := token.SignedString(d.deps.Config().Http.SecretKey)\n\tif err != nil {\n\t\td.deps.Logger().WithError(err).Error(\"error signing token\")\n\t}\n\n\treturn t, err\n}\n\nfunc NewAuthDomain(deps *dependencies.Dependencies) *AuthDomain {\n\treturn &AuthDomain{\n\t\tdeps: deps,\n\t}\n}\n"
  },
  {
    "path": "internal/domains/auth_test.go",
    "content": "package domains_test\n\nimport (\n\t\"context\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/domains\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestAuthDomainCheckToken(t *testing.T) {\n\tctx := context.TODO()\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\tdomain := domains.NewAuthDomain(deps)\n\n\tt.Run(\"valid token\", func(t *testing.T) {\n\t\t// Create a valid token\n\t\taccount := testutil.GetValidAccount().ToDTO()\n\t\ttoken, err := domain.CreateTokenForAccount(\n\t\t\t&account,\n\t\t\ttime.Now().Add(time.Hour*1),\n\t\t)\n\t\trequire.NoError(t, err)\n\n\t\tacc, err := domain.CheckToken(ctx, token)\n\t\trequire.NoError(t, err)\n\t\trequire.NotNil(t, acc)\n\t\trequire.Equal(t, model.DBID(99), acc.ID)\n\t})\n\n\tt.Run(\"expired token\", func(t *testing.T) {\n\t\t// Create an expired token\n\t\taccount := testutil.GetValidAccount().ToDTO()\n\t\ttoken, err := domain.CreateTokenForAccount(\n\t\t\t&account,\n\t\t\ttime.Now().Add(time.Hour*-1),\n\t\t)\n\t\trequire.NoError(t, err)\n\n\t\tacc, err := domain.CheckToken(ctx, token)\n\t\trequire.Error(t, err)\n\t\trequire.Nil(t, acc)\n\t})\n\n\tt.Run(\"invalid token\", func(t *testing.T) {\n\t\tclaims, err := domain.CheckToken(ctx, \"invalid-token\")\n\t\trequire.Error(t, err)\n\t\trequire.Nil(t, claims)\n\t})\n\n\tt.Run(\"nil account\", func(t *testing.T) {\n\t\ttoken, err := domain.CreateTokenForAccount(nil, time.Now().Add(time.Hour))\n\t\trequire.Error(t, err)\n\t\trequire.Empty(t, token)\n\t\trequire.Contains(t, err.Error(), \"account is nil\")\n\t})\n}\n\nfunc TestAuthDomainCheckTokenInvalidMethod(t *testing.T) {\n\tctx := context.TODO()\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\tdomain := domains.NewAuthDomain(deps)\n\n\t// Create a token with an unsupported signing method\n\taccount := testutil.GetValidAccount().ToDTO()\n\tclaims := jwt.MapClaims{\n\t\t\"account\": account,\n\t\t\"exp\":     time.Now().Add(time.Hour).UTC().Unix(),\n\t}\n\ttoken := jwt.NewWithClaims(jwt.SigningMethodNone, claims)\n\ttokenString, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType)\n\trequire.NoError(t, err)\n\n\t// Try to verify the token\n\tacc, err := domain.CheckToken(ctx, tokenString)\n\trequire.Error(t, err)\n\trequire.Nil(t, acc)\n\trequire.Contains(t, err.Error(), \"unexpected signing method\")\n}\n\nfunc TestAuthDomainGetAccountFromCredentials(t *testing.T) {\n\tctx := context.TODO()\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\tdomain := domains.NewAuthDomain(deps)\n\n\t_, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{\n\t\tUsername: \"test\",\n\t\tPassword: \"test\",\n\t})\n\trequire.NoError(t, err)\n\n\tt.Run(\"valid credentials\", func(t *testing.T) {\n\t\tacc, err := domain.GetAccountFromCredentials(ctx, \"test\", \"test\")\n\t\trequire.NoError(t, err)\n\t\trequire.NotNil(t, acc)\n\t\trequire.Equal(t, \"test\", acc.Username)\n\t})\n\n\tt.Run(\"invalid credentials\", func(t *testing.T) {\n\t\tacc, err := domain.GetAccountFromCredentials(ctx, \"test\", \"invalid\")\n\t\trequire.Error(t, err)\n\t\trequire.Nil(t, acc)\n\t})\n\n\tt.Run(\"invalid username\", func(t *testing.T) {\n\t\tacc, err := domain.GetAccountFromCredentials(ctx, \"nope\", \"invalid\")\n\t\trequire.Error(t, err)\n\t\trequire.Nil(t, acc)\n\t})\n}\n"
  },
  {
    "path": "internal/domains/bookmark_tags_test.go",
    "content": "package domains_test\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestBookmarkTagOperations(t *testing.T) {\n\tctx := context.Background()\n\tlogger := logrus.New()\n\n\t// Setup using the test configuration and dependencies\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\tbookmarksDomain := deps.Domains().Bookmarks()\n\ttagsDomain := deps.Domains().Tags()\n\tdb := deps.Database()\n\n\t// Create a test bookmark\n\tbookmark := model.BookmarkDTO{\n\t\tURL:   \"https://example.com/bookmark-tags-test\",\n\t\tTitle: \"Bookmark Tags Test\",\n\t}\n\tsavedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark)\n\trequire.NoError(t, err)\n\trequire.Len(t, savedBookmarks, 1)\n\tbookmarkID := savedBookmarks[0].ID\n\n\t// Create a test tag\n\ttagDTO := model.TagDTO{\n\t\tTag: model.Tag{\n\t\t\tName: \"test-tag\",\n\t\t},\n\t}\n\tcreatedTag, err := tagsDomain.CreateTag(ctx, tagDTO)\n\trequire.NoError(t, err)\n\ttagID := createdTag.ID\n\n\t// Test BookmarkExists\n\tt.Run(\"BookmarkExists\", func(t *testing.T) {\n\t\t// Test with existing bookmark\n\t\texists, err := bookmarksDomain.BookmarkExists(ctx, bookmarkID)\n\t\trequire.NoError(t, err)\n\t\tassert.True(t, exists, \"Bookmark should exist\")\n\n\t\t// Test with non-existent bookmark\n\t\texists, err = bookmarksDomain.BookmarkExists(ctx, 9999)\n\t\trequire.NoError(t, err)\n\t\tassert.False(t, exists, \"Non-existent bookmark should not exist\")\n\t})\n\n\t// Test TagExists\n\tt.Run(\"TagExists\", func(t *testing.T) {\n\t\t// Test with existing tag\n\t\texists, err := tagsDomain.TagExists(ctx, tagID)\n\t\trequire.NoError(t, err)\n\t\tassert.True(t, exists, \"Tag should exist\")\n\n\t\t// Test with non-existent tag\n\t\texists, err = tagsDomain.TagExists(ctx, 9999)\n\t\trequire.NoError(t, err)\n\t\tassert.False(t, exists, \"Non-existent tag should not exist\")\n\t})\n\n\t// Test AddTagToBookmark\n\tt.Run(\"AddTagToBookmark\", func(t *testing.T) {\n\t\t// Add tag to bookmark\n\t\terr := bookmarksDomain.AddTagToBookmark(ctx, bookmarkID, tagID)\n\t\trequire.NoError(t, err)\n\n\t\t// Verify tag was added by listing tags for the bookmark\n\t\ttags, err := tagsDomain.ListTags(ctx, model.ListTagsOptions{\n\t\t\tBookmarkID: bookmarkID,\n\t\t})\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, tags, 1, \"Should have exactly one tag\")\n\t\tassert.Equal(t, tagID, tags[0].ID, \"Tag ID should match\")\n\t\tassert.Equal(t, \"test-tag\", tags[0].Name, \"Tag name should match\")\n\n\t\t// Test adding the same tag again (should not error)\n\t\terr = bookmarksDomain.AddTagToBookmark(ctx, bookmarkID, tagID)\n\t\trequire.NoError(t, err, \"Adding the same tag again should not error\")\n\n\t\t// Test adding tag to non-existent bookmark\n\t\terr = bookmarksDomain.AddTagToBookmark(ctx, 9999, tagID)\n\t\trequire.Error(t, err)\n\t\tassert.ErrorIs(t, err, model.ErrBookmarkNotFound, \"Should return bookmark not found error\")\n\n\t\t// Test adding non-existent tag to bookmark\n\t\terr = bookmarksDomain.AddTagToBookmark(ctx, bookmarkID, 9999)\n\t\trequire.Error(t, err)\n\t\tassert.ErrorIs(t, err, model.ErrTagNotFound, \"Should return tag not found error\")\n\t})\n\n\t// Test RemoveTagFromBookmark\n\tt.Run(\"RemoveTagFromBookmark\", func(t *testing.T) {\n\t\t// Remove tag from bookmark\n\t\terr := bookmarksDomain.RemoveTagFromBookmark(ctx, bookmarkID, tagID)\n\t\trequire.NoError(t, err)\n\n\t\t// Verify tag was removed by listing tags for the bookmark\n\t\ttags, err := tagsDomain.ListTags(ctx, model.ListTagsOptions{\n\t\t\tBookmarkID: bookmarkID,\n\t\t})\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, tags, 0, \"Should have no tags after removal\")\n\n\t\t// Test removing a tag that's not associated with the bookmark (should not error)\n\t\terr = bookmarksDomain.RemoveTagFromBookmark(ctx, bookmarkID, tagID)\n\t\trequire.NoError(t, err, \"Removing a tag that's not associated should not error\")\n\n\t\t// Test removing tag from non-existent bookmark\n\t\terr = bookmarksDomain.RemoveTagFromBookmark(ctx, 9999, tagID)\n\t\trequire.Error(t, err)\n\t\tassert.ErrorIs(t, err, model.ErrBookmarkNotFound, \"Should return bookmark not found error\")\n\n\t\t// Test removing non-existent tag from bookmark\n\t\terr = bookmarksDomain.RemoveTagFromBookmark(ctx, bookmarkID, 9999)\n\t\trequire.Error(t, err)\n\t\tassert.ErrorIs(t, err, model.ErrTagNotFound, \"Should return tag not found error\")\n\t})\n}\n"
  },
  {
    "path": "internal/domains/bookmarks.go",
    "content": "package domains\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/go-shiori/shiori/internal/core\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\ntype BookmarksDomain struct {\n\tdeps model.Dependencies\n}\n\nfunc (d *BookmarksDomain) HasEbook(b *model.BookmarkDTO) bool {\n\tebookPath := model.GetEbookPath(b)\n\treturn d.deps.Domains().Storage().FileExists(ebookPath)\n}\n\nfunc (d *BookmarksDomain) HasArchive(b *model.BookmarkDTO) bool {\n\tarchivePath := model.GetArchivePath(b)\n\treturn d.deps.Domains().Storage().FileExists(archivePath)\n}\n\nfunc (d *BookmarksDomain) HasThumbnail(b *model.BookmarkDTO) bool {\n\tthumbnailPath := model.GetThumbnailPath(b)\n\treturn d.deps.Domains().Storage().FileExists(thumbnailPath)\n}\n\nfunc (d *BookmarksDomain) GetBookmark(ctx context.Context, id model.DBID) (*model.BookmarkDTO, error) {\n\tbookmark, exists, err := d.deps.Database().GetBookmark(ctx, int(id), \"\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get bookmark: %w\", err)\n\t}\n\n\tif !exists {\n\t\treturn nil, model.ErrBookmarkNotFound\n\t}\n\n\t// Check if it has ebook and archive.\n\tbookmark.HasEbook = d.HasEbook(&bookmark)\n\tbookmark.HasArchive = d.HasArchive(&bookmark)\n\n\treturn &bookmark, nil\n}\n\nfunc (d *BookmarksDomain) GetBookmarks(ctx context.Context, ids []int) ([]model.BookmarkDTO, error) {\n\tvar bookmarks []model.BookmarkDTO\n\tfor _, id := range ids {\n\t\tbookmark, exists, err := d.deps.Database().GetBookmark(ctx, id, \"\")\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to get bookmark %d: %w\", id, err)\n\t\t}\n\t\tif !exists {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Check if it has ebook and archive\n\t\tbookmark.HasEbook = d.HasEbook(&bookmark)\n\t\tbookmark.HasArchive = d.HasArchive(&bookmark)\n\t\tbookmarks = append(bookmarks, bookmark)\n\t}\n\treturn bookmarks, nil\n}\n\nfunc (d *BookmarksDomain) UpdateBookmarkCache(ctx context.Context, bookmark model.BookmarkDTO, keepMetadata bool, skipExist bool) (*model.BookmarkDTO, error) {\n\t// Download data from internet\n\tcontent, contentType, err := core.DownloadBookmark(bookmark.URL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to download bookmark: %w\", err)\n\t}\n\tdefer content.Close()\n\n\t// Check if we should skip existing ebook\n\tif skipExist && bookmark.CreateEbook {\n\t\tebookPath := model.GetEbookPath(&bookmark)\n\t\tif d.deps.Domains().Storage().FileExists(ebookPath) {\n\t\t\tbookmark.CreateEbook = false\n\t\t\tbookmark.HasEbook = true\n\t\t}\n\t}\n\n\t// Process the bookmark\n\trequest := core.ProcessRequest{\n\t\tDataDir:     d.deps.Config().Storage.DataDir,\n\t\tBookmark:    bookmark,\n\t\tContent:     content,\n\t\tContentType: contentType,\n\t\tKeepTitle:   keepMetadata,\n\t\tKeepExcerpt: keepMetadata,\n\t}\n\n\tprocessedBookmark, _, err := core.ProcessBookmark(d.deps, request)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to process bookmark: %w\", err)\n\t}\n\n\treturn &processedBookmark, nil\n}\n\n// BulkUpdateBookmarkTags updates tags for multiple bookmarks using tag IDs\nfunc (d *BookmarksDomain) BulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error {\n\tif len(bookmarkIDs) == 0 {\n\t\treturn nil\n\t}\n\n\t// Call the database method directly\n\terr := d.deps.Database().BulkUpdateBookmarkTags(ctx, bookmarkIDs, tagIDs)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to update bookmark tags: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// AddTagToBookmark adds a tag to a bookmark\nfunc (d *BookmarksDomain) AddTagToBookmark(ctx context.Context, bookmarkID int, tagID int) error {\n\t// Check if bookmark exists\n\texists, err := d.BookmarkExists(ctx, bookmarkID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !exists {\n\t\treturn model.ErrBookmarkNotFound\n\t}\n\n\t// Check if tag exists\n\texists, err = d.deps.Domains().Tags().TagExists(ctx, tagID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !exists {\n\t\treturn model.ErrTagNotFound\n\t}\n\n\t// Add tag to bookmark\n\treturn d.deps.Database().AddTagToBookmark(ctx, bookmarkID, tagID)\n}\n\n// RemoveTagFromBookmark removes a tag from a bookmark\nfunc (d *BookmarksDomain) RemoveTagFromBookmark(ctx context.Context, bookmarkID int, tagID int) error {\n\t// Check if bookmark exists\n\texists, err := d.BookmarkExists(ctx, bookmarkID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !exists {\n\t\treturn model.ErrBookmarkNotFound\n\t}\n\n\t// Check if tag exists\n\texists, err = d.deps.Domains().Tags().TagExists(ctx, tagID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !exists {\n\t\treturn model.ErrTagNotFound\n\t}\n\n\t// Remove tag from bookmark\n\treturn d.deps.Database().RemoveTagFromBookmark(ctx, bookmarkID, tagID)\n}\n\n// BookmarkExists checks if a bookmark with the given ID exists\nfunc (d *BookmarksDomain) BookmarkExists(ctx context.Context, id int) (bool, error) {\n\treturn d.deps.Database().BookmarkExists(ctx, id)\n}\n\nfunc NewBookmarksDomain(deps model.Dependencies) *BookmarksDomain {\n\treturn &BookmarksDomain{\n\t\tdeps: deps,\n\t}\n}\n"
  },
  {
    "path": "internal/domains/bookmarks_test.go",
    "content": "package domains_test\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/domains\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/spf13/afero\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestBookmarkDomain(t *testing.T) {\n\tfs := afero.NewMemMapFs()\n\tctx := context.Background()\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\tdeps.Domains().SetStorage(domains.NewStorageDomain(deps, fs))\n\n\tfs.MkdirAll(\"thumb\", 0755)\n\tfs.Create(\"thumb/1\")\n\tfs.MkdirAll(\"ebook\", 0755)\n\tfs.Create(\"ebook/1.epub\")\n\tfs.MkdirAll(\"archive\", 0755)\n\t// TODO: write a valid archive file\n\tfs.Create(\"archive/1\")\n\n\tdomain := domains.NewBookmarksDomain(deps)\n\tt.Run(\"HasEbook\", func(t *testing.T) {\n\t\tt.Run(\"Yes\", func(t *testing.T) {\n\t\t\trequire.True(t, domain.HasEbook(&model.BookmarkDTO{ID: 1}))\n\t\t})\n\t\tt.Run(\"No\", func(t *testing.T) {\n\t\t\trequire.False(t, domain.HasEbook(&model.BookmarkDTO{ID: 2}))\n\t\t})\n\t})\n\n\tt.Run(\"HasArchive\", func(t *testing.T) {\n\t\tt.Run(\"Yes\", func(t *testing.T) {\n\t\t\trequire.True(t, domain.HasArchive(&model.BookmarkDTO{ID: 1}))\n\t\t})\n\t\tt.Run(\"No\", func(t *testing.T) {\n\t\t\trequire.False(t, domain.HasArchive(&model.BookmarkDTO{ID: 2}))\n\t\t})\n\t})\n\n\tt.Run(\"HasThumbnail\", func(t *testing.T) {\n\t\tt.Run(\"Yes\", func(t *testing.T) {\n\t\t\trequire.True(t, domain.HasThumbnail(&model.BookmarkDTO{ID: 1}))\n\t\t})\n\t\tt.Run(\"No\", func(t *testing.T) {\n\t\t\trequire.False(t, domain.HasThumbnail(&model.BookmarkDTO{ID: 2}))\n\t\t})\n\t})\n\n\tt.Run(\"GetBookmark\", func(t *testing.T) {\n\t\tt.Run(\"Success\", func(t *testing.T) {\n\t\t\t_, err := deps.Database().SaveBookmarks(context.TODO(), true, *testutil.GetValidBookmark())\n\t\t\trequire.NoError(t, err)\n\t\t\tbookmark, err := domain.GetBookmark(context.Background(), 1)\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.Equal(t, 1, bookmark.ID)\n\n\t\t\t// Check DTO attributes\n\t\t\trequire.True(t, bookmark.HasEbook)\n\t\t\trequire.True(t, bookmark.HasArchive)\n\t\t})\n\n\t\tt.Run(\"NotFound\", func(t *testing.T) {\n\t\t\tbookmark, err := domain.GetBookmark(context.Background(), 999)\n\t\t\trequire.Error(t, err)\n\t\t\trequire.Nil(t, bookmark)\n\t\t\trequire.Equal(t, model.ErrBookmarkNotFound, err)\n\t\t})\n\n\t\tt.Run(\"DatabaseError\", func(t *testing.T) {\n\t\t\t// Create a new context with a timeout to force an error\n\t\t\tcancelCtx, cancel := context.WithCancel(context.Background())\n\t\t\tcancel() // Cancel immediately to force error\n\t\t\tbookmark, err := domain.GetBookmark(cancelCtx, 1)\n\t\t\trequire.Error(t, err)\n\t\t\trequire.Nil(t, bookmark)\n\t\t\trequire.Contains(t, err.Error(), \"failed to get bookmark\")\n\t\t})\n\t})\n\n\tt.Run(\"GetBookmarks\", func(t *testing.T) {\n\t\tt.Run(\"Success\", func(t *testing.T) {\n\t\t\t// Create multiple bookmarks\n\t\t\tbookmark1 := testutil.GetValidBookmark()\n\t\t\tbookmark1.ID = 1\n\t\t\tbookmark2 := testutil.GetValidBookmark()\n\t\t\tbookmark2.ID = 2\n\t\t\tbookmark2.URL = \"https://example.com\"\n\n\t\t\t_, err := deps.Database().SaveBookmarks(context.TODO(), true, *bookmark1, *bookmark2)\n\t\t\trequire.NoError(t, err)\n\n\t\t\t// Test getting multiple bookmarks\n\t\t\tbookmarks, err := domain.GetBookmarks(context.Background(), []int{1, 2})\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.Len(t, bookmarks, 2)\n\n\t\t\t// Verify the bookmarks have the correct properties\n\t\t\tassert.Equal(t, 1, bookmarks[0].ID)\n\t\t\tassert.True(t, bookmarks[0].HasEbook)\n\t\t\tassert.True(t, bookmarks[0].HasArchive)\n\n\t\t\tassert.Equal(t, 2, bookmarks[1].ID)\n\t\t\tassert.False(t, bookmarks[1].HasEbook)\n\t\t\tassert.False(t, bookmarks[1].HasArchive)\n\t\t})\n\n\t\tt.Run(\"PartialResults\", func(t *testing.T) {\n\t\t\t// Test with a mix of existing and non-existing IDs\n\t\t\tbookmarks, err := domain.GetBookmarks(context.Background(), []int{1, 999})\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.Len(t, bookmarks, 1)\n\t\t\tassert.Equal(t, 1, bookmarks[0].ID)\n\t\t})\n\n\t\tt.Run(\"EmptyResults\", func(t *testing.T) {\n\t\t\t// Test with non-existing IDs\n\t\t\tbookmarks, err := domain.GetBookmarks(context.Background(), []int{998, 999})\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.Len(t, bookmarks, 0)\n\t\t})\n\n\t\tt.Run(\"DatabaseError\", func(t *testing.T) {\n\t\t\t// Create a new context with a timeout to force an error\n\t\t\tcancelCtx, cancel := context.WithCancel(context.Background())\n\t\t\tcancel() // Cancel immediately to force error\n\t\t\tbookmarks, err := domain.GetBookmarks(cancelCtx, []int{1})\n\t\t\trequire.Error(t, err)\n\t\t\trequire.Nil(t, bookmarks)\n\t\t\trequire.Contains(t, err.Error(), \"failed to get bookmark\")\n\t\t})\n\t})\n\n\tt.Run(\"UpdateBookmarkCache\", func(t *testing.T) {\n\t\t// Create a new test environment for this specific test\n\t\tfs := afero.NewMemMapFs()\n\t\tctx := context.Background()\n\t\tlogger := logrus.New()\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tdeps.Domains().SetStorage(domains.NewStorageDomain(deps, fs))\n\n\t\t// Create necessary directories\n\t\tfs.MkdirAll(\"thumb\", 0755)\n\t\tfs.MkdirAll(\"ebook\", 0755)\n\t\tfs.MkdirAll(\"archive\", 0755)\n\n\t\tdomain := domains.NewBookmarksDomain(deps)\n\n\t\t// Create a test bookmark\n\t\tbookmark := model.BookmarkDTO{\n\t\t\tID:            1,\n\t\t\tURL:           \"https://example.com\",\n\t\t\tTitle:         \"Example\",\n\t\t\tCreateEbook:   true,\n\t\t\tCreateArchive: true,\n\t\t}\n\n\t\t// Save the bookmark to the database\n\t\t_, err := deps.Database().SaveBookmarks(context.TODO(), true, bookmark)\n\t\trequire.NoError(t, err)\n\n\t\t// Mock the core.DownloadBookmark function using monkey patching\n\t\t// Since we can't directly mock it, we'll test the error case\n\t\tt.Run(\"DownloadError\", func(t *testing.T) {\n\t\t\t// Use an invalid URL to trigger a download error\n\t\t\tbookmark.URL = \"invalid://url\"\n\n\t\t\tresult, err := domain.UpdateBookmarkCache(ctx, bookmark, true, false)\n\t\t\trequire.Error(t, err)\n\t\t\trequire.Nil(t, result)\n\t\t\trequire.Contains(t, err.Error(), \"failed to download bookmark\")\n\t\t})\n\n\t\t// Test the skip existing functionality\n\t\tt.Run(\"SkipExistingEbook\", func(t *testing.T) {\n\t\t\t// Create an ebook file\n\t\t\tebookPath := model.GetEbookPath(&bookmark)\n\t\t\t_, err := fs.Create(ebookPath)\n\t\t\trequire.NoError(t, err)\n\n\t\t\t// Set a valid URL\n\t\t\tbookmark.URL = \"https://example.com\"\n\t\t\tbookmark.CreateEbook = true\n\n\t\t\t// This test will still fail because we can't mock the HTTP client\n\t\t\t// But we can verify the logic for skipping existing ebooks\n\t\t\t_, err = domain.UpdateBookmarkCache(ctx, bookmark, true, true)\n\n\t\t\t// The test will fail at the download step, but we can check if the CreateEbook flag was set correctly\n\t\t\tif err != nil && !errors.Is(err, context.Canceled) {\n\t\t\t\t// This is expected since we can't mock the HTTP client\n\t\t\t\t// But we can check if the bookmark was modified correctly before the error\n\t\t\t\tassert.False(t, bookmark.CreateEbook)\n\t\t\t\tassert.True(t, bookmark.HasEbook)\n\t\t\t}\n\t\t})\n\t})\n}\n\nfunc TestBookmarksDomain_BulkUpdateBookmarkTags(t *testing.T) {\n\tctx := context.Background()\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\tdomain := domains.NewBookmarksDomain(deps)\n\n\tt.Run(\"empty_bookmark_ids\", func(t *testing.T) {\n\t\terr := domain.BulkUpdateBookmarkTags(ctx, []int{}, []int{1, 2, 3})\n\t\trequire.NoError(t, err) // Should not return an error for empty bookmark IDs\n\t})\n\n\tt.Run(\"empty_tag_ids\", func(t *testing.T) {\n\t\terr := domain.BulkUpdateBookmarkTags(ctx, []int{1, 2, 3}, []int{})\n\t\trequire.NoError(t, err) // Should not return an error for empty tag IDs\n\t})\n\n\tt.Run(\"non_existent_bookmarks\", func(t *testing.T) {\n\t\terr := domain.BulkUpdateBookmarkTags(ctx, []int{999, 1000}, []int{1, 2, 3})\n\t\trequire.Error(t, err)\n\t})\n\n\tt.Run(\"successful_update\", func(t *testing.T) {\n\t\t// Create test bookmarks\n\t\tbookmark1 := testutil.GetValidBookmark()\n\t\tbookmark2 := testutil.GetValidBookmark()\n\t\tbookmark2.URL = \"https://example.com/different\"\n\n\t\tsavedBookmarks, err := deps.Database().SaveBookmarks(ctx, true, *bookmark1, *bookmark2)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, savedBookmarks, 2)\n\n\t\t// Create test tags\n\t\ttag1 := model.Tag{Name: \"test-tag-1\"}\n\t\ttag2 := model.Tag{Name: \"test-tag-2\"}\n\t\tcreatedTags, err := deps.Database().CreateTags(ctx, tag1, tag2)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, createdTags, 2)\n\n\t\t// Get the bookmark and tag IDs\n\t\tbookmarkIDs := []int{savedBookmarks[0].ID, savedBookmarks[1].ID}\n\t\ttagIDs := []int{createdTags[0].ID, createdTags[1].ID}\n\n\t\t// Update the bookmarks with the tags\n\t\terr = domain.BulkUpdateBookmarkTags(ctx, bookmarkIDs, tagIDs)\n\t\trequire.NoError(t, err)\n\n\t\t// Verify the bookmarks have the tags\n\t\tfor _, bookmarkID := range bookmarkIDs {\n\t\t\tbookmark, err := domain.GetBookmark(ctx, model.DBID(bookmarkID))\n\t\t\trequire.NoError(t, err)\n\n\t\t\t// Check that the bookmark has both tags\n\t\t\trequire.Len(t, bookmark.Tags, 2)\n\n\t\t\t// Verify tag IDs match\n\t\t\ttagIDsMap := make(map[int]bool)\n\t\t\tfor _, tag := range bookmark.Tags {\n\t\t\t\ttagIDsMap[tag.ID] = true\n\t\t\t}\n\n\t\t\tassert.True(t, tagIDsMap[createdTags[0].ID], \"Bookmark should have the first tag\")\n\t\t\tassert.True(t, tagIDsMap[createdTags[1].ID], \"Bookmark should have the second tag\")\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "internal/domains/storage.go",
    "content": "package domains\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/spf13/afero\"\n)\n\ntype StorageDomain struct {\n\tdeps model.Dependencies\n\tfs   afero.Fs\n}\n\nfunc NewStorageDomain(deps model.Dependencies, fs afero.Fs) *StorageDomain {\n\treturn &StorageDomain{\n\t\tdeps: deps,\n\t\tfs:   fs,\n\t}\n}\n\n// Stat returns the FileInfo structure describing file.\nfunc (d *StorageDomain) Stat(name string) (fs.FileInfo, error) {\n\treturn d.fs.Stat(name)\n}\n\n// FS returns the filesystem used by this domain.\nfunc (d *StorageDomain) FS() afero.Fs {\n\treturn d.fs\n}\n\n// FileExists checks if a file exists in storage.\nfunc (d *StorageDomain) FileExists(name string) bool {\n\tinfo, err := d.Stat(name)\n\treturn err == nil && !info.IsDir()\n}\n\n// DirExists checks if a directory exists in storage.\nfunc (d *StorageDomain) DirExists(name string) bool {\n\tinfo, err := d.Stat(name)\n\treturn err == nil && info.IsDir()\n}\n\n// WriteData writes bytes data to a file in storage.\n// CAUTION: This function will overwrite existing file.\nfunc (d *StorageDomain) WriteData(dst string, data []byte) error {\n\t// Create directory if not exist\n\tdir := filepath.Dir(dst)\n\tif !d.DirExists(dir) {\n\t\terr := d.fs.MkdirAll(dir, os.ModePerm)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Create file\n\tfile, err := d.fs.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.ModePerm)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer file.Close()\n\n\t// Write data\n\t_, err = file.Write(data)\n\treturn err\n}\n\n// WriteFile writes a file to storage.\nfunc (d *StorageDomain) WriteFile(dst string, tmpFile *os.File) error {\n\tif dst != \"\" && !d.DirExists(dst) {\n\t\terr := d.fs.MkdirAll(filepath.Dir(dst), model.DataDirPerm)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create destination dir: %v\", err)\n\t\t}\n\t}\n\n\tdstFile, err := d.fs.Create(dst)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create destination file: %v\", err)\n\t}\n\tdefer dstFile.Close()\n\n\t_, err = tmpFile.Seek(0, io.SeekStart)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to rewind temporary file: %v\", err)\n\t}\n\n\t_, err = io.Copy(dstFile, tmpFile)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to copy file to the destination\")\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/domains/storage_test.go",
    "content": "package domains_test\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/domains\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/spf13/afero\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestDirExists(t *testing.T) {\n\tfs := afero.NewMemMapFs()\n\tfs.MkdirAll(\"foo\", 0755)\n\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tdomain := domains.NewStorageDomain(\n\t\tdeps,\n\t\tfs,\n\t)\n\n\trequire.True(t, domain.DirExists(\"foo\"))\n\trequire.False(t, domain.DirExists(\"foo/file\"))\n\trequire.False(t, domain.DirExists(\"bar\"))\n}\n\nfunc TestFileExists(t *testing.T) {\n\tfs := afero.NewMemMapFs()\n\tfs.MkdirAll(\"foo\", 0755)\n\tfs.Create(\"foo/file\")\n\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tdomain := domains.NewStorageDomain(\n\t\tdeps,\n\t\tfs,\n\t)\n\n\trequire.True(t, domain.FileExists(\"foo/file\"))\n\trequire.False(t, domain.FileExists(\"bar\"))\n}\n\nfunc TestWriteFile(t *testing.T) {\n\tfs := afero.NewMemMapFs()\n\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tdomain := domains.NewStorageDomain(\n\t\tdeps,\n\t\tfs,\n\t)\n\n\terr := domain.WriteData(\"foo/file.ext\", []byte(\"foo\"))\n\trequire.NoError(t, err)\n\trequire.True(t, domain.FileExists(\"foo/file.ext\"))\n\trequire.True(t, domain.DirExists(\"foo\"))\n\thandler, err := domain.FS().Open(\"foo/file.ext\")\n\trequire.NoError(t, err)\n\tdefer handler.Close()\n\n\tdata, err := afero.ReadAll(handler)\n\trequire.NoError(t, err)\n\n\trequire.Equal(t, \"foo\", string(data))\n}\n\nfunc TestSaveFile(t *testing.T) {\n\tfs := afero.NewMemMapFs()\n\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tdomain := domains.NewStorageDomain(\n\t\tdeps,\n\t\tfs,\n\t)\n\n\ttempFile, err := os.CreateTemp(\"\", \"\")\n\trequire.NoError(t, err)\n\tdefer os.Remove(tempFile.Name())\n\n\t_, err = tempFile.WriteString(\"foo\")\n\trequire.NoError(t, err)\n\n\terr = domain.WriteFile(\"foo/file.ext\", tempFile)\n\trequire.NoError(t, err)\n\trequire.True(t, domain.FileExists(\"foo/file.ext\"))\n\trequire.True(t, domain.DirExists(\"foo\"))\n\thandler, err := domain.FS().Open(\"foo/file.ext\")\n\trequire.NoError(t, err)\n\tdefer handler.Close()\n\n\tdata, err := afero.ReadAll(handler)\n\trequire.NoError(t, err)\n\n\trequire.Equal(t, \"foo\", string(data))\n}\n"
  },
  {
    "path": "internal/domains/tags.go",
    "content": "package domains\n\nimport (\n\t\"context\"\n\t\"errors\"\n\n\t\"github.com/go-shiori/shiori/internal/database\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\ntype tagsDomain struct {\n\tdeps model.Dependencies\n}\n\nfunc NewTagsDomain(deps model.Dependencies) model.TagsDomain {\n\treturn &tagsDomain{deps: deps}\n}\n\nfunc (d *tagsDomain) ListTags(ctx context.Context, opts model.ListTagsOptions) ([]model.TagDTO, error) {\n\ttags, err := d.deps.Database().GetTags(ctx, model.DBListTagsOptions(opts))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn tags, nil\n}\n\nfunc (d *tagsDomain) CreateTag(ctx context.Context, tagDTO model.TagDTO) (model.TagDTO, error) {\n\ttag := tagDTO.ToTag()\n\tcreatedTag, err := d.deps.Database().CreateTag(ctx, tag)\n\tif err != nil {\n\t\treturn model.TagDTO{}, err\n\t}\n\n\treturn createdTag.ToDTO(), nil\n}\n\nfunc (d *tagsDomain) GetTag(ctx context.Context, id int) (model.TagDTO, error) {\n\ttag, exists, err := d.deps.Database().GetTag(ctx, id)\n\tif err != nil {\n\t\treturn model.TagDTO{}, err\n\t}\n\tif !exists {\n\t\treturn model.TagDTO{}, model.ErrNotFound\n\t}\n\treturn tag, nil\n}\n\nfunc (d *tagsDomain) UpdateTag(ctx context.Context, tagDTO model.TagDTO) (model.TagDTO, error) {\n\ttag := tagDTO.ToTag()\n\terr := d.deps.Database().UpdateTag(ctx, tag)\n\tif err != nil {\n\t\tif errors.Is(err, database.ErrNotFound) {\n\t\t\treturn model.TagDTO{}, model.ErrNotFound\n\t\t}\n\t\treturn model.TagDTO{}, err\n\t}\n\n\t// Fetch the updated tag to return\n\tupdatedTag, err := d.GetTag(ctx, tag.ID)\n\tif err != nil {\n\t\treturn model.TagDTO{}, err\n\t}\n\n\treturn updatedTag, nil\n}\n\nfunc (d *tagsDomain) DeleteTag(ctx context.Context, id int) error {\n\tif err := d.deps.Database().DeleteTag(ctx, id); err != nil {\n\t\tif errors.Is(err, database.ErrNotFound) {\n\t\t\treturn model.ErrNotFound\n\t\t}\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// TagExists checks if a tag with the given ID exists\nfunc (d *tagsDomain) TagExists(ctx context.Context, id int) (bool, error) {\n\treturn d.deps.Database().TagExists(ctx, id)\n}\n"
  },
  {
    "path": "internal/domains/tags_test.go",
    "content": "package domains_test\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\n// Tests for the tagsDomain implementation\nfunc TestTagsDomain(t *testing.T) {\n\tctx := context.Background()\n\tlogger := logrus.New()\n\n\t// Setup using the test configuration and dependencies\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\ttagsDomain := deps.Domains().Tags()\n\tdb := deps.Database()\n\n\t// Test ListTags\n\tt.Run(\"ListTags\", func(t *testing.T) {\n\t\t// Create some test tags first\n\t\ttestTags := []model.Tag{\n\t\t\t{Name: \"tag1\"},\n\t\t\t{Name: \"tag2\"},\n\t\t}\n\t\tcreatedTags, err := db.CreateTags(ctx, testTags...)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, createdTags, 2)\n\n\t\t// List the tags\n\t\ttags, err := tagsDomain.ListTags(ctx, model.ListTagsOptions{})\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, tags, 2)\n\n\t\t// Verify the tags\n\t\tassert.Equal(t, \"tag1\", tags[0].Name)\n\t\tassert.Equal(t, \"tag2\", tags[1].Name)\n\t})\n\n\t// Test ListTags with WithBookmarkCount\n\tt.Run(\"ListTags_WithBookmarkCount\", func(t *testing.T) {\n\t\t// Create a test tag\n\t\ttag := model.Tag{Name: \"tag-with-count\"}\n\t\tcreatedTags, err := db.CreateTags(ctx, tag)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, createdTags, 1)\n\n\t\t// Create a bookmark with this tag\n\t\tbookmark := model.BookmarkDTO{\n\t\t\tURL:   \"https://example-count.com\",\n\t\t\tTitle: \"Example for Count\",\n\t\t\tTags: []model.TagDTO{\n\t\t\t\t{Tag: model.Tag{Name: tag.Name}},\n\t\t\t},\n\t\t}\n\t\t_, err = db.SaveBookmarks(ctx, true, bookmark)\n\t\trequire.NoError(t, err)\n\n\t\t// List tags with bookmark count\n\t\ttags, err := tagsDomain.ListTags(ctx, model.ListTagsOptions{\n\t\t\tWithBookmarkCount: true,\n\t\t})\n\t\trequire.NoError(t, err)\n\t\trequire.NotEmpty(t, tags)\n\n\t\t// Find our test tag and verify it has a bookmark count\n\t\tvar foundTag model.TagDTO\n\t\tfor _, t := range tags {\n\t\t\tif t.Name == tag.Name {\n\t\t\t\tfoundTag = t\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\trequire.NotZero(t, foundTag.ID, \"Should find the test tag\")\n\t\tassert.Equal(t, int64(1), foundTag.BookmarkCount, \"Tag should have a bookmark count of 1\")\n\t})\n\n\t// Test ListTags with BookmarkID\n\tt.Run(\"ListTags_WithBookmarkID\", func(t *testing.T) {\n\t\t// Create test tags\n\t\ttestTags := []model.Tag{\n\t\t\t{Name: \"tag-for-bookmark1\"},\n\t\t\t{Name: \"tag-for-bookmark2\"},\n\t\t}\n\t\tcreatedTags, err := db.CreateTags(ctx, testTags...)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, createdTags, 2)\n\n\t\t// Create bookmarks with different tags\n\t\tbookmark1 := model.BookmarkDTO{\n\t\t\tURL:   \"https://example-bookmark1.com\",\n\t\t\tTitle: \"Example Bookmark 1\",\n\t\t\tTags: []model.TagDTO{\n\t\t\t\t{Tag: model.Tag{Name: testTags[0].Name}},\n\t\t\t},\n\t\t}\n\n\t\tbookmark2 := model.BookmarkDTO{\n\t\t\tURL:   \"https://example-bookmark2.com\",\n\t\t\tTitle: \"Example Bookmark 2\",\n\t\t\tTags: []model.TagDTO{\n\t\t\t\t{Tag: model.Tag{Name: testTags[1].Name}},\n\t\t\t},\n\t\t}\n\n\t\tsavedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark1, bookmark2)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, savedBookmarks, 2)\n\n\t\t// Get tags for the first bookmark\n\t\ttags, err := tagsDomain.ListTags(ctx, model.ListTagsOptions{\n\t\t\tBookmarkID: savedBookmarks[0].ID,\n\t\t})\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, tags, 1, \"Should return exactly one tag for the bookmark\")\n\t\tassert.Equal(t, testTags[0].Name, tags[0].Name, \"Should return the correct tag for the bookmark\")\n\n\t\t// Get tags for the second bookmark\n\t\ttags, err = tagsDomain.ListTags(ctx, model.ListTagsOptions{\n\t\t\tBookmarkID: savedBookmarks[1].ID,\n\t\t})\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, tags, 1, \"Should return exactly one tag for the bookmark\")\n\t\tassert.Equal(t, testTags[1].Name, tags[0].Name, \"Should return the correct tag for the bookmark\")\n\t})\n\n\t// Test ListTags with both options\n\tt.Run(\"ListTags_WithBothOptions\", func(t *testing.T) {\n\t\t// Create a test tag\n\t\ttag := model.Tag{Name: \"tag-with-both-options\"}\n\t\tcreatedTags, err := db.CreateTags(ctx, tag)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, createdTags, 1)\n\n\t\t// Create a bookmark with this tag\n\t\tbookmark := model.BookmarkDTO{\n\t\t\tURL:   \"https://example-both-options.com\",\n\t\t\tTitle: \"Example for Both Options\",\n\t\t\tTags: []model.TagDTO{\n\t\t\t\t{Tag: model.Tag{Name: tag.Name}},\n\t\t\t},\n\t\t}\n\t\tsavedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, savedBookmarks, 1)\n\n\t\t// List tags with both options\n\t\ttags, err := tagsDomain.ListTags(ctx, model.ListTagsOptions{\n\t\t\tBookmarkID:        savedBookmarks[0].ID,\n\t\t\tWithBookmarkCount: true,\n\t\t})\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, tags, 1, \"Should return exactly one tag\")\n\t\tassert.Equal(t, tag.Name, tags[0].Name, \"Should return the correct tag\")\n\t\tassert.Equal(t, int64(1), tags[0].BookmarkCount, \"Tag should have a bookmark count of 1\")\n\t})\n\n\t// Test CreateTag\n\tt.Run(\"CreateTag\", func(t *testing.T) {\n\t\t// Create a new tag\n\t\ttagDTO := model.TagDTO{\n\t\t\tTag: model.Tag{\n\t\t\t\tName: \"new-tag\",\n\t\t\t},\n\t\t}\n\n\t\tcreatedTag, err := tagsDomain.CreateTag(ctx, tagDTO)\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, \"new-tag\", createdTag.Name)\n\t\tassert.Greater(t, createdTag.ID, 0, \"The created tag should have a valid ID\")\n\n\t\t// Verify the tag was created in the database\n\t\tallTags, err := db.GetTags(ctx, model.DBListTagsOptions{})\n\t\trequire.NoError(t, err)\n\t\trequire.GreaterOrEqual(t, len(allTags), 1) // At least our new tag\n\n\t\t// Find the created tag in the list\n\t\tvar found bool\n\t\tfor _, tag := range allTags {\n\t\t\tif tag.Name == \"new-tag\" {\n\t\t\t\tfound = true\n\t\t\t\tassert.Greater(t, tag.ID, 0, \"The tag in the database should have a valid ID\")\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tassert.True(t, found, \"The created tag should be found in the database\")\n\t})\n\n\t// Test GetTag - Success\n\tt.Run(\"GetTag_Success\", func(t *testing.T) {\n\t\t// Get all tags to find an ID\n\t\tallTags, err := db.GetTags(ctx, model.DBListTagsOptions{})\n\t\trequire.NoError(t, err)\n\t\trequire.NotEmpty(t, allTags)\n\n\t\ttagID := allTags[0].ID\n\n\t\t// Get the tag by ID\n\t\ttag, err := tagsDomain.GetTag(ctx, tagID)\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, tagID, tag.ID)\n\t\tassert.Equal(t, allTags[0].Name, tag.Name)\n\t})\n\n\t// Test GetTag - Not Found\n\tt.Run(\"GetTag_NotFound\", func(t *testing.T) {\n\t\t// Try to get a non-existent tag\n\t\t_, err := tagsDomain.GetTag(ctx, 9999)\n\t\trequire.Error(t, err)\n\t\tassert.Equal(t, model.ErrNotFound, err)\n\t})\n\n\t// Test UpdateTag\n\tt.Run(\"UpdateTag\", func(t *testing.T) {\n\t\t// Get all tags to find an ID\n\t\tallTags, err := db.GetTags(ctx, model.DBListTagsOptions{})\n\t\trequire.NoError(t, err)\n\t\trequire.NotEmpty(t, allTags)\n\n\t\ttagID := allTags[0].ID\n\n\t\t// Update the tag\n\t\ttagDTO := model.TagDTO{\n\t\t\tTag: model.Tag{\n\t\t\t\tID:   tagID,\n\t\t\t\tName: \"updated-tag\",\n\t\t\t},\n\t\t}\n\n\t\tupdatedTag, err := tagsDomain.UpdateTag(ctx, tagDTO)\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, tagID, updatedTag.ID)\n\t\tassert.Equal(t, \"updated-tag\", updatedTag.Name)\n\n\t\t// Verify the tag was updated in the database\n\t\tdbTag, exists, err := db.GetTag(ctx, tagID)\n\t\trequire.NoError(t, err)\n\t\trequire.True(t, exists)\n\t\tassert.Equal(t, \"updated-tag\", dbTag.Name)\n\t})\n\n\t// Test DeleteTag\n\tt.Run(\"DeleteTag\", func(t *testing.T) {\n\t\t// Get all tags to find an ID\n\t\tallTags, err := db.GetTags(ctx, model.DBListTagsOptions{})\n\t\trequire.NoError(t, err)\n\t\trequire.NotEmpty(t, allTags)\n\n\t\ttagID := allTags[1].ID\n\n\t\t// Delete the tag\n\t\terr = tagsDomain.DeleteTag(ctx, tagID)\n\t\trequire.NoError(t, err)\n\n\t\t// Verify the tag was deleted from the database\n\t\t_, exists, err := db.GetTag(ctx, tagID)\n\t\trequire.NoError(t, err)\n\t\trequire.False(t, exists)\n\t})\n\n\t// Test DeleteTag - Not Found\n\tt.Run(\"DeleteTag_NotFound\", func(t *testing.T) {\n\t\t// Try to delete a non-existent tag\n\t\terr := tagsDomain.DeleteTag(ctx, 9999)\n\t\trequire.Error(t, err)\n\t\t// Use errors.Is to check if the error is or wraps model.ErrNotFound\n\t\tassert.True(t, errors.Is(err, model.ErrNotFound) || strings.Contains(err.Error(), \"not found\"),\n\t\t\t\"Expected error to be or contain 'not found', got: %v\", err)\n\t})\n}\n"
  },
  {
    "path": "internal/http/handlers/api/v1/accounts.go",
    "content": "package api_v1\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/go-shiori/shiori/internal/http/middleware\"\n\t\"github.com/go-shiori/shiori/internal/http/response\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\ntype createAccountPayload struct {\n\tUsername string `json:\"username\"`\n\tPassword string `json:\"password\"`\n\tOwner    bool   `json:\"owner\"`\n}\n\nfunc (p *createAccountPayload) ToAccountDTO() model.AccountDTO {\n\treturn model.AccountDTO{\n\t\tUsername: p.Username,\n\t\tPassword: p.Password,\n\t\tOwner:    &p.Owner,\n\t}\n}\n\n// @Summary\t\tList accounts\n// @Description\tList accounts\n// @Tags\t\t\taccounts\n// @Produce\t\tjson\n// @Success\t\t200\t{array}\t\tmodel.AccountDTO\n// @Failure\t\t500\t{string}\tstring\t\"Internal Server Error\"\n// @Router\t\t\t/api/v1/accounts [get]\nfunc HandleListAccounts(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInAdmin(deps, c); err != nil {\n\t\treturn\n\t}\n\n\taccounts, err := deps.Domains().Accounts().ListAccounts(c.Request().Context())\n\tif err != nil {\n\t\tdeps.Logger().WithError(err).Error(\"error getting accounts\")\n\t\tresponse.SendInternalServerError(c)\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusOK, accounts)\n}\n\n// @Summary\tCreate an account\n// @Tags\t\taccounts\n// @Accept\t\tjson\n// @Produce\tjson\n// @Success\t201\t{object}\tmodel.AccountDTO\n// @Failure\t400\t{object}\tnil\t\"Bad Request\"\n// @Failure\t409\t{object}\tnil\t\"Account already exists\"\n// @Failure\t500\t{object}\tnil\t\"Internal Server Error\"\n// @Router\t\t/api/v1/accounts [post]\nfunc HandleCreateAccount(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInAdmin(deps, c); err != nil {\n\t\treturn\n\t}\n\n\tvar payload createAccountPayload\n\tif err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"invalid json\")\n\t\treturn\n\t}\n\n\taccount, err := deps.Domains().Accounts().CreateAccount(c.Request().Context(), payload.ToAccountDTO())\n\tif err, isValidationErr := err.(model.ValidationError); isValidationErr {\n\t\tresponse.SendError(c, http.StatusBadRequest, err.Error())\n\t\treturn\n\t}\n\n\tif errors.Is(err, model.ErrAlreadyExists) {\n\t\tresponse.SendError(c, http.StatusConflict, \"account already exists\")\n\t\treturn\n\t}\n\n\tif err != nil {\n\t\tdeps.Logger().WithError(err).Error(\"error creating account\")\n\t\tresponse.SendInternalServerError(c)\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusCreated, account)\n}\n\n// @Summary\tDelete an account\n// @Tags\t\taccounts\n// @Produce\tjson\n// @Param\t\tid\tpath\t\tint\ttrue\t\"Account ID\"\n// @Success\t204\t{object}\tnil\t\"No content\"\n// @Failure\t400\t{object}\tnil\t\"Invalid ID\"\n// @Failure\t404\t{object}\tnil\t\"Account not found\"\n// @Failure\t500\t{object}\tnil\t\"Internal Server Error\"\n// @Router\t\t/api/v1/accounts/{id} [delete]\nfunc HandleDeleteAccount(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInAdmin(deps, c); err != nil {\n\t\treturn\n\t}\n\n\tid, err := strconv.Atoi(c.Request().PathValue(\"id\"))\n\tif err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"invalid id\")\n\t\treturn\n\t}\n\n\terr = deps.Domains().Accounts().DeleteAccount(c.Request().Context(), id)\n\tif errors.Is(err, model.ErrNotFound) {\n\t\tresponse.SendError(c, http.StatusNotFound, \"account not found\")\n\t\treturn\n\t}\n\n\tif err != nil {\n\t\tdeps.Logger().WithError(err).Error(\"error deleting account\")\n\t\tresponse.SendInternalServerError(c)\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusNoContent, nil)\n}\n\n// @Summary\tUpdate an account\n// @Tags\t\taccounts\n// @Accept\t\tjson\n// @Produce\tjson\n// @Param\t\tid\t\tpath\t\tint\t\t\t\t\t\ttrue\t\"Account ID\"\n// @Param\t\taccount\tbody\t\tupdateAccountPayload\ttrue\t\"Account data\"\n// @Success\t200\t\t{object}\tmodel.AccountDTO\n// @Failure\t400\t\t{object}\tnil\t\"Invalid ID/data\"\n// @Failure\t404\t\t{object}\tnil\t\"Account not found\"\n// @Failure\t409\t\t{object}\tnil\t\"Account already exists\"\n// @Failure\t500\t\t{object}\tnil\t\"Internal Server Error\"\n// @Router\t\t/api/v1/accounts/{id} [patch]\nfunc HandleUpdateAccount(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInAdmin(deps, c); err != nil {\n\t\treturn\n\t}\n\n\taccountID, err := strconv.Atoi(c.Request().PathValue(\"id\"))\n\tif err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"invalid id\")\n\t\treturn\n\t}\n\n\tvar payload updateAccountPayload\n\tif err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"invalid json\")\n\t\treturn\n\t}\n\n\tupdatedAccount := payload.ToAccountDTO()\n\tupdatedAccount.ID = model.DBID(accountID)\n\n\taccount, err := deps.Domains().Accounts().UpdateAccount(c.Request().Context(), updatedAccount)\n\tif errors.Is(err, model.ErrNotFound) {\n\t\tresponse.SendError(c, http.StatusNotFound, \"account not found\")\n\t\treturn\n\t}\n\tif errors.Is(err, model.ErrAlreadyExists) {\n\t\tresponse.SendError(c, http.StatusConflict, \"account already exists\")\n\t\treturn\n\t}\n\tif err, isValidationErr := err.(model.ValidationError); isValidationErr {\n\t\tresponse.SendError(c, http.StatusBadRequest, err.Error())\n\t\treturn\n\t}\n\n\tif err != nil {\n\t\tdeps.Logger().WithError(err).Error(\"error updating account\")\n\t\tresponse.SendInternalServerError(c)\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusOK, account)\n}\n"
  },
  {
    "path": "internal/http/handlers/api/v1/accounts_test.go",
    "content": "package api_v1\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestHandleListAccounts(t *testing.T) {\n\tlogger := logrus.New()\n\tctx := context.Background()\n\n\tt.Run(\"requires authentication\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tc, w := testutil.NewTestWebContext()\n\t\tHandleListAccounts(deps, c)\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"requires admin access\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeUser(c)\n\t\tHandleListAccounts(deps, c)\n\t\trequire.Equal(t, http.StatusForbidden, w.Code)\n\t})\n\n\tt.Run(\"database error\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeAdmin(c)\n\n\t\t// Force DB error by closing connection\n\t\tdeps.Database().ReaderDB().Close()\n\n\t\tHandleListAccounts(deps, c)\n\t\trequire.Equal(t, http.StatusInternalServerError, w.Code)\n\t})\n\n\tt.Run(\"returns accounts list\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\t// Create test account\n\t\t_, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{\n\t\t\tUsername: \"gopher\",\n\t\t\tPassword: \"shiori\",\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeAdmin(c)\n\t\tHandleListAccounts(deps, c)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t\tresponse.AssertMessageIsListLength(t, 1) // Admin + created account\n\t})\n}\n\nfunc TestHandleCreateAccount(t *testing.T) {\n\tlogger := logrus.New()\n\tctx := context.Background()\n\n\tt.Run(\"requires authentication\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tc, w := testutil.NewTestWebContext()\n\t\tHandleCreateAccount(deps, c)\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"requires admin access\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeUser(c)\n\t\tHandleCreateAccount(deps, c)\n\t\trequire.Equal(t, http.StatusForbidden, w.Code)\n\t})\n\n\tt.Run(\"invalid json payload\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tbody := `invalid json`\n\t\tw := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) {\n\t\t\ttestutil.SetFakeAdmin(c)\n\t\t\tHandleCreateAccount(deps, c)\n\t\t}, \"POST\", \"/api/v1/accounts\", testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"database error\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\t// Force DB error\n\t\tdeps.Database().WriterDB().Close()\n\n\t\tbody := `{\n\t\t\t\"username\": \"gopher\",\n\t\t\t\"password\": \"shiori\"\n\t\t}`\n\t\tw := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) {\n\t\t\ttestutil.SetFakeAdmin(c)\n\t\t\tHandleCreateAccount(deps, c)\n\t\t}, \"POST\", \"/api/v1/accounts\", testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusInternalServerError, w.Code)\n\t})\n\n\tt.Run(\"account already exists\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\t// Create first account\n\t\t_, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{\n\t\t\tUsername: \"gopher\",\n\t\t\tPassword: \"shiori\",\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\t// Try to create duplicate account\n\t\tbody := `{\n\t\t\t\"username\": \"gopher\",\n\t\t\t\"password\": \"shiori\"\n\t\t}`\n\t\tw := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) {\n\t\t\ttestutil.SetFakeAdmin(c)\n\t\t\tHandleCreateAccount(deps, c)\n\t\t}, \"POST\", \"/api/v1/accounts\", testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusConflict, w.Code)\n\t})\n\n\tt.Run(\"successful creation\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tbody := `{\n\t\t\t\"username\": \"newuser\",\n\t\t\t\"password\": \"password\",\n\t\t\t\"owner\": false\n\t\t}`\n\t\tw := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) {\n\t\t\ttestutil.SetFakeAdmin(c)\n\t\t\tHandleCreateAccount(deps, c)\n\t\t}, \"POST\", \"/api/v1/accounts\", testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusCreated, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t\tresponse.AssertMessageJSONKeyValue(t, \"id\", func(t *testing.T, value any) {\n\t\t\trequire.NotZero(t, value)\n\t\t})\n\t})\n}\n\nfunc TestHandleDeleteAccount(t *testing.T) {\n\tlogger := logrus.New()\n\tctx := context.Background()\n\n\tt.Run(\"requires authentication\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tc, w := testutil.NewTestWebContext()\n\t\tHandleDeleteAccount(deps, c)\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"requires admin access\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeUser(c)\n\t\tHandleDeleteAccount(deps, c)\n\t\trequire.Equal(t, http.StatusForbidden, w.Code)\n\t})\n\n\tt.Run(\"invalid id\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeAdmin(c)\n\t\ttestutil.SetRequestPathValue(c, \"id\", \"invalid\")\n\t\tHandleDeleteAccount(deps, c)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"account not found\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeAdmin(c)\n\t\ttestutil.SetRequestPathValue(c, \"id\", \"999\")\n\t\tHandleDeleteAccount(deps, c)\n\t\trequire.Equal(t, http.StatusNotFound, w.Code)\n\t})\n\n\tt.Run(\"successful deletion\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\t// Create account to delete\n\t\taccount, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{\n\t\t\tUsername: \"todelete\",\n\t\t\tPassword: \"password\",\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeAdmin(c)\n\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(int(account.ID)))\n\t\tHandleDeleteAccount(deps, c)\n\t\trequire.Equal(t, http.StatusNoContent, w.Code)\n\t})\n}\n\nfunc TestHandleUpdateAccount(t *testing.T) {\n\tlogger := logrus.New()\n\tctx := context.Background()\n\n\tt.Run(\"requires authentication\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tc, w := testutil.NewTestWebContext()\n\t\tHandleUpdateAccount(deps, c)\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"requires admin access\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeUser(c)\n\t\tHandleUpdateAccount(deps, c)\n\t\trequire.Equal(t, http.StatusForbidden, w.Code)\n\t})\n\n\tt.Run(\"invalid id\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeAdmin(c)\n\t\ttestutil.SetRequestPathValue(c, \"id\", \"invalid\")\n\t\tHandleUpdateAccount(deps, c)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"invalid json payload\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tbody := `invalid json`\n\t\tw := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) {\n\t\t\ttestutil.SetFakeAdmin(c)\n\t\t\tHandleUpdateAccount(deps, c)\n\t\t}, \"PATCH\", \"/api/v1/accounts/1\", testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"account not found\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tbody := `{\"username\": \"newname\"}`\n\t\tw := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) {\n\t\t\ttestutil.SetRequestPathValue(c, \"id\", \"999\")\n\t\t\ttestutil.SetFakeAdmin(c)\n\t\t\tHandleUpdateAccount(deps, c)\n\t\t}, \"PATCH\", \"/api/v1/accounts/999\", testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusNotFound, w.Code)\n\t})\n\n\tt.Run(\"successful update\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\t// Create account to update\n\t\taccount, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{\n\t\t\tUsername: \"shiori\",\n\t\t\tPassword: \"gopher\",\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\tbody := `{\n\t\t\t\"username\": \"updated\",\n\t\t\t\"owner\": true\n\t\t}`\n\t\tw := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) {\n\t\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(int(account.ID)))\n\t\t\ttestutil.SetFakeAdmin(c)\n\t\t\tHandleUpdateAccount(deps, c)\n\t\t}, \"PATCH\", \"/api/v1/accounts/\"+strconv.Itoa(int(account.ID)), testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t\tresponse.AssertMessageJSONKeyValue(t, \"owner\", func(t *testing.T, value any) {\n\t\t\trequire.True(t, value.(bool))\n\t\t})\n\t})\n\n\tt.Run(\"update with empty payload\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\taccount, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{\n\t\t\tUsername: \"shiori\",\n\t\t\tPassword: \"gopher\",\n\t\t\tOwner:    model.Ptr(false),\n\t\t\tConfig: model.Ptr(model.UserConfig{\n\t\t\t\tShowId:        true,\n\t\t\t\tListMode:      true,\n\t\t\t\tHideThumbnail: true,\n\t\t\t}),\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\tbody := `{}`\n\t\tw := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) {\n\t\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(int(account.ID)))\n\t\t\ttestutil.SetFakeAdmin(c)\n\t\t\tHandleUpdateAccount(deps, c)\n\t\t}, \"PATCH\", \"/api/v1/accounts/\"+strconv.Itoa(int(account.ID)), testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\n\t\t// Verify no changes were made\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertNotOk(t)\n\t})\n\n\tt.Run(\"update username only\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\taccount, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{\n\t\t\tUsername: \"shiori\",\n\t\t\tPassword: \"gopher\",\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\tbody := `{\"username\": \"newname\"}`\n\t\tw := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) {\n\t\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(int(account.ID)))\n\t\t\ttestutil.SetFakeAdmin(c)\n\t\t\tHandleUpdateAccount(deps, c)\n\t\t}, \"PATCH\", \"/api/v1/accounts/\"+strconv.Itoa(int(account.ID)), testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t\tresponse.AssertMessageJSONKeyValue(t, \"username\", func(t *testing.T, value any) {\n\t\t\trequire.Equal(t, \"newname\", value)\n\t\t})\n\t})\n\n\tt.Run(\"update password only\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\taccount, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{\n\t\t\tUsername: \"shiori\",\n\t\t\tPassword: \"gopher\",\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\tbody := `{\"new_password\": \"newpass\"}`\n\t\tw := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) {\n\t\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(int(account.ID)))\n\t\t\ttestutil.SetFakeAdmin(c)\n\t\t\tHandleUpdateAccount(deps, c)\n\t\t}, \"PATCH\", \"/api/v1/accounts/\"+strconv.Itoa(int(account.ID)), testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\t// Verify we can login with new password\n\t\tloginBody := `{\"username\": \"shiori\", \"password\": \"newpass\"}`\n\t\tw = testutil.PerformRequest(deps, HandleLogin, \"POST\", \"/login\", testutil.WithBody(loginBody))\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t})\n\n\tt.Run(\"only admin can update other's passwords\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\taccount, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{\n\t\t\tUsername: \"shiori\",\n\t\t\tPassword: \"gopher\",\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\tbody := `{\"new_password\": \"newpass\"}`\n\t\tw := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) {\n\t\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(int(account.ID)))\n\t\t\ttestutil.SetFakeUser(c)\n\t\t\tHandleUpdateAccount(deps, c)\n\t\t}, \"PATCH\", \"/api/v1/accounts/\"+strconv.Itoa(int(account.ID)), testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusForbidden, w.Code)\n\t})\n\n\tt.Run(\"update config only\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\taccount, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{\n\t\t\tUsername: \"shiori\",\n\t\t\tPassword: \"gopher\",\n\t\t\tConfig: model.Ptr(model.UserConfig{\n\t\t\t\tShowId:   false,\n\t\t\t\tListMode: false,\n\t\t\t}),\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\tbody := `{\n\t\t\t\"config\": {\n\t\t\t\t\"ShowId\": true,\n\t\t\t\t\"ListMode\": true,\n\t\t\t\t\"HideThumbnail\": true,\n\t\t\t\t\"HideExcerpt\": true,\n\t\t\t\t\"Theme\": \"dark\",\n\t\t\t\t\"KeepMetadata\": true,\n\t\t\t\t\"UseArchive\": true,\n\t\t\t\t\"CreateEbook\": true,\n\t\t\t\t\"MakePublic\": true\n\t\t\t}\n\t\t}`\n\t\tw := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) {\n\t\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(int(account.ID)))\n\t\t\ttestutil.SetFakeAdmin(c)\n\t\t\tHandleUpdateAccount(deps, c)\n\t\t}, \"PATCH\", \"/api/v1/accounts/\"+strconv.Itoa(int(account.ID)), testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t\tresponse.AssertMessageJSONKeyValue(t, \"config\", func(t *testing.T, value any) {\n\t\t\tconfig := value.(map[string]any)\n\t\t\trequire.True(t, config[\"ShowId\"].(bool))\n\t\t\trequire.True(t, config[\"ListMode\"].(bool))\n\t\t\trequire.True(t, config[\"HideThumbnail\"].(bool))\n\t\t\trequire.True(t, config[\"HideExcerpt\"].(bool))\n\t\t\trequire.Equal(t, \"dark\", config[\"Theme\"])\n\t\t\trequire.True(t, config[\"KeepMetadata\"].(bool))\n\t\t\trequire.True(t, config[\"UseArchive\"].(bool))\n\t\t\trequire.True(t, config[\"CreateEbook\"].(bool))\n\t\t\trequire.True(t, config[\"MakePublic\"].(bool))\n\t\t})\n\t})\n\n\tt.Run(\"update all fields\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\taccount, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{\n\t\t\tUsername: \"shiori\",\n\t\t\tPassword: \"gopher\",\n\t\t\tOwner:    model.Ptr(false),\n\t\t\tConfig: model.Ptr(model.UserConfig{\n\t\t\t\tShowId:   false,\n\t\t\t\tListMode: false,\n\t\t\t}),\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\tbody := `{\n\t\t\t\"username\": \"updated\",\n\t\t\t\"new_password\": \"newpass\",\n\t\t\t\"owner\": true,\n\t\t\t\"config\": {\n\t\t\t\t\"ShowId\": true,\n\t\t\t\t\"ListMode\": true,\n\t\t\t\t\"HideThumbnail\": true,\n\t\t\t\t\"HideExcerpt\": true,\n\t\t\t\t\"Theme\": \"dark\"\n\t\t\t}\n\t\t}`\n\t\tw := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) {\n\t\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(int(account.ID)))\n\t\t\ttestutil.SetFakeAdmin(c)\n\t\t\tHandleUpdateAccount(deps, c)\n\t\t}, \"PATCH\", \"/api/v1/accounts/\"+strconv.Itoa(int(account.ID)), testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t\tresponse.AssertMessageJSONKeyValue(t, \"username\", func(t *testing.T, value any) {\n\t\t\trequire.Equal(t, \"updated\", value)\n\t\t})\n\t\tresponse.AssertMessageJSONKeyValue(t, \"owner\", func(t *testing.T, value any) {\n\t\t\trequire.True(t, value.(bool))\n\t\t})\n\t\tresponse.AssertMessageJSONKeyValue(t, \"config\", func(t *testing.T, value any) {\n\t\t\tconfig := value.(map[string]any)\n\t\t\trequire.True(t, config[\"ShowId\"].(bool))\n\t\t\trequire.True(t, config[\"ListMode\"].(bool))\n\t\t\trequire.True(t, config[\"HideThumbnail\"].(bool))\n\t\t\trequire.True(t, config[\"HideExcerpt\"].(bool))\n\t\t\trequire.Equal(t, \"dark\", config[\"Theme\"])\n\t\t})\n\n\t\t// Verify password change\n\t\tloginBody := `{\"username\": \"updated\", \"password\": \"newpass\"}`\n\t\tw = testutil.PerformRequest(deps, HandleLogin, \"POST\", \"/login\", testutil.WithBody(loginBody))\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t})\n}\n"
  },
  {
    "path": "internal/http/handlers/api/v1/auth.go",
    "content": "package api_v1\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/http/middleware\"\n\t\"github.com/go-shiori/shiori/internal/http/response\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\ntype loginRequestPayload struct {\n\tUsername   string `json:\"username\"`\n\tPassword   string `json:\"password\"`\n\tRememberMe bool   `json:\"remember_me\"`\n}\n\nfunc (p *loginRequestPayload) IsValid() error {\n\tif p.Username == \"\" {\n\t\treturn fmt.Errorf(\"username should not be empty\")\n\t}\n\tif p.Password == \"\" {\n\t\treturn fmt.Errorf(\"password should not be empty\")\n\t}\n\treturn nil\n}\n\ntype loginResponseMessage struct {\n\tToken      string `json:\"token\"`\n\tExpiration int64  `json:\"expires\"`\n}\n\n// @Summary\tLogin to an account using username and password\n// @Tags\t\tAuth\n// @Accept\t\tjson\n// @Produce\tjson\n// @Param\t\tpayload\tbody\t\tloginRequestPayload\t\tfalse\t\"Login data\"\n// @Success\t200\t\t{object}\tloginResponseMessage\t\"Login successful\"\n// @Failure\t400\t\t{object}\tnil\t\t\t\t\t\t\"Invalid login data\"\n// @Router\t\t/api/v1/auth/login [post]\nfunc HandleLogin(deps model.Dependencies, c model.WebContext) {\n\tvar payload loginRequestPayload\n\tif err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"Invalid JSON payload\")\n\t\treturn\n\t}\n\n\tif err := payload.IsValid(); err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, err.Error())\n\t\treturn\n\t}\n\n\taccount, err := deps.Domains().Auth().GetAccountFromCredentials(c.Request().Context(), payload.Username, payload.Password)\n\tif err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, err.Error())\n\t\treturn\n\t}\n\n\texpiration := time.Hour\n\tif payload.RememberMe {\n\t\texpiration = time.Hour * 24 * 30\n\t}\n\n\texpirationTime := time.Now().Add(expiration)\n\n\ttoken, err := deps.Domains().Auth().CreateTokenForAccount(account, expirationTime)\n\tif err != nil {\n\t\tresponse.SendInternalServerError(c)\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusOK, loginResponseMessage{\n\t\tToken:      token,\n\t\tExpiration: expirationTime.Unix(),\n\t})\n}\n\n// @Summary\t\t\t\t\tRefresh a token for an account\n// @Tags\t\t\t\t\t\tAuth\n// @securityDefinitions.apikey\tApiKeyAuth\n// @Produce\t\t\t\t\tjson\n// @Success\t\t\t\t\t200\t{object}\tloginResponseMessage\t\"Refresh successful\"\n// @Failure\t\t\t\t\t403\t{object}\tnil\t\t\t\t\t\t\"Token not provided/invalid\"\n// @Router\t\t\t\t\t\t/api/v1/auth/refresh [post]\nfunc HandleRefreshToken(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInUser(deps, c); err != nil {\n\t\treturn\n\t}\n\n\texpiration := time.Now().UTC().Add(time.Hour * 24 * 30)\n\taccount := c.GetAccount()\n\ttoken, err := deps.Domains().Auth().CreateTokenForAccount(account, expiration)\n\tif err != nil {\n\t\tresponse.SendInternalServerError(c)\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusAccepted, loginResponseMessage{\n\t\tToken:      token,\n\t\tExpiration: expiration.Unix(),\n\t})\n}\n\n// @Summary\t\t\t\t\tGet information for the current logged in user\n// @Tags\t\t\t\t\t\tAuth\n// @securityDefinitions.apikey\tApiKeyAuth\n// @Produce\t\t\t\t\tjson\n// @Success\t\t\t\t\t200\t{object}\tmodel.Account\n// @Failure\t\t\t\t\t403\t{object}\tnil\t\"Token not provided/invalid\"\n// @Router\t\t\t\t\t\t/api/v1/auth/me [get]\nfunc HandleGetMe(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInUser(deps, c); err != nil {\n\t\treturn\n\t}\n\tresponse.SendJSON(c, http.StatusOK, c.GetAccount())\n}\n\ntype updateAccountPayload struct {\n\tOldPassword string            `json:\"old_password\"`\n\tNewPassword string            `json:\"new_password\"`\n\tUsername    string            `json:\"username\"`\n\tOwner       *bool             `json:\"owner\"`\n\tConfig      *model.UserConfig `json:\"config\"`\n}\n\nfunc (p *updateAccountPayload) IsValid() error {\n\tif p.NewPassword != \"\" && p.OldPassword == \"\" {\n\t\treturn fmt.Errorf(\"to update the password the old one must be provided\")\n\t}\n\treturn nil\n}\n\nfunc (p *updateAccountPayload) ToAccountDTO() model.AccountDTO {\n\taccount := model.AccountDTO{\n\t\tConfig: p.Config,\n\t}\n\tif p.NewPassword != \"\" {\n\t\taccount.Password = p.NewPassword\n\t}\n\tif p.Owner != nil {\n\t\taccount.Owner = p.Owner\n\t}\n\tif p.Config != nil {\n\t\taccount.Config = p.Config\n\t}\n\tif p.Username != \"\" {\n\t\taccount.Username = p.Username\n\t}\n\treturn account\n}\n\n// @Summary\t\t\t\t\tUpdate account information\n// @Tags\t\t\t\t\t\tAuth\n// @securityDefinitions.apikey\tApiKeyAuth\n// @Param\t\t\t\t\t\tpayload\tbody\tupdateAccountPayload\tfalse\t\"Account data\"\n// @Produce\t\t\t\t\tjson\n// @Success\t\t\t\t\t200\t{object}\tmodel.Account\n// @Failure\t\t\t\t\t403\t{object}\tnil\t\"Token not provided/invalid\"\n// @Router\t\t\t\t\t\t/api/v1/auth/account [patch]\nfunc HandleUpdateLoggedAccount(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInUser(deps, c); err != nil {\n\t\treturn\n\t}\n\n\tvar payload updateAccountPayload\n\tif err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil {\n\t\tresponse.SendInternalServerError(c)\n\t\treturn\n\t}\n\n\tif err := payload.IsValid(); err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, err.Error())\n\t\treturn\n\t}\n\n\taccount := c.GetAccount()\n\n\tif payload.NewPassword != \"\" {\n\t\t_, err := deps.Domains().Auth().GetAccountFromCredentials(c.Request().Context(), account.Username, payload.OldPassword)\n\t\tif err != nil {\n\t\t\tresponse.SendError(c, http.StatusBadRequest, \"Old password is incorrect\")\n\t\t\treturn\n\t\t}\n\t}\n\n\t// TODO: Use a method in the AccountDTO to apply the updates directly:\n\t// account := domains.Accounts().GetAccount(...)\n\t// account.ApplyUpdates(payload)\n\tupdatedAccount := payload.ToAccountDTO()\n\tupdatedAccount.ID = account.ID\n\n\taccount, err := deps.Domains().Accounts().UpdateAccount(c.Request().Context(), updatedAccount)\n\tif err != nil {\n\t\tdeps.Logger().WithError(err).Error(\"failed to update account\")\n\t\tresponse.SendInternalServerError(c)\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusOK, account)\n}\n\n// @Summary\t\t\t\t\tLogout from the current session\n// @Tags\t\t\t\t\t\tAuth\n// @securityDefinitions.apikey\tApiKeyAuth\n// @Produce\t\t\t\t\tjson\n// @Success\t\t\t\t\t200\t{object}\tnil\t\"Logout successful\"\n// @Failure\t\t\t\t\t403\t{object}\tnil\t\"Token not provided/invalid\"\n// @Router\t\t\t\t\t\t/api/v1/auth/logout [post]\nfunc HandleLogout(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInUser(deps, c); err != nil {\n\t\treturn\n\t}\n\n\t// Remove token cookie\n\tc.Request().AddCookie(&http.Cookie{\n\t\tName:  \"token\",\n\t\tValue: \"\",\n\t})\n\n\tresponse.SendJSON(c, http.StatusOK, nil)\n}\n"
  },
  {
    "path": "internal/http/handlers/api/v1/auth_test.go",
    "content": "package api_v1\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestHandleLogin(t *testing.T) {\n\tlogger := logrus.New()\n\t// _, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger)\n\n\tt.Run(\"invalid json payload\", func(t *testing.T) {\n\t\tctx := context.Background()\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tbody := `{\"username\":}`\n\t\tw := testutil.PerformRequest(deps, HandleLogin, \"POST\", \"/login\", testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"missing username\", func(t *testing.T) {\n\t\tctx := context.Background()\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tbody := `{\"password\": \"test\"}`\n\t\tw := testutil.PerformRequest(deps, HandleLogin, \"POST\", \"/login\", testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"missing password\", func(t *testing.T) {\n\t\tctx := context.Background()\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tbody := `{\"username\": \"test\"}`\n\t\tw := testutil.PerformRequest(deps, HandleLogin, \"POST\", \"/login\", testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"invalid credentials\", func(t *testing.T) {\n\t\tctx := context.Background()\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tbody := `{\"username\": \"test\", \"password\": \"wrong\"}`\n\t\tw := testutil.PerformRequest(deps, HandleLogin, \"POST\", \"/login\", testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"successful login\", func(t *testing.T) {\n\t\tctx := context.Background()\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\taccount := testutil.GetValidAccount().ToDTO()\n\t\taccount.Password = \"test\"\n\t\t_, err := deps.Domains().Accounts().CreateAccount(context.Background(), account)\n\t\trequire.NoError(t, err)\n\n\t\tbody := `{\n\t\t\t\"username\": \"test\",\n\t\t\t\"password\": \"test\",\n\t\t\t\"remember_me\": true\n\t\t}`\n\t\tw := testutil.PerformRequest(deps, HandleLogin, \"POST\", \"/login\", testutil.WithBody(body))\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t\tresponse.AssertMessageJSONKeyValue(t, \"token\", func(t *testing.T, value any) {\n\t\t\trequire.NotEmpty(t, value)\n\t\t})\n\t\tresponse.AssertMessageJSONKeyValue(t, \"expires\", func(t *testing.T, value any) {\n\t\t\trequire.NotEmpty(t, value)\n\t\t})\n\t})\n}\n\nfunc TestHandleRefreshToken(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger)\n\n\tt.Run(\"requires authentication\", func(t *testing.T) {\n\t\tw := testutil.PerformRequest(deps, HandleRefreshToken, \"POST\", \"/refresh\")\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"successful refresh\", func(t *testing.T) {\n\t\taccount := testutil.GetValidAccount().ToDTO()\n\t\taccount.Password = \"test\"\n\t\t_, err := deps.Domains().Accounts().CreateAccount(context.Background(), account)\n\t\trequire.NoError(t, err)\n\n\t\tw := testutil.PerformRequest(deps, HandleRefreshToken, \"POST\", \"/refresh\", testutil.WithAccount(&account))\n\t\trequire.Equal(t, http.StatusAccepted, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t\tresponse.AssertMessageJSONKeyValue(t, \"token\", func(t *testing.T, value any) {\n\t\t\trequire.NotEmpty(t, value)\n\t\t})\n\t\tresponse.AssertMessageJSONKeyValue(t, \"expires\", func(t *testing.T, value any) {\n\t\t\trequire.NotZero(t, value)\n\t\t})\n\t})\n}\n\nfunc TestHandleGetMe(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger)\n\n\tt.Run(\"requires authentication\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\tHandleGetMe(deps, c)\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"returns user info\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeUser(c)\n\t\tHandleGetMe(deps, c)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t\tresponse.AssertMessageJSONKeyValue(t, \"username\", func(t *testing.T, value any) {\n\t\t\trequire.Equal(t, \"user\", value)\n\t\t})\n\t\tresponse.AssertMessageJSONKeyValue(t, \"owner\", func(t *testing.T, value any) {\n\t\t\trequire.False(t, value.(bool))\n\t\t})\n\t})\n\n\tt.Run(\"returns admin info\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeAdmin(c)\n\t\tHandleGetMe(deps, c)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t\tresponse.AssertMessageJSONKeyValue(t, \"username\", func(t *testing.T, value any) {\n\t\t\trequire.Equal(t, \"user\", value)\n\t\t})\n\t\tresponse.AssertMessageJSONKeyValue(t, \"owner\", func(t *testing.T, value any) {\n\t\t\trequire.True(t, value.(bool))\n\t\t})\n\t})\n}\n\nfunc TestHandleUpdateLoggedAccount(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger)\n\n\taccount, err := deps.Domains().Accounts().CreateAccount(context.Background(), model.AccountDTO{\n\t\tUsername: \"shiori\",\n\t\tPassword: \"gopher\",\n\t\tOwner:    model.Ptr(true),\n\t\tConfig: model.Ptr(model.UserConfig{\n\t\t\tShowId:        true,\n\t\t\tListMode:      true,\n\t\t\tHideThumbnail: true,\n\t\t\tHideExcerpt:   true,\n\t\t\tKeepMetadata:  true,\n\t\t\tUseArchive:    true,\n\t\t\tCreateEbook:   true,\n\t\t\tMakePublic:    true,\n\t\t}),\n\t})\n\trequire.NoError(t, err)\n\n\tt.Run(\"requires authentication\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\tHandleUpdateLoggedAccount(deps, c)\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"invalid json payload\", func(t *testing.T) {\n\t\tbody := `invalid json`\n\t\tw := testutil.PerformRequest(deps, HandleUpdateLoggedAccount, \"PATCH\", \"/account\", testutil.WithBody(body), testutil.WithAccount(account))\n\t\trequire.Equal(t, http.StatusInternalServerError, w.Code)\n\t})\n\n\tt.Run(\"missing old password\", func(t *testing.T) {\n\t\tbody := `{\"new_password\": \"newpass\"}`\n\t\tw := testutil.PerformRequest(deps, HandleUpdateLoggedAccount, \"PATCH\", \"/account\", testutil.WithBody(body), testutil.WithAccount(account))\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"incorrect old password\", func(t *testing.T) {\n\t\tbody := `{\n\t\t\t\"old_password\": \"wrong\",\n\t\t\t\"new_password\": \"newpass\"\n\t\t}`\n\t\tw := testutil.PerformRequest(deps, HandleUpdateLoggedAccount, \"PATCH\", \"/account\", testutil.WithBody(body), testutil.WithAccount(account))\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"successful update\", func(t *testing.T) {\n\t\tbody := `{\n\t\t\t\"old_password\": \"gopher\",\n\t\t\t\"new_password\": \"newpass\",\n\t\t\t\"config\": {\n\t\t\t\t\"ShowId\": true,\n\t\t\t\t\"ListMode\": true\n\t\t\t}\n\t\t}`\n\t\tw := testutil.PerformRequest(deps, HandleUpdateLoggedAccount, \"PATCH\", \"/account\", testutil.WithBody(body), testutil.WithAccount(account))\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t\tresponse.AssertMessageJSONKeyValue(t, \"username\", func(t *testing.T, value any) {\n\t\t\trequire.Equal(t, \"shiori\", value)\n\t\t})\n\t\tresponse.AssertMessageJSONKeyValue(t, \"config\", func(t *testing.T, value any) {\n\t\t\tconfig := value.(map[string]any)\n\t\t\trequire.True(t, config[\"ShowId\"].(bool))\n\t\t\trequire.True(t, config[\"ListMode\"].(bool))\n\t\t})\n\t})\n}\n\nfunc TestHandleLogout(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger)\n\n\tt.Run(\"requires authentication\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\tHandleLogout(deps, c)\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"successful logout\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeUser(c)\n\t\tHandleLogout(deps, c)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t})\n}\n"
  },
  {
    "path": "internal/http/handlers/api/v1/bookmark_tags_test.go",
    "content": "package api_v1_test\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"testing\"\n\n\tapi_v1 \"github.com/go-shiori/shiori/internal/http/handlers/api/v1\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\n// Define the BookmarkTagPayload struct to match the one in the API\ntype bookmarkTagPayload struct {\n\tTagID int `json:\"tag_id\"`\n}\n\nfunc TestBookmarkTagsAPI(t *testing.T) {\n\tctx := context.Background()\n\tlogger := logrus.New()\n\n\t// Setup using the test configuration and dependencies\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\tdb := deps.Database()\n\n\t// Create a test bookmark\n\tbookmark := model.BookmarkDTO{\n\t\tURL:   \"https://example.com/api-tags-test\",\n\t\tTitle: \"API Tags Test\",\n\t}\n\tsavedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark)\n\trequire.NoError(t, err)\n\trequire.Len(t, savedBookmarks, 1)\n\tbookmarkID := savedBookmarks[0].ID\n\n\t// Create a test tag\n\ttag := model.Tag{\n\t\tName: \"api-test-tag\",\n\t}\n\tcreatedTags, err := db.CreateTags(ctx, tag)\n\trequire.NoError(t, err)\n\trequire.Len(t, createdTags, 1)\n\ttagID := createdTags[0].ID\n\n\t// Test authentication requirements\n\tt.Run(\"AuthenticationRequirements\", func(t *testing.T) {\n\t\t// Test unauthenticated user for GetBookmarkTags\n\t\tt.Run(\"UnauthenticatedUserGetTags\", func(t *testing.T) {\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleGetBookmarkTags,\n\t\t\t\thttp.MethodGet,\n\t\t\t\t\"/api/v1/bookmarks/\"+strconv.Itoa(bookmarkID)+\"/tags\",\n\t\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(bookmarkID)),\n\t\t\t)\n\n\t\t\trequire.Equal(t, http.StatusUnauthorized, rec.Code)\n\t\t})\n\n\t\t// Test unauthenticated user for AddTagToBookmark\n\t\tt.Run(\"UnauthenticatedUserAddTag\", func(t *testing.T) {\n\t\t\tpayload := bookmarkTagPayload{\n\t\t\t\tTagID: tagID,\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleAddTagToBookmark,\n\t\t\t\thttp.MethodPost,\n\t\t\t\t\"/api/v1/bookmarks/\"+strconv.Itoa(bookmarkID)+\"/tags\",\n\t\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(bookmarkID)),\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\trequire.Equal(t, http.StatusUnauthorized, rec.Code)\n\t\t})\n\n\t\t// Test non-admin user for AddTagToBookmark (which requires admin)\n\t\tt.Run(\"NonAdminUserAddTag\", func(t *testing.T) {\n\t\t\tpayload := bookmarkTagPayload{\n\t\t\t\tTagID: tagID,\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleAddTagToBookmark,\n\t\t\t\thttp.MethodPost,\n\t\t\t\t\"/api/v1/bookmarks/\"+strconv.Itoa(bookmarkID)+\"/tags\",\n\t\t\t\ttestutil.WithFakeUser(), // Regular user, not admin\n\t\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(bookmarkID)),\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\t// Just check the status code since the response might vary\n\t\t\trequire.Equal(t, http.StatusForbidden, rec.Code)\n\t\t})\n\n\t\t// Test unauthenticated user for RemoveTagFromBookmark\n\t\tt.Run(\"UnauthenticatedUserRemoveTag\", func(t *testing.T) {\n\t\t\tpayload := bookmarkTagPayload{\n\t\t\t\tTagID: tagID,\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleRemoveTagFromBookmark,\n\t\t\t\thttp.MethodDelete,\n\t\t\t\t\"/api/v1/bookmarks/\"+strconv.Itoa(bookmarkID)+\"/tags\",\n\t\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(bookmarkID)),\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\trequire.Equal(t, http.StatusUnauthorized, rec.Code)\n\t\t})\n\t})\n\n\t// Test BulkUpdateBookmarkTags\n\tt.Run(\"BulkUpdateBookmarkTags\", func(t *testing.T) {\n\t\t// Define the payload struct\n\t\ttype bulkUpdatePayload struct {\n\t\t\tBookmarkIDs []int `json:\"bookmark_ids\"`\n\t\t\tTagIDs      []int `json:\"tag_ids\"`\n\t\t}\n\n\t\t// Test successful bulk update\n\t\tt.Run(\"SuccessfulBulkUpdate\", func(t *testing.T) {\n\t\t\tpayload := bulkUpdatePayload{\n\t\t\t\tBookmarkIDs: []int{bookmarkID},\n\t\t\t\tTagIDs:      []int{tagID},\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleBulkUpdateBookmarkTags,\n\t\t\t\thttp.MethodPut,\n\t\t\t\t\"/api/v1/bookmarks/bulk/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\trequire.Equal(t, http.StatusOK, rec.Code)\n\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertOk(t)\n\t\t})\n\n\t\t// Test unauthenticated user\n\t\tt.Run(\"UnauthenticatedUser\", func(t *testing.T) {\n\t\t\tpayload := bulkUpdatePayload{\n\t\t\t\tBookmarkIDs: []int{bookmarkID},\n\t\t\t\tTagIDs:      []int{tagID},\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleBulkUpdateBookmarkTags,\n\t\t\t\thttp.MethodPut,\n\t\t\t\t\"/api/v1/bookmarks/bulk/tags\",\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\trequire.Equal(t, http.StatusUnauthorized, rec.Code)\n\t\t})\n\n\t\t// Test invalid request payload\n\t\tt.Run(\"InvalidRequestPayload\", func(t *testing.T) {\n\t\t\tinvalidPayload := []byte(`{\"bookmark_ids\": \"invalid\", \"tag_ids\": [1]}`)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleBulkUpdateBookmarkTags,\n\t\t\t\thttp.MethodPut,\n\t\t\t\t\"/api/v1/bookmarks/bulk/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithBody(string(invalidPayload)),\n\t\t\t)\n\n\t\t\trequire.Equal(t, http.StatusBadRequest, rec.Code)\n\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertNotOk(t)\n\t\t\ttestResp.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\t\trequire.Equal(t, \"Invalid request payload\", value)\n\t\t\t})\n\t\t})\n\n\t\t// Test empty bookmark IDs\n\t\tt.Run(\"EmptyBookmarkIDs\", func(t *testing.T) {\n\t\t\tpayload := bulkUpdatePayload{\n\t\t\t\tBookmarkIDs: []int{},\n\t\t\t\tTagIDs:      []int{tagID},\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleBulkUpdateBookmarkTags,\n\t\t\t\thttp.MethodPut,\n\t\t\t\t\"/api/v1/bookmarks/bulk/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\trequire.Equal(t, http.StatusBadRequest, rec.Code)\n\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertNotOk(t)\n\t\t\ttestResp.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\t\trequire.Equal(t, \"bookmark_ids should not be empty\", value)\n\t\t\t})\n\t\t})\n\n\t\t// Test empty tag IDs\n\t\tt.Run(\"EmptyTagIDs\", func(t *testing.T) {\n\t\t\tpayload := bulkUpdatePayload{\n\t\t\t\tBookmarkIDs: []int{bookmarkID},\n\t\t\t\tTagIDs:      []int{},\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleBulkUpdateBookmarkTags,\n\t\t\t\thttp.MethodPut,\n\t\t\t\t\"/api/v1/bookmarks/bulk/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\trequire.Equal(t, http.StatusBadRequest, rec.Code)\n\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertNotOk(t)\n\t\t\ttestResp.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\t\trequire.Equal(t, \"tag_ids should not be empty\", value)\n\t\t\t})\n\t\t})\n\n\t\t// Test bookmark not found\n\t\tt.Run(\"BookmarkNotFound\", func(t *testing.T) {\n\t\t\tpayload := bulkUpdatePayload{\n\t\t\t\tBookmarkIDs: []int{9999}, // Non-existent bookmark ID\n\t\t\t\tTagIDs:      []int{tagID},\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleBulkUpdateBookmarkTags,\n\t\t\t\thttp.MethodPut,\n\t\t\t\t\"/api/v1/bookmarks/bulk/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\trequire.Equal(t, http.StatusInternalServerError, rec.Code)\n\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertNotOk(t)\n\t\t\ttestResp.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\t\trequire.Equal(t, \"Failed to update bookmarks\", value)\n\t\t\t})\n\t\t})\n\t})\n\n\t// Test GetBookmarkTags\n\tt.Run(\"GetBookmarkTags\", func(t *testing.T) {\n\t\t// Add a tag to the bookmark first\n\t\terr := db.AddTagToBookmark(ctx, bookmarkID, tagID)\n\t\trequire.NoError(t, err)\n\n\t\t// Create a request to get the tags\n\t\trec := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tapi_v1.HandleGetBookmarkTags,\n\t\t\thttp.MethodGet,\n\t\t\t\"/api/v1/bookmarks/\"+strconv.Itoa(bookmarkID)+\"/tags\",\n\t\t\ttestutil.WithFakeAdmin(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(bookmarkID)),\n\t\t)\n\n\t\t// Check the response\n\t\trequire.Equal(t, http.StatusOK, rec.Code)\n\n\t\t// Parse the response\n\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\ttestResp.AssertOk(t)\n\n\t\ttestResp.AssertMessageIsNotEmptyList(t)\n\n\t\ttestResp.ForEach(t, func(item map[string]any) {\n\t\t\trequire.NotZero(t, item[\"id\"])\n\t\t\trequire.NotEmpty(t, item[\"name\"])\n\t\t})\n\t})\n\n\t// Test AddTagToBookmark\n\tt.Run(\"AddTagToBookmark\", func(t *testing.T) {\n\t\t// Remove the tag first to ensure a clean state\n\t\terr := db.RemoveTagFromBookmark(ctx, bookmarkID, tagID)\n\t\trequire.NoError(t, err)\n\n\t\t// Create a request to add the tag\n\t\tpayload := bookmarkTagPayload{\n\t\t\tTagID: tagID,\n\t\t}\n\t\tpayloadBytes, err := json.Marshal(payload)\n\t\trequire.NoError(t, err)\n\n\t\trec := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tapi_v1.HandleAddTagToBookmark,\n\t\t\thttp.MethodPost,\n\t\t\t\"/api/v1/bookmarks/\"+strconv.Itoa(bookmarkID)+\"/tags\",\n\t\t\ttestutil.WithFakeAdmin(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(bookmarkID)),\n\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t)\n\n\t\t// Check the response\n\t\trequire.Equal(t, http.StatusCreated, rec.Code)\n\n\t\t// Verify the tag was added\n\t\ttags, err := deps.Domains().Tags().ListTags(ctx, model.ListTagsOptions{\n\t\t\tBookmarkID: bookmarkID,\n\t\t})\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, tags, 1)\n\t\tassert.Equal(t, tagID, tags[0].ID)\n\t})\n\n\t// Test RemoveTagFromBookmark\n\tt.Run(\"RemoveTagFromBookmark\", func(t *testing.T) {\n\t\t// Add the tag first to ensure it exists\n\t\terr := db.AddTagToBookmark(ctx, bookmarkID, tagID)\n\t\trequire.NoError(t, err)\n\n\t\t// Create a request to remove the tag\n\t\tpayload := bookmarkTagPayload{\n\t\t\tTagID: tagID,\n\t\t}\n\t\tpayloadBytes, err := json.Marshal(payload)\n\t\trequire.NoError(t, err)\n\n\t\trec := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tapi_v1.HandleRemoveTagFromBookmark,\n\t\t\thttp.MethodDelete,\n\t\t\t\"/api/v1/bookmarks/\"+strconv.Itoa(bookmarkID)+\"/tags\",\n\t\t\ttestutil.WithFakeAdmin(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(bookmarkID)),\n\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t)\n\n\t\t// Check the response\n\t\trequire.Equal(t, http.StatusOK, rec.Code)\n\n\t\t// Verify the tag was removed\n\t\ttags, err := deps.Domains().Tags().ListTags(ctx, model.ListTagsOptions{\n\t\t\tBookmarkID: bookmarkID,\n\t\t})\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, tags, 0)\n\t})\n\n\t// Test error cases\n\tt.Run(\"ErrorCases\", func(t *testing.T) {\n\t\t// Test non-existent bookmark\n\t\tt.Run(\"NonExistentBookmark\", func(t *testing.T) {\n\t\t\t// Create a request to get tags for a non-existent bookmark\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleGetBookmarkTags,\n\t\t\t\thttp.MethodGet,\n\t\t\t\t\"/api/v1/bookmarks/9999/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithRequestPathValue(\"id\", \"9999\"),\n\t\t\t)\n\n\t\t\t// Check the response\n\t\t\trequire.Equal(t, http.StatusNotFound, rec.Code)\n\n\t\t\t// Parse the response\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertNotOk(t)\n\t\t\ttestResp.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\t\trequire.Equal(t, \"Bookmark not found\", value)\n\t\t\t})\n\t\t})\n\n\t\t// Test non-existent tag\n\t\tt.Run(\"NonExistentTag\", func(t *testing.T) {\n\t\t\t// Create a request to add a non-existent tag\n\t\t\tpayload := bookmarkTagPayload{\n\t\t\t\tTagID: 9999,\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleAddTagToBookmark,\n\t\t\t\thttp.MethodPost,\n\t\t\t\t\"/api/v1/bookmarks/\"+strconv.Itoa(bookmarkID)+\"/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(bookmarkID)),\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\t// Check the response\n\t\t\trequire.Equal(t, http.StatusNotFound, rec.Code)\n\n\t\t\t// Parse the response\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertNotOk(t)\n\t\t\ttestResp.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\t\trequire.Equal(t, \"Tag not found\", value)\n\t\t\t})\n\t\t})\n\n\t\t// Test non-existent bookmark for AddTagToBookmark\n\t\tt.Run(\"NonExistentBookmarkForAddTag\", func(t *testing.T) {\n\t\t\t// Create a request to add a tag to a non-existent bookmark\n\t\t\tpayload := bookmarkTagPayload{\n\t\t\t\tTagID: tagID,\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleAddTagToBookmark,\n\t\t\t\thttp.MethodPost,\n\t\t\t\t\"/api/v1/bookmarks/9999/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithRequestPathValue(\"id\", \"9999\"),\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\t// Check the response\n\t\t\trequire.Equal(t, http.StatusNotFound, rec.Code)\n\n\t\t\t// Parse the response\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertNotOk(t)\n\t\t\ttestResp.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\t\trequire.Equal(t, \"Bookmark not found\", value)\n\t\t\t})\n\t\t})\n\n\t\t// Test non-existent bookmark for RemoveTagFromBookmark\n\t\tt.Run(\"NonExistentBookmarkForRemoveTag\", func(t *testing.T) {\n\t\t\t// Create a request to remove a tag from a non-existent bookmark\n\t\t\tpayload := bookmarkTagPayload{\n\t\t\t\tTagID: tagID,\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleRemoveTagFromBookmark,\n\t\t\t\thttp.MethodDelete,\n\t\t\t\t\"/api/v1/bookmarks/9999/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithRequestPathValue(\"id\", \"9999\"),\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\t// Check the response\n\t\t\trequire.Equal(t, http.StatusNotFound, rec.Code)\n\n\t\t\t// Parse the response\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertNotOk(t)\n\t\t\ttestResp.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\t\trequire.Equal(t, \"Bookmark not found\", value)\n\t\t\t})\n\t\t})\n\n\t\t// Test non-existent tag for RemoveTagFromBookmark\n\t\tt.Run(\"NonExistentTagForRemoveTag\", func(t *testing.T) {\n\t\t\t// Create a request to remove a non-existent tag\n\t\t\tpayload := bookmarkTagPayload{\n\t\t\t\tTagID: 9999,\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleRemoveTagFromBookmark,\n\t\t\t\thttp.MethodDelete,\n\t\t\t\t\"/api/v1/bookmarks/\"+strconv.Itoa(bookmarkID)+\"/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(bookmarkID)),\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\t// Check the response\n\t\t\trequire.Equal(t, http.StatusNotFound, rec.Code)\n\n\t\t\t// Parse the response\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertNotOk(t)\n\t\t\ttestResp.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\t\trequire.Equal(t, \"Tag not found\", value)\n\t\t\t})\n\t\t})\n\n\t\t// Test invalid bookmark ID\n\t\tt.Run(\"InvalidBookmarkID\", func(t *testing.T) {\n\t\t\t// Create a request with an invalid bookmark ID\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleGetBookmarkTags,\n\t\t\t\thttp.MethodGet,\n\t\t\t\t\"/api/v1/bookmarks/invalid/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithRequestPathValue(\"id\", \"invalid\"),\n\t\t\t)\n\n\t\t\t// Check the response\n\t\t\trequire.Equal(t, http.StatusBadRequest, rec.Code)\n\n\t\t\t// Parse the response\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertNotOk(t)\n\t\t\ttestResp.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\t\trequire.Equal(t, \"Invalid bookmark ID\", value)\n\t\t\t})\n\t\t})\n\n\t\t// Test invalid bookmark ID for AddTagToBookmark\n\t\tt.Run(\"InvalidBookmarkIDForAddTag\", func(t *testing.T) {\n\t\t\t// Create a request with an invalid bookmark ID\n\t\t\tpayload := bookmarkTagPayload{\n\t\t\t\tTagID: tagID,\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleAddTagToBookmark,\n\t\t\t\thttp.MethodPost,\n\t\t\t\t\"/api/v1/bookmarks/invalid/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithRequestPathValue(\"id\", \"invalid\"),\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\t// Check the response\n\t\t\trequire.Equal(t, http.StatusBadRequest, rec.Code)\n\n\t\t\t// Parse the response\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertNotOk(t)\n\t\t\ttestResp.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\t\trequire.Equal(t, \"Invalid bookmark ID\", value)\n\t\t\t})\n\t\t})\n\n\t\t// Test invalid bookmark ID for RemoveTagFromBookmark\n\t\tt.Run(\"InvalidBookmarkIDForRemoveTag\", func(t *testing.T) {\n\t\t\t// Create a request with an invalid bookmark ID\n\t\t\tpayload := bookmarkTagPayload{\n\t\t\t\tTagID: tagID,\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleRemoveTagFromBookmark,\n\t\t\t\thttp.MethodDelete,\n\t\t\t\t\"/api/v1/bookmarks/invalid/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithRequestPathValue(\"id\", \"invalid\"),\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\t// Check the response\n\t\t\trequire.Equal(t, http.StatusBadRequest, rec.Code)\n\n\t\t\t// Parse the response\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertNotOk(t)\n\t\t\ttestResp.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\t\trequire.Equal(t, \"Invalid bookmark ID\", value)\n\t\t\t})\n\t\t})\n\n\t\t// Test invalid payload\n\t\tt.Run(\"InvalidPayload\", func(t *testing.T) {\n\t\t\t// Create a request with an invalid payload\n\t\t\tinvalidPayload := []byte(`{\"tag_id\": \"invalid\"}`)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleAddTagToBookmark,\n\t\t\t\thttp.MethodPost,\n\t\t\t\t\"/api/v1/bookmarks/\"+strconv.Itoa(bookmarkID)+\"/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(bookmarkID)),\n\t\t\t\ttestutil.WithBody(string(invalidPayload)),\n\t\t\t)\n\n\t\t\t// Check the response\n\t\t\trequire.Equal(t, http.StatusBadRequest, rec.Code)\n\n\t\t\t// Parse the response\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertNotOk(t)\n\t\t\ttestResp.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\t\trequire.Equal(t, \"Invalid request payload\", value)\n\t\t\t})\n\t\t})\n\n\t\t// Test zero tag ID\n\t\tt.Run(\"ZeroTagID\", func(t *testing.T) {\n\t\t\t// Create a request with a zero tag ID\n\t\t\tpayload := bookmarkTagPayload{\n\t\t\t\tTagID: 0,\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleAddTagToBookmark,\n\t\t\t\thttp.MethodPost,\n\t\t\t\t\"/api/v1/bookmarks/\"+strconv.Itoa(bookmarkID)+\"/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(bookmarkID)),\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\t// Check the response\n\t\t\trequire.Equal(t, http.StatusBadRequest, rec.Code)\n\n\t\t\t// Parse the response\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertNotOk(t)\n\t\t\ttestResp.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\t\trequire.Equal(t, \"tag_id should be a positive integer\", value)\n\t\t\t})\n\t\t})\n\n\t\t// Test negative tag ID\n\t\tt.Run(\"NegativeTagID\", func(t *testing.T) {\n\t\t\t// Create a request with a negative tag ID\n\t\t\tpayload := bookmarkTagPayload{\n\t\t\t\tTagID: -1,\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleAddTagToBookmark,\n\t\t\t\thttp.MethodPost,\n\t\t\t\t\"/api/v1/bookmarks/\"+strconv.Itoa(bookmarkID)+\"/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(bookmarkID)),\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\t// Check the response\n\t\t\trequire.Equal(t, http.StatusBadRequest, rec.Code)\n\n\t\t\t// Parse the response\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertNotOk(t)\n\t\t\ttestResp.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\t\trequire.Equal(t, \"tag_id should be a positive integer\", value)\n\t\t\t})\n\t\t})\n\n\t\t// Test validation for RemoveTagFromBookmark\n\t\tt.Run(\"RemoveTagValidation\", func(t *testing.T) {\n\t\t\t// Create a request with a zero tag ID\n\t\t\tpayload := bookmarkTagPayload{\n\t\t\t\tTagID: 0,\n\t\t\t}\n\t\t\tpayloadBytes, err := json.Marshal(payload)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trec := testutil.PerformRequest(\n\t\t\t\tdeps,\n\t\t\t\tapi_v1.HandleRemoveTagFromBookmark,\n\t\t\t\thttp.MethodDelete,\n\t\t\t\t\"/api/v1/bookmarks/\"+strconv.Itoa(bookmarkID)+\"/tags\",\n\t\t\t\ttestutil.WithFakeAdmin(),\n\t\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(bookmarkID)),\n\t\t\t\ttestutil.WithBody(string(payloadBytes)),\n\t\t\t)\n\n\t\t\t// Check the response\n\t\t\trequire.Equal(t, http.StatusBadRequest, rec.Code)\n\n\t\t\t// Parse the response\n\t\t\ttestResp := testutil.NewTestResponseFromRecorder(rec)\n\t\t\ttestResp.AssertNotOk(t)\n\t\t\ttestResp.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\t\trequire.Equal(t, \"tag_id should be a positive integer\", value)\n\t\t\t})\n\t\t})\n\t})\n}\n"
  },
  {
    "path": "internal/http/handlers/api/v1/bookmarks.go",
    "content": "package api_v1\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"sync\"\n\n\t\"github.com/go-shiori/shiori/internal/http/middleware\"\n\t\"github.com/go-shiori/shiori/internal/http/response\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\ntype updateCachePayload struct {\n\tIds           []int `json:\"ids\"    validate:\"required\"`\n\tKeepMetadata  bool  `json:\"keep_metadata\"`\n\tCreateArchive bool  `json:\"create_archive\"`\n\tCreateEbook   bool  `json:\"create_ebook\"`\n\tSkipExist     bool  `json:\"skip_exist\"`\n}\n\nfunc (p *updateCachePayload) IsValid() error {\n\tif len(p.Ids) == 0 {\n\t\treturn fmt.Errorf(\"id should not be empty\")\n\t}\n\tfor _, id := range p.Ids {\n\t\tif id <= 0 {\n\t\t\treturn fmt.Errorf(\"id should not be 0 or negative\")\n\t\t}\n\t}\n\treturn nil\n}\n\ntype readableResponseMessage struct {\n\tContent string `json:\"content\"`\n\tHTML    string `json:\"html\"`\n}\n\n// HandleBookmarkReadable returns the readable version of a bookmark\n//\n//\t@Summary\t\t\t\t\tGet readable version of bookmark.\n//\t@Tags\t\t\t\t\t\tAuth\n//\t@securityDefinitions.apikey\tApiKeyAuth\n//\t@Produce\t\t\t\t\tjson\n//\t@Success\t\t\t\t\t200\t{object}\treadableResponseMessage\n//\t@Failure\t\t\t\t\t403\t{object}\tnil\t\"Token not provided/invalid\"\n//\t@Router\t\t\t\t\t\t/api/v1/bookmarks/id/readable [get]\nfunc HandleBookmarkReadable(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInUser(deps, c); err != nil {\n\t\tresponse.SendError(c, http.StatusForbidden, err.Error())\n\t\treturn\n\t}\n\n\tbookmarkID, err := strconv.Atoi(c.Request().PathValue(\"id\"))\n\tif err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"Invalid bookmark ID\")\n\t\treturn\n\t}\n\n\tbookmark, err := deps.Domains().Bookmarks().GetBookmark(c.Request().Context(), model.DBID(bookmarkID))\n\tif err != nil {\n\t\tresponse.SendError(c, http.StatusNotFound, \"Bookmark not found\")\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusOK, readableResponseMessage{\n\t\tContent: bookmark.Content,\n\t\tHTML:    bookmark.HTML,\n\t})\n}\n\n// HandleUpdateCache updates the cache and ebook for bookmarks\n//\n//\t@Summary\t\t\t\t\tUpdate Cache and Ebook on server.\n//\t@Tags\t\t\t\t\t\tAuth\n//\t@securityDefinitions.apikey\tApiKeyAuth\n//\t@Param\t\t\t\t\t\tpayload\tbody\tupdateCachePayload\ttrue\t\"Update Cache Payload\"\n//\t@Produce\t\t\t\t\tjson\n//\t@Success\t\t\t\t\t200\t{object}\tmodel.BookmarkDTO\n//\t@Failure\t\t\t\t\t403\t{object}\tnil\t\"Token not provided/invalid\"\n//\t@Router\t\t\t\t\t\t/api/v1/bookmarks/cache [put]\nfunc HandleUpdateCache(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInAdmin(deps, c); err != nil {\n\t\tresponse.SendError(c, http.StatusForbidden, err.Error())\n\t\treturn\n\t}\n\n\t// Parse request payload\n\tvar payload updateCachePayload\n\tif err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"Invalid request payload\")\n\t\treturn\n\t}\n\n\tif err := payload.IsValid(); err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, err.Error())\n\t\treturn\n\t}\n\n\t// Get bookmarks from database\n\tbookmarks, err := deps.Domains().Bookmarks().GetBookmarks(c.Request().Context(), payload.Ids)\n\tif err != nil {\n\t\tresponse.SendError(c, http.StatusInternalServerError, \"Failed to get bookmarks\")\n\t\treturn\n\t}\n\n\tif len(bookmarks) == 0 {\n\t\tresponse.SendError(c, http.StatusNotFound, \"No bookmarks found\")\n\t\treturn\n\t}\n\n\t// Process bookmarks concurrently\n\tmx := sync.RWMutex{}\n\twg := sync.WaitGroup{}\n\tchDone := make(chan struct{})\n\tchProblem := make(chan int, 10)\n\tsemaphore := make(chan struct{}, 10)\n\n\tfor i, book := range bookmarks {\n\t\twg.Add(1)\n\n\t\tbook.CreateArchive = payload.CreateArchive\n\t\tbook.CreateEbook = payload.CreateEbook\n\n\t\tgo func(i int, book model.BookmarkDTO) {\n\t\t\tdefer wg.Done()\n\t\t\tdefer func() { <-semaphore }()\n\t\t\tsemaphore <- struct{}{}\n\n\t\t\t// Download and process bookmark\n\t\t\tupdatedBook, err := deps.Domains().Bookmarks().UpdateBookmarkCache(c.Request().Context(), book, payload.KeepMetadata, payload.SkipExist)\n\t\t\tif err != nil {\n\t\t\t\tdeps.Logger().WithError(err).Error(\"error updating bookmark cache\")\n\t\t\t\tchProblem <- book.ID\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tmx.Lock()\n\t\t\tbookmarks[i] = *updatedBook\n\t\t\tmx.Unlock()\n\t\t}(i, book)\n\t}\n\n\t// Collect problematic bookmarks\n\tidWithProblems := []int{}\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-chDone:\n\t\t\t\treturn\n\t\t\tcase id := <-chProblem:\n\t\t\t\tidWithProblems = append(idWithProblems, id)\n\t\t\t}\n\t\t}\n\t}()\n\n\twg.Wait()\n\tclose(chDone)\n\n\tresponse.SendJSON(c, http.StatusOK, bookmarks)\n}\n\ntype bulkUpdateBookmarkTagsPayload struct {\n\tBookmarkIDs []int `json:\"bookmark_ids\" validate:\"required\"`\n\tTagIDs      []int `json:\"tag_ids\" validate:\"required\"`\n}\n\nfunc (p *bulkUpdateBookmarkTagsPayload) IsValid() error {\n\tif len(p.BookmarkIDs) == 0 {\n\t\treturn fmt.Errorf(\"bookmark_ids should not be empty\")\n\t}\n\tif len(p.TagIDs) == 0 {\n\t\treturn fmt.Errorf(\"tag_ids should not be empty\")\n\t}\n\treturn nil\n}\n\n// HandleGetBookmarkTags gets the tags for a bookmark\n//\n//\t@Summary\t\t\t\t\tGet tags for a bookmark.\n//\t@Tags\t\t\t\t\t\tAuth\n//\t@securityDefinitions.apikey\tApiKeyAuth\n//\t@Produce\t\t\t\t\tjson\n//\t@Param\t\t\t\t\t\tid\tpath\t\tint\ttrue\t\"Bookmark ID\"\n//\t@Success\t\t\t\t\t200\t{array}\t\tmodel.TagDTO\n//\t@Failure\t\t\t\t\t403\t{object}\tnil\t\"Token not provided/invalid\"\n//\t@Failure\t\t\t\t\t404\t{object}\tnil\t\"Bookmark not found\"\n//\t@Router\t\t\t\t\t\t/api/v1/bookmarks/{id}/tags [get]\nfunc HandleGetBookmarkTags(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInUser(deps, c); err != nil {\n\t\tresponse.SendError(c, http.StatusForbidden, err.Error())\n\t\treturn\n\t}\n\n\tbookmarkID, err := strconv.Atoi(c.Request().PathValue(\"id\"))\n\tif err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"Invalid bookmark ID\")\n\t\treturn\n\t}\n\n\t// Check if bookmark exists\n\texists, err := deps.Domains().Bookmarks().BookmarkExists(c.Request().Context(), bookmarkID)\n\tif err != nil {\n\t\tresponse.SendError(c, http.StatusInternalServerError, \"Failed to check if bookmark exists\")\n\t\treturn\n\t}\n\tif !exists {\n\t\tresponse.SendError(c, http.StatusNotFound, \"Bookmark not found\")\n\t\treturn\n\t}\n\n\t// Get bookmark to retrieve its tags\n\ttags, err := deps.Domains().Tags().ListTags(c.Request().Context(), model.ListTagsOptions{\n\t\tBookmarkID: bookmarkID,\n\t})\n\tif err != nil {\n\t\tresponse.SendError(c, http.StatusInternalServerError, \"Failed to get bookmark tags\")\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusOK, tags)\n}\n\n// bookmarkTagPayload is used for both adding and removing tags from bookmarks\ntype bookmarkTagPayload struct {\n\tTagID int `json:\"tag_id\" validate:\"required\"`\n}\n\nfunc (p *bookmarkTagPayload) IsValid() error {\n\tif p.TagID <= 0 {\n\t\treturn fmt.Errorf(\"tag_id should be a positive integer\")\n\t}\n\treturn nil\n}\n\n// HandleAddTagToBookmark adds a tag to a bookmark\n//\n//\t@Summary\t\t\t\t\tAdd a tag to a bookmark.\n//\t@Tags\t\t\t\t\t\tAuth\n//\t@securityDefinitions.apikey\tApiKeyAuth\n//\t@Param\t\t\t\t\t\tid\t\tpath\tint\t\t\t\t\ttrue\t\"Bookmark ID\"\n//\t@Param\t\t\t\t\t\tpayload\tbody\tbookmarkTagPayload\ttrue\t\"Add Tag Payload\"\n//\t@Produce\t\t\t\t\tjson\n//\t@Success\t\t\t\t\t200\t{object}\tnil\n//\t@Failure\t\t\t\t\t403\t{object}\tnil\t\"Token not provided/invalid\"\n//\t@Failure\t\t\t\t\t404\t{object}\tnil\t\"Bookmark or tag not found\"\n//\t@Router\t\t\t\t\t\t/api/v1/bookmarks/{id}/tags [post]\nfunc HandleAddTagToBookmark(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInAdmin(deps, c); err != nil {\n\t\tresponse.SendError(c, http.StatusForbidden, err.Error())\n\t\treturn\n\t}\n\n\tbookmarkID, err := strconv.Atoi(c.Request().PathValue(\"id\"))\n\tif err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"Invalid bookmark ID\")\n\t\treturn\n\t}\n\n\t// Parse request payload\n\tvar payload bookmarkTagPayload\n\tif err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"Invalid request payload\")\n\t\treturn\n\t}\n\n\tif err := payload.IsValid(); err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, err.Error())\n\t\treturn\n\t}\n\n\t// Add tag to bookmark\n\terr = deps.Domains().Bookmarks().AddTagToBookmark(c.Request().Context(), bookmarkID, payload.TagID)\n\tif err != nil {\n\t\tif errors.Is(err, model.ErrBookmarkNotFound) {\n\t\t\tresponse.SendError(c, http.StatusNotFound, \"Bookmark not found\")\n\t\t\treturn\n\t\t}\n\t\tif errors.Is(err, model.ErrTagNotFound) {\n\t\t\tresponse.SendError(c, http.StatusNotFound, \"Tag not found\")\n\t\t\treturn\n\t\t}\n\t\tresponse.SendError(c, http.StatusInternalServerError, \"Failed to add tag to bookmark\")\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusCreated, nil)\n}\n\n// HandleRemoveTagFromBookmark removes a tag from a bookmark\n//\n//\t@Summary\t\t\t\t\tRemove a tag from a bookmark.\n//\t@Tags\t\t\t\t\t\tAuth\n//\t@securityDefinitions.apikey\tApiKeyAuth\n//\t@Param\t\t\t\t\t\tid\t\tpath\tint\t\t\t\t\ttrue\t\"Bookmark ID\"\n//\t@Param\t\t\t\t\t\tpayload\tbody\tbookmarkTagPayload\ttrue\t\"Remove Tag Payload\"\n//\t@Produce\t\t\t\t\tjson\n//\t@Success\t\t\t\t\t200\t{object}\tnil\n//\t@Failure\t\t\t\t\t403\t{object}\tnil\t\"Token not provided/invalid\"\n//\t@Failure\t\t\t\t\t404\t{object}\tnil\t\"Bookmark not found\"\n//\t@Router\t\t\t\t\t\t/api/v1/bookmarks/{id}/tags [delete]\nfunc HandleRemoveTagFromBookmark(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInUser(deps, c); err != nil {\n\t\tresponse.SendError(c, http.StatusForbidden, err.Error())\n\t\treturn\n\t}\n\n\tbookmarkID, err := strconv.Atoi(c.Request().PathValue(\"id\"))\n\tif err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"Invalid bookmark ID\")\n\t\treturn\n\t}\n\n\t// Parse request payload\n\tvar payload bookmarkTagPayload\n\tif err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"Invalid request payload\")\n\t\treturn\n\t}\n\n\tif err := payload.IsValid(); err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, err.Error())\n\t\treturn\n\t}\n\n\t// Remove tag from bookmark\n\terr = deps.Domains().Bookmarks().RemoveTagFromBookmark(c.Request().Context(), bookmarkID, payload.TagID)\n\tif err != nil {\n\t\tif errors.Is(err, model.ErrBookmarkNotFound) {\n\t\t\tresponse.SendError(c, http.StatusNotFound, \"Bookmark not found\")\n\t\t\treturn\n\t\t}\n\t\tif errors.Is(err, model.ErrTagNotFound) {\n\t\t\tresponse.SendError(c, http.StatusNotFound, \"Tag not found\")\n\t\t\treturn\n\t\t}\n\t\tresponse.SendError(c, http.StatusInternalServerError, \"Failed to remove tag from bookmark\")\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusOK, nil)\n}\n\n// HandleBulkUpdateBookmarkTags updates the tags for multiple bookmarks\n//\n//\t@Summary\t\t\t\t\tBulk update tags for multiple bookmarks.\n//\t@Tags\t\t\t\t\t\tAuth\n//\t@securityDefinitions.apikey\tApiKeyAuth\n//\t@Param\t\t\t\t\t\tpayload\tbody\tbulkUpdateBookmarkTagsPayload\ttrue\t\"Bulk Update Bookmark Tags Payload\"\n//\t@Produce\t\t\t\t\tjson\n//\t@Success\t\t\t\t\t200\t{object}\t[]model.BookmarkDTO\n//\t@Failure\t\t\t\t\t403\t{object}\tnil\t\"Token not provided/invalid\"\n//\t@Failure\t\t\t\t\t400\t{object}\tnil\t\"Invalid request payload\"\n//\t@Failure\t\t\t\t\t404\t{object}\tnil\t\"No bookmarks found\"\n//\t@Router\t\t\t\t\t\t/api/v1/bookmarks/bulk/tags [put]\nfunc HandleBulkUpdateBookmarkTags(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInUser(deps, c); err != nil {\n\t\tresponse.SendError(c, http.StatusForbidden, err.Error())\n\t\treturn\n\t}\n\n\t// Parse request payload\n\tvar payload bulkUpdateBookmarkTagsPayload\n\tif err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"Invalid request payload\")\n\t\treturn\n\t}\n\n\tif err := payload.IsValid(); err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, err.Error())\n\t\treturn\n\t}\n\n\t// Use the domain method to update bookmark tags\n\terr := deps.Domains().Bookmarks().BulkUpdateBookmarkTags(c.Request().Context(), payload.BookmarkIDs, payload.TagIDs)\n\tif err != nil {\n\t\tif errors.Is(err, model.ErrBookmarkNotFound) {\n\t\t\tresponse.SendError(c, http.StatusNotFound, \"No bookmarks found\")\n\t\t\treturn\n\t\t}\n\t\tresponse.SendError(c, http.StatusInternalServerError, \"Failed to update bookmarks\")\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusOK, nil)\n}\n"
  },
  {
    "path": "internal/http/handlers/api/v1/bookmarks_test.go",
    "content": "package api_v1\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestHandleBookmarkReadable(t *testing.T) {\n\tlogger := logrus.New()\n\tctx := context.Background()\n\n\tt.Run(\"requires authentication\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleBookmarkReadable,\n\t\t\thttp.MethodGet,\n\t\t\t\"/api/v1/bookmarks/1/readable\",\n\t\t\ttestutil.WithRequestPathValue(\"id\", \"1\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"invalid bookmark id\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleBookmarkReadable,\n\t\t\thttp.MethodGet,\n\t\t\t\"/api/v1/bookmarks/invalid/readable\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", \"invalid\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"bookmark not found\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleBookmarkReadable,\n\t\t\thttp.MethodGet,\n\t\t\t\"/api/v1/bookmarks/999/readable\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", \"999\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusNotFound, w.Code)\n\t})\n\n\tt.Run(\"success\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\t// Create test bookmark\n\t\tbookmark := testutil.GetValidBookmark()\n\t\tbookmark.Content = \"test content\"\n\t\tbookmark.HTML = \"<p>test content</p>\"\n\t\tsavedBookmark, err := deps.Database().SaveBookmarks(ctx, true, *bookmark)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, savedBookmark, 1)\n\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleBookmarkReadable,\n\t\t\thttp.MethodGet,\n\t\t\t\"/api/v1/bookmarks/\"+strconv.Itoa(savedBookmark[0].ID)+\"/readable\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(savedBookmark[0].ID)),\n\t\t)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t\tresponse.AssertMessageJSONKeyValue(t, \"content\", func(t *testing.T, value any) {\n\t\t\trequire.Equal(t, bookmark.Content, value)\n\t\t})\n\t\tresponse.AssertMessageJSONKeyValue(t, \"html\", func(t *testing.T, value any) {\n\t\t\trequire.Equal(t, bookmark.HTML, value)\n\t\t})\n\t})\n}\n\nfunc TestHandleUpdateCache(t *testing.T) {\n\tlogger := logrus.New()\n\tctx := context.Background()\n\n\tt.Run(\"requires authentication\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleUpdateCache,\n\t\t\thttp.MethodPut,\n\t\t\t\"/api/v1/bookmarks/cache\",\n\t\t)\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"requires admin access\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleUpdateCache,\n\t\t\thttp.MethodPut,\n\t\t\t\"/api/v1/bookmarks/cache\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t)\n\t\trequire.Equal(t, http.StatusForbidden, w.Code)\n\t})\n\n\tt.Run(\"invalid json payload\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleUpdateCache,\n\t\t\thttp.MethodPut,\n\t\t\t\"/api/v1/bookmarks/cache\",\n\t\t\ttestutil.WithFakeAdmin(),\n\t\t\ttestutil.WithBody(\"invalid json\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"empty bookmark ids\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleUpdateCache,\n\t\t\thttp.MethodPut,\n\t\t\t\"/api/v1/bookmarks/cache\",\n\t\t\ttestutil.WithFakeAdmin(),\n\t\t\ttestutil.WithBody(`{\"ids\": []}`),\n\t\t)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"bookmarks not found\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleUpdateCache,\n\t\t\thttp.MethodPut,\n\t\t\t\"/api/v1/bookmarks/cache\",\n\t\t\ttestutil.WithFakeAdmin(),\n\t\t\ttestutil.WithBody(`{\"ids\": [999]}`),\n\t\t)\n\t\trequire.Equal(t, http.StatusNotFound, w.Code)\n\t})\n\n\tt.Run(\"successful update\", func(t *testing.T) {\n\t\tt.Skip(\"skipping due to concurrent execution and no easy way to test it\")\n\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\t// Create test bookmark\n\t\tbookmark := testutil.GetValidBookmark()\n\t\tsavedBookmark, err := deps.Database().SaveBookmarks(ctx, true, *bookmark)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, savedBookmark, 1)\n\n\t\tbody := `{\n\t\t\t\"ids\": [` + strconv.Itoa(savedBookmark[0].ID) + `],\n\t\t\t\"keep_metadata\": true,\n\t\t\t\"create_archive\": true,\n\t\t\t\"create_ebook\": true\n\t\t}`\n\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleUpdateCache,\n\t\t\thttp.MethodPut,\n\t\t\t\"/api/v1/bookmarks/cache\",\n\t\t\ttestutil.WithFakeAdmin(),\n\t\t\ttestutil.WithBody(body),\n\t\t)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\n\t\t// TODO: remove this sleep after refactoring into a job system\n\t\ttime.Sleep(1 * time.Second)\n\n\t\t// Verify bookmark was updated\n\t\tupdatedBookmark, exists, err := deps.Database().GetBookmark(ctx, savedBookmark[0].ID, \"\")\n\t\trequire.NoError(t, err)\n\t\trequire.True(t, exists)\n\t\trequire.True(t, updatedBookmark.HasEbook)\n\t\trequire.True(t, updatedBookmark.HasArchive)\n\t})\n}\n\nfunc TestHandleUpdateBookmarkTags(t *testing.T) {\n\tctx := context.Background()\n\tlogger := logrus.New()\n\tlogger.SetOutput(io.Discard)\n\n\tt.Run(\"requires_authentication\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleBulkUpdateBookmarkTags,\n\t\t\t\"PUT\",\n\t\t\t\"/api/v1/bookmarks/tags\",\n\t\t)\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"invalid_json_payload\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleBulkUpdateBookmarkTags,\n\t\t\t\"PUT\",\n\t\t\t\"/api/v1/bookmarks/tags\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithBody(\"invalid json\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"empty_ids\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tpayload := map[string]interface{}{\n\t\t\t\"ids\":  []int{},\n\t\t\t\"tags\": []model.Tag{{Name: \"test\"}},\n\t\t}\n\t\tbody, _ := json.Marshal(payload)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleBulkUpdateBookmarkTags,\n\t\t\t\"PUT\",\n\t\t\t\"/api/v1/bookmarks/tags\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithBody(string(body)),\n\t\t)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"empty_tags\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tpayload := map[string]interface{}{\n\t\t\t\"ids\":  []int{1},\n\t\t\t\"tags\": []model.Tag{},\n\t\t}\n\t\tbody, _ := json.Marshal(payload)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleBulkUpdateBookmarkTags,\n\t\t\t\"PUT\",\n\t\t\t\"/api/v1/bookmarks/tags\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithBody(string(body)),\n\t\t)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"bookmark_not_found\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tpayload := map[string]interface{}{\n\t\t\t\"ids\":  []int{999},\n\t\t\t\"tags\": []model.Tag{{Name: \"test\"}},\n\t\t}\n\t\tbody, _ := json.Marshal(payload)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleBulkUpdateBookmarkTags,\n\t\t\t\"PUT\",\n\t\t\t\"/api/v1/bookmarks/tags\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithBody(string(body)),\n\t\t)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"successful_update\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\t// Create a bookmark first\n\t\tbookmark := testutil.GetValidBookmark()\n\t\tsavedBookmark, err := deps.Database().SaveBookmarks(ctx, true, *bookmark)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, savedBookmark, 1)\n\n\t\t// Create a tag\n\t\ttag := model.TagDTO{Tag: model.Tag{Name: \"newtag\"}}\n\t\tcreatedTag, err := deps.Database().CreateTag(ctx, tag.Tag)\n\t\trequire.NoError(t, err)\n\n\t\t// Update the bookmark tags\n\t\tpayload := map[string]interface{}{\n\t\t\t\"bookmark_ids\": []int{savedBookmark[0].ID},\n\t\t\t\"tag_ids\":      []int{createdTag.ID},\n\t\t}\n\t\tbody, _ := json.Marshal(payload)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleBulkUpdateBookmarkTags,\n\t\t\t\"PUT\",\n\t\t\t\"/api/v1/bookmarks/tags\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithBody(string(body)),\n\t\t)\n\t\tt.Log(w.Body.String())\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\t// Verify the response\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t})\n}\n"
  },
  {
    "path": "internal/http/handlers/api/v1/system.go",
    "content": "package api_v1\n\nimport (\n\t\"net/http\"\n\t\"runtime\"\n\n\t\"github.com/go-shiori/shiori/internal/http/middleware\"\n\t\"github.com/go-shiori/shiori/internal/http/response\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\ntype infoResponse struct {\n\tVersion struct {\n\t\tTag    string `json:\"tag\"`\n\t\tCommit string `json:\"commit\"`\n\t\tDate   string `json:\"date\"`\n\t} `json:\"version\"`\n\tDatabase string `json:\"database\"`\n\tOS       string `json:\"os\"`\n}\n\n// @Summary\t\t\t\t\tGet general system information\n// @Description\t\t\t\tGet general system information like Shiori version, database, and OS\n// @Tags\t\t\t\t\t\tSystem\n// @securityDefinitions.apikey\tApiKeyAuth\n// @Produce\t\t\t\t\tjson\n// @Success\t\t\t\t\t200\t{object}\tinfoResponse\n// @Failure\t\t\t\t\t403\t{object}\tnil\t\"Only owners can access this endpoint\"\n// @Router\t\t\t\t\t\t/api/v1/system/info [get]\nfunc HandleSystemInfo(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInAdmin(deps, c); err != nil {\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusOK, infoResponse{\n\t\tVersion: struct {\n\t\t\tTag    string `json:\"tag\"`\n\t\t\tCommit string `json:\"commit\"`\n\t\t\tDate   string `json:\"date\"`\n\t\t}{\n\t\t\tTag:    model.BuildVersion,\n\t\t\tCommit: model.BuildCommit,\n\t\t\tDate:   model.BuildDate,\n\t\t},\n\t\tDatabase: deps.Database().ReaderDB().DriverName(),\n\t\tOS:       runtime.GOOS + \" (\" + runtime.GOARCH + \")\",\n\t})\n}\n"
  },
  {
    "path": "internal/http/handlers/api/v1/system_test.go",
    "content": "package api_v1\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestHandleSystemInfo(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger)\n\n\tt.Run(\"requires authentication\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\tHandleSystemInfo(deps, c)\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"requires admin access\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeUser(c)\n\t\tHandleSystemInfo(deps, c)\n\t\trequire.Equal(t, http.StatusForbidden, w.Code)\n\t})\n\n\tt.Run(\"returns system info for admin\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeAdmin(c)\n\t\tHandleSystemInfo(deps, c)\n\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t\trequire.Equal(t, \"application/json\", w.Header().Get(\"Content-Type\"))\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\n\t\tresponse.AssertOk(t)\n\t\tresponse.AssertMessageJSONKeyValue(t, \"version\", func(t *testing.T, value any) {\n\t\t\trequire.NotEmpty(t, value)\n\t\t})\n\t\tresponse.AssertMessageJSONKeyValue(t, \"database\", func(t *testing.T, value any) {\n\t\t\trequire.NotEmpty(t, value)\n\t\t})\n\t\tresponse.AssertMessageJSONKeyValue(t, \"os\", func(t *testing.T, value any) {\n\t\t\trequire.NotEmpty(t, value)\n\t\t})\n\t})\n}\n"
  },
  {
    "path": "internal/http/handlers/api/v1/tags.go",
    "content": "package api_v1\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/go-shiori/shiori/internal/http/middleware\"\n\t\"github.com/go-shiori/shiori/internal/http/response\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\n// @Summary\t\t\t\t\tList tags\n// @Description\t\t\t\tList all tags\n// @Tags\t\t\t\t\t\tTags\n// @securityDefinitions.apikey\tApiKeyAuth\n// @Produce\t\t\t\t\tjson\n// @Param\t\t\t\t\t\twith_bookmark_count\tquery\t\tboolean\tfalse\t\"Include bookmark count for each tag\"\n// @Param\t\t\t\t\t\tbookmark_id\t\t\tquery\t\tinteger\tfalse\t\"Filter tags by bookmark ID\"\n// @Param\t\t\t\t\t\tsearch\t\t\t\tquery\t\tstring\tfalse\t\"Search tags by name\"\n// @Success\t\t\t\t\t200\t\t\t\t\t{array}\t\tmodel.TagDTO\n// @Failure\t\t\t\t\t403\t\t\t\t\t{object}\tnil\t\"Authentication required\"\n// @Failure\t\t\t\t\t500\t\t\t\t\t{object}\tnil\t\"Internal server error\"\n// @Router\t\t\t\t\t\t/api/v1/tags [get]\nfunc HandleListTags(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInUser(deps, c); err != nil {\n\t\treturn\n\t}\n\n\t// Parse query parameters\n\twithBookmarkCount := c.Request().URL.Query().Get(\"with_bookmark_count\") == \"true\"\n\tsearch := c.Request().URL.Query().Get(\"search\")\n\n\tvar bookmarkID int\n\tif bookmarkIDStr := c.Request().URL.Query().Get(\"bookmark_id\"); bookmarkIDStr != \"\" {\n\t\tvar err error\n\t\tbookmarkID, err = strconv.Atoi(bookmarkIDStr)\n\t\tif err != nil {\n\t\t\tresponse.SendError(c, http.StatusBadRequest, \"Invalid bookmark ID\")\n\t\t\treturn\n\t\t}\n\t}\n\n\t// Create options and validate\n\topts := model.ListTagsOptions{\n\t\tWithBookmarkCount: withBookmarkCount,\n\t\tBookmarkID:        bookmarkID,\n\t\tOrderBy:           model.DBTagOrderByTagName,\n\t\tSearch:            search,\n\t}\n\n\tif err := opts.IsValid(); err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, err.Error())\n\t\treturn\n\t}\n\n\ttags, err := deps.Domains().Tags().ListTags(c.Request().Context(), opts)\n\tif err != nil {\n\t\tdeps.Logger().WithError(err).Error(\"failed to get tags\")\n\t\tresponse.SendInternalServerError(c)\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusOK, tags)\n}\n\n// @Summary\t\t\t\t\tGet tag\n// @Description\t\t\t\tGet a tag by ID\n// @Tags\t\t\t\t\t\tTags\n// @securityDefinitions.apikey\tApiKeyAuth\n// @Produce\t\t\t\t\tjson\n// @Param\t\t\t\t\t\tid\tpath\t\tint\ttrue\t\"Tag ID\"\n// @Success\t\t\t\t\t200\t{object}\tmodel.TagDTO\n// @Failure\t\t\t\t\t403\t{object}\tnil\t\"Authentication required\"\n// @Failure\t\t\t\t\t404\t{object}\tnil\t\"Tag not found\"\n// @Failure\t\t\t\t\t500\t{object}\tnil\t\"Internal server error\"\n// @Router\t\t\t\t\t\t/api/v1/tags/{id} [get]\nfunc HandleGetTag(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInUser(deps, c); err != nil {\n\t\treturn\n\t}\n\n\tidParam := c.Request().PathValue(\"id\")\n\tid, err := strconv.Atoi(idParam)\n\tif err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"Invalid tag ID\")\n\t\treturn\n\t}\n\n\ttag, err := deps.Domains().Tags().GetTag(c.Request().Context(), id)\n\tif err != nil {\n\t\tif err == model.ErrNotFound {\n\t\t\tresponse.NotFound(c)\n\t\t\treturn\n\t\t}\n\t\tdeps.Logger().WithError(err).Error(\"failed to get tag\")\n\t\tresponse.SendInternalServerError(c)\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusOK, tag)\n}\n\n// @Summary\t\t\t\t\tCreate tag\n// @Description\t\t\t\tCreate a new tag\n// @Tags\t\t\t\t\t\tTags\n// @securityDefinitions.apikey\tApiKeyAuth\n// @Accept\t\t\t\t\t\tjson\n// @Produce\t\t\t\t\tjson\n// @Param\t\t\t\t\t\ttag\tbody\t\tmodel.TagDTO\ttrue\t\"Tag data\"\n// @Success\t\t\t\t\t201\t{object}\tmodel.TagDTO\n// @Failure\t\t\t\t\t400\t{object}\tnil\t\"Invalid request\"\n// @Failure\t\t\t\t\t403\t{object}\tnil\t\"Authentication required\"\n// @Failure\t\t\t\t\t500\t{object}\tnil\t\"Internal server error\"\n// @Router\t\t\t\t\t\t/api/v1/tags [post]\nfunc HandleCreateTag(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInUser(deps, c); err != nil {\n\t\treturn\n\t}\n\n\tvar tag model.TagDTO\n\terr := json.NewDecoder(c.Request().Body).Decode(&tag)\n\tif err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"Invalid request body\")\n\t\treturn\n\t}\n\n\tif tag.Name == \"\" {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"Tag name is required\")\n\t\treturn\n\t}\n\n\tcreatedTag, err := deps.Domains().Tags().CreateTag(c.Request().Context(), tag)\n\tif err != nil {\n\t\tdeps.Logger().WithError(err).Error(\"failed to create tag\")\n\t\tresponse.SendInternalServerError(c)\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusCreated, createdTag)\n}\n\n// @Summary\t\t\t\t\tUpdate tag\n// @Description\t\t\t\tUpdate an existing tag\n// @Tags\t\t\t\t\t\tTags\n// @securityDefinitions.apikey\tApiKeyAuth\n// @Accept\t\t\t\t\t\tjson\n// @Produce\t\t\t\t\tjson\n// @Param\t\t\t\t\t\tid\tpath\t\tint\t\t\t\ttrue\t\"Tag ID\"\n// @Param\t\t\t\t\t\ttag\tbody\t\tmodel.TagDTO\ttrue\t\"Tag data\"\n// @Success\t\t\t\t\t200\t{object}\tmodel.TagDTO\n// @Failure\t\t\t\t\t400\t{object}\tnil\t\"Invalid request\"\n// @Failure\t\t\t\t\t403\t{object}\tnil\t\"Authentication required\"\n// @Failure\t\t\t\t\t404\t{object}\tnil\t\"Tag not found\"\n// @Failure\t\t\t\t\t500\t{object}\tnil\t\"Internal server error\"\n// @Router\t\t\t\t\t\t/api/v1/tags/{id} [put]\nfunc HandleUpdateTag(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInUser(deps, c); err != nil {\n\t\treturn\n\t}\n\n\tidParam := c.Request().PathValue(\"id\")\n\tid, err := strconv.Atoi(idParam)\n\tif err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"Invalid tag ID\")\n\t\treturn\n\t}\n\n\tvar tag model.TagDTO\n\terr = json.NewDecoder(c.Request().Body).Decode(&tag)\n\tif err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"Invalid request body\")\n\t\treturn\n\t}\n\n\tif tag.Name == \"\" {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"Tag name is required\")\n\t\treturn\n\t}\n\n\t// Ensure the ID in the URL matches the ID in the body\n\ttag.ID = id\n\n\tupdatedTag, err := deps.Domains().Tags().UpdateTag(c.Request().Context(), tag)\n\tif err != nil {\n\t\tif err == model.ErrNotFound {\n\t\t\tresponse.NotFound(c)\n\t\t\treturn\n\t\t}\n\t\tdeps.Logger().WithError(err).Error(\"failed to update tag\")\n\t\tresponse.SendInternalServerError(c)\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusOK, updatedTag)\n}\n\n// @Summary\t\t\t\t\tDelete tag\n// @Description\t\t\t\tDelete a tag\n// @Tags\t\t\t\t\t\tTags\n// @securityDefinitions.apikey\tApiKeyAuth\n// @Param\t\t\t\t\t\tid\tpath\t\tint\ttrue\t\"Tag ID\"\n// @Success\t\t\t\t\t204\t{object}\tnil\n// @Failure\t\t\t\t\t403\t{object}\tnil\t\"Authentication required\"\n// @Failure\t\t\t\t\t404\t{object}\tnil\t\"Tag not found\"\n// @Failure\t\t\t\t\t500\t{object}\tnil\t\"Internal server error\"\n// @Router\t\t\t\t\t\t/api/v1/tags/{id} [delete]\nfunc HandleDeleteTag(deps model.Dependencies, c model.WebContext) {\n\tif err := middleware.RequireLoggedInAdmin(deps, c); err != nil {\n\t\treturn\n\t}\n\n\tidParam := c.Request().PathValue(\"id\")\n\tid, err := strconv.Atoi(idParam)\n\tif err != nil {\n\t\tresponse.SendError(c, http.StatusBadRequest, \"Invalid tag ID\")\n\t\treturn\n\t}\n\n\terr = deps.Domains().Tags().DeleteTag(c.Request().Context(), id)\n\tif err != nil {\n\t\tif err == model.ErrNotFound {\n\t\t\tresponse.NotFound(c)\n\t\t\treturn\n\t\t}\n\t\tdeps.Logger().WithError(err).Error(\"failed to delete tag\")\n\t\tresponse.SendInternalServerError(c)\n\t\treturn\n\t}\n\n\tresponse.SendJSON(c, http.StatusNoContent, nil)\n}\n"
  },
  {
    "path": "internal/http/handlers/api/v1/tags_test.go",
    "content": "package api_v1\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestHandleListTags(t *testing.T) {\n\tlogger := logrus.New()\n\tctx := context.Background()\n\n\tt.Run(\"requires authentication\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(deps, HandleListTags, \"GET\", \"/api/v1/tags\")\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"returns tags list\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\t// Create a test tag\n\t\ttag := model.Tag{Name: \"test-tag\"}\n\t\tcreatedTags, err := deps.Database().CreateTags(ctx, tag)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, createdTags, 1)\n\n\t\tw := testutil.PerformRequest(deps, HandleListTags, \"GET\", \"/api/v1/tags\", testutil.WithFakeUser())\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t\tresponse.AssertMessageIsNotEmptyList(t)\n\t})\n\n\tt.Run(\"with_bookmark_count parameter\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\t// Create a test tag\n\t\ttag := model.Tag{Name: \"test-tag-with-count\"}\n\t\tcreatedTags, err := deps.Database().CreateTags(ctx, tag)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, createdTags, 1)\n\n\t\t// Create a bookmark with this tag\n\t\tbookmark := model.BookmarkDTO{\n\t\t\tURL:   \"https://example.com/test\",\n\t\t\tTitle: \"Test Bookmark\",\n\t\t\tTags:  []model.TagDTO{{Tag: model.Tag{Name: tag.Name}}},\n\t\t}\n\t\t_, err = deps.Database().SaveBookmarks(ctx, true, bookmark)\n\t\trequire.NoError(t, err)\n\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleListTags,\n\t\t\t\"GET\",\n\t\t\t\"/api/v1/tags\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestQueryParam(\"with_bookmark_count\", \"true\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\n\t\tresponse.AssertMessageIsNotEmptyList(t)\n\n\t\tresponse.ForEach(t, func(item map[string]any) {\n\t\t\tt.Logf(\"item: %+v\", item)\n\t\t\tif tag, ok := item[\"name\"].(string); ok {\n\t\t\t\tif tag == \"test-tag-with-count\" {\n\t\t\t\t\trequire.NotZero(t, item[\"bookmark_count\"])\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t})\n\n\tt.Run(\"invalid bookmark_id parameter\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleListTags,\n\t\t\t\"GET\",\n\t\t\t\"/api/v1/tags\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestQueryParam(\"bookmark_id\", \"invalid\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"bookmark_id parameter\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\t// Create a test bookmark\n\t\tbookmark := testutil.GetValidBookmark()\n\t\tbookmarks, err := deps.Database().SaveBookmarks(ctx, true, *bookmark)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, bookmarks, 1)\n\t\tbookmarkID := bookmarks[0].ID\n\n\t\t// Create a test tag\n\t\ttag := model.Tag{Name: \"test-tag-for-bookmark\"}\n\t\tcreatedTags, err := deps.Database().CreateTags(ctx, tag)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, createdTags, 1)\n\n\t\t// Associate the tag with the bookmark\n\t\terr = deps.Database().BulkUpdateBookmarkTags(ctx, []int{bookmarkID}, []int{createdTags[0].ID})\n\t\trequire.NoError(t, err)\n\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleListTags,\n\t\t\t\"GET\",\n\t\t\t\"/api/v1/tags\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestQueryParam(\"bookmark_id\", strconv.Itoa(bookmarkID)),\n\t\t)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\n\t\t// Verify the response contains the tag associated with the bookmark\n\t\tfound := false\n\t\tresponse.ForEach(t, func(item map[string]any) {\n\t\t\tif tag, ok := item[\"name\"].(string); ok {\n\t\t\t\tif tag == \"test-tag-for-bookmark\" {\n\t\t\t\t\tfound = true\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t\trequire.True(t, found, \"The tag associated with the bookmark should be in the response\")\n\t})\n\n\tt.Run(\"search parameter\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\t// Create test tags with different names\n\t\ttags := []model.Tag{\n\t\t\t{Name: \"golang\"},\n\t\t\t{Name: \"python\"},\n\t\t\t{Name: \"javascript\"},\n\t\t}\n\t\tcreatedTags, err := deps.Database().CreateTags(ctx, tags...)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, createdTags, 3)\n\n\t\t// Test searching for \"go\"\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleListTags,\n\t\t\t\"GET\",\n\t\t\t\"/api/v1/tags\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestQueryParam(\"search\", \"go\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\n\t\tresponse.AssertMessageIsNotEmptyList(t)\n\n\t\tfound := false\n\t\tresponse.ForEach(t, func(item map[string]any) {\n\t\t\tif tag, ok := item[\"name\"].(string); ok {\n\t\t\t\tif tag == \"golang\" {\n\t\t\t\t\tfound = true\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t\trequire.True(t, found, \"Tag 'golang' should be present\")\n\n\t\t// Test searching for \"on\"\n\t\tw = testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleListTags,\n\t\t\t\"GET\",\n\t\t\t\"/api/v1/tags\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestQueryParam(\"search\", \"on\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse = testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\n\t\tresponse.AssertMessageIsNotEmptyList(t)\n\n\t\tfound = false\n\t\tresponse.ForEach(t, func(item map[string]any) {\n\t\t\tif tag, ok := item[\"name\"].(string); ok {\n\t\t\t\tif strings.Contains(tag, \"python\") {\n\t\t\t\t\tfound = true\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t\trequire.True(t, found, \"Tag 'python' should be present\")\n\t})\n\n\tt.Run(\"search and bookmark_id parameters together\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\t// Create a test bookmark\n\t\tbookmark := testutil.GetValidBookmark()\n\t\tbookmarks, err := deps.Database().SaveBookmarks(ctx, true, *bookmark)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, bookmarks, 1)\n\t\tbookmarkID := bookmarks[0].ID\n\n\t\t// Test using both search and bookmark_id parameters\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleListTags,\n\t\t\t\"GET\",\n\t\t\t\"/api/v1/tags\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestQueryParam(\"search\", \"go\"),\n\t\t\ttestutil.WithRequestQueryParam(\"bookmark_id\", strconv.Itoa(bookmarkID)),\n\t\t)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertNotOk(t)\n\n\t\t// Verify the error message\n\t\tresponse.AssertMessageJSONKeyValue(t, \"error\", func(t *testing.T, value any) {\n\t\t\trequire.Equal(t, \"search and bookmark ID filtering cannot be used together\", value)\n\t\t})\n\t})\n}\n\nfunc TestHandleGetTag(t *testing.T) {\n\tlogger := logrus.New()\n\tctx := context.Background()\n\n\tt.Run(\"requires authentication\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleGetTag,\n\t\t\t\"GET\",\n\t\t\t\"/api/v1/tags/1\",\n\t\t\ttestutil.WithRequestPathValue(\"id\", \"1\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"invalid tag id\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleGetTag,\n\t\t\t\"GET\",\n\t\t\t\"/api/v1/tags/invalid\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", \"invalid\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"tag not found\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleGetTag,\n\t\t\t\"GET\",\n\t\t\t\"/api/v1/tags/999\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", \"999\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusNotFound, w.Code)\n\t})\n\n\tt.Run(\"success\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\t// Create a test tag\n\t\ttag := model.Tag{Name: \"test-tag\"}\n\t\tcreatedTags, err := deps.Database().CreateTags(ctx, tag)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, createdTags, 1)\n\n\t\ttagID := createdTags[0].ID\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleGetTag,\n\t\t\t\"GET\",\n\t\t\t\"/api/v1/tags/\"+strconv.Itoa(tagID),\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(tagID)),\n\t\t)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\n\t\t// Verify the tag data\n\t\tresponse.AssertMessageJSONKeyValue(t, \"id\", func(t *testing.T, value any) {\n\t\t\trequire.Equal(t, tagID, int(value.(float64))) // TODO: Float64??\n\t\t})\n\t\tresponse.AssertMessageJSONKeyValue(t, \"name\", func(t *testing.T, value any) {\n\t\t\trequire.Equal(t, \"test-tag\", value)\n\t\t})\n\t})\n}\n\nfunc TestHandleCreateTag(t *testing.T) {\n\tlogger := logrus.New()\n\tctx := context.Background()\n\n\tt.Run(\"requires authentication\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(deps, HandleCreateTag, \"POST\", \"/api/v1/tags\")\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"invalid json payload\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleCreateTag,\n\t\t\t\"POST\",\n\t\t\t\"/api/v1/tags\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithBody(\"invalid json\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"empty tag name\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleCreateTag,\n\t\t\t\"POST\",\n\t\t\t\"/api/v1/tags\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithBody(`{\"name\": \"\"}`),\n\t\t)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"successful creation\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleCreateTag,\n\t\t\t\"POST\",\n\t\t\t\"/api/v1/tags\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithBody(`{\"name\": \"new-test-tag\"}`),\n\t\t)\n\t\trequire.Equal(t, http.StatusCreated, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\n\t\t// Verify the created tag\n\t\tresponse.AssertMessageJSONKeyValue(t, \"name\", func(t *testing.T, value any) {\n\t\t\trequire.Equal(t, \"new-test-tag\", value)\n\t\t})\n\t\tresponse.AssertMessageJSONKeyValue(t, \"id\", func(t *testing.T, value any) {\n\t\t\trequire.Greater(t, value.(float64), float64(0)) // TODO: Float64??\n\t\t})\n\t})\n}\n\nfunc TestHandleUpdateTag(t *testing.T) {\n\tlogger := logrus.New()\n\tctx := context.Background()\n\n\tt.Run(\"requires authentication\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleUpdateTag,\n\t\t\t\"PUT\",\n\t\t\t\"/api/v1/tags/1\",\n\t\t\ttestutil.WithRequestPathValue(\"id\", \"1\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"invalid tag id\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleUpdateTag,\n\t\t\t\"PUT\",\n\t\t\t\"/api/v1/tags/invalid\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", \"invalid\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"invalid json payload\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleUpdateTag,\n\t\t\t\"PUT\",\n\t\t\t\"/api/v1/tags/1\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", \"1\"),\n\t\t\ttestutil.WithBody(\"invalid json\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"empty tag name\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleUpdateTag,\n\t\t\t\"PUT\",\n\t\t\t\"/api/v1/tags/1\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", \"1\"),\n\t\t\ttestutil.WithBody(`{\"name\": \"\"}`),\n\t\t)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"tag not found\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleUpdateTag,\n\t\t\t\"PUT\",\n\t\t\t\"/api/v1/tags/999\",\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", \"999\"),\n\t\t\ttestutil.WithBody(`{\"name\": \"updated-tag\"}`),\n\t\t)\n\t\trequire.Equal(t, http.StatusNotFound, w.Code)\n\t})\n\n\tt.Run(\"successful update\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\t// Create a test tag\n\t\ttag := model.Tag{Name: \"test-tag-for-update\"}\n\t\tcreatedTags, err := deps.Database().CreateTags(ctx, tag)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, createdTags, 1)\n\n\t\ttagID := createdTags[0].ID\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleUpdateTag,\n\t\t\t\"PUT\",\n\t\t\t\"/api/v1/tags/\"+strconv.Itoa(tagID),\n\t\t\ttestutil.WithFakeUser(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(tagID)),\n\t\t\ttestutil.WithBody(`{\"name\": \"updated-test-tag\"}`),\n\t\t)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\n\t\t// Verify the updated tag\n\t\tresponse.AssertMessageJSONKeyValue(t, \"name\", func(t *testing.T, value any) {\n\t\t\trequire.Equal(t, \"updated-test-tag\", value)\n\t\t})\n\n\t\t// Ensure database was updated\n\t\tupdatedTag, exists, err := deps.Database().GetTag(ctx, tagID)\n\t\trequire.NoError(t, err)\n\t\trequire.True(t, exists)\n\t\trequire.Equal(t, \"updated-test-tag\", updatedTag.Name)\n\t})\n}\n\nfunc TestHandleDeleteTag(t *testing.T) {\n\tlogger := logrus.New()\n\tctx := context.Background()\n\n\tt.Run(\"requires authentication\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleDeleteTag,\n\t\t\t\"DELETE\",\n\t\t\t\"/api/v1/tags/1\",\n\t\t\ttestutil.WithRequestPathValue(\"id\", \"1\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"requires admin privileges\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleDeleteTag,\n\t\t\t\"DELETE\",\n\t\t\t\"/api/v1/tags/1\",\n\t\t\ttestutil.WithFakeUser(), // Regular user, not admin\n\t\t\ttestutil.WithRequestPathValue(\"id\", \"1\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusForbidden, w.Code)\n\t})\n\n\tt.Run(\"invalid tag id\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleDeleteTag,\n\t\t\t\"DELETE\",\n\t\t\t\"/api/v1/tags/invalid\",\n\t\t\ttestutil.WithFakeAdmin(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", \"invalid\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"tag not found\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleDeleteTag,\n\t\t\t\"DELETE\",\n\t\t\t\"/api/v1/tags/999\",\n\t\t\ttestutil.WithFakeAdmin(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", \"999\"),\n\t\t)\n\t\trequire.Equal(t, http.StatusNotFound, w.Code)\n\t})\n\n\tt.Run(\"successful deletion\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t\t// Create a test tag\n\t\ttag := model.Tag{Name: \"test-tag-for-deletion\"}\n\t\tcreatedTags, err := deps.Database().CreateTags(ctx, tag)\n\t\trequire.NoError(t, err)\n\t\trequire.Len(t, createdTags, 1)\n\n\t\ttagID := createdTags[0].ID\n\t\tw := testutil.PerformRequest(\n\t\t\tdeps,\n\t\t\tHandleDeleteTag,\n\t\t\t\"DELETE\",\n\t\t\t\"/api/v1/tags/\"+strconv.Itoa(tagID),\n\t\t\ttestutil.WithFakeAdmin(),\n\t\t\ttestutil.WithRequestPathValue(\"id\", strconv.Itoa(tagID)),\n\t\t)\n\t\trequire.Equal(t, http.StatusNoContent, w.Code)\n\n\t\t// Verify the tag was deleted\n\t\t_, exists, err := deps.Database().GetTag(ctx, tagID)\n\t\trequire.NoError(t, err)\n\t\trequire.False(t, exists)\n\t})\n}\n"
  },
  {
    "path": "internal/http/handlers/api.go",
    "content": "package handlers\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/go-shiori/shiori/internal/dependencies\"\n\t\"github.com/sirupsen/logrus\"\n)\n\ntype APIHandler struct {\n\tlogger *logrus.Logger\n\tdeps   *dependencies.Dependencies\n}\n\nfunc (h *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\tswitch r.Method {\n\tcase http.MethodGet:\n\t\th.handleGet(w, r)\n\tcase http.MethodPost:\n\t\th.handlePost(w, r)\n\tdefault:\n\t\thttp.Error(w, \"Method not allowed\", http.StatusMethodNotAllowed)\n\t}\n}\n\nfunc (h *APIHandler) handleGet(w http.ResponseWriter, r *http.Request) {\n\t// Implementation\n}\n\nfunc (h *APIHandler) handlePost(w http.ResponseWriter, r *http.Request) {\n\t// Implementation\n}\n\nfunc NewAPIHandler(logger *logrus.Logger, deps *dependencies.Dependencies) *APIHandler {\n\treturn &APIHandler{\n\t\tlogger: logger,\n\t\tdeps:   deps,\n\t}\n}\n"
  },
  {
    "path": "internal/http/handlers/bookmark.go",
    "content": "package handlers\n\nimport (\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/go-shiori/shiori/internal/http/response\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/gofrs/uuid/v5\"\n)\n\n// getBookmark retrieves and validates a bookmark by ID from the request\nfunc getBookmark(deps model.Dependencies, c model.WebContext) (*model.BookmarkDTO, error) {\n\tbookmarkID, err := strconv.Atoi(c.Request().PathValue(\"id\"))\n\tif err != nil {\n\t\treturn nil, response.SendError(c, http.StatusNotFound, \"Invalid bookmark ID\")\n\t}\n\n\tif bookmarkID == 0 {\n\t\treturn nil, response.SendError(c, http.StatusNotFound, \"Bookmark not found\")\n\t}\n\n\t// Get bookmark from database\n\tbookmark, err := deps.Domains().Bookmarks().GetBookmark(c.Request().Context(), model.DBID(bookmarkID))\n\tif err != nil {\n\t\treturn nil, response.SendError(c, http.StatusNotFound, \"Bookmark not found\")\n\t}\n\n\t// Check access permissions\n\tif bookmark.Public != 1 && !c.UserIsLogged() {\n\t\tresponse.RedirectToLogin(c, deps.Config().Http.RootPath, c.Request().URL.String())\n\t\treturn nil, nil\n\t}\n\n\treturn bookmark, nil\n}\n\n// HandleBookmarkContent serves the bookmark content page\nfunc HandleBookmarkContent(deps model.Dependencies, c model.WebContext) {\n\tbookmark, err := getBookmark(deps, c)\n\tif err != nil || bookmark == nil {\n\t\treturn\n\t}\n\n\tdata := map[string]any{\n\t\t\"RootPath\": deps.Config().Http.RootPath,\n\t\t\"Version\":  model.BuildVersion,\n\t\t\"Book\":     bookmark,\n\t\t\"HTML\":     template.HTML(bookmark.HTML),\n\t}\n\n\tif err := response.SendTemplate(c, \"content.html\", data); err != nil {\n\t\tdeps.Logger().WithError(err).Error(\"failed to render content template\")\n\t}\n}\n\n// HandleBookmarkArchive serves the bookmark archive page\nfunc HandleBookmarkArchive(deps model.Dependencies, c model.WebContext) {\n\tbookmark, err := getBookmark(deps, c)\n\tif err != nil || bookmark == nil {\n\t\treturn\n\t}\n\n\tif !deps.Domains().Bookmarks().HasArchive(bookmark) {\n\t\tresponse.NotFound(c)\n\t\treturn\n\t}\n\n\tdata := map[string]any{\n\t\t\"RootPath\": deps.Config().Http.RootPath,\n\t\t\"Version\":  model.BuildVersion,\n\t\t\"Book\":     bookmark,\n\t}\n\n\tif err := response.SendTemplate(c, \"archive.html\", data); err != nil {\n\t\tdeps.Logger().WithError(err).Error(\"failed to render archive template\")\n\t}\n}\n\n// HandleBookmarkArchiveFile serves files from the bookmark archive\nfunc HandleBookmarkArchiveFile(deps model.Dependencies, c model.WebContext) {\n\tbookmark, err := getBookmark(deps, c)\n\tif err != nil || bookmark == nil {\n\t\treturn\n\t}\n\n\tif !deps.Domains().Bookmarks().HasArchive(bookmark) {\n\t\tresponse.NotFound(c)\n\t\treturn\n\t}\n\n\tresourcePath := c.Request().PathValue(\"path\")\n\n\tarchive, err := deps.Domains().Archiver().GetBookmarkArchive(bookmark)\n\tif err != nil {\n\t\tdeps.Logger().WithError(err).Error(\"error opening archive\")\n\t\tresponse.SendInternalServerError(c)\n\t\treturn\n\t}\n\tdefer archive.Close()\n\n\tif !archive.HasResource(resourcePath) {\n\t\tresponse.NotFound(c)\n\t\treturn\n\t}\n\n\tcontent, resourceContentType, err := archive.Read(resourcePath)\n\tif err != nil {\n\t\tdeps.Logger().WithError(err).Error(\"error reading archive file\")\n\t\tresponse.SendInternalServerError(c)\n\t\treturn\n\t}\n\n\t// Generate weak ETAG\n\tshioriUUID := uuid.NewV5(uuid.NamespaceURL, model.ShioriURLNamespace)\n\tetag := fmt.Sprintf(\"W/%s\", uuid.NewV5(shioriUUID, fmt.Sprintf(\"%x-%x-%x\", bookmark.ID, resourcePath, len(content))))\n\n\tc.ResponseWriter().Header().Set(\"Etag\", etag)\n\tc.ResponseWriter().Header().Set(\"Cache-Control\", \"max-age=31536000\")\n\tc.ResponseWriter().Header().Set(\"Content-Encoding\", \"gzip\")\n\tc.ResponseWriter().Header().Set(\"Content-Type\", resourceContentType)\n\tc.ResponseWriter().WriteHeader(http.StatusOK)\n\tc.ResponseWriter().Write(content)\n}\n\n// HandleBookmarkThumbnail serves the bookmark thumbnail\nfunc HandleBookmarkThumbnail(deps model.Dependencies, c model.WebContext) {\n\tbookmark, err := getBookmark(deps, c)\n\tif err != nil || bookmark == nil {\n\t\treturn\n\t}\n\n\tif !deps.Domains().Bookmarks().HasThumbnail(bookmark) {\n\t\tresponse.NotFound(c)\n\t\treturn\n\t}\n\n\tetag := \"w/\" + model.GetThumbnailPath(bookmark) + \"-\" + bookmark.ModifiedAt\n\n\t// Check if the client's ETag matches\n\tif c.Request().Header.Get(\"If-None-Match\") == etag {\n\t\tc.ResponseWriter().WriteHeader(http.StatusNotModified)\n\t\treturn\n\t}\n\n\toptions := &response.SendFileOptions{\n\t\tHeaders: []http.Header{\n\t\t\t{\"Cache-Control\": {\"no-cache, must-revalidate\"}},\n\t\t\t{\"Last-Modified\": {bookmark.ModifiedAt}},\n\t\t\t{\"ETag\": {etag}},\n\t\t},\n\t}\n\n\tresponse.SendFile(c, deps.Domains().Storage(), model.GetThumbnailPath(bookmark), options)\n}\n\n// HandleBookmarkEbook serves the bookmark's ebook file\nfunc HandleBookmarkEbook(deps model.Dependencies, c model.WebContext) {\n\tbookmark, err := getBookmark(deps, c)\n\tif err != nil || bookmark == nil {\n\t\treturn\n\t}\n\n\tebookPath := model.GetEbookPath(bookmark)\n\tif !deps.Domains().Storage().FileExists(ebookPath) {\n\t\tresponse.SendError(c, http.StatusNotFound, \"Ebook not found\")\n\t\treturn\n\t}\n\n\tc.ResponseWriter().Header().Set(\"Content-Disposition\", fmt.Sprintf(`attachment; filename=\"%s.epub\"`, bookmark.Title))\n\tresponse.SendFile(c, deps.Domains().Storage(), ebookPath, nil)\n}\n"
  },
  {
    "path": "internal/http/handlers/bookmark_test.go",
    "content": "package handlers\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/http/templates\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestGetBookmark(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\terr := templates.SetupTemplates(deps.Config())\n\trequire.NoError(t, err)\n\n\t// Create a private and a public bookmark to use in tests\n\tpublicBookmark := testutil.GetValidBookmark()\n\tpublicBookmark.Public = 1\n\tbookmarks, err := deps.Database().SaveBookmarks(context.TODO(), true, []model.BookmarkDTO{\n\t\t*testutil.GetValidBookmark(),\n\t\t*publicBookmark,\n\t}...)\n\trequire.NoError(t, err)\n\n\tt.Run(\"bookmark ID is not parsable number\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContextWithMethod(\"GET\", \"/bookmark/notanumber\")\n\t\ttestutil.SetRequestPathValue(c, \"id\", \"notanumber\")\n\t\tbookmark, _ := getBookmark(deps, c)\n\t\trequire.Nil(t, bookmark)\n\t\trequire.Equal(t, http.StatusNotFound, w.Code)\n\t})\n\n\tt.Run(\"bookmark ID does not exist\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContextWithMethod(\"GET\", \"/bookmark/99999\")\n\t\ttestutil.SetRequestPathValue(c, \"id\", \"99999\")\n\t\tbookmark, _ := getBookmark(deps, c)\n\t\trequire.Nil(t, bookmark)\n\t\trequire.Equal(t, http.StatusNotFound, w.Code)\n\t})\n\n\tt.Run(\"bookmark ID exists but user is not logged in\", func(t *testing.T) {\n\t\tc, _ := testutil.NewTestWebContextWithMethod(\"GET\", \"/bookmark/\"+strconv.Itoa(bookmarks[0].ID))\n\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(bookmarks[0].ID))\n\t\tbookmark, _ := getBookmark(deps, c)\n\t\trequire.NoError(t, err) // No error because it redirects\n\t\trequire.Nil(t, bookmark)\n\t})\n\n\tt.Run(\"bookmark ID exists and its public and user is not logged in\", func(t *testing.T) {\n\t\tc, _ := testutil.NewTestWebContextWithMethod(\"GET\", \"/bookmark/\"+strconv.Itoa(bookmarks[1].ID))\n\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(bookmarks[1].ID))\n\t\tbookmark, _ := getBookmark(deps, c)\n\t\trequire.NoError(t, err)\n\t\trequire.NotNil(t, bookmark)\n\t})\n\n\tt.Run(\"bookmark ID exists and user is logged in\", func(t *testing.T) {\n\t\tc, _ := testutil.NewTestWebContextWithMethod(\"GET\", \"/bookmark/\"+strconv.Itoa(bookmarks[0].ID)+\"/content\")\n\t\ttestutil.SetFakeUser(c)\n\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(bookmarks[0].ID))\n\t\tbookmark, _ := getBookmark(deps, c)\n\t\trequire.NoError(t, err)\n\t\trequire.NotNil(t, bookmark)\n\t})\n}\n\nfunc TestBookmarkContentHandler(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger)\n\n\terr := templates.SetupTemplates(deps.Config())\n\trequire.NoError(t, err)\n\n\tbookmark := testutil.GetValidBookmark()\n\tbookmark.HTML = \"<html><body><h1>Bookmark HTML content</h1></body></html>\"\n\tbookmarks, err := deps.Database().SaveBookmarks(context.TODO(), true, *bookmark)\n\trequire.NoError(t, err)\n\tbookmark = &bookmarks[0]\n\n\tt.Run(\"not logged in\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContextWithMethod(\"GET\", \"/bookmark/\"+strconv.Itoa(bookmark.ID)+\"/content\")\n\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(bookmark.ID))\n\t\tHandleBookmarkContent(deps, c)\n\t\trequire.Equal(t, http.StatusFound, w.Code) // Redirects to login\n\t})\n\n\tt.Run(\"get existing bookmark content\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContextWithMethod(\"GET\", \"/bookmark/\"+strconv.Itoa(bookmark.ID)+\"/content\")\n\t\ttestutil.SetFakeUser(c)\n\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(bookmark.ID))\n\t\tHandleBookmarkContent(deps, c)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t\trequire.Contains(t, w.Body.String(), bookmark.HTML)\n\t})\n}\n\nfunc TestBookmarkFileHandlers(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger)\n\n\terr := templates.SetupTemplates(deps.Config())\n\trequire.NoError(t, err)\n\n\tbookmark := testutil.GetValidBookmark()\n\tbookmark.HTML = \"<html><body><h1>Bookmark HTML content</h1></body></html>\"\n\tbookmark.HasArchive = true\n\tbookmark.CreateArchive = true\n\tbookmark.CreateEbook = true\n\tbookmarks, err := deps.Database().SaveBookmarks(context.TODO(), true, *bookmark)\n\trequire.NoError(t, err)\n\n\tbookmark, err = deps.Domains().Archiver().DownloadBookmarkArchive(bookmarks[0])\n\trequire.NoError(t, err)\n\n\tbookmarks, err = deps.Database().SaveBookmarks(context.TODO(), false, *bookmark)\n\trequire.NoError(t, err)\n\tbookmark = &bookmarks[0]\n\n\tt.Run(\"get existing bookmark archive\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContextWithMethod(\"GET\", \"/bookmark/\"+strconv.Itoa(bookmark.ID)+\"/archive\")\n\t\ttestutil.SetFakeUser(c)\n\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(bookmark.ID))\n\t\tHandleBookmarkArchive(deps, c)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t\trequire.Contains(t, w.Body.String(), \"iframe\")\n\t})\n\n\tt.Run(\"get existing bookmark thumbnail\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContextWithMethod(\"GET\", \"/bookmark/\"+strconv.Itoa(bookmark.ID)+\"/thumb\")\n\t\ttestutil.SetFakeUser(c)\n\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(bookmark.ID))\n\t\tHandleBookmarkThumbnail(deps, c)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t})\n\n\tt.Run(\"bookmark without archive\", func(t *testing.T) {\n\t\tbookmark := testutil.GetValidBookmark()\n\t\tbookmarks, err := deps.Database().SaveBookmarks(context.TODO(), true, *bookmark)\n\t\trequire.NoError(t, err)\n\n\t\tc, w := testutil.NewTestWebContextWithMethod(\"GET\", \"/bookmark/\"+strconv.Itoa(bookmarks[0].ID)+\"/archive\")\n\t\ttestutil.SetFakeUser(c)\n\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(bookmarks[0].ID))\n\t\tHandleBookmarkArchive(deps, c)\n\t\trequire.Equal(t, http.StatusNotFound, w.Code)\n\t})\n\n\tt.Run(\"get existing bookmark archive file\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContextWithMethod(\"GET\", \"/bookmark/\"+strconv.Itoa(bookmark.ID)+\"/archive/file/\")\n\t\ttestutil.SetFakeUser(c)\n\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(bookmark.ID))\n\t\tHandleBookmarkArchiveFile(deps, c)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t})\n\n\tt.Run(\"bookmark with ebook\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContextWithMethod(\"GET\", \"/bookmark/\"+strconv.Itoa(bookmark.ID)+\"/ebook\")\n\t\ttestutil.SetFakeUser(c)\n\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(bookmark.ID))\n\t\tHandleBookmarkEbook(deps, c)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t})\n\n\tt.Run(\"bookmark without ebook\", func(t *testing.T) {\n\t\tbookmark := testutil.GetValidBookmark()\n\t\tbookmarks, err := deps.Database().SaveBookmarks(context.TODO(), true, *bookmark)\n\t\trequire.NoError(t, err)\n\n\t\tc, w := testutil.NewTestWebContextWithMethod(\"GET\", \"/bookmark/\"+strconv.Itoa(bookmarks[0].ID)+\"/ebook\")\n\t\ttestutil.SetFakeUser(c)\n\t\ttestutil.SetRequestPathValue(c, \"id\", strconv.Itoa(bookmarks[0].ID))\n\t\tHandleBookmarkEbook(deps, c)\n\t\trequire.Equal(t, http.StatusNotFound, w.Code)\n\t})\n}\n"
  },
  {
    "path": "internal/http/handlers/frontend.go",
    "content": "package handlers\n\nimport (\n\t\"embed\"\n\t\"net/http\"\n\t\"path\"\n\n\t\"github.com/go-shiori/shiori/internal/http/response\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\tviews \"github.com/go-shiori/shiori/internal/view\"\n\twebapp \"github.com/go-shiori/shiori/webapp\"\n)\n\ntype assetsFS struct {\n\thttp.FileSystem\n\tserveWebUIV2 bool\n}\n\nfunc (fs assetsFS) Open(name string) (http.File, error) {\n\tpathJoin := \"assets\"\n\tif fs.serveWebUIV2 {\n\t\tpathJoin = \"dist/assets\"\n\t}\n\n\treturn fs.FileSystem.Open(path.Join(pathJoin, name))\n}\n\nfunc newAssetsFS(fs embed.FS, serveWebUIV2 bool) http.FileSystem {\n\treturn assetsFS{\n\t\tFileSystem:   http.FS(fs),\n\t\tserveWebUIV2: serveWebUIV2,\n\t}\n}\n\n// HandleFrontend serves the main frontend page\nfunc HandleFrontend(deps model.Dependencies, c model.WebContext) {\n\tdata := map[string]any{\n\t\t\"RootPath\": deps.Config().Http.RootPath,\n\t\t\"Version\":  model.BuildVersion,\n\t}\n\n\tif err := response.SendTemplate(c, \"index.html\", data); err != nil {\n\t\tdeps.Logger().WithError(err).Error(\"failed to render template\")\n\t}\n}\n\n// HandleAssets serves static assets\nfunc HandleAssets(deps model.Dependencies, c model.WebContext) {\n\tfs := views.Assets\n\tif deps.Config().Http.ServeWebUIV2 {\n\t\tfs = webapp.Assets\n\t}\n\thttp.StripPrefix(\"/assets/\", http.FileServer(newAssetsFS(fs, deps.Config().Http.ServeWebUIV2))).ServeHTTP(c.ResponseWriter(), c.Request())\n}\n"
  },
  {
    "path": "internal/http/handlers/frontend_test.go",
    "content": "package handlers\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/http/templates\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestHandleFrontend(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger)\n\n\terr := templates.SetupTemplates(deps.Config())\n\trequire.NoError(t, err)\n\n\tt.Run(\"serves index page\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\tHandleFrontend(deps, c)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t\trequire.Contains(t, w.Header().Get(\"Content-Type\"), \"text/html\")\n\t})\n}\n\nfunc TestHandleAssets(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger)\n\n\tt.Run(\"serves css file\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContextWithMethod(\"GET\", \"/assets/css/style.css\")\n\t\tHandleAssets(deps, c)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t\trequire.Contains(t, w.Header().Get(\"Content-Type\"), \"text/css\")\n\t})\n\n\tt.Run(\"returns 404 for missing file\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContextWithMethod(\"GET\", \"/assets/not-found.txt\")\n\t\tHandleAssets(deps, c)\n\t\trequire.Equal(t, http.StatusNotFound, w.Code)\n\t})\n}\n"
  },
  {
    "path": "internal/http/handlers/legacy.go",
    "content": "package handlers\n\nimport (\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/webserver\"\n\t\"github.com/gofrs/uuid/v5\"\n\t\"github.com/julienschmidt/httprouter\"\n\t\"github.com/pkg/errors\"\n)\n\ntype LegacyHandler struct {\n\tlegacyHandler *webserver.Handler\n}\n\nfunc NewLegacyHandler(deps model.Dependencies) *LegacyHandler {\n\thandler := webserver.GetLegacyHandler(webserver.Config{\n\t\tDB:       deps.Database(),\n\t\tDataDir:  deps.Config().Storage.DataDir,\n\t\tRootPath: deps.Config().Http.RootPath,\n\t\tLog:      false, // Already handled by middleware\n\t}, deps)\n\thandler.PrepareSessionCache()\n\n\treturn &LegacyHandler{\n\t\tlegacyHandler: handler,\n\t}\n}\n\n// convertParams converts standard URL parameters to httprouter.Params\nfunc (h *LegacyHandler) convertParams(r *http.Request) httprouter.Params {\n\trouterParams := httprouter.Params{}\n\tfor key, value := range r.URL.Query() {\n\t\trouterParams = append(routerParams, httprouter.Param{\n\t\t\tKey:   key,\n\t\t\tValue: value[0],\n\t\t})\n\t}\n\n\treturn routerParams\n}\n\n// HandleLogin handles the legacy login endpoint\nfunc (h *LegacyHandler) HandleLogin(account *model.AccountDTO, expTime time.Duration) (string, error) {\n\tsessionID, err := uuid.NewV4()\n\tif err != nil {\n\t\treturn \"\", errors.Wrap(err, \"failed to create session ID\")\n\t}\n\n\tstrSessionID := sessionID.String()\n\n\treturn strSessionID, nil\n}\n\n// HandleLogout handles the legacy logout endpoint\nfunc (h *LegacyHandler) HandleLogout(deps model.Dependencies, c model.WebContext) {\n\t// TODO: Leave cookie handling to API consumer or middleware?\n\t// Remove token cookie\n\tc.Request().AddCookie(&http.Cookie{\n\t\tName:  \"token\",\n\t\tValue: \"\",\n\t})\n}\n\n// HandleGetTags handles GET /api/tags\nfunc (h *LegacyHandler) HandleGetTags(deps model.Dependencies, c model.WebContext) {\n\th.legacyHandler.ApiGetTags(c.ResponseWriter(), c.Request(), h.convertParams(c.Request()))\n}\n\n// HandleRenameTag handles PUT /api/tags\nfunc (h *LegacyHandler) HandleRenameTag(deps model.Dependencies, c model.WebContext) {\n\th.legacyHandler.ApiRenameTag(c.ResponseWriter(), c.Request(), h.convertParams(c.Request()))\n}\n\n// HandleGetBookmarks handles GET /api/bookmarks\nfunc (h *LegacyHandler) HandleGetBookmarks(deps model.Dependencies, c model.WebContext) {\n\th.legacyHandler.ApiGetBookmarks(c.ResponseWriter(), c.Request(), h.convertParams(c.Request()))\n}\n\n// HandleInsertBookmark handles POST /api/bookmarks\nfunc (h *LegacyHandler) HandleInsertBookmark(deps model.Dependencies, c model.WebContext) {\n\th.legacyHandler.ApiInsertBookmark(c.ResponseWriter(), c.Request(), h.convertParams(c.Request()))\n}\n\n// HandleDeleteBookmark handles DELETE /api/bookmarks\nfunc (h *LegacyHandler) HandleDeleteBookmark(deps model.Dependencies, c model.WebContext) {\n\th.legacyHandler.ApiDeleteBookmark(c.ResponseWriter(), c.Request(), h.convertParams(c.Request()))\n}\n\n// HandleUpdateBookmark handles PUT /api/bookmarks\nfunc (h *LegacyHandler) HandleUpdateBookmark(deps model.Dependencies, c model.WebContext) {\n\th.legacyHandler.ApiUpdateBookmark(c.ResponseWriter(), c.Request(), h.convertParams(c.Request()))\n}\n\n// HandleUpdateBookmarkTags handles PUT /api/bookmarks/tags\nfunc (h *LegacyHandler) HandleUpdateBookmarkTags(deps model.Dependencies, c model.WebContext) {\n\th.legacyHandler.ApiUpdateBookmarkTags(c.ResponseWriter(), c.Request(), h.convertParams(c.Request()))\n}\n\n// HandleInsertViaExtension handles POST /api/bookmarks/ext\nfunc (h *LegacyHandler) HandleInsertViaExtension(deps model.Dependencies, c model.WebContext) {\n\th.legacyHandler.ApiInsertViaExtension(c.ResponseWriter(), c.Request(), h.convertParams(c.Request()))\n}\n\n// HandleDeleteViaExtension handles DELETE /api/bookmarks/ext\nfunc (h *LegacyHandler) HandleDeleteViaExtension(deps model.Dependencies, c model.WebContext) {\n\th.legacyHandler.ApiDeleteViaExtension(c.ResponseWriter(), c.Request(), h.convertParams(c.Request()))\n}\n"
  },
  {
    "path": "internal/http/handlers/legacy_test.go",
    "content": "package handlers\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\n// SetFakeAuthorizationHeader sets a fake authorization header for the request in order to have\n// a valid session. If we don't set this the `validateSession` function will return an error.\nfunc SetFakeAuthorizationHeader(t *testing.T, deps model.Dependencies, c model.WebContext) {\n\ttoken, err := deps.Domains().Auth().CreateTokenForAccount(c.GetAccount(), time.Now().Add(time.Hour))\n\trequire.NoError(t, err)\n\tc.Request().Header.Set(\"Authorization\", fmt.Sprintf(\"Bearer %s\", token))\n}\n\nfunc TestLegacyHandler(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger)\n\thandler := NewLegacyHandler(deps)\n\n\tt.Run(\"HandleLogin\", func(t *testing.T) {\n\t\taccount := &model.AccountDTO{\n\t\t\tID:       1,\n\t\t\tUsername: \"test\",\n\t\t\tOwner:    model.Ptr(false),\n\t\t}\n\n\t\tsessionID, err := handler.HandleLogin(account, time.Hour)\n\t\trequire.NoError(t, err)\n\t\trequire.NotEmpty(t, sessionID)\n\t})\n\n\tt.Run(\"HandleGetTags\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeUser(c)\n\t\tSetFakeAuthorizationHeader(t, deps, c)\n\t\thandler.HandleGetTags(deps, c)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t})\n\n\tt.Run(\"HandleGetBookmarks\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeUser(c)\n\t\tSetFakeAuthorizationHeader(t, deps, c)\n\t\thandler.HandleGetBookmarks(deps, c)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t})\n\n\tt.Run(\"convertParams\", func(t *testing.T) {\n\t\tr, _ := http.NewRequest(http.MethodGet, \"/api/bookmarks?page=1&tags=test,dev\", http.NoBody)\n\t\tparams := handler.convertParams(r)\n\n\t\trequire.Len(t, params, 2)\n\n\t\t// Create a map to check for parameters regardless of order\n\t\tparamMap := make(map[string]string)\n\t\tfor _, param := range params {\n\t\t\tparamMap[param.Key] = param.Value\n\t\t}\n\n\t\t// Check that both parameters exist with the correct values\n\t\trequire.Contains(t, paramMap, \"page\")\n\t\trequire.Equal(t, \"1\", paramMap[\"page\"])\n\t\trequire.Contains(t, paramMap, \"tags\")\n\t\trequire.Equal(t, \"test,dev\", paramMap[\"tags\"])\n\t})\n}\n"
  },
  {
    "path": "internal/http/handlers/swagger.go",
    "content": "package handlers\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\thttpSwagger \"github.com/swaggo/http-swagger/v2\"\n\n\t_ \"github.com/go-shiori/shiori/docs/swagger\" // swagger docs\n)\n\n// HandleSwagger serves the swagger documentation UI\nfunc HandleSwagger(deps model.Dependencies, c model.WebContext) {\n\t// Redirect /swagger to /swagger/\n\tpath := c.Request().URL.Path\n\tif path == \"/swagger\" {\n\t\thttp.Redirect(c.ResponseWriter(), c.Request(), \"/swagger/index.html\", http.StatusPermanentRedirect)\n\t\treturn\n\t}\n\n\t// Strip /swagger prefix and serve swagger UI\n\thandler := httpSwagger.Handler(\n\t\thttpSwagger.URL(\"/swagger/doc.json\"), // URL pointing to API definition\n\t)\n\thttp.StripPrefix(\"/swagger\", handler).ServeHTTP(c.ResponseWriter(), c.Request())\n}\n"
  },
  {
    "path": "internal/http/handlers/swagger_test.go",
    "content": "package handlers\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestHandleSwagger(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger)\n\n\tt.Run(\"serves swagger doc.json\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContextWithMethod(\"GET\", \"/swagger/doc.json\")\n\t\tHandleSwagger(deps, c)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t})\n\n\tt.Run(\"redirects /swagger/ to index\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContextWithMethod(\"GET\", \"/swagger/\")\n\t\tHandleSwagger(deps, c)\n\t\trequire.Equal(t, 301, w.Code)\n\t\trequire.Equal(t, \"/swagger/index.html\", w.Header().Get(\"Location\"))\n\t})\n\n\tt.Run(\"redirects /swagger to index\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContextWithMethod(\"GET\", \"/swagger\")\n\t\tHandleSwagger(deps, c)\n\t\trequire.Equal(t, http.StatusPermanentRedirect, w.Code)\n\t\trequire.Equal(t, \"/swagger/index.html\", w.Header().Get(\"Location\"))\n\t})\n}\n"
  },
  {
    "path": "internal/http/handlers/system.go",
    "content": "package handlers\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/go-shiori/shiori/internal/http/response\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\n// HandleLiveness handles the liveness check endpoint\nfunc HandleLiveness(deps model.Dependencies, c model.WebContext) {\n\tresponse.SendJSON(c, http.StatusOK, struct {\n\t\tVersion string `json:\"version\"`\n\t\tCommit  string `json:\"commit\"`\n\t\tDate    string `json:\"date\"`\n\t}{\n\t\tVersion: model.BuildVersion,\n\t\tCommit:  model.BuildCommit,\n\t\tDate:    model.BuildDate,\n\t})\n}\n"
  },
  {
    "path": "internal/http/handlers/system_test.go",
    "content": "package handlers\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n)\n\nfunc TestHandleLiveness(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger)\n\n\tt.Run(\"returns build info\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\tHandleLiveness(deps, c)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t\tresponse.AssertMessageJSONContains(t, `{\"version\":\"dev\",\"commit\":\"none\",\"date\":\"unknown\"}`)\n\t})\n\n\tt.Run(\"handles without auth\", func(t *testing.T) {\n\t\t// Test that liveness check works without authentication\n\t\tc, w := testutil.NewTestWebContext()\n\t\tHandleLiveness(deps, c)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t})\n}\n"
  },
  {
    "path": "internal/http/http.go",
    "content": "package http\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/go-shiori/shiori/internal/http/webcontext\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\n// ToHTTPHandler converts a model.HttpHandler to http.HandlerFunc with dependencies and middlewares\nfunc ToHTTPHandler(deps model.Dependencies, h model.HttpHandler, middlewares ...model.HttpMiddleware) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tc := webcontext.NewWebContext(w, r)\n\n\t\t// Execute OnRequest middlewares\n\t\tfor _, m := range middlewares {\n\t\t\tif err := m.OnRequest(deps, c); err != nil {\n\t\t\t\t// Handle middleware error\n\t\t\t\tdeps.Logger().WithError(err).Error(\"middleware error in request\")\n\t\t\t\thttp.Error(w, \"Internal Server Error\", http.StatusInternalServerError)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// Execute handler\n\t\th(deps, c)\n\n\t\t// Execute OnResponse middlewares in reverse order\n\t\tfor i := len(middlewares) - 1; i >= 0; i-- {\n\t\t\tm := middlewares[i]\n\t\t\tif err := m.OnResponse(deps, c); err != nil {\n\t\t\t\tdeps.Logger().WithError(err).Error(\"middleware error in response\")\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/http/http_test.go",
    "content": "package http\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype testMiddleware struct {\n\tonRequestCalled  bool\n\tonResponseCalled bool\n\treturnError      bool\n}\n\nfunc (m *testMiddleware) OnRequest(deps model.Dependencies, c model.WebContext) error {\n\tm.onRequestCalled = true\n\tif m.returnError {\n\t\treturn errors.New(\"test error\")\n\t}\n\treturn nil\n}\n\nfunc (m *testMiddleware) OnResponse(deps model.Dependencies, c model.WebContext) error {\n\tm.onResponseCalled = true\n\tif m.returnError {\n\t\treturn errors.New(\"test error\")\n\t}\n\treturn nil\n}\n\nfunc TestToHTTPHandler(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tt.Run(\"executes handler without middleware\", func(t *testing.T) {\n\t\thandlerCalled := false\n\t\thandler := func(deps model.Dependencies, c model.WebContext) {\n\t\t\thandlerCalled = true\n\t\t\tc.ResponseWriter().WriteHeader(http.StatusOK)\n\t\t}\n\n\t\tc, w := testutil.NewTestWebContext()\n\t\thttpHandler := ToHTTPHandler(deps, handler)\n\t\thttpHandler.ServeHTTP(w, c.Request())\n\n\t\trequire.True(t, handlerCalled)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t})\n\n\tt.Run(\"executes middleware chain\", func(t *testing.T) {\n\t\tmiddleware1 := &testMiddleware{}\n\t\tmiddleware2 := &testMiddleware{}\n\n\t\thandlerCalled := false\n\t\thandler := func(deps model.Dependencies, c model.WebContext) {\n\t\t\thandlerCalled = true\n\t\t\tc.ResponseWriter().WriteHeader(http.StatusOK)\n\t\t}\n\n\t\tc, w := testutil.NewTestWebContext()\n\t\thttpHandler := ToHTTPHandler(deps, handler, middleware1, middleware2)\n\t\thttpHandler.ServeHTTP(w, c.Request())\n\n\t\trequire.True(t, handlerCalled)\n\t\trequire.True(t, middleware1.onRequestCalled)\n\t\trequire.True(t, middleware1.onResponseCalled)\n\t\trequire.True(t, middleware2.onRequestCalled)\n\t\trequire.True(t, middleware2.onResponseCalled)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t})\n\n\tt.Run(\"stops on middleware request error\", func(t *testing.T) {\n\t\tmiddleware1 := &testMiddleware{returnError: true}\n\t\tmiddleware2 := &testMiddleware{}\n\n\t\thandlerCalled := false\n\t\thandler := func(deps model.Dependencies, c model.WebContext) {\n\t\t\thandlerCalled = true\n\t\t}\n\n\t\tc, w := testutil.NewTestWebContext()\n\t\thttpHandler := ToHTTPHandler(deps, handler, middleware1, middleware2)\n\t\thttpHandler.ServeHTTP(w, c.Request())\n\n\t\trequire.False(t, handlerCalled)\n\t\trequire.True(t, middleware1.onRequestCalled)\n\t\trequire.False(t, middleware1.onResponseCalled)\n\t\trequire.False(t, middleware2.onRequestCalled)\n\t\trequire.False(t, middleware2.onResponseCalled)\n\t\trequire.Equal(t, http.StatusInternalServerError, w.Code)\n\t})\n}\n"
  },
  {
    "path": "internal/http/middleware/auth.go",
    "content": "package middleware\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/go-shiori/shiori/internal/http/response\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\n// AuthMiddleware handles authentication for incoming request by checking the token\n// from the Authorization header or the token cookie and setting the account in the\n// request context.\ntype AuthMiddleware struct {\n\tdeps model.Dependencies\n}\n\nfunc NewAuthMiddleware(deps model.Dependencies) *AuthMiddleware {\n\treturn &AuthMiddleware{deps: deps}\n}\n\nfunc (m *AuthMiddleware) OnRequest(deps model.Dependencies, c model.WebContext) error {\n\tif c.UserIsLogged() {\n\t\treturn nil\n\t}\n\n\ttoken := getTokenFromHeader(c.Request())\n\tif token == \"\" {\n\t\ttoken = getTokenFromCookie(c.Request())\n\t}\n\n\tif token == \"\" {\n\t\treturn nil\n\t}\n\n\taccount, err := deps.Domains().Auth().CheckToken(c.Request().Context(), token)\n\tif err != nil {\n\t\t// If we fail to check token, remove the token cookie and redirect to login\n\t\tdeps.Logger().WithError(err).WithField(\"request_id\", c.GetRequestID()).Error(\"Failed to check token\")\n\t\thttp.SetCookie(c.ResponseWriter(), &http.Cookie{\n\t\t\tName:   \"token\",\n\t\t\tValue:  \"\",\n\t\t\tMaxAge: -1,\n\t\t})\n\t\treturn nil\n\t}\n\n\tc.SetAccount(account)\n\treturn nil\n}\n\nfunc (m *AuthMiddleware) OnResponse(deps model.Dependencies, c model.WebContext) error {\n\treturn nil\n}\n\n// RequireLoggedInUser ensures a user is authenticated\nfunc RequireLoggedInUser(deps model.Dependencies, c model.WebContext) error {\n\tif !c.UserIsLogged() {\n\t\tresponse.SendError(c, http.StatusUnauthorized, \"Authentication required\")\n\t\treturn fmt.Errorf(\"authentication required\")\n\t}\n\treturn nil\n}\n\n// RequireLoggedInAdmin ensures a user is authenticated and is an admin\nfunc RequireLoggedInAdmin(deps model.Dependencies, c model.WebContext) error {\n\taccount := c.GetAccount()\n\tif err := RequireLoggedInUser(deps, c); err != nil {\n\t\treturn err\n\t}\n\n\tif !account.IsOwner() {\n\t\tresponse.SendError(c, http.StatusForbidden, \"Admin access required\")\n\t\treturn fmt.Errorf(\"admin access required\")\n\t}\n\n\treturn nil\n}\n\n// getTokenFromHeader returns the token from the Authorization header\nfunc getTokenFromHeader(r *http.Request) string {\n\tauthorization := r.Header.Get(model.AuthorizationHeader)\n\tif authorization == \"\" {\n\t\treturn \"\"\n\t}\n\n\tauthParts := strings.SplitN(authorization, \" \", 2)\n\tif len(authParts) != 2 || authParts[0] != model.AuthorizationTokenType {\n\t\treturn \"\"\n\t}\n\n\treturn authParts[1]\n}\n\n// getTokenFromCookie returns the token from the token cookie\nfunc getTokenFromCookie(r *http.Request) string {\n\tcookie, err := r.Cookie(\"token\")\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn cookie.Value\n}\n"
  },
  {
    "path": "internal/http/middleware/auth_sso_proxy.go",
    "content": "package middleware\n\nimport (\n\t\"errors\"\n\t\"net\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\n// AuthMiddleware handles authentication for incoming request by checking the token\n// from the Authorization header or the token cookie and setting the account in the\n// request context.\ntype AuthSSOProxyMiddleware struct {\n\tdeps model.Dependencies\n\n\ttrustedIPs []*net.IPNet\n}\n\nfunc NewAuthSSOProxyMiddleware(deps model.Dependencies) *AuthSSOProxyMiddleware {\n\tplainIPs := deps.Config().Http.SSOProxyAuthTrusted\n\ttrustedIPs := make([]*net.IPNet, len(plainIPs))\n\tfor i, ip := range plainIPs {\n\t\t_, ipNet, err := net.ParseCIDR(ip)\n\t\tif err != nil {\n\t\t\tdeps.Logger().WithError(err).WithField(\"ip\", ip).Error(\"Failed to parse trusted ip cidr\")\n\t\t\tcontinue\n\t\t}\n\n\t\ttrustedIPs[i] = ipNet\n\t}\n\n\treturn &AuthSSOProxyMiddleware{\n\t\tdeps:       deps,\n\t\ttrustedIPs: trustedIPs,\n\t}\n}\n\nfunc (m *AuthSSOProxyMiddleware) OnRequest(deps model.Dependencies, c model.WebContext) error {\n\tif c.UserIsLogged() {\n\t\treturn nil\n\t}\n\n\taccount, err := m.ssoAccount(deps, c)\n\tif err != nil {\n\t\tdeps.Logger().\n\t\t\tWithError(err).\n\t\t\tWithField(\"remote_addr\", c.Request().RemoteAddr).\n\t\t\tWithField(\"request_id\", c.GetRequestID()).\n\t\t\tError(\"getting sso account\")\n\t\treturn nil\n\t}\n\tif account != nil {\n\t\tc.SetAccount(account)\n\t\treturn nil\n\t}\n\n\treturn nil\n}\n\nfunc (m *AuthSSOProxyMiddleware) ssoAccount(deps model.Dependencies, c model.WebContext) (*model.AccountDTO, error) {\n\tif !deps.Config().Http.SSOProxyAuth {\n\t\treturn nil, nil\n\t}\n\n\tremoteAddr := c.Request().RemoteAddr\n\tip, _, err := net.SplitHostPort(remoteAddr)\n\tif err != nil {\n\t\tvar addrErr *net.AddrError\n\t\tif errors.As(err, &addrErr) && addrErr.Err == \"missing port in address\" {\n\t\t\tip = remoteAddr\n\t\t} else {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\trequestIP := net.ParseIP(ip)\n\tif !m.isTrustedIP(requestIP) {\n\t\treturn nil, errors.New(\"remoteAddr is not a trusted ip\")\n\t}\n\n\theaderName := deps.Config().Http.SSOProxyAuthHeaderName\n\tuserName := c.Request().Header.Get(headerName)\n\tif userName == \"\" {\n\t\treturn nil, nil\n\t}\n\n\taccount, err := deps.Domains().Accounts().GetAccountByUsername(c.Request().Context(), userName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn account, nil\n}\nfunc (m *AuthSSOProxyMiddleware) isTrustedIP(ip net.IP) bool {\n\tfor _, net := range m.trustedIPs {\n\t\tif ok := net.Contains(ip); ok {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (m *AuthSSOProxyMiddleware) OnResponse(deps model.Dependencies, c model.WebContext) error {\n\treturn nil\n}\n"
  },
  {
    "path": "internal/http/middleware/auth_sso_proxy_test.go",
    "content": "package middleware\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/http/webcontext\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestAuthMiddlewareWithSSO(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\tdeps.Config().Http.SSOProxyAuth = true\n\n\taccount, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{\n\t\tID:       model.DBID(98),\n\t\tUsername: \"test_username\",\n\t\tPassword: \"super_secure_password\",\n\t})\n\trequire.NoError(t, err)\n\n\tt.Run(\"test no authorization method\", func(t *testing.T) {\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tc := webcontext.NewWebContext(w, r)\n\n\t\tmiddleware := NewAuthSSOProxyMiddleware(deps)\n\t\terr := middleware.OnRequest(deps, c)\n\t\trequire.NoError(t, err)\n\t\trequire.Nil(t, c.GetAccount())\n\t})\n\n\tt.Run(\"test untrusted ip\", func(t *testing.T) {\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tr.RemoteAddr = \"invalid-ip\"\n\t\tc := webcontext.NewWebContext(w, r)\n\n\t\tmiddleware := NewAuthSSOProxyMiddleware(deps)\n\t\terr := middleware.OnRequest(deps, c)\n\t\trequire.NoError(t, err)\n\t\trequire.Nil(t, c.GetAccount())\n\t})\n\n\tt.Run(\"test empty header\", func(t *testing.T) {\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tr.RemoteAddr = \"10.0.0.3\"\n\t\tc := webcontext.NewWebContext(w, r)\n\n\t\tmiddleware := NewAuthSSOProxyMiddleware(deps)\n\t\terr := middleware.OnRequest(deps, c)\n\t\trequire.NoError(t, err)\n\t\trequire.Nil(t, c.GetAccount())\n\t})\n\n\tt.Run(\"test invalid sso username\", func(t *testing.T) {\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tr.RemoteAddr = \"10.0.0.3\"\n\t\tr.Header.Add(\"Remote-User\", \"username\")\n\t\tc := webcontext.NewWebContext(w, r)\n\n\t\tmiddleware := NewAuthSSOProxyMiddleware(deps)\n\t\terr := middleware.OnRequest(deps, c)\n\t\trequire.NoError(t, err)\n\t\trequire.Nil(t, c.GetAccount())\n\t})\n\n\tt.Run(\"test sso login\", func(t *testing.T) {\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tr.RemoteAddr = \"10.0.0.3\"\n\t\tr.Header.Add(\"Remote-User\", account.Username)\n\t\tc := webcontext.NewWebContext(w, r)\n\n\t\tmiddleware := NewAuthSSOProxyMiddleware(deps)\n\t\terr := middleware.OnRequest(deps, c)\n\t\trequire.NoError(t, err)\n\t\trequire.NotNil(t, c.GetAccount())\n\t})\n\n\tt.Run(\"test sso login ip:port\", func(t *testing.T) {\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tr.RemoteAddr = \"10.0.0.3:65342\"\n\t\tr.Header.Add(\"Remote-User\", account.Username)\n\t\tc := webcontext.NewWebContext(w, r)\n\n\t\tmiddleware := NewAuthSSOProxyMiddleware(deps)\n\t\terr := middleware.OnRequest(deps, c)\n\t\trequire.NoError(t, err)\n\t\trequire.NotNil(t, c.GetAccount())\n\t})\n}\n"
  },
  {
    "path": "internal/http/middleware/auth_test.go",
    "content": "package middleware\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/http/webcontext\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestAuthMiddleware(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tt.Run(\"test no authorization method\", func(t *testing.T) {\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tc := webcontext.NewWebContext(w, r)\n\n\t\tmiddleware := NewAuthMiddleware(deps)\n\t\terr := middleware.OnRequest(deps, c)\n\t\trequire.NoError(t, err)\n\t\trequire.Nil(t, c.GetAccount())\n\t})\n\n\tt.Run(\"test authorization header\", func(t *testing.T) {\n\t\taccount := testutil.GetValidAccount().ToDTO()\n\t\ttoken, err := deps.Domains().Auth().CreateTokenForAccount(&account, time.Now().Add(time.Minute))\n\t\trequire.NoError(t, err)\n\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tr.Header.Set(model.AuthorizationHeader, model.AuthorizationTokenType+\" \"+token)\n\t\tc := webcontext.NewWebContext(w, r)\n\n\t\tmiddleware := NewAuthMiddleware(deps)\n\t\terr = middleware.OnRequest(deps, c)\n\t\trequire.NoError(t, err)\n\t\trequire.NotNil(t, c.GetAccount())\n\t})\n\n\tt.Run(\"test authorization cookie\", func(t *testing.T) {\n\t\taccount := model.AccountDTO{Username: \"shiori\"}\n\t\ttoken, err := deps.Domains().Auth().CreateTokenForAccount(&account, time.Now().Add(time.Minute))\n\t\trequire.NoError(t, err)\n\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tr.AddCookie(&http.Cookie{\n\t\t\tName:   \"token\",\n\t\t\tValue:  token,\n\t\t\tMaxAge: int(time.Now().Add(time.Minute).Unix()),\n\t\t})\n\t\tc := webcontext.NewWebContext(w, r)\n\n\t\tmiddleware := NewAuthMiddleware(deps)\n\t\terr = middleware.OnRequest(deps, c)\n\t\trequire.NoError(t, err)\n\t\trequire.NotNil(t, c.GetAccount())\n\t})\n\n\tt.Run(\"test invalid token cookie is removed\", func(t *testing.T) {\n\t\t// Create an invalid token\n\t\tinvalidToken := \"invalid-token\"\n\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tr.AddCookie(&http.Cookie{\n\t\t\tName:   \"token\",\n\t\t\tValue:  invalidToken,\n\t\t\tMaxAge: int(time.Now().Add(time.Minute).Unix()),\n\t\t})\n\t\tc := webcontext.NewWebContext(w, r)\n\n\t\tmiddleware := NewAuthMiddleware(deps)\n\t\terr := middleware.OnRequest(deps, c)\n\t\trequire.NoError(t, err)\n\t\trequire.Nil(t, c.GetAccount())\n\n\t\t// Check that the token cookie was removed in the response\n\t\tresponseCookies := w.Result().Cookies()\n\n\t\tvar tokenCookie *http.Cookie\n\t\tfor _, cookie := range responseCookies {\n\t\t\tif cookie.Name == \"token\" {\n\t\t\t\ttokenCookie = cookie\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\trequire.NotNil(t, tokenCookie, \"Token cookie should exist in response\")\n\t\trequire.Empty(t, tokenCookie.Value, \"Token cookie value should be empty\")\n\t})\n}\n\nfunc TestRequireLoggedInUser(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tt.Run(\"returns error when user not logged in\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\terr := RequireLoggedInUser(deps, c)\n\t\trequire.Error(t, err)\n\t\trequire.Equal(t, \"authentication required\", err.Error())\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"succeeds when user is logged in\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeUser(c)\n\t\terr := RequireLoggedInUser(deps, c)\n\t\trequire.NoError(t, err)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t})\n\n\tt.Run(\"succeeds when admin is logged in\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeAdmin(c)\n\t\terr := RequireLoggedInUser(deps, c)\n\t\trequire.NoError(t, err)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t})\n}\n\nfunc TestRequireLoggedInAdmin(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tt.Run(\"returns error when user not logged in\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\terr := RequireLoggedInAdmin(deps, c)\n\t\trequire.Error(t, err)\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t})\n\n\tt.Run(\"returns error when non-admin user is logged in\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeUser(c)\n\t\terr := RequireLoggedInAdmin(deps, c)\n\t\trequire.Error(t, err)\n\t\trequire.Equal(t, http.StatusForbidden, w.Code)\n\t})\n\n\tt.Run(\"succeeds when admin is logged in\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\ttestutil.SetFakeAdmin(c)\n\t\terr := RequireLoggedInAdmin(deps, c)\n\t\trequire.NoError(t, err)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t})\n}\n"
  },
  {
    "path": "internal/http/middleware/cors.go",
    "content": "package middleware\n\nimport (\n\t\"strings\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\ntype CORSMiddleware struct {\n\tallowedOrigins []string\n}\n\nfunc (m *CORSMiddleware) OnRequest(deps model.Dependencies, c model.WebContext) error {\n\tc.ResponseWriter().Header().Set(\"Access-Control-Allow-Origin\", strings.Join(m.allowedOrigins, \", \"))\n\tc.ResponseWriter().Header().Set(\"Access-Control-Allow-Methods\", \"GET, POST, PUT, DELETE, OPTIONS\")\n\tc.ResponseWriter().Header().Set(\"Access-Control-Allow-Headers\", \"Content-Type, Authorization, X-Shiori-Response-Format\")\n\treturn nil\n}\n\nfunc (m *CORSMiddleware) OnResponse(deps model.Dependencies, c model.WebContext) error {\n\tc.ResponseWriter().Header().Set(\"Access-Control-Allow-Origin\", strings.Join(m.allowedOrigins, \", \"))\n\tc.ResponseWriter().Header().Set(\"Access-Control-Allow-Methods\", \"GET, POST, PUT, DELETE, OPTIONS\")\n\tc.ResponseWriter().Header().Set(\"Access-Control-Allow-Headers\", \"Content-Type, Authorization, X-Shiori-Response-Format\")\n\treturn nil\n}\n\nfunc NewCORSMiddleware(allowedOrigins []string) *CORSMiddleware {\n\treturn &CORSMiddleware{allowedOrigins: allowedOrigins}\n}\n"
  },
  {
    "path": "internal/http/middleware/cors_test.go",
    "content": "package middleware\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/http/webcontext\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestCORSMiddleware(t *testing.T) {\n\tt.Run(\"test single origin\", func(t *testing.T) {\n\t\tallowedOrigins := []string{\"http://localhost:8080\"}\n\t\tmiddleware := NewCORSMiddleware(allowedOrigins)\n\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tc := webcontext.NewWebContext(w, r)\n\n\t\terr := middleware.OnRequest(nil, c)\n\t\trequire.NoError(t, err)\n\n\t\theaders := w.Header()\n\t\tassert.Equal(t, \"http://localhost:8080\", headers.Get(\"Access-Control-Allow-Origin\"))\n\t\tassert.Equal(t, \"GET, POST, PUT, DELETE, OPTIONS\", headers.Get(\"Access-Control-Allow-Methods\"))\n\t\tassert.Equal(t, \"Content-Type, Authorization, X-Shiori-Response-Format\", headers.Get(\"Access-Control-Allow-Headers\"))\n\t})\n\n\tt.Run(\"test multiple origins\", func(t *testing.T) {\n\t\tallowedOrigins := []string{\"http://localhost:8080\", \"http://example.com\"}\n\t\tmiddleware := NewCORSMiddleware(allowedOrigins)\n\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tc := webcontext.NewWebContext(w, r)\n\n\t\terr := middleware.OnRequest(nil, c)\n\t\trequire.NoError(t, err)\n\n\t\theaders := w.Header()\n\t\tassert.Equal(t, strings.Join(allowedOrigins, \", \"), headers.Get(\"Access-Control-Allow-Origin\"))\n\t})\n\n\tt.Run(\"test empty origins\", func(t *testing.T) {\n\t\tmiddleware := NewCORSMiddleware([]string{})\n\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tc := webcontext.NewWebContext(w, r)\n\n\t\terr := middleware.OnRequest(nil, c)\n\t\trequire.NoError(t, err)\n\n\t\theaders := w.Header()\n\t\tassert.Equal(t, \"\", headers.Get(\"Access-Control-Allow-Origin\"))\n\t})\n\n\tt.Run(\"test OnResponse headers\", func(t *testing.T) {\n\t\tallowedOrigins := []string{\"http://localhost:8080\"}\n\t\tmiddleware := NewCORSMiddleware(allowedOrigins)\n\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tc := webcontext.NewWebContext(w, r)\n\n\t\terr := middleware.OnResponse(nil, c)\n\t\trequire.NoError(t, err)\n\n\t\theaders := w.Header()\n\t\tassert.Equal(t, \"http://localhost:8080\", headers.Get(\"Access-Control-Allow-Origin\"))\n\t\tassert.Equal(t, \"GET, POST, PUT, DELETE, OPTIONS\", headers.Get(\"Access-Control-Allow-Methods\"))\n\t\tassert.Equal(t, \"Content-Type, Authorization, X-Shiori-Response-Format\", headers.Get(\"Access-Control-Allow-Headers\"))\n\t})\n}\n"
  },
  {
    "path": "internal/http/middleware/logging.go",
    "content": "package middleware\n\nimport (\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/sirupsen/logrus\"\n)\n\nvar _ model.HttpMiddleware = &LoggingMiddleware{}\n\n// LoggingMiddleware is a middleware that logs the request and response\ntype LoggingMiddleware struct {\n\tstartTime time.Time\n}\n\nfunc (m *LoggingMiddleware) OnRequest(deps model.Dependencies, c model.WebContext) error {\n\tm.startTime = time.Now()\n\treturn nil\n}\n\nfunc (m *LoggingMiddleware) OnResponse(deps model.Dependencies, c model.WebContext) error {\n\tduration := time.Since(m.startTime)\n\tdeps.Logger().WithFields(logrus.Fields{\n\t\t\"path\":       c.Request().URL.Path,\n\t\t\"duration\":   duration,\n\t\t\"request_id\": c.GetRequestID(),\n\t}).Info(\"request completed\")\n\treturn nil\n}\n\nfunc NewLoggingMiddleware() *LoggingMiddleware {\n\treturn &LoggingMiddleware{}\n}\n"
  },
  {
    "path": "internal/http/middleware/message_response.go",
    "content": "package middleware\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\ntype responseMiddlewareBody struct {\n\tOk      bool `json:\"ok\"`\n\tMessage any  `json:\"message\"`\n}\n\ntype MessageResponseMiddleware struct {\n\tdeps model.Dependencies\n}\n\nfunc (m *MessageResponseMiddleware) OnRequest(deps model.Dependencies, c model.WebContext) error {\n\tif c.Request().Header.Get(\"X-Shiori-Response-Format\") == \"new\" {\n\t\treturn nil\n\t}\n\n\t// Create a response recorder and wrap the original ResponseWriter\n\trecorder := newResponseRecorder(c.ResponseWriter())\n\tc.SetResponseWriter(recorder)\n\treturn nil\n}\n\nfunc (m *MessageResponseMiddleware) OnResponse(deps model.Dependencies, c model.WebContext) error {\n\tif c.Request().Header.Get(\"X-Shiori-Response-Format\") == \"new\" {\n\t\treturn nil\n\t}\n\n\twriter := c.ResponseWriter()\n\n\t// Get the response recorder\n\trecorder, ok := writer.(*responseRecorder)\n\tif !ok {\n\t\treturn nil\n\t}\n\n\t// Copy all headers to the original response writer\n\tfor k, v := range recorder.header {\n\t\tif k != \"Content-Length\" {\n\t\t\trecorder.ResponseWriter.Header().Set(k, strings.Join(v, \"\"))\n\t\t}\n\t}\n\n\t// Write the status code\n\trecorder.ResponseWriter.WriteHeader(recorder.statusCode)\n\n\t// If it's not a JSON response, write the original response and return\n\tif ct := recorder.header.Get(\"Content-Type\"); ct != \"application/json\" {\n\t\t_, err := recorder.ResponseWriter.Write(recorder.body.Bytes())\n\t\treturn err\n\t}\n\n\t// For JSON responses, wrap them in our format\n\twrappedResponse := responseMiddlewareBody{\n\t\tOk:      recorder.statusCode < 400,\n\t\tMessage: nil,\n\t}\n\n\t// If there's a response body and status code allows body, parse it\n\tif recorder.body.Len() > 0 && recorder.statusCode != http.StatusNoContent {\n\t\tvar originalBody any\n\t\tif err := json.NewDecoder(&recorder.body).Decode(&originalBody); err != nil {\n\t\t\treturn err\n\t\t}\n\t\twrappedResponse.Message = originalBody\n\t\t// Write the status code and wrapped response\n\t\tif err := json.NewEncoder(recorder.ResponseWriter).Encode(wrappedResponse); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc NewMessageResponseMiddleware(deps model.Dependencies) *MessageResponseMiddleware {\n\treturn &MessageResponseMiddleware{deps: deps}\n}\n\n// responseRecorder is a custom ResponseWriter that captures the response\ntype responseRecorder struct {\n\thttp.ResponseWriter\n\tstatusCode int\n\tbody       bytes.Buffer\n\theader     http.Header\n}\n\nfunc newResponseRecorder(original http.ResponseWriter) *responseRecorder {\n\treturn &responseRecorder{\n\t\tResponseWriter: original,\n\t\tstatusCode:     http.StatusOK,\n\t\theader:         make(http.Header),\n\t\tbody:           bytes.Buffer{},\n\t}\n}\n\nfunc (r *responseRecorder) Header() http.Header {\n\treturn r.header\n}\n\nfunc (r *responseRecorder) WriteHeader(statusCode int) {\n\tr.statusCode = statusCode\n}\n\nfunc (r *responseRecorder) Write(b []byte) (int, error) {\n\t// Only write to the buffer, we'll write to the actual ResponseWriter in OnResponse\n\treturn r.body.Write(b)\n}\n"
  },
  {
    "path": "internal/http/middleware/message_response_test.go",
    "content": "package middleware\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestMessageResponseMiddleware(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tt.Run(\"wraps JSON response with success status\", func(t *testing.T) {\n\t\t// Create test handler that returns JSON\n\t\thandler := func(deps model.Dependencies, c model.WebContext) {\n\t\t\tresponse := map[string]string{\"data\": \"test\"}\n\t\t\tc.ResponseWriter().Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tc.ResponseWriter().WriteHeader(http.StatusOK)\n\t\t\tjson.NewEncoder(c.ResponseWriter()).Encode(response)\n\t\t}\n\n\t\t// Create test context\n\t\tc, w := testutil.NewTestWebContext()\n\n\t\t// Create and apply middleware\n\t\tmiddleware := NewMessageResponseMiddleware(deps)\n\t\trequire.NoError(t, middleware.OnRequest(deps, c))\n\n\t\t// Execute handler\n\t\thandler(deps, c)\n\n\t\t// Apply response middleware\n\t\trequire.NoError(t, middleware.OnResponse(deps, c))\n\n\t\t// Verify response\n\t\tvar response responseMiddlewareBody\n\t\terr := json.NewDecoder(w.Body).Decode(&response)\n\t\trequire.NoError(t, err)\n\n\t\tassert.True(t, response.Ok)\n\t\tassert.Equal(t, map[string]any{\"data\": \"test\"}, response.Message)\n\t\tassert.Equal(t, http.StatusOK, w.Code)\n\t\tassert.Equal(t, \"application/json\", w.Header().Get(\"Content-Type\"))\n\t})\n\n\tt.Run(\"wraps JSON response with error status\", func(t *testing.T) {\n\t\t// Create test handler that returns JSON error\n\t\thandler := func(deps model.Dependencies, c model.WebContext) {\n\t\t\tresponse := map[string]string{\"error\": \"test error\"}\n\t\t\tc.ResponseWriter().Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tc.ResponseWriter().WriteHeader(http.StatusBadRequest)\n\t\t\tjson.NewEncoder(c.ResponseWriter()).Encode(response)\n\t\t}\n\n\t\t// Create test context\n\t\tc, w := testutil.NewTestWebContext()\n\n\t\t// Create and apply middleware\n\t\tmiddleware := NewMessageResponseMiddleware(deps)\n\t\trequire.NoError(t, middleware.OnRequest(deps, c))\n\n\t\t// Execute handler\n\t\thandler(deps, c)\n\n\t\t// Apply response middleware\n\t\trequire.NoError(t, middleware.OnResponse(deps, c))\n\n\t\t// Verify response\n\t\tvar response responseMiddlewareBody\n\t\terr := json.NewDecoder(w.Body).Decode(&response)\n\t\trequire.NoError(t, err)\n\n\t\tassert.False(t, response.Ok)\n\t\tassert.Equal(t, map[string]any{\"error\": \"test error\"}, response.Message)\n\t\tassert.Equal(t, http.StatusBadRequest, w.Code)\n\t\tassert.Equal(t, \"application/json\", w.Header().Get(\"Content-Type\"))\n\t})\n\n\tt.Run(\"does not modify non-JSON response\", func(t *testing.T) {\n\t\t// Create test handler that returns plain text\n\t\thandler := func(deps model.Dependencies, c model.WebContext) {\n\t\t\tc.ResponseWriter().Header().Set(\"Content-Type\", \"text/plain\")\n\t\t\tc.ResponseWriter().WriteHeader(http.StatusOK)\n\t\t\tc.ResponseWriter().Write([]byte(\"test message\"))\n\t\t}\n\n\t\t// Create test context\n\t\tc, w := testutil.NewTestWebContext()\n\n\t\t// Create and apply middleware\n\t\tmiddleware := NewMessageResponseMiddleware(deps)\n\t\trequire.NoError(t, middleware.OnRequest(deps, c))\n\n\t\t// Execute handler\n\t\thandler(deps, c)\n\n\t\t// Apply response middleware\n\t\trequire.NoError(t, middleware.OnResponse(deps, c))\n\n\t\t// Verify response is unchanged\n\t\tassert.Equal(t, \"test message\", w.Body.String())\n\t\tassert.Equal(t, http.StatusOK, w.Code)\n\t\tassert.Equal(t, \"text/plain\", w.Header().Get(\"Content-Type\"))\n\t})\n\n\tt.Run(\"handles empty JSON response\", func(t *testing.T) {\n\t\t// Create test handler that returns empty JSON\n\t\thandler := func(deps model.Dependencies, c model.WebContext) {\n\t\t\tc.ResponseWriter().Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tc.ResponseWriter().WriteHeader(http.StatusOK)\n\t\t\tc.ResponseWriter().Write([]byte(\"{}\"))\n\t\t}\n\n\t\t// Create test context\n\t\tc, w := testutil.NewTestWebContext()\n\n\t\t// Create and apply middleware\n\t\tmiddleware := NewMessageResponseMiddleware(deps)\n\t\trequire.NoError(t, middleware.OnRequest(deps, c))\n\n\t\t// Execute handler\n\t\thandler(deps, c)\n\n\t\t// Apply response middleware\n\t\trequire.NoError(t, middleware.OnResponse(deps, c))\n\n\t\t// Verify response\n\t\tvar response responseMiddlewareBody\n\t\terr := json.NewDecoder(w.Body).Decode(&response)\n\t\trequire.NoError(t, err)\n\n\t\tassert.True(t, response.Ok)\n\t\tassert.Equal(t, map[string]any{}, response.Message)\n\t\tassert.Equal(t, http.StatusOK, w.Code)\n\t\tassert.Equal(t, \"application/json\", w.Header().Get(\"Content-Type\"))\n\t})\n\n\tt.Run(\"preserves custom headers\", func(t *testing.T) {\n\t\t// Create test handler that sets custom headers\n\t\thandler := func(deps model.Dependencies, c model.WebContext) {\n\t\t\tc.ResponseWriter().Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tc.ResponseWriter().Header().Set(\"X-Custom-Header\", \"test-value\")\n\t\t\tc.ResponseWriter().WriteHeader(http.StatusOK)\n\t\t\tjson.NewEncoder(c.ResponseWriter()).Encode(map[string]string{\"data\": \"test\"})\n\t\t}\n\n\t\t// Create test context\n\t\tc, w := testutil.NewTestWebContext()\n\n\t\t// Create and apply middleware\n\t\tmiddleware := NewMessageResponseMiddleware(deps)\n\t\trequire.NoError(t, middleware.OnRequest(deps, c))\n\n\t\t// Execute handler\n\t\thandler(deps, c)\n\n\t\t// Apply response middleware\n\t\trequire.NoError(t, middleware.OnResponse(deps, c))\n\n\t\t// Verify headers are preserved\n\t\tassert.Equal(t, \"test-value\", w.Header().Get(\"X-Custom-Header\"))\n\t})\n}\n"
  },
  {
    "path": "internal/http/middleware/request_id.go",
    "content": "package middleware\n\nimport (\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/gofrs/uuid/v5\"\n)\n\nconst (\n\t// RequestIDHeader is the header key for the request ID\n\tRequestIDHeader = \"X-Request-ID\"\n)\n\n// RequestIDMiddleware adds a unique request ID to each request\ntype RequestIDMiddleware struct {\n\tdeps model.Dependencies\n}\n\n// NewRequestIDMiddleware creates a new RequestIDMiddleware\nfunc NewRequestIDMiddleware(deps model.Dependencies) *RequestIDMiddleware {\n\treturn &RequestIDMiddleware{deps: deps}\n}\n\n// OnRequest adds a request ID to the request context and response headers\nfunc (m *RequestIDMiddleware) OnRequest(deps model.Dependencies, c model.WebContext) error {\n\t// Generate request ID\n\trequestID, err := uuid.NewV4()\n\tif err != nil {\n\t\tdeps.Logger().WithError(err).Error(\"Failed to generate request ID\")\n\t\treturn err\n\t}\n\n\t// Add request ID to response headers\n\tc.ResponseWriter().Header().Set(RequestIDHeader, requestID.String())\n\n\t// Add request ID to context\n\tc.SetRequestID(requestID.String())\n\n\treturn nil\n}\n\n// OnResponse is a no-op for this middleware\nfunc (m *RequestIDMiddleware) OnResponse(deps model.Dependencies, c model.WebContext) error {\n\treturn nil\n}\n"
  },
  {
    "path": "internal/http/middleware/request_id_test.go",
    "content": "package middleware\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestRequestIDMiddleware(t *testing.T) {\n\tlogger := logrus.New()\n\tctx := context.Background()\n\n\tt.Run(\"adds request ID to context and headers\", func(t *testing.T) {\n\t\t_, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tmiddleware := NewRequestIDMiddleware(deps)\n\n\t\tc, w := testutil.NewTestWebContext()\n\t\terr := middleware.OnRequest(deps, c)\n\t\trequire.NoError(t, err)\n\n\t\t// Check that request ID was added to context\n\t\trequestID := c.GetRequestID()\n\t\trequire.NotEmpty(t, requestID)\n\n\t\t// Check that request ID was added to headers\n\t\theaderRequestID := w.Header().Get(RequestIDHeader)\n\t\trequire.Equal(t, requestID, headerRequestID)\n\t})\n}\n"
  },
  {
    "path": "internal/http/response/file.go",
    "content": "package response\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"mime\"\n\t\"net/http\"\n\t\"path/filepath\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\n// SendFileOptions contains options for sending files\ntype SendFileOptions struct {\n\tHeaders []http.Header\n}\n\n// SendFile sends a file from storage to the response writer\nfunc SendFile(c model.WebContext, storage model.StorageDomain, path string, options *SendFileOptions) error {\n\tif !storage.FileExists(path) {\n\t\treturn SendError(c, http.StatusNotFound, \"File not found\")\n\t}\n\n\tfile, err := storage.FS().Open(path)\n\tif err != nil {\n\t\treturn SendInternalServerError(c)\n\t}\n\tdefer file.Close()\n\n\t// First try to get content type from extension\n\tcontentType := mime.TypeByExtension(filepath.Ext(path))\n\tif contentType == \"\" {\n\t\t// If no extension or unknown, try to detect from content\n\t\t// Only the first 512 bytes are used to sniff the content type\n\t\tbuffer := make([]byte, 512)\n\t\tn, err := file.Read(buffer)\n\t\tif err != nil && err != io.EOF {\n\t\t\treturn fmt.Errorf(\"failed to read file header: %w\", err)\n\t\t}\n\t\tcontentType = http.DetectContentType(buffer[:n])\n\n\t\t// Seek back to start since we read some bytes\n\t\tif _, err := file.Seek(0, 0); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to seek file: %w\", err)\n\t\t}\n\t}\n\n\t// Set content type\n\tc.ResponseWriter().Header().Set(\"Content-Type\", contentType)\n\n\t// Set additional headers if provided\n\tif options != nil {\n\t\tfor _, header := range options.Headers {\n\t\t\tfor key, values := range header {\n\t\t\t\tfor _, value := range values {\n\t\t\t\t\tc.ResponseWriter().Header().Add(key, value)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Copy file to response writer\n\t_, err = io.Copy(c.ResponseWriter(), file)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to send file: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/http/response/file_test.go",
    "content": "package response_test\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/domains\"\n\t\"github.com/go-shiori/shiori/internal/http/response\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/spf13/afero\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc newMockStorage(deps model.Dependencies, fs afero.Fs) model.StorageDomain {\n\treturn domains.NewStorageDomain(deps, fs)\n}\n\nfunc TestSendFile(t *testing.T) {\n\tlogger := logrus.New()\n\t_, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger)\n\n\tstorage := newMockStorage(deps, afero.NewMemMapFs())\n\n\tt.Run(\"sends file with correct content type from extension\", func(t *testing.T) {\n\t\t// Create test file\n\t\tcontent := []byte(\"body { color: red; }\")\n\t\terr := storage.WriteData(\"test.css\", content)\n\t\trequire.NoError(t, err)\n\n\t\tc, w := testutil.NewTestWebContext()\n\t\terr = response.SendFile(c, storage, \"test.css\", nil)\n\t\trequire.NoError(t, err)\n\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t\trequire.Equal(t, \"text/css; charset=utf-8\", w.Header().Get(\"Content-Type\"))\n\t\trequire.Equal(t, content, w.Body.Bytes())\n\t})\n\n\tt.Run(\"sends file with detected content type\", func(t *testing.T) {\n\t\t// Create test file without extension\n\t\tcontent := []byte(\"<html><body>Hello</body></html>\")\n\t\terr := storage.WriteData(\"test\", content)\n\t\trequire.NoError(t, err)\n\n\t\tc, w := testutil.NewTestWebContext()\n\t\terr = response.SendFile(c, storage, \"test\", nil)\n\t\trequire.NoError(t, err)\n\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t\trequire.Equal(t, \"text/html; charset=utf-8\", w.Header().Get(\"Content-Type\"))\n\t\trequire.Equal(t, content, w.Body.Bytes())\n\t})\n\n\tt.Run(\"handles non-existent file\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\t_ = response.SendFile(c, storage, \"nonexistent.txt\", nil)\n\t\trequire.Equal(t, http.StatusNotFound, w.Code)\n\t\trequire.Contains(t, w.Body.String(), \"File not found\")\n\t})\n\n\tt.Run(\"sets custom headers\", func(t *testing.T) {\n\t\t// Create test file\n\t\tcontent := []byte(\"test content\")\n\t\terr := storage.WriteData(\"test.txt\", content)\n\t\trequire.NoError(t, err)\n\n\t\toptions := &response.SendFileOptions{\n\t\t\tHeaders: []http.Header{\n\t\t\t\t{\"Cache-Control\": {\"no-cache\"}},\n\t\t\t\t{\"X-Custom\": {\"value1\", \"value2\"}},\n\t\t\t},\n\t\t}\n\n\t\tc, w := testutil.NewTestWebContext()\n\t\terr = response.SendFile(c, storage, \"test.txt\", options)\n\t\trequire.NoError(t, err)\n\n\t\trequire.Equal(t, \"no-cache\", w.Header().Get(\"Cache-Control\"))\n\t\trequire.Equal(t, []string{\"value1\", \"value2\"}, w.Header().Values(\"X-Custom\"))\n\t})\n\n\tt.Run(\"handles large files\", func(t *testing.T) {\n\t\t// Create large test file (>512 bytes to test content type detection)\n\t\tbinaryData := bytes.Repeat([]byte{0xFF, 0x00}, 1024*1024)\n\t\terr := storage.WriteData(\"large.bin\", binaryData)\n\t\trequire.NoError(t, err)\n\n\t\tc, w := testutil.NewTestWebContext()\n\t\terr = response.SendFile(c, storage, \"large.bin\", nil)\n\t\trequire.NoError(t, err)\n\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t\trequire.Equal(t, \"application/octet-stream\", w.Header().Get(\"Content-Type\"))\n\t\trequire.Equal(t, binaryData, w.Body.Bytes())\n\t})\n\n\tt.Run(\"handles empty files\", func(t *testing.T) {\n\t\terr := storage.WriteData(\"empty.txt\", []byte{})\n\t\trequire.NoError(t, err)\n\n\t\tc, w := testutil.NewTestWebContext()\n\t\terr = response.SendFile(c, storage, \"empty.txt\", nil)\n\t\trequire.NoError(t, err)\n\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\t\trequire.Equal(t, \"text/plain; charset=utf-8\", w.Header().Get(\"Content-Type\"))\n\t\trequire.Empty(t, w.Body.Bytes())\n\t})\n\n\tt.Run(\"handles read errors\", func(t *testing.T) {\n\t\t// Create mock file that returns error on read\n\t\terrorFs := &errorFs{\n\t\t\tFs:  afero.NewMemMapFs(),\n\t\t\terr: io.ErrClosedPipe,\n\t\t}\n\t\tstorage := newMockStorage(deps, errorFs)\n\t\terr := storage.WriteData(\"test.txt\", []byte(\"test\"))\n\t\trequire.NoError(t, err)\n\n\t\tc, w := testutil.NewTestWebContext()\n\t\t_ = response.SendFile(c, storage, \"test.txt\", nil)\n\t\trequire.Equal(t, http.StatusInternalServerError, w.Code)\n\t})\n}\n\n// errorFs is a mock filesystem that returns errors\ntype errorFs struct {\n\tafero.Fs\n\terr error\n}\n\nfunc (e *errorFs) Open(name string) (afero.File, error) {\n\treturn nil, e.err\n}\n"
  },
  {
    "path": "internal/http/response/response.go",
    "content": "package response\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\ntype Response struct {\n\t// Data the payload of the response, depending on the endpoint/response status\n\tData any `json:\"message\"`\n\n\t// statusCode used for the http response status code\n\tstatusCode int\n}\n\n// GetData returns the data of the response\nfunc (r *Response) GetData() any {\n\treturn r.Data\n}\n\n// IsError returns true if the response is an error\nfunc (r *Response) IsError() bool {\n\treturn r.statusCode >= http.StatusBadRequest\n}\n\n// Send sends the response to the client\nfunc (r *Response) Send(c model.WebContext, contentType string) error {\n\tc.ResponseWriter().Header().Set(\"Content-Type\", contentType)\n\tc.ResponseWriter().WriteHeader(r.statusCode)\n\t_, err := c.ResponseWriter().Write([]byte(r.GetData().(string)))\n\treturn err\n}\n\n// SendJSON sends the response to the client\nfunc (r *Response) SendJSON(c model.WebContext) error {\n\tc.ResponseWriter().Header().Set(\"Content-Type\", \"application/json\")\n\tc.ResponseWriter().WriteHeader(r.statusCode)\n\treturn json.NewEncoder(c.ResponseWriter()).Encode(r.GetData())\n}\n\n// NewResponse creates a new response\nfunc NewResponse(message any, statusCode int) *Response {\n\treturn &Response{\n\t\tData:       message,\n\t\tstatusCode: statusCode,\n\t}\n}\n"
  },
  {
    "path": "internal/http/response/response_test.go",
    "content": "package response\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/http/webcontext\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestNewResponse(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tok         bool\n\t\tmessage    any\n\t\terrParams  map[string]string\n\t\tstatusCode int\n\t}{\n\t\t{\n\t\t\tname:       \"successful response\",\n\t\t\tok:         true,\n\t\t\tmessage:    \"success\",\n\t\t\terrParams:  nil,\n\t\t\tstatusCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tname:       \"error response\",\n\t\t\tok:         false,\n\t\t\tmessage:    \"error occurred\",\n\t\t\terrParams:  map[string]string{\"field\": \"invalid\"},\n\t\t\tstatusCode: http.StatusBadRequest,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresp := NewResponse(tt.message, tt.statusCode)\n\t\t\tassert.Equal(t, tt.message, resp.GetData())\n\t\t\tassert.Equal(t, tt.statusCode, resp.statusCode)\n\t\t})\n\t}\n}\n\nfunc TestResponse_IsError(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tresponse *Response\n\t\twant     bool\n\t}{\n\t\t{\n\t\t\tname:     \"successful response\",\n\t\t\tresponse: NewResponse(\"success\", http.StatusOK),\n\t\t\twant:     false,\n\t\t},\n\t\t{\n\t\t\tname:     \"error response\",\n\t\t\tresponse: NewResponse(\"error\", http.StatusBadRequest),\n\t\t\twant:     true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tassert.Equal(t, tt.want, tt.response.IsError())\n\t\t})\n\t}\n}\n\nfunc TestResponse_GetMessage(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tresponse *Response\n\t\twant     any\n\t}{\n\t\t{\n\t\t\tname:     \"string message\",\n\t\t\tresponse: NewResponse(\"test message\", http.StatusOK),\n\t\t\twant:     \"test message\",\n\t\t},\n\t\t{\n\t\t\tname:     \"struct message\",\n\t\t\tresponse: NewResponse(struct{ Data string }{Data: \"test\"}, http.StatusOK),\n\t\t\twant:     struct{ Data string }{Data: \"test\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tassert.Equal(t, tt.want, tt.response.GetData())\n\t\t})\n\t}\n}\n\nfunc TestResponse_Send(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tresponse       *Response\n\t\texpectedStatus int\n\t\texpectedBody   any\n\t}{\n\t\t{\n\t\t\tname:           \"plain response\",\n\t\t\tresponse:       NewResponse(\"success\", http.StatusOK),\n\t\t\texpectedStatus: http.StatusOK,\n\t\t\texpectedBody:   \"success\",\n\t\t},\n\t\t{\n\t\t\tname: \"json response\",\n\t\t\tresponse: NewResponse(map[string]any{\n\t\t\t\t\"message\": \"success\",\n\t\t\t}, http.StatusOK),\n\t\t\texpectedStatus: http.StatusOK,\n\t\t\texpectedBody: map[string]any{\n\t\t\t\t\"message\": \"success\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tw := httptest.NewRecorder()\n\t\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\t\tctx := webcontext.NewWebContext(w, r)\n\n\t\t\terr := tt.response.SendJSON(ctx)\n\t\t\tassert.NoError(t, err)\n\n\t\t\tassert.Equal(t, tt.expectedStatus, w.Code)\n\t\t\tassert.Equal(t, \"application/json\", w.Header().Get(\"Content-Type\"))\n\n\t\t\tvar responseBody any\n\t\t\terr = json.NewDecoder(w.Body).Decode(&responseBody)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expectedBody, responseBody)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/http/response/shortcuts.go",
    "content": "package response\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"github.com/go-shiori/shiori/internal/http/templates\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\nconst internalServerErrorMessage = \"Internal server error, please contact an administrator\"\n\n// New provides a shortcut to a successful response object\nfunc New(statusCode int, data any) *Response {\n\treturn NewResponse(data, statusCode)\n}\n\n// Send provides a shortcut to send a (potentially) successful response\nfunc Send(c model.WebContext, statusCode int, message any, contentType string) error {\n\treturn NewResponse(message, statusCode).Send(c, contentType)\n}\n\n// SendError provides a shortcut to send an unsuccessful response\nfunc SendError(c model.WebContext, statusCode int, message any) error {\n\tresp := NewResponse(struct {\n\t\tError string `json:\"error\"`\n\t}{Error: message.(string)}, statusCode)\n\treturn resp.SendJSON(c)\n}\n\n// SendErrorWithParams the same as above but for errors that require error parameters\nfunc SendErrorWithParams(c model.WebContext, statusCode int, data any, errorParams map[string]string) error {\n\treturn NewResponse(data, statusCode).SendJSON(c)\n}\n\n// SendInternalServerError directly sends an internal server error response\nfunc SendInternalServerError(c model.WebContext) error {\n\treturn SendError(c, http.StatusInternalServerError, internalServerErrorMessage)\n}\n\n// RedirectToLogin redirects to the login page with an optional destination\nfunc RedirectToLogin(c model.WebContext, webroot, dst string) {\n\tredirectURL := url.URL{\n\t\tPath: webroot,\n\t\tRawQuery: url.Values{\n\t\t\t\"dst\": []string{dst},\n\t\t}.Encode(),\n\t}\n\thttp.Redirect(c.ResponseWriter(), c.Request(), redirectURL.String(), http.StatusFound)\n}\n\n// NotFound sends a not found response\nfunc NotFound(c model.WebContext) {\n\thttp.NotFound(c.ResponseWriter(), c.Request())\n}\n\n// SendJSON is a helper function to send JSON responses\nfunc SendJSON(c model.WebContext, statusCode int, data any) error {\n\tresponse := NewResponse(data, statusCode)\n\treturn response.SendJSON(c)\n}\n\n// SendTemplate renders and sends an HTML template\nfunc SendTemplate(c model.WebContext, name string, data any) error {\n\tc.ResponseWriter().Header().Set(\"Content-Type\", \"text/html; charset=utf-8\")\n\tif err := templates.RenderTemplate(c.ResponseWriter(), name, data); err != nil {\n\t\treturn SendInternalServerError(c)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/http/response/shortcuts_test.go",
    "content": "package response_test\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/http/response\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNew(t *testing.T) {\n\tt.Run(\"creates successful response\", func(t *testing.T) {\n\t\tresp := response.New(http.StatusOK, \"test data\")\n\t\tassert.False(t, resp.IsError())\n\t\tassert.Equal(t, \"test data\", resp.GetData())\n\t})\n\n\tt.Run(\"creates error response\", func(t *testing.T) {\n\t\tresp := response.New(http.StatusBadRequest, \"error data\")\n\t\tassert.True(t, resp.IsError())\n\t\tassert.Equal(t, \"error data\", resp.GetData())\n\t})\n}\n\nfunc TestSend(t *testing.T) {\n\tt.Run(\"sends successful response\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\terr := response.Send(c, http.StatusOK, \"success message\", \"text/plain\")\n\t\trequire.NoError(t, err)\n\n\t\tassert.Equal(t, http.StatusOK, w.Code)\n\n\t\tresponse := testutil.NewTestResponseFromRecorder(w)\n\t\tresponse.AssertOk(t)\n\t\tresponse.AssertMessageIsBytes(t, []byte(\"success message\"))\n\t})\n\n\tt.Run(\"sends error response for status >= 400\", func(t *testing.T) {\n\t\tmessage := \"error message\"\n\t\tc, w := testutil.NewTestWebContext()\n\t\terr := response.Send(c, http.StatusBadRequest, message, \"text/plain\")\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, http.StatusBadRequest, w.Code)\n\n\t\tresponse := response.NewResponse(message, http.StatusBadRequest)\n\t\tassert.True(t, response.IsError())\n\t\tassert.Equal(t, message, response.GetData())\n\t})\n}\n\nfunc TestSendError(t *testing.T) {\n\tt.Run(\"sends error response without params\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\terr := response.SendError(c, http.StatusBadRequest, \"error message\")\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, http.StatusBadRequest, w.Code)\n\n\t\tresponseBody := struct {\n\t\t\tError string `json:\"error\"`\n\t\t}{Error: \"error message\"}\n\t\tresponse := response.NewResponse(responseBody, http.StatusBadRequest)\n\n\t\tassert.True(t, response.IsError())\n\t\tassert.Equal(t, responseBody, response.GetData())\n\t})\n\n\tt.Run(\"sends error response with params\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\terr := response.SendError(c, http.StatusBadRequest, \"error message\")\n\t\trequire.NoError(t, err)\n\n\t\tassert.Equal(t, http.StatusBadRequest, w.Code)\n\n\t\tresponseBody := struct {\n\t\t\tError string `json:\"error\"`\n\t\t}{Error: \"error message\"}\n\t\tresponse := response.NewResponse(responseBody, http.StatusBadRequest)\n\n\t\tassert.True(t, response.IsError())\n\t\tassert.Equal(t, responseBody, response.GetData())\n\t})\n}\n\nfunc TestSendInternalServerError(t *testing.T) {\n\tc, w := testutil.NewTestWebContext()\n\terr := response.SendInternalServerError(c)\n\trequire.NoError(t, err)\n\n\tassert.Equal(t, http.StatusInternalServerError, w.Code)\n\n\tresponseBody := struct {\n\t\tError string `json:\"error\"`\n\t}{Error: \"Internal server error, please contact an administrator\"}\n\tresponse := response.NewResponse(responseBody, http.StatusInternalServerError)\n\n\tassert.True(t, response.IsError())\n\tassert.Equal(t, responseBody, response.GetData())\n}\n\nfunc TestRedirectToLogin(t *testing.T) {\n\tt.Run(\"redirects to login without destination\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\tresponse.RedirectToLogin(c, \"/\", \"\")\n\n\t\tassert.Equal(t, http.StatusFound, w.Code)\n\t\tassert.Equal(t, \"/?dst=\", w.Header().Get(\"Location\"))\n\t})\n\n\tt.Run(\"redirects to login with destination\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\tresponse.RedirectToLogin(c, \"/\", \"/dashboard\")\n\n\t\tassert.Equal(t, http.StatusFound, w.Code)\n\t\tassert.Equal(t, \"/?dst=%2Fdashboard\", w.Header().Get(\"Location\"))\n\t})\n}\n\nfunc TestNotFound(t *testing.T) {\n\tc, w := testutil.NewTestWebContext()\n\tresponse.NotFound(c)\n\n\tassert.Equal(t, http.StatusNotFound, w.Code)\n\tassert.Contains(t, w.Body.String(), \"404 page not found\")\n}\n\nfunc TestSendJSON(t *testing.T) {\n\tt.Run(\"sends JSON response\", func(t *testing.T) {\n\t\tc, w := testutil.NewTestWebContext()\n\t\tdata := map[string]string{\"key\": \"value\"}\n\t\terr := response.SendJSON(c, http.StatusOK, data)\n\t\trequire.NoError(t, err)\n\n\t\tassert.Equal(t, http.StatusOK, w.Code)\n\t\tassert.Equal(t, \"application/json\", w.Header().Get(\"Content-Type\"))\n\n\t\tvar result map[string]string\n\t\terr = json.Unmarshal(w.Body.Bytes(), &result)\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, data, result)\n\t})\n\n\tt.Run(\"handles encoding error\", func(t *testing.T) {\n\t\tc, _ := testutil.NewTestWebContext()\n\t\t// Create a value that can't be marshaled to JSON\n\t\tdata := map[string]any{\"fn\": func() {}}\n\t\terr := response.SendJSON(c, http.StatusOK, data)\n\t\tassert.Error(t, err)\n\t})\n}\n"
  },
  {
    "path": "internal/http/server.go",
    "content": "package http\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\n\t\"github.com/go-shiori/shiori/internal/config\"\n\t\"github.com/go-shiori/shiori/internal/dependencies\"\n\t\"github.com/go-shiori/shiori/internal/http/handlers\"\n\tapi_v1 \"github.com/go-shiori/shiori/internal/http/handlers/api/v1\"\n\t\"github.com/go-shiori/shiori/internal/http/middleware\"\n\t\"github.com/go-shiori/shiori/internal/http/templates\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/sirupsen/logrus\"\n)\n\ntype HttpServer struct {\n\tmux    *http.ServeMux\n\tserver *http.Server\n\tlogger *logrus.Logger\n}\n\nfunc (s *HttpServer) Setup(cfg *config.Config, deps *dependencies.Dependencies) (*HttpServer, error) {\n\ts.mux = http.NewServeMux()\n\n\tif err := templates.SetupTemplates(cfg); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to setup templates: %w\", err)\n\t}\n\n\tglobalMiddleware := []model.HttpMiddleware{}\n\n\tif cfg.Http.SSOProxyAuth {\n\t\tglobalMiddleware = append(globalMiddleware, middleware.NewAuthSSOProxyMiddleware(deps))\n\t}\n\n\t// Add message response middleware if legacy message response is enabled\n\tglobalMiddleware = append(globalMiddleware, []model.HttpMiddleware{\n\t\tmiddleware.NewMessageResponseMiddleware(deps),\n\t\tmiddleware.NewAuthMiddleware(deps),\n\t\tmiddleware.NewRequestIDMiddleware(deps),\n\t\tmiddleware.NewCORSMiddleware([]string{\"*\"}),\n\t}...)\n\n\tif cfg.Http.AccessLog {\n\t\tglobalMiddleware = append(globalMiddleware, middleware.NewLoggingMiddleware())\n\t}\n\n\t// System routes with logging middleware\n\ts.mux.HandleFunc(\"GET /system/liveness\", ToHTTPHandler(deps,\n\t\thandlers.HandleLiveness,\n\t\tglobalMiddleware...,\n\t))\n\n\t// Bookmark routes\n\ts.mux.HandleFunc(\"GET /bookmark/{id}/content\", ToHTTPHandler(deps, handlers.HandleBookmarkContent, globalMiddleware...))\n\ts.mux.HandleFunc(\"GET /bookmark/{id}/archive\", ToHTTPHandler(deps, handlers.HandleBookmarkArchive, globalMiddleware...))\n\ts.mux.HandleFunc(\"GET /bookmark/{id}/archive/file/{path...}\", ToHTTPHandler(deps, handlers.HandleBookmarkArchiveFile, globalMiddleware...))\n\ts.mux.HandleFunc(\"GET /bookmark/{id}/thumb\", ToHTTPHandler(deps, handlers.HandleBookmarkThumbnail, globalMiddleware...))\n\ts.mux.HandleFunc(\"GET /bookmark/{id}/ebook\", ToHTTPHandler(deps, handlers.HandleBookmarkEbook, globalMiddleware...))\n\n\t// Add this inside Setup() where other routes are registered\n\tif cfg.Http.ServeSwagger {\n\t\ts.mux.HandleFunc(\"/swagger/\", ToHTTPHandler(deps,\n\t\t\thandlers.HandleSwagger,\n\t\t\tglobalMiddleware...,\n\t\t))\n\t}\n\n\t// API v1 routes\n\ts.mux.HandleFunc(\"GET /api/v1/system/info\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleSystemInfo,\n\t\tglobalMiddleware...,\n\t))\n\n\t// Legacy API routes\n\t// TODO: Remove this once the legacy API is removed\n\tlegacyHandler := handlers.NewLegacyHandler(deps)\n\n\ts.mux.HandleFunc(\"GET /api/tags\", ToHTTPHandler(deps, legacyHandler.HandleGetTags, globalMiddleware...))\n\ts.mux.HandleFunc(\"PUT /api/tags\", ToHTTPHandler(deps, legacyHandler.HandleRenameTag, globalMiddleware...))\n\ts.mux.HandleFunc(\"GET /api/bookmarks\", ToHTTPHandler(deps, legacyHandler.HandleGetBookmarks, globalMiddleware...))\n\ts.mux.HandleFunc(\"POST /api/bookmarks\", ToHTTPHandler(deps, legacyHandler.HandleInsertBookmark, globalMiddleware...))\n\ts.mux.HandleFunc(\"DELETE /api/bookmarks\", ToHTTPHandler(deps, legacyHandler.HandleDeleteBookmark, globalMiddleware...))\n\ts.mux.HandleFunc(\"PUT /api/bookmarks\", ToHTTPHandler(deps, legacyHandler.HandleUpdateBookmark, globalMiddleware...))\n\ts.mux.HandleFunc(\"PUT /api/bookmarks/tags\", ToHTTPHandler(deps, legacyHandler.HandleUpdateBookmarkTags, globalMiddleware...))\n\ts.mux.HandleFunc(\"POST /api/bookmarks/ext\", ToHTTPHandler(deps, legacyHandler.HandleInsertViaExtension, globalMiddleware...))\n\ts.mux.HandleFunc(\"DELETE /api/bookmarks/ext\", ToHTTPHandler(deps, legacyHandler.HandleDeleteViaExtension, globalMiddleware...))\n\n\t// Register routes using standard http handlers\n\tif cfg.Http.ServeWebUI {\n\t\t// Frontend routes\n\t\ts.mux.HandleFunc(\"/\", ToHTTPHandler(deps,\n\t\t\thandlers.HandleFrontend,\n\t\t\tglobalMiddleware...,\n\t\t))\n\t\ts.mux.HandleFunc(\"GET /assets/\", ToHTTPHandler(deps,\n\t\t\thandlers.HandleAssets,\n\t\t\tglobalMiddleware...,\n\t\t))\n\t}\n\n\t// API v1 routes\n\t// Auth\n\ts.mux.HandleFunc(\"POST /api/v1/auth/login\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleLogin,\n\t\tglobalMiddleware...,\n\t))\n\ts.mux.HandleFunc(\"POST /api/v1/auth/refresh\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleRefreshToken,\n\t\tglobalMiddleware...,\n\t))\n\ts.mux.HandleFunc(\"GET /api/v1/auth/me\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleGetMe,\n\t\tglobalMiddleware...,\n\t))\n\ts.mux.HandleFunc(\"PATCH /api/v1/auth/account\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleUpdateLoggedAccount,\n\t\tglobalMiddleware...,\n\t))\n\ts.mux.HandleFunc(\"POST /api/v1/auth/logout\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleLogout,\n\t\tglobalMiddleware...,\n\t))\n\t// Accounts\n\ts.mux.HandleFunc(\"GET /api/v1/accounts\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleListAccounts,\n\t\tglobalMiddleware...,\n\t))\n\ts.mux.HandleFunc(\"POST /api/v1/accounts\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleCreateAccount,\n\t\tglobalMiddleware...,\n\t))\n\ts.mux.HandleFunc(\"DELETE /api/v1/accounts/{id}\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleDeleteAccount,\n\t\tglobalMiddleware...,\n\t))\n\ts.mux.HandleFunc(\"PATCH /api/v1/accounts/{id}\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleUpdateAccount,\n\t\tglobalMiddleware...,\n\t))\n\t// Tags\n\ts.mux.HandleFunc(\"GET /api/v1/tags\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleListTags,\n\t\tglobalMiddleware...,\n\t))\n\ts.mux.HandleFunc(\"GET /api/v1/tags/{id}\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleGetTag,\n\t\tglobalMiddleware...,\n\t))\n\ts.mux.HandleFunc(\"POST /api/v1/tags\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleCreateTag,\n\t\tglobalMiddleware...,\n\t))\n\ts.mux.HandleFunc(\"PUT /api/v1/tags/{id}\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleUpdateTag,\n\t\tglobalMiddleware...,\n\t))\n\ts.mux.HandleFunc(\"DELETE /api/v1/tags/{id}\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleDeleteTag,\n\t\tglobalMiddleware...,\n\t))\n\t// Bookmarks\n\ts.mux.HandleFunc(\"PUT /api/v1/bookmarks/cache\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleUpdateCache,\n\t\tglobalMiddleware...,\n\t))\n\ts.mux.HandleFunc(\"GET /api/v1/bookmarks/{id}/readable\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleBookmarkReadable,\n\t\tglobalMiddleware...,\n\t))\n\ts.mux.HandleFunc(\"PUT /api/v1/bookmarks/bulk/tags\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleBulkUpdateBookmarkTags,\n\t\tglobalMiddleware...,\n\t))\n\t// Bookmark tags endpoints\n\ts.mux.HandleFunc(\"GET /api/v1/bookmarks/{id}/tags\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleGetBookmarkTags,\n\t\tglobalMiddleware...,\n\t))\n\ts.mux.HandleFunc(\"POST /api/v1/bookmarks/{id}/tags\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleAddTagToBookmark,\n\t\tglobalMiddleware...,\n\t))\n\ts.mux.HandleFunc(\"DELETE /api/v1/bookmarks/{id}/tags\", ToHTTPHandler(deps,\n\t\tapi_v1.HandleRemoveTagFromBookmark,\n\t\tglobalMiddleware...,\n\t))\n\n\ts.server = &http.Server{\n\t\tAddr:    fmt.Sprintf(\"%s%d\", cfg.Http.Address, cfg.Http.Port),\n\t\tHandler: s.mux,\n\t}\n\n\treturn s, nil\n}\n\nfunc (s *HttpServer) Start(_ context.Context) error {\n\ts.logger.WithField(\"addr\", s.server.Addr).Info(\"starting http server\")\n\tgo func() {\n\t\tif err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {\n\t\t\ts.logger.Fatalf(\"listen and serve error: %s\\n\", err)\n\t\t}\n\t}()\n\treturn nil\n}\n\nfunc (s *HttpServer) Stop(ctx context.Context) error {\n\ts.logger.WithField(\"addr\", s.server.Addr).Info(\"stopping http server\")\n\treturn s.server.Shutdown(ctx)\n}\n\nfunc (s *HttpServer) WaitStop(ctx context.Context) {\n\tsignals := make(chan os.Signal, 1)\n\tsignal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)\n\n\tsig := <-signals\n\ts.logger.WithField(\"signal\", sig.String()).Info(\"signal received, shutting down\")\n\n\tif err := s.Stop(ctx); err != nil {\n\t\ts.logger.WithError(err).Error(\"error stopping server\")\n\t}\n}\n\nfunc NewHttpServer(logger *logrus.Logger) *HttpServer {\n\treturn &HttpServer{\n\t\tlogger: logger,\n\t}\n}\n"
  },
  {
    "path": "internal/http/server_test.go",
    "content": "package http\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/go-shiori/shiori/internal/testutil\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewHttpServer(t *testing.T) {\n\tlogger := logrus.New()\n\tserver := NewHttpServer(logger)\n\trequire.NotNil(t, server)\n\trequire.Equal(t, logger, server.logger)\n}\n\nfunc TestHttpServer_Setup(t *testing.T) {\n\tlogger := logrus.New()\n\tctx := context.Background()\n\n\tt.Run(\"successful setup\", func(t *testing.T) {\n\t\tcfg, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tserver := NewHttpServer(logger)\n\n\t\ts, err := server.Setup(cfg, deps)\n\t\trequire.NoError(t, err)\n\t\trequire.NotNil(t, s)\n\t\trequire.NotNil(t, s.mux)\n\t\trequire.NotNil(t, s.server)\n\t\trequire.Equal(t, fmt.Sprintf(\"%s%d\", cfg.Http.Address, cfg.Http.Port), s.server.Addr)\n\t})\n\n\tt.Run(\"routes are registered correctly\", func(t *testing.T) {\n\t\tcfg, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tserver := NewHttpServer(logger)\n\n\t\ts, err := server.Setup(cfg, deps)\n\t\trequire.NoError(t, err)\n\n\t\t// Test some key routes\n\t\troutes := []struct {\n\t\t\tmethod string\n\t\t\tpath   string\n\t\t\twant   int\n\t\t}{\n\t\t\t{\"GET\", \"/system/liveness\", http.StatusOK},\n\t\t\t{\"GET\", \"/api/v1/system/info\", http.StatusUnauthorized}, // Requires auth\n\t\t\t{\"GET\", \"/api/v1/accounts\", http.StatusUnauthorized},    // Requires auth\n\t\t\t{\"POST\", \"/api/v1/auth/login\", http.StatusBadRequest},   // Bad request because no body\n\t\t}\n\n\t\tfor _, tt := range routes {\n\t\t\tt.Run(fmt.Sprintf(\"%s %s\", tt.method, tt.path), func(t *testing.T) {\n\t\t\t\treq := httptest.NewRequest(tt.method, tt.path, nil)\n\t\t\t\tw := httptest.NewRecorder()\n\t\t\t\ts.mux.ServeHTTP(w, req)\n\t\t\t\trequire.Equal(t, tt.want, w.Code)\n\t\t\t})\n\t\t}\n\t})\n\n\tt.Run(\"swagger routes when enabled\", func(t *testing.T) {\n\t\tcfg, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tcfg.Http.ServeSwagger = true\n\t\tserver := NewHttpServer(logger)\n\n\t\ts, err := server.Setup(cfg, deps)\n\t\trequire.NoError(t, err)\n\n\t\t// Test swagger doc endpoint\n\t\treq := httptest.NewRequest(\"GET\", \"/swagger/doc.json\", nil)\n\t\tw := httptest.NewRecorder()\n\t\ts.mux.ServeHTTP(w, req)\n\t\trequire.Equal(t, http.StatusOK, w.Code)\n\n\t\t// Test swagger UI endpoint (should redirect)\n\t\treq = httptest.NewRequest(\"GET\", \"/swagger/\", nil)\n\t\tw = httptest.NewRecorder()\n\t\ts.mux.ServeHTTP(w, req)\n\t\trequire.Equal(t, http.StatusMovedPermanently, w.Code)\n\t\trequire.Equal(t, \"/swagger/index.html\", w.Header().Get(\"Location\"))\n\t})\n\n\tt.Run(\"web UI routes when enabled\", func(t *testing.T) {\n\t\tcfg, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\t\tcfg.Http.ServeWebUI = true\n\t\tserver := NewHttpServer(logger)\n\n\t\ts, err := server.Setup(cfg, deps)\n\t\trequire.NoError(t, err)\n\n\t\troutes := []struct {\n\t\t\tpath string\n\t\t\twant int\n\t\t}{\n\t\t\t{\"/\", http.StatusOK},\n\t\t\t{\"/assets/style.css\", http.StatusNotFound}, // 404 because no actual assets in test\n\t\t}\n\n\t\tfor _, tt := range routes {\n\t\t\tt.Run(tt.path, func(t *testing.T) {\n\t\t\t\treq := httptest.NewRequest(\"GET\", tt.path, nil)\n\t\t\t\tw := httptest.NewRecorder()\n\t\t\t\ts.mux.ServeHTTP(w, req)\n\t\t\t\trequire.Equal(t, tt.want, w.Code)\n\t\t\t})\n\t\t}\n\t})\n}\n\nfunc TestHttpServer_StartStop(t *testing.T) {\n\tlogger := logrus.New()\n\tctx := context.Background()\n\tcfg, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\t// Use a random port to avoid conflicts\n\tcfg.Http.Port = 0\n\n\tserver := NewHttpServer(logger)\n\ts, err := server.Setup(cfg, deps)\n\trequire.NoError(t, err)\n\n\t// Start the server\n\terr = s.Start(ctx)\n\trequire.NoError(t, err)\n\n\t// Give it a moment to start\n\ttime.Sleep(100 * time.Millisecond)\n\n\t// Stop the server\n\terr = s.Stop(ctx)\n\trequire.NoError(t, err)\n}\n\nfunc TestHttpServer_Middleware(t *testing.T) {\n\tlogger := logrus.New()\n\tctx := context.Background()\n\tcfg, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\tserver := NewHttpServer(logger)\n\n\ts, err := server.Setup(cfg, deps)\n\trequire.NoError(t, err)\n\n\tt.Run(\"logging middleware\", func(t *testing.T) {\n\t\t// Capture log output\n\t\tvar logBuf strings.Builder\n\t\tlogger.SetOutput(&logBuf)\n\t\tlogger.SetLevel(logrus.InfoLevel)\n\n\t\treq := httptest.NewRequest(\"GET\", \"/system/liveness\", nil)\n\t\tw := httptest.NewRecorder()\n\t\ts.mux.ServeHTTP(w, req)\n\n\t\t// Verify log contains request info\n\t\tlogOutput := logBuf.String()\n\t\trequire.Contains(t, logOutput, \"request completed\")\n\t\trequire.Contains(t, logOutput, \"path=/system/liveness\")\n\t})\n\n\tt.Run(\"auth middleware\", func(t *testing.T) {\n\t\tprotectedRoutes := []struct {\n\t\t\tmethod string\n\t\t\tpath   string\n\t\t\twant   int\n\t\t\tauth   bool\n\t\t}{\n\t\t\t{\"GET\", \"/api/v1/accounts\", http.StatusUnauthorized, false},\n\t\t\t{\"GET\", \"/api/v1/auth/me\", http.StatusUnauthorized, false},\n\t\t\t{\"PUT\", \"/api/v1/bookmarks/cache\", http.StatusForbidden, true}, // Requires admin access\n\t\t}\n\n\t\tfor _, route := range protectedRoutes {\n\t\t\tt.Run(route.path, func(t *testing.T) {\n\t\t\t\treq := httptest.NewRequest(route.method, route.path, nil)\n\n\t\t\t\tif route.auth {\n\t\t\t\t\t// Create a non-admin user token\n\t\t\t\t\taccount := testutil.GetValidAccount()\n\t\t\t\t\taccount.Owner = false // Ensure not admin\n\t\t\t\t\taccountDTO := account.ToDTO()\n\t\t\t\t\ttoken, err := deps.Domains().Auth().CreateTokenForAccount(&accountDTO, time.Now().Add(time.Hour))\n\t\t\t\t\trequire.NoError(t, err)\n\t\t\t\t\treq.Header.Set(model.AuthorizationHeader, model.AuthorizationTokenType+\" \"+token)\n\t\t\t\t}\n\n\t\t\t\tw := httptest.NewRecorder()\n\t\t\t\ts.mux.ServeHTTP(w, req)\n\t\t\t\trequire.Equal(t, route.want, w.Code)\n\t\t\t})\n\t\t}\n\t})\n}\n\nfunc TestHttpServer_APIEndpoints(t *testing.T) {\n\tlogger := logrus.New()\n\tctx := context.Background()\n\tcfg, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger)\n\tserver := NewHttpServer(logger)\n\n\ts, err := server.Setup(cfg, deps)\n\trequire.NoError(t, err)\n\n\tt.Run(\"login endpoint\", func(t *testing.T) {\n\t\tbody := strings.NewReader(`{\"username\": \"test\", \"password\": \"test\"}`)\n\t\treq := httptest.NewRequest(\"POST\", \"/api/v1/auth/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\t\ts.mux.ServeHTTP(w, req)\n\n\t\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\t\trespBody, _ := io.ReadAll(w.Body)\n\t\trequire.Contains(t, string(respBody), \"username or password do not match\")\n\t})\n\n\tt.Run(\"system info endpoint\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(\"GET\", \"/api/v1/system/info\", nil)\n\t\tw := httptest.NewRecorder()\n\t\ts.mux.ServeHTTP(w, req)\n\n\t\trequire.Equal(t, http.StatusUnauthorized, w.Code)\n\t\trespBody, _ := io.ReadAll(w.Body)\n\t\trequire.Contains(t, string(respBody), \"Authentication required\")\n\t})\n}\n"
  },
  {
    "path": "internal/http/templates/templates.go",
    "content": "package templates\n\nimport (\n\t\"fmt\"\n\t\"html/template\"\n\t\"io\"\n\n\t\"github.com/go-shiori/shiori/internal/config\"\n\tviews \"github.com/go-shiori/shiori/internal/view\"\n\twebapp \"github.com/go-shiori/shiori/webapp\"\n)\n\nconst (\n\tleftTemplateDelim  = \"$$\"\n\trightTemplateDelim = \"$$\"\n)\n\nvar templates *template.Template\n\n// SetupTemplates initializes the templates for the webserver\nfunc SetupTemplates(config *config.Config) error {\n\tvar err error\n\tfs := views.Templates\n\n\tglobs := []string{\"*.html\"}\n\n\tif config.Http.ServeWebUIV2 {\n\t\tfs = webapp.Templates\n\t\tglobs = []string{\"**/*.html\"}\n\t}\n\n\ttemplates, err = template.New(\"html\").\n\t\tDelims(leftTemplateDelim, rightTemplateDelim).\n\t\tParseFS(fs, globs...)\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse templates: %w\", err)\n\t}\n\treturn nil\n}\n\n// RenderTemplate renders a template with the given data\nfunc RenderTemplate(w io.Writer, name string, data any) error {\n\tif templates == nil {\n\t\treturn fmt.Errorf(\"templates not initialized\")\n\t}\n\treturn templates.ExecuteTemplate(w, name, data)\n}\n"
  },
  {
    "path": "internal/http/webcontext/auth.go",
    "content": "package webcontext\n\nimport (\n\t\"context\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\n// UserIsLogged returns a boolean indicating if the user is authenticated or not\nfunc (c *WebContext) UserIsLogged() bool {\n\treturn c.GetAccount() != nil\n}\n\n// GetAccount retrieves the account from the request context\nfunc (c *WebContext) GetAccount() *model.AccountDTO {\n\tif acc := c.request.Context().Value(accountKey); acc != nil {\n\t\treturn acc.(*model.AccountDTO)\n\t}\n\treturn nil\n}\n\n// SetAccount stores the account in the request context\nfunc (c *WebContext) SetAccount(account *model.AccountDTO) {\n\tctx := WithAccount(c.request.Context(), account)\n\tc.request = c.request.WithContext(ctx)\n}\n\n// WithAccount creates a new context with the account\nfunc WithAccount(ctx context.Context, account *model.AccountDTO) context.Context {\n\treturn context.WithValue(ctx, accountKey, account)\n}\n"
  },
  {
    "path": "internal/http/webcontext/auth_test.go",
    "content": "package webcontext\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestUserIsLogged(t *testing.T) {\n\tt.Run(\"test user is logged\", func(t *testing.T) {\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tc := NewWebContext(w, r)\n\n\t\tc.SetAccount(&model.AccountDTO{Username: \"test\"})\n\n\t\trequire.True(t, c.UserIsLogged())\n\t\taccount := c.GetAccount()\n\t\trequire.NotNil(t, account)\n\t\trequire.Equal(t, \"test\", account.Username)\n\t})\n\n\tt.Run(\"test user is not logged\", func(t *testing.T) {\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tc := NewWebContext(w, r)\n\n\t\trequire.False(t, c.UserIsLogged())\n\t\trequire.Nil(t, c.GetAccount())\n\t})\n}\n\nfunc TestGetAccount(t *testing.T) {\n\tt.Run(\"test get account (logged in)\", func(t *testing.T) {\n\t\taccount := model.AccountDTO{\n\t\t\tUsername: \"shiori\",\n\t\t}\n\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tc := NewWebContext(w, r)\n\n\t\tc.SetAccount(&account)\n\t\tgotAccount := c.GetAccount()\n\n\t\trequire.NotNil(t, gotAccount)\n\t\trequire.Equal(t, account, *gotAccount)\n\t})\n\n\tt.Run(\"test get account (not logged in)\", func(t *testing.T) {\n\t\tw := httptest.NewRecorder()\n\t\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\t\tc := NewWebContext(w, r)\n\n\t\trequire.Nil(t, c.GetAccount())\n\t})\n}\n\nfunc TestWithAccount(t *testing.T) {\n\taccount := &model.AccountDTO{\n\t\tUsername: \"shiori\",\n\t}\n\n\tw := httptest.NewRecorder()\n\tr := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\tc := NewWebContext(w, r)\n\n\tc.SetAccount(account)\n\tgotAccount := c.GetAccount()\n\n\trequire.Equal(t, account, gotAccount)\n}\n"
  },
  {
    "path": "internal/http/webcontext/context.go",
    "content": "package webcontext\n\nimport (\n\t\"context\"\n\t\"net/http\"\n)\n\n// WebContext wraps the standard request and response writer\ntype WebContext struct {\n\trequest        *http.Request\n\tresponseWriter http.ResponseWriter\n}\n\n// NewWebContext creates a new WebContext from http.ResponseWriter and *http.Request\nfunc NewWebContext(w http.ResponseWriter, r *http.Request) *WebContext {\n\treturn &WebContext{\n\t\trequest:        r,\n\t\tresponseWriter: w,\n\t}\n}\n\n// Context returns the request's context\nfunc (c *WebContext) Context() context.Context {\n\treturn c.request.Context()\n}\n\n// WithContext returns a shallow copy of c with its context changed to ctx\nfunc (c *WebContext) WithContext(ctx context.Context) *WebContext {\n\tc2 := new(WebContext)\n\t*c2 = *c\n\tc2.request = c2.request.WithContext(ctx)\n\treturn c2\n}\n\nfunc (c *WebContext) ResponseWriter() http.ResponseWriter {\n\treturn c.responseWriter\n}\n\n// SetResponseWriter sets the response writer for the context\nfunc (c *WebContext) SetResponseWriter(w http.ResponseWriter) {\n\tc.responseWriter = w\n}\n\nfunc (c *WebContext) Request() *http.Request {\n\treturn c.request\n}\n\n// GetRequestID returns the request ID from the context\nfunc (c *WebContext) GetRequestID() string {\n\tif id := c.request.Context().Value(requestIDKey); id != nil {\n\t\treturn id.(string)\n\t}\n\treturn \"\"\n}\n\n// SetRequestID stores the request ID in the context\nfunc (c *WebContext) SetRequestID(id string) {\n\tctx := context.WithValue(c.request.Context(), requestIDKey, id)\n\tc.request = c.request.WithContext(ctx)\n}\n"
  },
  {
    "path": "internal/http/webcontext/keys.go",
    "content": "package webcontext\n\ntype contextKey string\n\nconst (\n\taccountKey   contextKey = \"account\"\n\trequestIDKey contextKey = \"requestID\"\n)\n"
  },
  {
    "path": "internal/model/account.go",
    "content": "package model\n\nimport (\n\t\"database/sql/driver\"\n\t\"encoding/json\"\n\t\"fmt\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n)\n\n// Account is the database representation for account.\ntype Account struct {\n\tID       DBID       `db:\"id\"       json:\"id\"`\n\tUsername string     `db:\"username\" json:\"username\"`\n\tPassword string     `db:\"password\" json:\"password,omitempty\"`\n\tOwner    bool       `db:\"owner\"    json:\"owner\"`\n\tConfig   UserConfig `db:\"config\"               json:\"config\"`\n}\n\ntype UserConfig struct {\n\tShowId        bool\n\tListMode      bool\n\tHideThumbnail bool\n\tHideExcerpt   bool\n\tTheme         string\n\tKeepMetadata  bool\n\tUseArchive    bool\n\tCreateEbook   bool\n\tMakePublic    bool\n}\n\nfunc (c *UserConfig) Scan(value interface{}) error {\n\tswitch v := value.(type) {\n\tcase []byte:\n\t\tjson.Unmarshal(v, &c)\n\t\treturn nil\n\tcase string:\n\t\tjson.Unmarshal([]byte(v), &c)\n\t\treturn nil\n\tdefault:\n\t\treturn fmt.Errorf(\"unsupported type: %T\", v)\n\t}\n}\n\nfunc (c UserConfig) Value() (driver.Value, error) {\n\treturn json.Marshal(c)\n}\n\n// ToDTO converts Account to AccountDTO.\nfunc (a Account) ToDTO() AccountDTO {\n\towner := a.Owner\n\tconfig := a.Config\n\n\treturn AccountDTO{\n\t\tID:       a.ID,\n\t\tUsername: a.Username,\n\t\tOwner:    &owner,\n\t\tConfig:   &config,\n\t}\n}\n\n// AccountDTO is data transfer object for Account.\ntype AccountDTO struct {\n\tID       DBID        `json:\"id\"`\n\tUsername string      `json:\"username\"`\n\tPassword string      `json:\"passowrd,omitempty\"` // Used only to store, not to retrieve\n\tOwner    *bool       `json:\"owner\"`\n\tConfig   *UserConfig `json:\"config\"`\n}\n\nfunc (adto *AccountDTO) IsOwner() bool {\n\treturn adto.Owner != nil && *adto.Owner\n}\n\nfunc (adto *AccountDTO) IsValidCreate() error {\n\tif adto.Username == \"\" {\n\t\treturn NewValidationError(\"username\", \"username should not be empty\")\n\t}\n\n\tif adto.Password == \"\" {\n\t\treturn NewValidationError(\"password\", \"password should not be empty\")\n\t}\n\n\treturn nil\n}\n\nfunc (adto *AccountDTO) IsValidUpdate() error {\n\tif adto.Username == \"\" && adto.Password == \"\" && adto.Owner == nil && adto.Config == nil {\n\t\treturn NewValidationError(\"account\", \"no fields to update\")\n\t}\n\n\treturn nil\n}\n\ntype JWTClaim struct {\n\tjwt.RegisteredClaims\n\n\tAccount *Account\n}\n"
  },
  {
    "path": "internal/model/bookmark.go",
    "content": "package model\n\nimport (\n\t\"path/filepath\"\n\t\"strconv\"\n)\n\n// Bookmark is the database representation of a bookmark\ntype Bookmark struct {\n\tID         int    `db:\"id\"`\n\tURL        string `db:\"url\"`\n\tTitle      string `db:\"title\"`\n\tExcerpt    string `db:\"excerpt\"`\n\tAuthor     string `db:\"author\"`\n\tPublic     int    `db:\"public\"`\n\tCreatedAt  string `db:\"created_at\"`\n\tModifiedAt string `db:\"modified_at\"`\n\tHasContent bool   `db:\"has_content\"`\n}\n\n// BookmarkDTO is the bookmark object representation in database and the data transfer object\n// at the same time, pending a refactor to two separate object to represent each role.\ntype BookmarkDTO struct {\n\tID            int      `db:\"id\"            json:\"id\"`\n\tURL           string   `db:\"url\"           json:\"url\"`\n\tTitle         string   `db:\"title\"         json:\"title\"`\n\tExcerpt       string   `db:\"excerpt\"       json:\"excerpt\"`\n\tAuthor        string   `db:\"author\"        json:\"author\"`\n\tPublic        int      `db:\"public\"        json:\"public\"`\n\tCreatedAt     string   `db:\"created_at\"    json:\"createdAt\"`\n\tModifiedAt    string   `db:\"modified_at\"   json:\"modifiedAt\"`\n\tContent       string   `db:\"content\"       json:\"-\"`\n\tHTML          string   `db:\"html\"          json:\"html,omitempty\"`\n\tImageURL      string   `db:\"image_url\"     json:\"imageURL\"`\n\tHasContent    bool     `db:\"has_content\"   json:\"hasContent\"`\n\tTags          []TagDTO `json:\"tags\"`\n\tHasArchive    bool     `json:\"hasArchive\"`\n\tHasEbook      bool     `json:\"hasEbook\"`\n\tCreateArchive bool     `json:\"create_archive\"` // TODO: migrate outside the DTO\n\tCreateEbook   bool     `json:\"create_ebook\"`   // TODO: migrate outside the DTO\n}\n\n// ToBookmark converts a BookmarkDTO to a Bookmark\nfunc (dto *BookmarkDTO) ToBookmark() Bookmark {\n\treturn Bookmark{\n\t\tID:         dto.ID,\n\t\tURL:        dto.URL,\n\t\tTitle:      dto.Title,\n\t\tExcerpt:    dto.Excerpt,\n\t\tAuthor:     dto.Author,\n\t\tPublic:     dto.Public,\n\t\tCreatedAt:  dto.CreatedAt,\n\t\tModifiedAt: dto.ModifiedAt,\n\t\tHasContent: dto.HasContent,\n\t}\n}\n\n// ToDTO converts a Bookmark to a BookmarkDTO\nfunc (b *Bookmark) ToDTO() BookmarkDTO {\n\treturn BookmarkDTO{\n\t\tID:         b.ID,\n\t\tURL:        b.URL,\n\t\tTitle:      b.Title,\n\t\tExcerpt:    b.Excerpt,\n\t\tAuthor:     b.Author,\n\t\tPublic:     b.Public,\n\t\tCreatedAt:  b.CreatedAt,\n\t\tModifiedAt: b.ModifiedAt,\n\t\tHasContent: b.HasContent,\n\t\tTags:       []TagDTO{},\n\t}\n}\n\n// GetTumnbailPath returns the relative path to the thumbnail of a bookmark in the filesystem\nfunc GetThumbnailPath(bookmark *BookmarkDTO) string {\n\treturn filepath.Join(\"thumb\", strconv.Itoa(bookmark.ID))\n}\n\n// GetEbookPath returns the relative path to the ebook of a bookmark in the filesystem\nfunc GetEbookPath(bookmark *BookmarkDTO) string {\n\treturn filepath.Join(\"ebook\", strconv.Itoa(bookmark.ID)+\".epub\")\n}\n\n// GetArchivePath returns the relative path to the archive of a bookmark in the filesystem\nfunc GetArchivePath(bookmark *BookmarkDTO) string {\n\treturn filepath.Join(\"archive\", strconv.Itoa(bookmark.ID))\n}\n"
  },
  {
    "path": "internal/model/bookmark_test.go",
    "content": "package model\n\nimport (\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestBookmarkToDTO(t *testing.T) {\n\t// Create a test bookmark\n\tbookmark := Bookmark{\n\t\tID:         123,\n\t\tURL:        \"https://example.com\",\n\t\tTitle:      \"Example Title\",\n\t\tExcerpt:    \"This is an excerpt\",\n\t\tAuthor:     \"John Doe\",\n\t\tPublic:     1,\n\t\tCreatedAt:  \"2023-01-01 12:00:00\",\n\t\tModifiedAt: \"2023-01-02 12:00:00\",\n\t\tHasContent: true,\n\t}\n\n\t// Convert to DTO\n\tdto := bookmark.ToDTO()\n\n\t// Verify all fields are correctly transferred\n\tassert.Equal(t, bookmark.ID, dto.ID, \"ID should match\")\n\tassert.Equal(t, bookmark.URL, dto.URL, \"URL should match\")\n\tassert.Equal(t, bookmark.Title, dto.Title, \"Title should match\")\n\tassert.Equal(t, bookmark.Excerpt, dto.Excerpt, \"Excerpt should match\")\n\tassert.Equal(t, bookmark.Author, dto.Author, \"Author should match\")\n\tassert.Equal(t, bookmark.Public, dto.Public, \"Public should match\")\n\tassert.Equal(t, bookmark.CreatedAt, dto.CreatedAt, \"CreatedAt should match\")\n\tassert.Equal(t, bookmark.ModifiedAt, dto.ModifiedAt, \"ModifiedAt should match\")\n\tassert.Equal(t, bookmark.HasContent, dto.HasContent, \"HasContent should match\")\n\n\t// Verify default values for fields not in Bookmark\n\tassert.Empty(t, dto.Content, \"Content should be empty\")\n\tassert.Empty(t, dto.HTML, \"HTML should be empty\")\n\tassert.Empty(t, dto.ImageURL, \"ImageURL should be empty\")\n\tassert.Empty(t, dto.Tags, \"Tags should be empty\")\n\tassert.False(t, dto.HasArchive, \"HasArchive should be false\")\n\tassert.False(t, dto.HasEbook, \"HasEbook should be false\")\n\tassert.False(t, dto.CreateArchive, \"CreateArchive should be false\")\n\tassert.False(t, dto.CreateEbook, \"CreateEbook should be false\")\n}\n\nfunc TestBookmarkDTOToBookmark(t *testing.T) {\n\t// Create a test BookmarkDTO with all fields populated\n\tdto := BookmarkDTO{\n\t\tID:            123,\n\t\tURL:           \"https://example.com\",\n\t\tTitle:         \"Example Title\",\n\t\tExcerpt:       \"This is an excerpt\",\n\t\tAuthor:        \"John Doe\",\n\t\tPublic:        1,\n\t\tCreatedAt:     \"2023-01-01 12:00:00\",\n\t\tModifiedAt:    \"2023-01-02 12:00:00\",\n\t\tContent:       \"This is the content\",\n\t\tHTML:          \"<p>This is HTML</p>\",\n\t\tImageURL:      \"https://example.com/image.jpg\",\n\t\tHasContent:    true,\n\t\tTags:          []TagDTO{{Tag: Tag{ID: 1, Name: \"tag1\"}}, {Tag: Tag{ID: 2, Name: \"tag2\"}}},\n\t\tHasArchive:    true,\n\t\tHasEbook:      true,\n\t\tCreateArchive: true,\n\t\tCreateEbook:   true,\n\t}\n\n\t// Convert to Bookmark\n\tbookmark := dto.ToBookmark()\n\n\t// Verify all fields are correctly transferred\n\tassert.Equal(t, dto.ID, bookmark.ID, \"ID should match\")\n\tassert.Equal(t, dto.URL, bookmark.URL, \"URL should match\")\n\tassert.Equal(t, dto.Title, bookmark.Title, \"Title should match\")\n\tassert.Equal(t, dto.Excerpt, bookmark.Excerpt, \"Excerpt should match\")\n\tassert.Equal(t, dto.Author, bookmark.Author, \"Author should match\")\n\tassert.Equal(t, dto.Public, bookmark.Public, \"Public should match\")\n\tassert.Equal(t, dto.CreatedAt, bookmark.CreatedAt, \"CreatedAt should match\")\n\tassert.Equal(t, dto.ModifiedAt, bookmark.ModifiedAt, \"ModifiedAt should match\")\n\tassert.Equal(t, dto.HasContent, bookmark.HasContent, \"HasContent should match\")\n\n\t// Fields that should not be transferred\n\t// These fields are only in BookmarkDTO and not in Bookmark\n}\n\nfunc TestGetThumbnailPath(t *testing.T) {\n\t// Test cases\n\ttestCases := []struct {\n\t\tname     string\n\t\tbookmark BookmarkDTO\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname: \"With ID\",\n\t\t\tbookmark: BookmarkDTO{\n\t\t\t\tID: 123,\n\t\t\t},\n\t\t\texpected: filepath.Join(\"thumb\", \"123\"),\n\t\t},\n\t\t{\n\t\t\tname: \"With zero ID\",\n\t\t\tbookmark: BookmarkDTO{\n\t\t\t\tID: 0,\n\t\t\t},\n\t\t\texpected: filepath.Join(\"thumb\", \"0\"),\n\t\t},\n\t}\n\n\t// Run test cases\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tpath := GetThumbnailPath(&tc.bookmark)\n\t\t\tassert.Equal(t, tc.expected, path, \"Thumbnail path should match expected value\")\n\t\t})\n\t}\n}\n\nfunc TestGetEbookPath(t *testing.T) {\n\t// Test cases\n\ttestCases := []struct {\n\t\tname     string\n\t\tbookmark BookmarkDTO\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname: \"With ID\",\n\t\t\tbookmark: BookmarkDTO{\n\t\t\t\tID: 123,\n\t\t\t},\n\t\t\texpected: filepath.Join(\"ebook\", \"123.epub\"),\n\t\t},\n\t\t{\n\t\t\tname: \"With zero ID\",\n\t\t\tbookmark: BookmarkDTO{\n\t\t\t\tID: 0,\n\t\t\t},\n\t\t\texpected: filepath.Join(\"ebook\", \"0.epub\"),\n\t\t},\n\t}\n\n\t// Run test cases\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tpath := GetEbookPath(&tc.bookmark)\n\t\t\tassert.Equal(t, tc.expected, path, \"Ebook path should match expected value\")\n\t\t})\n\t}\n}\n\nfunc TestGetArchivePath(t *testing.T) {\n\t// Test cases\n\ttestCases := []struct {\n\t\tname     string\n\t\tbookmark BookmarkDTO\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname: \"With ID\",\n\t\t\tbookmark: BookmarkDTO{\n\t\t\t\tID: 123,\n\t\t\t},\n\t\t\texpected: filepath.Join(\"archive\", \"123\"),\n\t\t},\n\t\t{\n\t\t\tname: \"With zero ID\",\n\t\t\tbookmark: BookmarkDTO{\n\t\t\t\tID: 0,\n\t\t\t},\n\t\t\texpected: filepath.Join(\"archive\", \"0\"),\n\t\t},\n\t}\n\n\t// Run test cases\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tpath := GetArchivePath(&tc.bookmark)\n\t\t\tassert.Equal(t, tc.expected, path, \"Archive path should match expected value\")\n\t\t})\n\t}\n}\n\nfunc TestBookmarkRoundTrip(t *testing.T) {\n\t// Test that converting from Bookmark to DTO and back preserves data\n\toriginal := Bookmark{\n\t\tID:         123,\n\t\tURL:        \"https://example.com\",\n\t\tTitle:      \"Example Title\",\n\t\tExcerpt:    \"This is an excerpt\",\n\t\tAuthor:     \"John Doe\",\n\t\tPublic:     1,\n\t\tCreatedAt:  \"2023-01-01 12:00:00\",\n\t\tModifiedAt: \"2023-01-02 12:00:00\",\n\t\tHasContent: true,\n\t}\n\n\t// Convert to DTO and back\n\tdto := original.ToDTO()\n\troundTrip := dto.ToBookmark()\n\n\t// Verify all fields are preserved\n\tassert.Equal(t, original.ID, roundTrip.ID, \"ID should be preserved\")\n\tassert.Equal(t, original.URL, roundTrip.URL, \"URL should be preserved\")\n\tassert.Equal(t, original.Title, roundTrip.Title, \"Title should be preserved\")\n\tassert.Equal(t, original.Excerpt, roundTrip.Excerpt, \"Excerpt should be preserved\")\n\tassert.Equal(t, original.Author, roundTrip.Author, \"Author should be preserved\")\n\tassert.Equal(t, original.Public, roundTrip.Public, \"Public should be preserved\")\n\tassert.Equal(t, original.CreatedAt, roundTrip.CreatedAt, \"CreatedAt should be preserved\")\n\tassert.Equal(t, original.ModifiedAt, roundTrip.ModifiedAt, \"ModifiedAt should be preserved\")\n\tassert.Equal(t, original.HasContent, roundTrip.HasContent, \"HasContent should be preserved\")\n}\n"
  },
  {
    "path": "internal/model/const.go",
    "content": "package model\n\n// DataDirPerm the default filesystem permissions for the data directory/archives\nconst DataDirPerm = 0744\n\n// DatabaseDateFormat the string formatting of datetimes for the database\nconst DatabaseDateFormat = \"2006-01-02 15:04:05\"\n"
  },
  {
    "path": "internal/model/database.go",
    "content": "package model\n\nimport (\n\t\"context\"\n\n\t\"github.com/jmoiron/sqlx\"\n)\n\ntype DBID int\n\n// DB is interface for accessing and manipulating data in database.\ntype DB interface {\n\t// WriterDB is the underlying sqlx.DB\n\tWriterDB() *sqlx.DB\n\n\t// ReaderDB is the underlying sqlx.DB\n\tReaderDB() *sqlx.DB\n\n\t// Flavor is the flavor of the database\n\t// Flavor() sqlbuilder.Flavor\n\n\t// Init initializes the database\n\tInit(ctx context.Context) error\n\n\t// Migrate runs migrations for this database\n\tMigrate(ctx context.Context) error\n\n\t// GetDatabaseSchemaVersion gets the version of the database\n\tGetDatabaseSchemaVersion(ctx context.Context) (string, error)\n\n\t// SetDatabaseSchemaVersion sets the version of the database\n\tSetDatabaseSchemaVersion(ctx context.Context, version string) error\n\n\t// SaveBookmarks saves bookmarks data to database.\n\tSaveBookmarks(ctx context.Context, create bool, bookmarks ...BookmarkDTO) ([]BookmarkDTO, error)\n\n\t// SaveBookmark saves a single bookmark to database without handling tags.\n\t// It only updates the bookmark data in the database.\n\tSaveBookmark(ctx context.Context, bookmark Bookmark) error\n\n\t// GetBookmarks fetch list of bookmarks based on submitted options.\n\tGetBookmarks(ctx context.Context, opts DBGetBookmarksOptions) ([]BookmarkDTO, error)\n\n\t// GetBookmarksCount get count of bookmarks in database.\n\tGetBookmarksCount(ctx context.Context, opts DBGetBookmarksOptions) (int, error)\n\n\t// DeleteBookmarks removes all record with matching ids from database.\n\tDeleteBookmarks(ctx context.Context, ids ...int) error\n\n\t// GetBookmark fetches bookmark based on its ID or URL.\n\tGetBookmark(ctx context.Context, id int, url string) (BookmarkDTO, bool, error)\n\n\t// CreateAccount saves new account in database\n\tCreateAccount(ctx context.Context, a Account) (*Account, error)\n\n\t// UpdateAccount updates account in database\n\tUpdateAccount(ctx context.Context, a Account) error\n\n\t// ListAccounts fetch list of account (without its password) with matching keyword.\n\tListAccounts(ctx context.Context, opts DBListAccountsOptions) ([]Account, error)\n\n\t// GetAccount fetch account with matching username.\n\tGetAccount(ctx context.Context, id DBID) (*Account, bool, error)\n\n\t// DeleteAccount removes account with matching id\n\tDeleteAccount(ctx context.Context, id DBID) error\n\n\t// CreateTags creates new tags in database.\n\tCreateTags(ctx context.Context, tags ...Tag) ([]Tag, error)\n\n\t// CreateTag creates a new tag in database.\n\tCreateTag(ctx context.Context, tag Tag) (Tag, error)\n\n\t// GetTags fetch list of tags and its frequency from database.\n\tGetTags(ctx context.Context, opts DBListTagsOptions) ([]TagDTO, error)\n\n\t// RenameTag change the name of a tag.\n\tRenameTag(ctx context.Context, id int, newName string) error\n\n\t// GetTag fetch a tag by its ID.\n\tGetTag(ctx context.Context, id int) (TagDTO, bool, error)\n\n\t// UpdateTag updates a tag in the database.\n\tUpdateTag(ctx context.Context, tag Tag) error\n\n\t// DeleteTag removes a tag from the database.\n\tDeleteTag(ctx context.Context, id int) error\n\n\t// BulkUpdateBookmarkTags updates tags for multiple bookmarks.\n\t// It ensures that all bookmarks and tags exist before proceeding.\n\tBulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error\n\n\t// AddTagToBookmark adds a tag to a bookmark\n\tAddTagToBookmark(ctx context.Context, bookmarkID int, tagID int) error\n\n\t// RemoveTagFromBookmark removes a tag from a bookmark\n\tRemoveTagFromBookmark(ctx context.Context, bookmarkID int, tagID int) error\n\n\t// TagExists checks if a tag with the given ID exists in the database\n\tTagExists(ctx context.Context, tagID int) (bool, error)\n\n\t// BookmarkExists checks if a bookmark with the given ID exists in the database\n\tBookmarkExists(ctx context.Context, bookmarkID int) (bool, error)\n}\n\n// DBOrderMethod is the order method for getting bookmarks\ntype DBOrderMethod int\n\nconst (\n\t// DefaultOrder is oldest to newest.\n\tDefaultOrder DBOrderMethod = iota\n\t// ByLastAdded is from newest addition to the oldest.\n\tByLastAdded\n\t// ByLastModified is from latest modified to the oldest.\n\tByLastModified\n)\n\n// DBGetBookmarksOptions is options for fetching bookmarks from database.\ntype DBGetBookmarksOptions struct {\n\tIDs          []int\n\tTags         []string\n\tExcludedTags []string\n\tKeyword      string\n\tWithContent  bool\n\tOrderMethod  DBOrderMethod\n\tLimit        int\n\tOffset       int\n}\n\n// DBListAccountsOptions is options for fetching accounts from database.\ntype DBListAccountsOptions struct {\n\t// Filter accounts by a keyword\n\tKeyword string\n\t// Filter accounts by exact useranme\n\tUsername string\n\t// Return owner accounts only\n\tOwner bool\n\t// Retrieve password content\n\tWithPassword bool\n}\n\ntype DBTagOrderBy string\n\nconst (\n\tDBTagOrderByTagName DBTagOrderBy = \"name\"\n)\n\n// DBListTagsOptions is options for fetching tags from database.\ntype DBListTagsOptions struct {\n\tBookmarkID        int\n\tWithBookmarkCount bool\n\tOrderBy           DBTagOrderBy\n\tSearch            string\n}\n"
  },
  {
    "path": "internal/model/dependencies.go",
    "content": "package model\n\nimport (\n\t\"github.com/go-shiori/shiori/internal/config\"\n\t\"github.com/sirupsen/logrus\"\n)\n\n// Dependencies represents the interface for application dependencies\ntype Dependencies interface {\n\tLogger() *logrus.Logger\n\tDomains() DomainDependencies\n\tConfig() *config.Config\n\tDatabase() DB\n}\n\n// DomainDependencies represents the interface for domain-specific dependencies\ntype DomainDependencies interface {\n\tAuth() AuthDomain\n\tSetAuth(auth AuthDomain)\n\tAccounts() AccountsDomain\n\tSetAccounts(accounts AccountsDomain)\n\tBookmarks() BookmarksDomain\n\tSetBookmarks(bookmarks BookmarksDomain)\n\tArchiver() ArchiverDomain\n\tSetArchiver(archiver ArchiverDomain)\n\tStorage() StorageDomain\n\tSetStorage(storage StorageDomain)\n\tTags() TagsDomain\n\tSetTags(tags TagsDomain)\n}\n"
  },
  {
    "path": "internal/model/domains.go",
    "content": "package model\n\nimport (\n\t\"context\"\n\t\"io/fs\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/go-shiori/warc\"\n\t\"github.com/spf13/afero\"\n)\n\ntype BookmarksDomain interface {\n\tHasEbook(b *BookmarkDTO) bool\n\tHasArchive(b *BookmarkDTO) bool\n\tHasThumbnail(b *BookmarkDTO) bool\n\tGetBookmark(ctx context.Context, id DBID) (*BookmarkDTO, error)\n\tGetBookmarks(ctx context.Context, ids []int) ([]BookmarkDTO, error)\n\tUpdateBookmarkCache(ctx context.Context, bookmark BookmarkDTO, keepMetadata bool, skipExist bool) (*BookmarkDTO, error)\n\tBulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error\n\tAddTagToBookmark(ctx context.Context, bookmarkID int, tagID int) error\n\tRemoveTagFromBookmark(ctx context.Context, bookmarkID int, tagID int) error\n\tBookmarkExists(ctx context.Context, id int) (bool, error)\n}\n\ntype AuthDomain interface {\n\tCheckToken(ctx context.Context, userJWT string) (*AccountDTO, error)\n\tGetAccountFromCredentials(ctx context.Context, username, password string) (*AccountDTO, error)\n\tCreateTokenForAccount(account *AccountDTO, expiration time.Time) (string, error)\n}\n\ntype AccountsDomain interface {\n\tListAccounts(ctx context.Context) ([]AccountDTO, error)\n\tGetAccountByUsername(ctx context.Context, username string) (*AccountDTO, error)\n\tCreateAccount(ctx context.Context, account AccountDTO) (*AccountDTO, error)\n\tUpdateAccount(ctx context.Context, account AccountDTO) (*AccountDTO, error)\n\tDeleteAccount(ctx context.Context, id int) error\n}\n\ntype ArchiverDomain interface {\n\tDownloadBookmarkArchive(book BookmarkDTO) (*BookmarkDTO, error)\n\tGetBookmarkArchive(book *BookmarkDTO) (*warc.Archive, error)\n}\n\ntype StorageDomain interface {\n\tStat(name string) (fs.FileInfo, error)\n\tFS() afero.Fs\n\tFileExists(path string) bool\n\tDirExists(path string) bool\n\tWriteData(dst string, data []byte) error\n\tWriteFile(dst string, src *os.File) error\n}\n\ntype TagsDomain interface {\n\tListTags(ctx context.Context, opts ListTagsOptions) ([]TagDTO, error)\n\tCreateTag(ctx context.Context, tag TagDTO) (TagDTO, error)\n\tGetTag(ctx context.Context, id int) (TagDTO, error)\n\tUpdateTag(ctx context.Context, tag TagDTO) (TagDTO, error)\n\tDeleteTag(ctx context.Context, id int) error\n\tTagExists(ctx context.Context, id int) (bool, error)\n}\n"
  },
  {
    "path": "internal/model/errors.go",
    "content": "package model\n\nimport \"errors\"\n\nvar (\n\tErrBookmarkNotFound  = errors.New(\"bookmark not found\")\n\tErrBookmarkInvalidID = errors.New(\"invalid bookmark ID\")\n\tErrTagNotFound       = errors.New(\"tag not found\")\n\n\tErrUnauthorized  = errors.New(\"unauthorized user\")\n\tErrNotFound      = errors.New(\"not found\")\n\tErrAlreadyExists = errors.New(\"already exists\")\n)\n"
  },
  {
    "path": "internal/model/http.go",
    "content": "package model\n\nimport \"net/http\"\n\nconst (\n\t// ContextAccountKey is the key used to store the account model in the gin context.\n\tContextAccountKey = \"account\"\n\n\t// AuthorizationHeader is the name of the header used to send the token.\n\tAuthorizationHeader = \"Authorization\"\n\t// AuthorizationTokenType is the type of token used in the Authorization header.\n\tAuthorizationTokenType = \"Bearer\"\n)\n\n// WebContext represents the context of an HTTP request\ntype WebContext interface {\n\tRequest() *http.Request\n\tResponseWriter() http.ResponseWriter\n\tSetResponseWriter(w http.ResponseWriter)\n\tGetAccount() *AccountDTO\n\tSetAccount(*AccountDTO)\n\tUserIsLogged() bool\n\tGetRequestID() string\n\tSetRequestID(id string)\n}\n\n// Handler is a custom handler function that receives dependencies and web context\ntype HttpHandler func(deps Dependencies, c WebContext)\n\n// Middleware defines the interface for request/response customization\ntype HttpMiddleware interface {\n\tOnRequest(deps Dependencies, c WebContext) error\n\tOnResponse(deps Dependencies, c WebContext) error\n}\n"
  },
  {
    "path": "internal/model/legacy.go",
    "content": "package model\n\nimport \"time\"\n\ntype LegacyLoginHandler func(account *AccountDTO, expTime time.Duration) (string, error)\n"
  },
  {
    "path": "internal/model/main.go",
    "content": "package model\n\n// Variables set my the main package coming from ldflags\nvar (\n\tBuildVersion = \"dev\"\n\tBuildCommit  = \"none\"\n\tBuildDate    = \"unknown\"\n)\n\nconst (\n\t// ShioriNamespace\n\tShioriURLNamespace = \"https://github.com/go-shiori/shiori\"\n)\n"
  },
  {
    "path": "internal/model/ptr.go",
    "content": "package model\n\n// Ptr returns a pointer to the value passed as argument.\nfunc Ptr[t any](a t) *t {\n\treturn &a\n}\n"
  },
  {
    "path": "internal/model/slices.go",
    "content": "package model\n\n// SliceDifference returns the elements that are in haystack but not in needle.\n// It's a generic function that works with any comparable type.\nfunc SliceDifference[T comparable](haystack, needle []T) []T {\n\t// Create a map of needle elements for quick lookup\n\tneedleMap := make(map[T]bool)\n\tfor _, item := range needle {\n\t\tneedleMap[item] = true\n\t}\n\n\t// Find elements in haystack that are not in needle\n\tvar difference []T\n\tfor _, item := range haystack {\n\t\tif !needleMap[item] {\n\t\t\tdifference = append(difference, item)\n\t\t}\n\t}\n\n\treturn difference\n}\n"
  },
  {
    "path": "internal/model/slices_test.go",
    "content": "package model\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestSliceDifference(t *testing.T) {\n\tt.Run(\"empty_slices\", func(t *testing.T) {\n\t\tresult := SliceDifference([]int{}, []int{})\n\t\tassert.Empty(t, result, \"Difference of empty slices should be empty\")\n\t})\n\n\tt.Run(\"empty_haystack\", func(t *testing.T) {\n\t\tresult := SliceDifference([]int{}, []int{1, 2, 3})\n\t\tassert.Empty(t, result, \"Difference with empty haystack should be empty\")\n\t})\n\n\tt.Run(\"empty_needle\", func(t *testing.T) {\n\t\tresult := SliceDifference([]int{1, 2, 3}, []int{})\n\t\tassert.Equal(t, []int{1, 2, 3}, result, \"Difference with empty needle should be the haystack\")\n\t})\n\n\tt.Run(\"no_difference\", func(t *testing.T) {\n\t\tresult := SliceDifference([]int{1, 2, 3}, []int{1, 2, 3})\n\t\tassert.Empty(t, result, \"Difference of identical slices should be empty\")\n\t})\n\n\tt.Run(\"partial_difference\", func(t *testing.T) {\n\t\tresult := SliceDifference([]int{1, 2, 3, 4}, []int{2, 4})\n\t\tassert.Equal(t, []int{1, 3}, result, \"Should return elements in haystack but not in needle\")\n\t})\n\n\tt.Run(\"complete_difference\", func(t *testing.T) {\n\t\tresult := SliceDifference([]int{1, 2, 3}, []int{4, 5, 6})\n\t\tassert.Equal(t, []int{1, 2, 3}, result, \"Should return all elements from haystack when needle has no common elements\")\n\t})\n\n\tt.Run(\"with_duplicates\", func(t *testing.T) {\n\t\tresult := SliceDifference([]int{1, 2, 2, 3, 3, 3}, []int{2, 3})\n\t\tassert.Equal(t, []int{1}, result, \"Should handle duplicates correctly\")\n\t})\n\n\tt.Run(\"string_type\", func(t *testing.T) {\n\t\tresult := SliceDifference([]string{\"a\", \"b\", \"c\"}, []string{\"b\"})\n\t\tassert.Equal(t, []string{\"a\", \"c\"}, result, \"Should work with string type\")\n\t})\n}\n"
  },
  {
    "path": "internal/model/tag.go",
    "content": "package model\n\nimport (\n\t\"errors\"\n)\n\n// BookmarkTag is the relationship between a bookmark and a tag.\ntype BookmarkTag struct {\n\tBookmarkID int `db:\"bookmark_id\"`\n\tTagID      int `db:\"tag_id\"`\n}\n\n// Tag is the tag for a bookmark.\ntype Tag struct {\n\tID   int    `db:\"id\"          json:\"id\"`\n\tName string `db:\"name\"        json:\"name\"`\n}\n\n// TagDTO represents a tag in the application\ntype TagDTO struct {\n\tTag\n\tBookmarkCount int64 `db:\"bookmark_count\" json:\"bookmark_count\"` // Number of bookmarks with this tag\n\tDeleted       bool  `json:\"deleted\"`                            // Marks when a tag is deleted from a bookmark\n}\n\nfunc (t *Tag) ToDTO() TagDTO {\n\treturn TagDTO{\n\t\tTag: Tag{\n\t\t\tID:   t.ID,\n\t\t\tName: t.Name,\n\t\t},\n\t}\n}\n\nfunc (t *TagDTO) ToTag() Tag {\n\treturn Tag{\n\t\tID:   t.ID,\n\t\tName: t.Name,\n\t}\n}\n\n// ListTagsOptions is options for fetching tags from database.\ntype ListTagsOptions struct {\n\tBookmarkID        int\n\tWithBookmarkCount bool\n\tOrderBy           DBTagOrderBy\n\tSearch            string\n}\n\n// IsValid validates the ListTagsOptions.\n// Returns an error if the options are invalid, nil otherwise.\n// Currently, it checks that Search and BookmarkID are not used together.\nfunc (o ListTagsOptions) IsValid() error {\n\tif o.Search != \"\" && o.BookmarkID > 0 {\n\t\treturn errors.New(\"search and bookmark ID filtering cannot be used together\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/model/tag_test.go",
    "content": "package model\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestListTagsOptions_IsValid(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\toptions ListTagsOptions\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"valid options with search\",\n\t\t\toptions: ListTagsOptions{\n\t\t\t\tSearch:            \"test\",\n\t\t\t\tWithBookmarkCount: true,\n\t\t\t\tOrderBy:           DBTagOrderByTagName,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"valid options with bookmark ID\",\n\t\t\toptions: ListTagsOptions{\n\t\t\t\tBookmarkID:        123,\n\t\t\t\tWithBookmarkCount: true,\n\t\t\t\tOrderBy:           DBTagOrderByTagName,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid options with both search and bookmark ID\",\n\t\t\toptions: ListTagsOptions{\n\t\t\t\tSearch:            \"test\",\n\t\t\t\tBookmarkID:        123,\n\t\t\t\tWithBookmarkCount: true,\n\t\t\t\tOrderBy:           DBTagOrderByTagName,\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"valid options with neither search nor bookmark ID\",\n\t\t\toptions: ListTagsOptions{\n\t\t\t\tWithBookmarkCount: true,\n\t\t\t\tOrderBy:           DBTagOrderByTagName,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := tt.options.IsValid()\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Contains(t, err.Error(), \"search and bookmark ID filtering cannot be used together\")\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/model/validation.go",
    "content": "package model\n\n// ValidationError represents a validation error.\n// This errors are used in the domain layer to indicate an error that is caused generally\n// by the user and has to be sent back via the API or appropriate channel.\ntype ValidationError struct {\n\tField   string `json:\"field\"`\n\tMessage string `json:\"message\"`\n}\n\nfunc (v ValidationError) Error() string {\n\treturn v.Message\n}\n\nfunc NewValidationError(field, message string) ValidationError {\n\treturn ValidationError{\n\t\tField:   field,\n\t\tMessage: message,\n\t}\n}\n"
  },
  {
    "path": "internal/testutil/accounts.go",
    "content": "package testutil\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\n// NewAdminUser creates a new admin user and returns its account and token.\n// Use this when testing the API endpoints that require admin authentication to\n// generate the user and obtain a token that can be easily added as `WithAuthToken()`\n// option in the request.\nfunc NewAdminUser(deps model.Dependencies) (*model.AccountDTO, string, error) {\n\taccount, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{\n\t\tUsername: \"admin\",\n\t\tPassword: \"admin\",\n\t\tOwner:    model.Ptr(true),\n\t})\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\ttoken, err := deps.Domains().Auth().CreateTokenForAccount(account, time.Now().Add(time.Hour*24*365))\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\treturn account, token, nil\n}\n"
  },
  {
    "path": "internal/testutil/accounts_test.go",
    "content": "package testutil\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewAdminUser(t *testing.T) {\n\tctx := context.Background()\n\tlogger := logrus.New()\n\t_, deps := GetTestConfigurationAndDependencies(t, ctx, logger)\n\n\tt.Run(\"successful admin user creation\", func(t *testing.T) {\n\t\taccount, token, err := NewAdminUser(deps)\n\t\trequire.NoError(t, err)\n\t\trequire.NotEmpty(t, token)\n\t\trequire.NotNil(t, account)\n\t\trequire.Equal(t, \"admin\", account.Username)\n\t\trequire.True(t, *account.Owner)\n\n\t\t// Verify the token works\n\t\ttokenAccount, err := deps.Domains().Auth().CheckToken(ctx, token)\n\t\trequire.NoError(t, err)\n\t\trequire.NotNil(t, tokenAccount)\n\t\trequire.Equal(t, account.ID, tokenAccount.ID)\n\t\trequire.Equal(t, account.Username, tokenAccount.Username)\n\t\trequire.True(t, *tokenAccount.Owner)\n\t})\n\n\tt.Run(\"duplicate admin user creation\", func(t *testing.T) {\n\t\t// Try to create another admin user\n\t\taccount, token, err := NewAdminUser(deps)\n\t\trequire.Error(t, err)\n\t\trequire.Empty(t, token)\n\t\trequire.Nil(t, account)\n\t})\n}\n"
  },
  {
    "path": "internal/testutil/http.go",
    "content": "package testutil\n\nimport (\n\t\"io\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\n\t\"github.com/go-shiori/shiori/internal/http/webcontext\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n)\n\ntype Option = func(c model.WebContext)\n\n// NewTestWebContext creates a new WebContext with test recorder and request\nfunc NewTestWebContext() (model.WebContext, *httptest.ResponseRecorder) {\n\tw := httptest.NewRecorder()\n\tr := httptest.NewRequest(\"GET\", \"/\", nil)\n\treturn webcontext.NewWebContext(w, r), w\n}\n\n// NewTestWebContextWithMethod creates a new WebContext with specified method\nfunc NewTestWebContextWithMethod(method, path string, opts ...Option) (model.WebContext, *httptest.ResponseRecorder) {\n\tw := httptest.NewRecorder()\n\tr := httptest.NewRequest(method, path, nil)\n\tc := webcontext.NewWebContext(w, r)\n\tfor _, opt := range opts {\n\t\topt(c)\n\t}\n\treturn c, w\n}\n\nfunc WithBody(body string) Option {\n\treturn func(c model.WebContext) {\n\t\tc.Request().Body = io.NopCloser(strings.NewReader(body))\n\t}\n}\n\nfunc WithHeader(name, value string) Option {\n\treturn func(c model.WebContext) {\n\t\tc.Request().Header.Add(name, value)\n\t}\n}\n\n// WithAuthToken adds an authorization token to the request\nfunc WithAuthToken(token string) Option {\n\treturn func(c model.WebContext) {\n\t\tc.Request().Header.Add(model.AuthorizationHeader, model.AuthorizationTokenType+\" \"+token)\n\t}\n}\n\nfunc WithAccount(account *model.AccountDTO) Option {\n\treturn func(c model.WebContext) {\n\t\tc.SetAccount(account)\n\t}\n}\n\n// WithFakeAccount adds a fake account to the request context\nfunc WithFakeAccount(isAdmin bool) Option {\n\treturn func(c model.WebContext) {\n\t\tc.SetAccount(FakeAccount(isAdmin))\n\t}\n}\n\n// WithRequestPathValue adds a path value to the request\nfunc WithRequestPathValue(key, value string) Option {\n\treturn func(c model.WebContext) {\n\t\tc.Request().SetPathValue(key, value)\n\t}\n}\n\n// WithRequestQueryParam adds a query parameter to the request\nfunc WithRequestQueryParam(key, value string) Option {\n\treturn func(c model.WebContext) {\n\t\tq := c.Request().URL.Query()\n\t\tq.Add(key, value)\n\t\tc.Request().URL.RawQuery = q.Encode()\n\t}\n}\n\n// PerformRequest executes a request against a handler\nfunc PerformRequest(deps model.Dependencies, handler model.HttpHandler, method, path string, options ...Option) *httptest.ResponseRecorder {\n\tw := httptest.NewRecorder()\n\tr := httptest.NewRequest(method, path, nil)\n\tc := webcontext.NewWebContext(w, r)\n\tfor _, opt := range options {\n\t\topt(c)\n\t}\n\n\thandler(deps, c)\n\n\treturn w\n}\n\n// PerformRequestOnRecorder executes a request against a handler and returns the response recorder\nfunc PerformRequestOnRecorder(deps model.Dependencies, w *httptest.ResponseRecorder, handler model.HttpHandler, method, path string, options ...Option) {\n\tr := httptest.NewRequest(method, path, nil)\n\tc := webcontext.NewWebContext(w, r)\n\tfor _, opt := range options {\n\t\topt(c)\n\t}\n\thandler(deps, c)\n}\n\n// FakeAccount creates a fake account for testing\nfunc FakeAccount(isAdmin bool) *model.AccountDTO {\n\treturn &model.AccountDTO{\n\t\tID:       1,\n\t\tUsername: \"user\",\n\t\tOwner:    model.Ptr(isAdmin),\n\t}\n}\n\n// SetFakeUser sets a fake user account in the WebContext\nfunc SetFakeUser(c model.WebContext) {\n\tc.SetAccount(&model.AccountDTO{\n\t\tID:       1,\n\t\tUsername: \"user\",\n\t\tOwner:    model.Ptr(false),\n\t})\n}\n\n// SetFakeAdmin sets a fake admin account in the WebContext\nfunc SetFakeAdmin(c model.WebContext) {\n\tc.SetAccount(&model.AccountDTO{\n\t\tID:       1,\n\t\tUsername: \"user\",\n\t\tOwner:    model.Ptr(true),\n\t})\n}\n\n// WithFakeUser returns an Option that sets a fake user account\nfunc WithFakeUser() Option {\n\treturn WithFakeAccount(false)\n}\n\n// WithFakeAdmin returns an Option that sets a fake admin account\nfunc WithFakeAdmin() Option {\n\treturn WithFakeAccount(true)\n}\n\n// SetRequestPathValue sets a path value for the request\nfunc SetRequestPathValue(c model.WebContext, key, value string) {\n\tc.Request().SetPathValue(key, value)\n}\n"
  },
  {
    "path": "internal/testutil/response.go",
    "content": "package testutil\n\nimport (\n\t\"encoding/json\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/http/response\"\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype testResponse struct {\n\tResponse response.Response\n}\n\nfunc (r *testResponse) AssertMessageIsEmptyList(t *testing.T) {\n\tvar jsonData []any\n\terr := json.Unmarshal(r.Response.GetData().([]byte), &jsonData)\n\trequire.NoError(t, err)\n\trequire.Equal(t, []any{}, jsonData)\n}\n\nfunc (r *testResponse) AssertMessageIsNotEmptyList(t *testing.T) {\n\tvar jsonData []any\n\terr := json.Unmarshal(r.Response.GetData().([]byte), &jsonData)\n\trequire.NoError(t, err)\n\trequire.Greater(t, len(jsonData), 0)\n}\n\nfunc (r *testResponse) AssertMessageIsListLength(t *testing.T, length int) {\n\tvar jsonData []any\n\terr := json.Unmarshal(r.Response.GetData().([]byte), &jsonData)\n\trequire.NoError(t, err)\n\trequire.Len(t, jsonData, length)\n}\n\n// ForEach iterates over the items in the response and calls the provided function\n// with each item.\nfunc (r *testResponse) ForEach(t *testing.T, fn func(item map[string]any)) {\n\tvar jsonData []any\n\terr := json.Unmarshal(r.Response.GetData().([]byte), &jsonData)\n\trequire.NoError(t, err)\n\tfor _, item := range jsonData {\n\t\tfn(item.(map[string]any))\n\t}\n}\n\nfunc (r *testResponse) AssertNilMessage(t *testing.T) {\n\trequire.Equal(t, nil, r.Response.GetData())\n}\n\nfunc (r testResponse) AssertMessageEquals(t *testing.T, expected any) {\n\trequire.Equal(t, expected, r.Response.GetData())\n}\n\nfunc (r testResponse) AssertMessageJSONContains(t *testing.T, expected string) {\n\trequire.JSONEq(t, expected, string(r.Response.GetData().([]byte)))\n}\n\n// AssertMessageJSONContainsKey asserts that the response message contains a key\n// and returns the value of the key to be used in other comparisons depending on the\n// value type.\nfunc (r testResponse) AssertMessageJSONContainsKey(t *testing.T, key string) any {\n\tvar jsonData map[string]any\n\terr := json.Unmarshal(r.Response.GetData().([]byte), &jsonData)\n\trequire.NoError(t, err)\n\trequire.Contains(t, jsonData, key)\n\treturn jsonData[key]\n}\n\n// AssertMessageJSONKeyValue asserts that the response message contains a key\n// and calls the provided function with the value of the key to be used in other\n// comparisons depending on the value type.\nfunc (r *testResponse) AssertMessageJSONKeyValue(t *testing.T, key string, valueAssertFunc func(t *testing.T, value any)) {\n\tvalue := r.AssertMessageJSONContainsKey(t, key)\n\tvalueAssertFunc(t, value)\n}\n\nfunc (r *testResponse) AssertMessageContains(t *testing.T, expected string) {\n\trequire.Contains(t, r.Response.GetData(), expected)\n}\n\nfunc (r *testResponse) AssertMessageIsBytes(t *testing.T, expected []byte) {\n\trequire.Equal(t, expected, r.Response.GetData().([]byte))\n}\n\nfunc (r *testResponse) AssertOk(t *testing.T) {\n\trequire.False(t, r.Response.IsError())\n}\n\nfunc (r *testResponse) AssertNotOk(t *testing.T) {\n\trequire.True(t, r.Response.IsError())\n}\n\nfunc NewTestResponseFromRecorder(w *httptest.ResponseRecorder) *testResponse {\n\treturn &testResponse{Response: *response.NewResponse(w.Body.Bytes(), w.Code)}\n}\n"
  },
  {
    "path": "internal/testutil/shiori.go",
    "content": "package testutil\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/go-shiori/shiori/internal/config\"\n\t\"github.com/go-shiori/shiori/internal/database\"\n\t\"github.com/go-shiori/shiori/internal/dependencies\"\n\t\"github.com/go-shiori/shiori/internal/domains\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/gofrs/uuid/v5\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/spf13/afero\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc GetTestConfigurationAndDependencies(t *testing.T, ctx context.Context, logger *logrus.Logger) (*config.Config, *dependencies.Dependencies) {\n\tt.Helper()\n\n\ttmp, err := os.CreateTemp(\"\", \"\")\n\trequire.NoError(t, err)\n\tt.Cleanup(func() {\n\t\tos.Remove(tmp.Name())\n\t})\n\n\tcfg := config.ParseServerConfiguration(ctx, logger)\n\tcfg.Http.SecretKey = []byte(\"test\")\n\n\ttmpDir, err := os.MkdirTemp(\"\", \"\")\n\trequire.NoError(t, err)\n\n\tdb, err := database.OpenSQLiteDatabase(ctx, tmp.Name())\n\trequire.NoError(t, err)\n\trequire.NoError(t, db.Migrate(context.TODO()))\n\n\tcfg.Storage.DataDir = tmpDir\n\n\tdeps := dependencies.NewDependencies(logger, db, cfg)\n\tdeps.Domains().SetAccounts(domains.NewAccountsDomain(deps))\n\tdeps.Domains().SetArchiver(domains.NewArchiverDomain(deps))\n\tdeps.Domains().SetAuth(domains.NewAuthDomain(deps))\n\tdeps.Domains().SetBookmarks(domains.NewBookmarksDomain(deps))\n\tdeps.Domains().SetStorage(domains.NewStorageDomain(deps, afero.NewBasePathFs(afero.NewOsFs(), cfg.Storage.DataDir)))\n\tdeps.Domains().SetTags(domains.NewTagsDomain(deps))\n\n\treturn cfg, deps\n}\n\nfunc GetValidBookmark() *model.BookmarkDTO {\n\tuuidV4, _ := uuid.NewV4()\n\treturn &model.BookmarkDTO{\n\t\tURL:   \"https://github.com/go-shiori/shiori#\" + uuidV4.String(),\n\t\tTitle: \"Shiori repository\",\n\t}\n}\n\n// GetValidAccount returns a valid account for testing\n// It includes an ID to properly use the account when testing authentication methods\n// without interacting with the database.\nfunc GetValidAccount() *model.Account {\n\treturn &model.Account{\n\t\tID:       99,\n\t\tUsername: \"test\",\n\t\tPassword: \"test\",\n\t}\n}\n"
  },
  {
    "path": "internal/view/404.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <link href=\"assets/css/style.css\" rel=\"stylesheet\">\n    <title>Shiori</title>\n</head>\n<body>\n    Not found\n</body>\n</html>\n"
  },
  {
    "path": "internal/view/archive.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n    <base href=\"$$.RootPath$$\">\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>$$.Book.Title$$</title>\n    <link rel=\"stylesheet\" href=\"assets/css/archive.css\" />\n</head>\n\n<body class=\"archive\">\n    <div id=\"shiori-archive-header\" class=\"header\">\n        <p id=\"shiori-logo\"><span>栞</span>shiori</p>\n        <div class=\"spacer\"></div>\n        <a href=\"$$.Book.URL$$\" target=\"_blank\">View Original</a>\n        $$if .Book.HasContent$$\n        <a href=\"bookmark/$$.Book.ID$$/content\">View Readable</a>\n        $$end$$\n    </div>\n    <iframe src=\"bookmark/$$.Book.ID$$/archive/file/\" frameborder=\"0\"></iframe>\n</body>\n\n</html>\n"
  },
  {
    "path": "internal/view/assets/css/archive.css",
    "content": ":root{--main:#f44336;--border:#e5e5e5;--colorLink:#999;--archiveHeaderBg:rgba(255, 255, 255, 0.95)}@media (prefers-color-scheme:dark){:root{--border:#191919;--archiveHeaderBg:rgba(41, 41, 41, 0.95)}}body{padding:0;margin:0}*{box-sizing:border-box}body.archive{display:grid;grid-template-rows:minmax(1px,auto) 1fr;height:100dvh;width:100%}body.archive .header{display:flex;flex-flow:row wrap;height:60px;box-sizing:border-box;padding:0 16px;align-items:center;font-size:16px;border-bottom:1px solid var(--border);background-color:var(--archiveHeaderBg);grid-row:1}body.archive .header *{border-width:0;box-sizing:border-box;font-family:\"Source Sans Pro\",sans-serif;margin:0;padding:0}body.archive .header>:not(:last-child){margin-right:8px}body.archive .header>.spacer{flex:1}body.archive .header #shiori-logo{font-size:2em;font-weight:100;color:var(--main)}body.archive .header #shiori-logo span{margin-right:8px}body.archive .header a{display:block;color:var(--colorLink);text-decoration:underline}body.archive .header a:focus,body.archive .header a:hover{color:var(--main)}@media (max-width:600px){body.archive .header{font-size:14px;height:50px}body.archive .header #shiori-logo{font-size:1.5em}}body.archive iframe{width:100%;height:100%;border:none;grid-row:2}"
  },
  {
    "path": "internal/view/assets/css/style.css",
    "content": "@font-face{font-family:'Source Sans Pro';font-style:normal;font-weight:200;src:local('Source Sans Pro ExtraLight'),local('SourceSansPro-ExtraLight'),url(libs/fonts/source-sans-pro-v13-latin-200.woff2) format('woff2'),url(libs/fonts/source-sans-pro-v13-latin-200.woff) format('woff')}@font-face{font-family:'Source Sans Pro';font-style:normal;font-weight:400;src:local('Source Sans Pro Regular'),local('SourceSansPro-Regular'),url(libs/fonts/source-sans-pro-v13-latin-regular.woff2) format('woff2'),url(libs/fonts/source-sans-pro-v13-latin-regular.woff) format('woff')}@font-face{font-family:'Source Sans Pro';font-style:normal;font-weight:600;src:local('Source Sans Pro SemiBold'),local('SourceSansPro-SemiBold'),url(libs/fonts/source-sans-pro-v13-latin-600.woff2) format('woff2'),url(libs/fonts/source-sans-pro-v13-latin-600.woff) format('woff')}@font-face{font-family:'Source Sans Pro';font-style:normal;font-weight:700;src:local('Source Sans Pro Bold'),local('SourceSansPro-Bold'),url(libs/fonts/source-sans-pro-v13-latin-700.woff2) format('woff2'),url(libs/fonts/source-sans-pro-v13-latin-700.woff) format('woff')}.fa,.fab,.fal,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-.0667em}.fa-xs{font-size:.75em}.fa-sm{font-size:.875em}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:2.5em;padding-left:0}.fa-ul>li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:.08em solid #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{animation:fa-spin 2s infinite linear}.fa-pulse{animation:fa-spin 1s infinite steps(8)}@keyframes fa-spin{0%{transform:rotate(0)}to{transform:rotate(1turn)}}.fa-rotate-90{transform:rotate(90deg)}.fa-rotate-180{transform:rotate(180deg)}.fa-rotate-270{transform:rotate(270deg)}.fa-flip-horizontal{transform:scaleX(-1)}.fa-flip-vertical{transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{transform:scale(-1)}:root .fa-flip-both,:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-rotate-90{filter:none}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-500px:before{content:\"\\f26e\"}.fa-accessible-icon:before{content:\"\\f368\"}.fa-accusoft:before{content:\"\\f369\"}.fa-acquisitions-incorporated:before{content:\"\\f6af\"}.fa-ad:before{content:\"\\f641\"}.fa-address-book:before{content:\"\\f2b9\"}.fa-address-card:before{content:\"\\f2bb\"}.fa-adjust:before{content:\"\\f042\"}.fa-adn:before{content:\"\\f170\"}.fa-adobe:before{content:\"\\f778\"}.fa-adversal:before{content:\"\\f36a\"}.fa-affiliatetheme:before{content:\"\\f36b\"}.fa-air-freshener:before{content:\"\\f5d0\"}.fa-algolia:before{content:\"\\f36c\"}.fa-align-center:before{content:\"\\f037\"}.fa-align-justify:before{content:\"\\f039\"}.fa-align-left:before{content:\"\\f036\"}.fa-align-right:before{content:\"\\f038\"}.fa-alipay:before{content:\"\\f642\"}.fa-allergies:before{content:\"\\f461\"}.fa-amazon:before{content:\"\\f270\"}.fa-amazon-pay:before{content:\"\\f42c\"}.fa-ambulance:before{content:\"\\f0f9\"}.fa-american-sign-language-interpreting:before{content:\"\\f2a3\"}.fa-amilia:before{content:\"\\f36d\"}.fa-anchor:before{content:\"\\f13d\"}.fa-android:before{content:\"\\f17b\"}.fa-angellist:before{content:\"\\f209\"}.fa-angle-double-down:before{content:\"\\f103\"}.fa-angle-double-left:before{content:\"\\f100\"}.fa-angle-double-right:before{content:\"\\f101\"}.fa-angle-double-up:before{content:\"\\f102\"}.fa-angle-down:before{content:\"\\f107\"}.fa-angle-left:before{content:\"\\f104\"}.fa-angle-right:before{content:\"\\f105\"}.fa-angle-up:before{content:\"\\f106\"}.fa-angry:before{content:\"\\f556\"}.fa-angrycreative:before{content:\"\\f36e\"}.fa-angular:before{content:\"\\f420\"}.fa-ankh:before{content:\"\\f644\"}.fa-app-store:before{content:\"\\f36f\"}.fa-app-store-ios:before{content:\"\\f370\"}.fa-apper:before{content:\"\\f371\"}.fa-apple:before{content:\"\\f179\"}.fa-apple-alt:before{content:\"\\f5d1\"}.fa-apple-pay:before{content:\"\\f415\"}.fa-archive:before{content:\"\\f187\"}.fa-archway:before{content:\"\\f557\"}.fa-arrow-alt-circle-down:before{content:\"\\f358\"}.fa-arrow-alt-circle-left:before{content:\"\\f359\"}.fa-arrow-alt-circle-right:before{content:\"\\f35a\"}.fa-arrow-alt-circle-up:before{content:\"\\f35b\"}.fa-arrow-circle-down:before{content:\"\\f0ab\"}.fa-arrow-circle-left:before{content:\"\\f0a8\"}.fa-arrow-circle-right:before{content:\"\\f0a9\"}.fa-arrow-circle-up:before{content:\"\\f0aa\"}.fa-arrow-down:before{content:\"\\f063\"}.fa-arrow-left:before{content:\"\\f060\"}.fa-arrow-right:before{content:\"\\f061\"}.fa-arrow-up:before{content:\"\\f062\"}.fa-arrows-alt:before{content:\"\\f0b2\"}.fa-arrows-alt-h:before{content:\"\\f337\"}.fa-arrows-alt-v:before{content:\"\\f338\"}.fa-artstation:before{content:\"\\f77a\"}.fa-assistive-listening-systems:before{content:\"\\f2a2\"}.fa-asterisk:before{content:\"\\f069\"}.fa-asymmetrik:before{content:\"\\f372\"}.fa-at:before{content:\"\\f1fa\"}.fa-atlas:before{content:\"\\f558\"}.fa-atlassian:before{content:\"\\f77b\"}.fa-atom:before{content:\"\\f5d2\"}.fa-audible:before{content:\"\\f373\"}.fa-audio-description:before{content:\"\\f29e\"}.fa-autoprefixer:before{content:\"\\f41c\"}.fa-avianex:before{content:\"\\f374\"}.fa-aviato:before{content:\"\\f421\"}.fa-award:before{content:\"\\f559\"}.fa-aws:before{content:\"\\f375\"}.fa-baby:before{content:\"\\f77c\"}.fa-baby-carriage:before{content:\"\\f77d\"}.fa-backspace:before{content:\"\\f55a\"}.fa-backward:before{content:\"\\f04a\"}.fa-bacon:before{content:\"\\f7e5\"}.fa-balance-scale:before{content:\"\\f24e\"}.fa-ban:before{content:\"\\f05e\"}.fa-band-aid:before{content:\"\\f462\"}.fa-bandcamp:before{content:\"\\f2d5\"}.fa-barcode:before{content:\"\\f02a\"}.fa-bars:before{content:\"\\f0c9\"}.fa-baseball-ball:before{content:\"\\f433\"}.fa-basketball-ball:before{content:\"\\f434\"}.fa-bath:before{content:\"\\f2cd\"}.fa-battery-empty:before{content:\"\\f244\"}.fa-battery-full:before{content:\"\\f240\"}.fa-battery-half:before{content:\"\\f242\"}.fa-battery-quarter:before{content:\"\\f243\"}.fa-battery-three-quarters:before{content:\"\\f241\"}.fa-bed:before{content:\"\\f236\"}.fa-beer:before{content:\"\\f0fc\"}.fa-behance:before{content:\"\\f1b4\"}.fa-behance-square:before{content:\"\\f1b5\"}.fa-bell:before{content:\"\\f0f3\"}.fa-bell-slash:before{content:\"\\f1f6\"}.fa-bezier-curve:before{content:\"\\f55b\"}.fa-bible:before{content:\"\\f647\"}.fa-bicycle:before{content:\"\\f206\"}.fa-bimobject:before{content:\"\\f378\"}.fa-binoculars:before{content:\"\\f1e5\"}.fa-biohazard:before{content:\"\\f780\"}.fa-birthday-cake:before{content:\"\\f1fd\"}.fa-bitbucket:before{content:\"\\f171\"}.fa-bitcoin:before{content:\"\\f379\"}.fa-bity:before{content:\"\\f37a\"}.fa-black-tie:before{content:\"\\f27e\"}.fa-blackberry:before{content:\"\\f37b\"}.fa-blender:before{content:\"\\f517\"}.fa-blender-phone:before{content:\"\\f6b6\"}.fa-blind:before{content:\"\\f29d\"}.fa-blog:before{content:\"\\f781\"}.fa-blogger:before{content:\"\\f37c\"}.fa-blogger-b:before{content:\"\\f37d\"}.fa-bluetooth:before{content:\"\\f293\"}.fa-bluetooth-b:before{content:\"\\f294\"}.fa-bold:before{content:\"\\f032\"}.fa-bolt:before{content:\"\\f0e7\"}.fa-bomb:before{content:\"\\f1e2\"}.fa-bone:before{content:\"\\f5d7\"}.fa-bong:before{content:\"\\f55c\"}.fa-book:before{content:\"\\f02d\"}.fa-book-dead:before{content:\"\\f6b7\"}.fa-book-medical:before{content:\"\\f7e6\"}.fa-book-open:before{content:\"\\f518\"}.fa-book-reader:before{content:\"\\f5da\"}.fa-bookmark:before{content:\"\\f02e\"}.fa-bowling-ball:before{content:\"\\f436\"}.fa-box:before{content:\"\\f466\"}.fa-box-open:before{content:\"\\f49e\"}.fa-boxes:before{content:\"\\f468\"}.fa-braille:before{content:\"\\f2a1\"}.fa-brain:before{content:\"\\f5dc\"}.fa-bread-slice:before{content:\"\\f7ec\"}.fa-briefcase:before{content:\"\\f0b1\"}.fa-briefcase-medical:before{content:\"\\f469\"}.fa-broadcast-tower:before{content:\"\\f519\"}.fa-broom:before{content:\"\\f51a\"}.fa-brush:before{content:\"\\f55d\"}.fa-btc:before{content:\"\\f15a\"}.fa-bug:before{content:\"\\f188\"}.fa-building:before{content:\"\\f1ad\"}.fa-bullhorn:before{content:\"\\f0a1\"}.fa-bullseye:before{content:\"\\f140\"}.fa-burn:before{content:\"\\f46a\"}.fa-buromobelexperte:before{content:\"\\f37f\"}.fa-bus:before{content:\"\\f207\"}.fa-bus-alt:before{content:\"\\f55e\"}.fa-business-time:before{content:\"\\f64a\"}.fa-buysellads:before{content:\"\\f20d\"}.fa-calculator:before{content:\"\\f1ec\"}.fa-calendar:before{content:\"\\f133\"}.fa-calendar-alt:before{content:\"\\f073\"}.fa-calendar-check:before{content:\"\\f274\"}.fa-calendar-day:before{content:\"\\f783\"}.fa-calendar-minus:before{content:\"\\f272\"}.fa-calendar-plus:before{content:\"\\f271\"}.fa-calendar-times:before{content:\"\\f273\"}.fa-calendar-week:before{content:\"\\f784\"}.fa-camera:before{content:\"\\f030\"}.fa-camera-retro:before{content:\"\\f083\"}.fa-campground:before{content:\"\\f6bb\"}.fa-canadian-maple-leaf:before{content:\"\\f785\"}.fa-candy-cane:before{content:\"\\f786\"}.fa-cannabis:before{content:\"\\f55f\"}.fa-capsules:before{content:\"\\f46b\"}.fa-car:before{content:\"\\f1b9\"}.fa-car-alt:before{content:\"\\f5de\"}.fa-car-battery:before{content:\"\\f5df\"}.fa-car-crash:before{content:\"\\f5e1\"}.fa-car-side:before{content:\"\\f5e4\"}.fa-caret-down:before{content:\"\\f0d7\"}.fa-caret-left:before{content:\"\\f0d9\"}.fa-caret-right:before{content:\"\\f0da\"}.fa-caret-square-down:before{content:\"\\f150\"}.fa-caret-square-left:before{content:\"\\f191\"}.fa-caret-square-right:before{content:\"\\f152\"}.fa-caret-square-up:before{content:\"\\f151\"}.fa-caret-up:before{content:\"\\f0d8\"}.fa-carrot:before{content:\"\\f787\"}.fa-cart-arrow-down:before{content:\"\\f218\"}.fa-cart-plus:before{content:\"\\f217\"}.fa-cash-register:before{content:\"\\f788\"}.fa-cat:before{content:\"\\f6be\"}.fa-cc-amazon-pay:before{content:\"\\f42d\"}.fa-cc-amex:before{content:\"\\f1f3\"}.fa-cc-apple-pay:before{content:\"\\f416\"}.fa-cc-diners-club:before{content:\"\\f24c\"}.fa-cc-discover:before{content:\"\\f1f2\"}.fa-cc-jcb:before{content:\"\\f24b\"}.fa-cc-mastercard:before{content:\"\\f1f1\"}.fa-cc-paypal:before{content:\"\\f1f4\"}.fa-cc-stripe:before{content:\"\\f1f5\"}.fa-cc-visa:before{content:\"\\f1f0\"}.fa-centercode:before{content:\"\\f380\"}.fa-centos:before{content:\"\\f789\"}.fa-certificate:before{content:\"\\f0a3\"}.fa-chair:before{content:\"\\f6c0\"}.fa-chalkboard:before{content:\"\\f51b\"}.fa-chalkboard-teacher:before{content:\"\\f51c\"}.fa-charging-station:before{content:\"\\f5e7\"}.fa-chart-area:before{content:\"\\f1fe\"}.fa-chart-bar:before{content:\"\\f080\"}.fa-chart-line:before{content:\"\\f201\"}.fa-chart-pie:before{content:\"\\f200\"}.fa-check:before{content:\"\\f00c\"}.fa-check-circle:before{content:\"\\f058\"}.fa-check-double:before{content:\"\\f560\"}.fa-check-square:before{content:\"\\f14a\"}.fa-cheese:before{content:\"\\f7ef\"}.fa-chess:before{content:\"\\f439\"}.fa-chess-bishop:before{content:\"\\f43a\"}.fa-chess-board:before{content:\"\\f43c\"}.fa-chess-king:before{content:\"\\f43f\"}.fa-chess-knight:before{content:\"\\f441\"}.fa-chess-pawn:before{content:\"\\f443\"}.fa-chess-queen:before{content:\"\\f445\"}.fa-chess-rook:before{content:\"\\f447\"}.fa-chevron-circle-down:before{content:\"\\f13a\"}.fa-chevron-circle-left:before{content:\"\\f137\"}.fa-chevron-circle-right:before{content:\"\\f138\"}.fa-chevron-circle-up:before{content:\"\\f139\"}.fa-chevron-down:before{content:\"\\f078\"}.fa-chevron-left:before{content:\"\\f053\"}.fa-chevron-right:before{content:\"\\f054\"}.fa-chevron-up:before{content:\"\\f077\"}.fa-child:before{content:\"\\f1ae\"}.fa-chrome:before{content:\"\\f268\"}.fa-church:before{content:\"\\f51d\"}.fa-circle:before{content:\"\\f111\"}.fa-circle-notch:before{content:\"\\f1ce\"}.fa-city:before{content:\"\\f64f\"}.fa-clinic-medical:before{content:\"\\f7f2\"}.fa-clipboard:before{content:\"\\f328\"}.fa-clipboard-check:before{content:\"\\f46c\"}.fa-clipboard-list:before{content:\"\\f46d\"}.fa-clock:before{content:\"\\f017\"}.fa-clone:before{content:\"\\f24d\"}.fa-closed-captioning:before{content:\"\\f20a\"}.fa-cloud:before{content:\"\\f0c2\"}.fa-cloud-download-alt:before{content:\"\\f381\"}.fa-cloud-meatball:before{content:\"\\f73b\"}.fa-cloud-moon:before{content:\"\\f6c3\"}.fa-cloud-moon-rain:before{content:\"\\f73c\"}.fa-cloud-rain:before{content:\"\\f73d\"}.fa-cloud-showers-heavy:before{content:\"\\f740\"}.fa-cloud-sun:before{content:\"\\f6c4\"}.fa-cloud-sun-rain:before{content:\"\\f743\"}.fa-cloud-upload-alt:before{content:\"\\f382\"}.fa-cloudscale:before{content:\"\\f383\"}.fa-cloudsmith:before{content:\"\\f384\"}.fa-cloudversify:before{content:\"\\f385\"}.fa-cocktail:before{content:\"\\f561\"}.fa-code:before{content:\"\\f121\"}.fa-code-branch:before{content:\"\\f126\"}.fa-codepen:before{content:\"\\f1cb\"}.fa-codiepie:before{content:\"\\f284\"}.fa-coffee:before{content:\"\\f0f4\"}.fa-cog:before{content:\"\\f013\"}.fa-cogs:before{content:\"\\f085\"}.fa-coins:before{content:\"\\f51e\"}.fa-columns:before{content:\"\\f0db\"}.fa-comment:before{content:\"\\f075\"}.fa-comment-alt:before{content:\"\\f27a\"}.fa-comment-dollar:before{content:\"\\f651\"}.fa-comment-dots:before{content:\"\\f4ad\"}.fa-comment-medical:before{content:\"\\f7f5\"}.fa-comment-slash:before{content:\"\\f4b3\"}.fa-comments:before{content:\"\\f086\"}.fa-comments-dollar:before{content:\"\\f653\"}.fa-compact-disc:before{content:\"\\f51f\"}.fa-compass:before{content:\"\\f14e\"}.fa-compress:before{content:\"\\f066\"}.fa-compress-arrows-alt:before{content:\"\\f78c\"}.fa-concierge-bell:before{content:\"\\f562\"}.fa-confluence:before{content:\"\\f78d\"}.fa-connectdevelop:before{content:\"\\f20e\"}.fa-contao:before{content:\"\\f26d\"}.fa-cookie:before{content:\"\\f563\"}.fa-cookie-bite:before{content:\"\\f564\"}.fa-copy:before{content:\"\\f0c5\"}.fa-copyright:before{content:\"\\f1f9\"}.fa-couch:before{content:\"\\f4b8\"}.fa-cpanel:before{content:\"\\f388\"}.fa-creative-commons:before{content:\"\\f25e\"}.fa-creative-commons-by:before{content:\"\\f4e7\"}.fa-creative-commons-nc:before{content:\"\\f4e8\"}.fa-creative-commons-nc-eu:before{content:\"\\f4e9\"}.fa-creative-commons-nc-jp:before{content:\"\\f4ea\"}.fa-creative-commons-nd:before{content:\"\\f4eb\"}.fa-creative-commons-pd:before{content:\"\\f4ec\"}.fa-creative-commons-pd-alt:before{content:\"\\f4ed\"}.fa-creative-commons-remix:before{content:\"\\f4ee\"}.fa-creative-commons-sa:before{content:\"\\f4ef\"}.fa-creative-commons-sampling:before{content:\"\\f4f0\"}.fa-creative-commons-sampling-plus:before{content:\"\\f4f1\"}.fa-creative-commons-share:before{content:\"\\f4f2\"}.fa-creative-commons-zero:before{content:\"\\f4f3\"}.fa-credit-card:before{content:\"\\f09d\"}.fa-critical-role:before{content:\"\\f6c9\"}.fa-crop:before{content:\"\\f125\"}.fa-crop-alt:before{content:\"\\f565\"}.fa-cross:before{content:\"\\f654\"}.fa-crosshairs:before{content:\"\\f05b\"}.fa-crow:before{content:\"\\f520\"}.fa-crown:before{content:\"\\f521\"}.fa-crutch:before{content:\"\\f7f7\"}.fa-css3:before{content:\"\\f13c\"}.fa-css3-alt:before{content:\"\\f38b\"}.fa-cube:before{content:\"\\f1b2\"}.fa-cubes:before{content:\"\\f1b3\"}.fa-cut:before{content:\"\\f0c4\"}.fa-cuttlefish:before{content:\"\\f38c\"}.fa-d-and-d:before{content:\"\\f38d\"}.fa-d-and-d-beyond:before{content:\"\\f6ca\"}.fa-dashcube:before{content:\"\\f210\"}.fa-database:before{content:\"\\f1c0\"}.fa-deaf:before{content:\"\\f2a4\"}.fa-delicious:before{content:\"\\f1a5\"}.fa-democrat:before{content:\"\\f747\"}.fa-deploydog:before{content:\"\\f38e\"}.fa-deskpro:before{content:\"\\f38f\"}.fa-desktop:before{content:\"\\f108\"}.fa-dev:before{content:\"\\f6cc\"}.fa-deviantart:before{content:\"\\f1bd\"}.fa-dharmachakra:before{content:\"\\f655\"}.fa-dhl:before{content:\"\\f790\"}.fa-diagnoses:before{content:\"\\f470\"}.fa-diaspora:before{content:\"\\f791\"}.fa-dice:before{content:\"\\f522\"}.fa-dice-d20:before{content:\"\\f6cf\"}.fa-dice-d6:before{content:\"\\f6d1\"}.fa-dice-five:before{content:\"\\f523\"}.fa-dice-four:before{content:\"\\f524\"}.fa-dice-one:before{content:\"\\f525\"}.fa-dice-six:before{content:\"\\f526\"}.fa-dice-three:before{content:\"\\f527\"}.fa-dice-two:before{content:\"\\f528\"}.fa-digg:before{content:\"\\f1a6\"}.fa-digital-ocean:before{content:\"\\f391\"}.fa-digital-tachograph:before{content:\"\\f566\"}.fa-directions:before{content:\"\\f5eb\"}.fa-discord:before{content:\"\\f392\"}.fa-discourse:before{content:\"\\f393\"}.fa-divide:before{content:\"\\f529\"}.fa-dizzy:before{content:\"\\f567\"}.fa-dna:before{content:\"\\f471\"}.fa-dochub:before{content:\"\\f394\"}.fa-docker:before{content:\"\\f395\"}.fa-dog:before{content:\"\\f6d3\"}.fa-dollar-sign:before{content:\"\\f155\"}.fa-dolly:before{content:\"\\f472\"}.fa-dolly-flatbed:before{content:\"\\f474\"}.fa-donate:before{content:\"\\f4b9\"}.fa-door-closed:before{content:\"\\f52a\"}.fa-door-open:before{content:\"\\f52b\"}.fa-dot-circle:before{content:\"\\f192\"}.fa-dove:before{content:\"\\f4ba\"}.fa-download:before{content:\"\\f019\"}.fa-draft2digital:before{content:\"\\f396\"}.fa-drafting-compass:before{content:\"\\f568\"}.fa-dragon:before{content:\"\\f6d5\"}.fa-draw-polygon:before{content:\"\\f5ee\"}.fa-dribbble:before{content:\"\\f17d\"}.fa-dribbble-square:before{content:\"\\f397\"}.fa-dropbox:before{content:\"\\f16b\"}.fa-drum:before{content:\"\\f569\"}.fa-drum-steelpan:before{content:\"\\f56a\"}.fa-drumstick-bite:before{content:\"\\f6d7\"}.fa-drupal:before{content:\"\\f1a9\"}.fa-dumbbell:before{content:\"\\f44b\"}.fa-dumpster:before{content:\"\\f793\"}.fa-dumpster-fire:before{content:\"\\f794\"}.fa-dungeon:before{content:\"\\f6d9\"}.fa-dyalog:before{content:\"\\f399\"}.fa-earlybirds:before{content:\"\\f39a\"}.fa-ebay:before{content:\"\\f4f4\"}.fa-edge:before{content:\"\\f282\"}.fa-edit:before{content:\"\\f044\"}.fa-egg:before{content:\"\\f7fb\"}.fa-eject:before{content:\"\\f052\"}.fa-elementor:before{content:\"\\f430\"}.fa-ellipsis-h:before{content:\"\\f141\"}.fa-ellipsis-v:before{content:\"\\f142\"}.fa-ello:before{content:\"\\f5f1\"}.fa-ember:before{content:\"\\f423\"}.fa-empire:before{content:\"\\f1d1\"}.fa-envelope:before{content:\"\\f0e0\"}.fa-envelope-open:before{content:\"\\f2b6\"}.fa-envelope-open-text:before{content:\"\\f658\"}.fa-envelope-square:before{content:\"\\f199\"}.fa-envira:before{content:\"\\f299\"}.fa-equals:before{content:\"\\f52c\"}.fa-eraser:before{content:\"\\f12d\"}.fa-erlang:before{content:\"\\f39d\"}.fa-ethereum:before{content:\"\\f42e\"}.fa-ethernet:before{content:\"\\f796\"}.fa-etsy:before{content:\"\\f2d7\"}.fa-euro-sign:before{content:\"\\f153\"}.fa-exchange-alt:before{content:\"\\f362\"}.fa-exclamation:before{content:\"\\f12a\"}.fa-exclamation-circle:before{content:\"\\f06a\"}.fa-exclamation-triangle:before{content:\"\\f071\"}.fa-expand:before{content:\"\\f065\"}.fa-expand-arrows-alt:before{content:\"\\f31e\"}.fa-expeditedssl:before{content:\"\\f23e\"}.fa-external-link-alt:before{content:\"\\f35d\"}.fa-external-link-square-alt:before{content:\"\\f360\"}.fa-eye:before{content:\"\\f06e\"}.fa-eye-dropper:before{content:\"\\f1fb\"}.fa-eye-slash:before{content:\"\\f070\"}.fa-facebook:before{content:\"\\f09a\"}.fa-facebook-f:before{content:\"\\f39e\"}.fa-facebook-messenger:before{content:\"\\f39f\"}.fa-facebook-square:before{content:\"\\f082\"}.fa-fantasy-flight-games:before{content:\"\\f6dc\"}.fa-fast-backward:before{content:\"\\f049\"}.fa-fast-forward:before{content:\"\\f050\"}.fa-fax:before{content:\"\\f1ac\"}.fa-feather:before{content:\"\\f52d\"}.fa-feather-alt:before{content:\"\\f56b\"}.fa-fedex:before{content:\"\\f797\"}.fa-fedora:before{content:\"\\f798\"}.fa-female:before{content:\"\\f182\"}.fa-fighter-jet:before{content:\"\\f0fb\"}.fa-figma:before{content:\"\\f799\"}.fa-file:before{content:\"\\f15b\"}.fa-file-alt:before{content:\"\\f15c\"}.fa-file-archive:before{content:\"\\f1c6\"}.fa-file-audio:before{content:\"\\f1c7\"}.fa-file-code:before{content:\"\\f1c9\"}.fa-file-contract:before{content:\"\\f56c\"}.fa-file-csv:before{content:\"\\f6dd\"}.fa-file-download:before{content:\"\\f56d\"}.fa-file-excel:before{content:\"\\f1c3\"}.fa-file-export:before{content:\"\\f56e\"}.fa-file-image:before{content:\"\\f1c5\"}.fa-file-import:before{content:\"\\f56f\"}.fa-file-invoice:before{content:\"\\f570\"}.fa-file-invoice-dollar:before{content:\"\\f571\"}.fa-file-medical:before{content:\"\\f477\"}.fa-file-medical-alt:before{content:\"\\f478\"}.fa-file-pdf:before{content:\"\\f1c1\"}.fa-file-powerpoint:before{content:\"\\f1c4\"}.fa-file-prescription:before{content:\"\\f572\"}.fa-file-signature:before{content:\"\\f573\"}.fa-file-upload:before{content:\"\\f574\"}.fa-file-video:before{content:\"\\f1c8\"}.fa-file-word:before{content:\"\\f1c2\"}.fa-fill:before{content:\"\\f575\"}.fa-fill-drip:before{content:\"\\f576\"}.fa-film:before{content:\"\\f008\"}.fa-filter:before{content:\"\\f0b0\"}.fa-fingerprint:before{content:\"\\f577\"}.fa-fire:before{content:\"\\f06d\"}.fa-fire-alt:before{content:\"\\f7e4\"}.fa-fire-extinguisher:before{content:\"\\f134\"}.fa-firefox:before{content:\"\\f269\"}.fa-first-aid:before{content:\"\\f479\"}.fa-first-order:before{content:\"\\f2b0\"}.fa-first-order-alt:before{content:\"\\f50a\"}.fa-firstdraft:before{content:\"\\f3a1\"}.fa-fish:before{content:\"\\f578\"}.fa-fist-raised:before{content:\"\\f6de\"}.fa-flag:before{content:\"\\f024\"}.fa-flag-checkered:before{content:\"\\f11e\"}.fa-flag-usa:before{content:\"\\f74d\"}.fa-flask:before{content:\"\\f0c3\"}.fa-flickr:before{content:\"\\f16e\"}.fa-flipboard:before{content:\"\\f44d\"}.fa-flushed:before{content:\"\\f579\"}.fa-fly:before{content:\"\\f417\"}.fa-folder:before{content:\"\\f07b\"}.fa-folder-minus:before{content:\"\\f65d\"}.fa-folder-open:before{content:\"\\f07c\"}.fa-folder-plus:before{content:\"\\f65e\"}.fa-font:before{content:\"\\f031\"}.fa-font-awesome:before{content:\"\\f2b4\"}.fa-font-awesome-alt:before{content:\"\\f35c\"}.fa-font-awesome-flag:before{content:\"\\f425\"}.fa-font-awesome-logo-full:before{content:\"\\f4e6\"}.fa-fonticons:before{content:\"\\f280\"}.fa-fonticons-fi:before{content:\"\\f3a2\"}.fa-football-ball:before{content:\"\\f44e\"}.fa-fort-awesome:before{content:\"\\f286\"}.fa-fort-awesome-alt:before{content:\"\\f3a3\"}.fa-forumbee:before{content:\"\\f211\"}.fa-forward:before{content:\"\\f04e\"}.fa-foursquare:before{content:\"\\f180\"}.fa-free-code-camp:before{content:\"\\f2c5\"}.fa-freebsd:before{content:\"\\f3a4\"}.fa-frog:before{content:\"\\f52e\"}.fa-frown:before{content:\"\\f119\"}.fa-frown-open:before{content:\"\\f57a\"}.fa-fulcrum:before{content:\"\\f50b\"}.fa-funnel-dollar:before{content:\"\\f662\"}.fa-futbol:before{content:\"\\f1e3\"}.fa-galactic-republic:before{content:\"\\f50c\"}.fa-galactic-senate:before{content:\"\\f50d\"}.fa-gamepad:before{content:\"\\f11b\"}.fa-gas-pump:before{content:\"\\f52f\"}.fa-gavel:before{content:\"\\f0e3\"}.fa-gem:before{content:\"\\f3a5\"}.fa-genderless:before{content:\"\\f22d\"}.fa-get-pocket:before{content:\"\\f265\"}.fa-gg:before{content:\"\\f260\"}.fa-gg-circle:before{content:\"\\f261\"}.fa-ghost:before{content:\"\\f6e2\"}.fa-gift:before{content:\"\\f06b\"}.fa-gifts:before{content:\"\\f79c\"}.fa-git:before{content:\"\\f1d3\"}.fa-git-square:before{content:\"\\f1d2\"}.fa-github:before{content:\"\\f09b\"}.fa-github-alt:before{content:\"\\f113\"}.fa-github-square:before{content:\"\\f092\"}.fa-gitkraken:before{content:\"\\f3a6\"}.fa-gitlab:before{content:\"\\f296\"}.fa-gitter:before{content:\"\\f426\"}.fa-glass-cheers:before{content:\"\\f79f\"}.fa-glass-martini:before{content:\"\\f000\"}.fa-glass-martini-alt:before{content:\"\\f57b\"}.fa-glass-whiskey:before{content:\"\\f7a0\"}.fa-glasses:before{content:\"\\f530\"}.fa-glide:before{content:\"\\f2a5\"}.fa-glide-g:before{content:\"\\f2a6\"}.fa-globe:before{content:\"\\f0ac\"}.fa-globe-africa:before{content:\"\\f57c\"}.fa-globe-americas:before{content:\"\\f57d\"}.fa-globe-asia:before{content:\"\\f57e\"}.fa-globe-europe:before{content:\"\\f7a2\"}.fa-gofore:before{content:\"\\f3a7\"}.fa-golf-ball:before{content:\"\\f450\"}.fa-goodreads:before{content:\"\\f3a8\"}.fa-goodreads-g:before{content:\"\\f3a9\"}.fa-google:before{content:\"\\f1a0\"}.fa-google-drive:before{content:\"\\f3aa\"}.fa-google-play:before{content:\"\\f3ab\"}.fa-google-plus:before{content:\"\\f2b3\"}.fa-google-plus-g:before{content:\"\\f0d5\"}.fa-google-plus-square:before{content:\"\\f0d4\"}.fa-google-wallet:before{content:\"\\f1ee\"}.fa-gopuram:before{content:\"\\f664\"}.fa-graduation-cap:before{content:\"\\f19d\"}.fa-gratipay:before{content:\"\\f184\"}.fa-grav:before{content:\"\\f2d6\"}.fa-greater-than:before{content:\"\\f531\"}.fa-greater-than-equal:before{content:\"\\f532\"}.fa-grimace:before{content:\"\\f57f\"}.fa-grin:before{content:\"\\f580\"}.fa-grin-alt:before{content:\"\\f581\"}.fa-grin-beam:before{content:\"\\f582\"}.fa-grin-beam-sweat:before{content:\"\\f583\"}.fa-grin-hearts:before{content:\"\\f584\"}.fa-grin-squint:before{content:\"\\f585\"}.fa-grin-squint-tears:before{content:\"\\f586\"}.fa-grin-stars:before{content:\"\\f587\"}.fa-grin-tears:before{content:\"\\f588\"}.fa-grin-tongue:before{content:\"\\f589\"}.fa-grin-tongue-squint:before{content:\"\\f58a\"}.fa-grin-tongue-wink:before{content:\"\\f58b\"}.fa-grin-wink:before{content:\"\\f58c\"}.fa-grip-horizontal:before{content:\"\\f58d\"}.fa-grip-lines:before{content:\"\\f7a4\"}.fa-grip-lines-vertical:before{content:\"\\f7a5\"}.fa-grip-vertical:before{content:\"\\f58e\"}.fa-gripfire:before{content:\"\\f3ac\"}.fa-grunt:before{content:\"\\f3ad\"}.fa-guitar:before{content:\"\\f7a6\"}.fa-gulp:before{content:\"\\f3ae\"}.fa-h-square:before{content:\"\\f0fd\"}.fa-hacker-news:before{content:\"\\f1d4\"}.fa-hacker-news-square:before{content:\"\\f3af\"}.fa-hackerrank:before{content:\"\\f5f7\"}.fa-hamburger:before{content:\"\\f805\"}.fa-hammer:before{content:\"\\f6e3\"}.fa-hamsa:before{content:\"\\f665\"}.fa-hand-holding:before{content:\"\\f4bd\"}.fa-hand-holding-heart:before{content:\"\\f4be\"}.fa-hand-holding-usd:before{content:\"\\f4c0\"}.fa-hand-lizard:before{content:\"\\f258\"}.fa-hand-middle-finger:before{content:\"\\f806\"}.fa-hand-paper:before{content:\"\\f256\"}.fa-hand-peace:before{content:\"\\f25b\"}.fa-hand-point-down:before{content:\"\\f0a7\"}.fa-hand-point-left:before{content:\"\\f0a5\"}.fa-hand-point-right:before{content:\"\\f0a4\"}.fa-hand-point-up:before{content:\"\\f0a6\"}.fa-hand-pointer:before{content:\"\\f25a\"}.fa-hand-rock:before{content:\"\\f255\"}.fa-hand-scissors:before{content:\"\\f257\"}.fa-hand-spock:before{content:\"\\f259\"}.fa-hands:before{content:\"\\f4c2\"}.fa-hands-helping:before{content:\"\\f4c4\"}.fa-handshake:before{content:\"\\f2b5\"}.fa-hanukiah:before{content:\"\\f6e6\"}.fa-hard-hat:before{content:\"\\f807\"}.fa-hashtag:before{content:\"\\f292\"}.fa-hat-wizard:before{content:\"\\f6e8\"}.fa-haykal:before{content:\"\\f666\"}.fa-hdd:before{content:\"\\f0a0\"}.fa-heading:before{content:\"\\f1dc\"}.fa-headphones:before{content:\"\\f025\"}.fa-headphones-alt:before{content:\"\\f58f\"}.fa-headset:before{content:\"\\f590\"}.fa-heart:before{content:\"\\f004\"}.fa-heart-broken:before{content:\"\\f7a9\"}.fa-heartbeat:before{content:\"\\f21e\"}.fa-helicopter:before{content:\"\\f533\"}.fa-highlighter:before{content:\"\\f591\"}.fa-hiking:before{content:\"\\f6ec\"}.fa-hippo:before{content:\"\\f6ed\"}.fa-hips:before{content:\"\\f452\"}.fa-hire-a-helper:before{content:\"\\f3b0\"}.fa-history:before{content:\"\\f1da\"}.fa-hockey-puck:before{content:\"\\f453\"}.fa-holly-berry:before{content:\"\\f7aa\"}.fa-home:before{content:\"\\f015\"}.fa-hooli:before{content:\"\\f427\"}.fa-hornbill:before{content:\"\\f592\"}.fa-horse:before{content:\"\\f6f0\"}.fa-horse-head:before{content:\"\\f7ab\"}.fa-hospital:before{content:\"\\f0f8\"}.fa-hospital-alt:before{content:\"\\f47d\"}.fa-hospital-symbol:before{content:\"\\f47e\"}.fa-hot-tub:before{content:\"\\f593\"}.fa-hotdog:before{content:\"\\f80f\"}.fa-hotel:before{content:\"\\f594\"}.fa-hotjar:before{content:\"\\f3b1\"}.fa-hourglass:before{content:\"\\f254\"}.fa-hourglass-end:before{content:\"\\f253\"}.fa-hourglass-half:before{content:\"\\f252\"}.fa-hourglass-start:before{content:\"\\f251\"}.fa-house-damage:before{content:\"\\f6f1\"}.fa-houzz:before{content:\"\\f27c\"}.fa-hryvnia:before{content:\"\\f6f2\"}.fa-html5:before{content:\"\\f13b\"}.fa-hubspot:before{content:\"\\f3b2\"}.fa-i-cursor:before{content:\"\\f246\"}.fa-ice-cream:before{content:\"\\f810\"}.fa-icicles:before{content:\"\\f7ad\"}.fa-id-badge:before{content:\"\\f2c1\"}.fa-id-card:before{content:\"\\f2c2\"}.fa-id-card-alt:before{content:\"\\f47f\"}.fa-igloo:before{content:\"\\f7ae\"}.fa-image:before{content:\"\\f03e\"}.fa-images:before{content:\"\\f302\"}.fa-imdb:before{content:\"\\f2d8\"}.fa-inbox:before{content:\"\\f01c\"}.fa-indent:before{content:\"\\f03c\"}.fa-industry:before{content:\"\\f275\"}.fa-infinity:before{content:\"\\f534\"}.fa-info:before{content:\"\\f129\"}.fa-info-circle:before{content:\"\\f05a\"}.fa-instagram:before{content:\"\\f16d\"}.fa-intercom:before{content:\"\\f7af\"}.fa-internet-explorer:before{content:\"\\f26b\"}.fa-invision:before{content:\"\\f7b0\"}.fa-ioxhost:before{content:\"\\f208\"}.fa-italic:before{content:\"\\f033\"}.fa-itunes:before{content:\"\\f3b4\"}.fa-itunes-note:before{content:\"\\f3b5\"}.fa-java:before{content:\"\\f4e4\"}.fa-jedi:before{content:\"\\f669\"}.fa-jedi-order:before{content:\"\\f50e\"}.fa-jenkins:before{content:\"\\f3b6\"}.fa-jira:before{content:\"\\f7b1\"}.fa-joget:before{content:\"\\f3b7\"}.fa-joint:before{content:\"\\f595\"}.fa-joomla:before{content:\"\\f1aa\"}.fa-journal-whills:before{content:\"\\f66a\"}.fa-js:before{content:\"\\f3b8\"}.fa-js-square:before{content:\"\\f3b9\"}.fa-jsfiddle:before{content:\"\\f1cc\"}.fa-kaaba:before{content:\"\\f66b\"}.fa-kaggle:before{content:\"\\f5fa\"}.fa-key:before{content:\"\\f084\"}.fa-keybase:before{content:\"\\f4f5\"}.fa-keyboard:before{content:\"\\f11c\"}.fa-keycdn:before{content:\"\\f3ba\"}.fa-khanda:before{content:\"\\f66d\"}.fa-kickstarter:before{content:\"\\f3bb\"}.fa-kickstarter-k:before{content:\"\\f3bc\"}.fa-kiss:before{content:\"\\f596\"}.fa-kiss-beam:before{content:\"\\f597\"}.fa-kiss-wink-heart:before{content:\"\\f598\"}.fa-kiwi-bird:before{content:\"\\f535\"}.fa-korvue:before{content:\"\\f42f\"}.fa-landmark:before{content:\"\\f66f\"}.fa-language:before{content:\"\\f1ab\"}.fa-laptop:before{content:\"\\f109\"}.fa-laptop-code:before{content:\"\\f5fc\"}.fa-laptop-medical:before{content:\"\\f812\"}.fa-laravel:before{content:\"\\f3bd\"}.fa-lastfm:before{content:\"\\f202\"}.fa-lastfm-square:before{content:\"\\f203\"}.fa-laugh:before{content:\"\\f599\"}.fa-laugh-beam:before{content:\"\\f59a\"}.fa-laugh-squint:before{content:\"\\f59b\"}.fa-laugh-wink:before{content:\"\\f59c\"}.fa-layer-group:before{content:\"\\f5fd\"}.fa-leaf:before{content:\"\\f06c\"}.fa-leanpub:before{content:\"\\f212\"}.fa-lemon:before{content:\"\\f094\"}.fa-less:before{content:\"\\f41d\"}.fa-less-than:before{content:\"\\f536\"}.fa-less-than-equal:before{content:\"\\f537\"}.fa-level-down-alt:before{content:\"\\f3be\"}.fa-level-up-alt:before{content:\"\\f3bf\"}.fa-life-ring:before{content:\"\\f1cd\"}.fa-lightbulb:before{content:\"\\f0eb\"}.fa-line:before{content:\"\\f3c0\"}.fa-link:before{content:\"\\f0c1\"}.fa-linkedin:before{content:\"\\f08c\"}.fa-linkedin-in:before{content:\"\\f0e1\"}.fa-linode:before{content:\"\\f2b8\"}.fa-linux:before{content:\"\\f17c\"}.fa-lira-sign:before{content:\"\\f195\"}.fa-list:before{content:\"\\f03a\"}.fa-list-alt:before{content:\"\\f022\"}.fa-list-ol:before{content:\"\\f0cb\"}.fa-list-ul:before{content:\"\\f0ca\"}.fa-location-arrow:before{content:\"\\f124\"}.fa-lock:before{content:\"\\f023\"}.fa-lock-open:before{content:\"\\f3c1\"}.fa-long-arrow-alt-down:before{content:\"\\f309\"}.fa-long-arrow-alt-left:before{content:\"\\f30a\"}.fa-long-arrow-alt-right:before{content:\"\\f30b\"}.fa-long-arrow-alt-up:before{content:\"\\f30c\"}.fa-low-vision:before{content:\"\\f2a8\"}.fa-luggage-cart:before{content:\"\\f59d\"}.fa-lyft:before{content:\"\\f3c3\"}.fa-magento:before{content:\"\\f3c4\"}.fa-magic:before{content:\"\\f0d0\"}.fa-magnet:before{content:\"\\f076\"}.fa-mail-bulk:before{content:\"\\f674\"}.fa-mailchimp:before{content:\"\\f59e\"}.fa-male:before{content:\"\\f183\"}.fa-mandalorian:before{content:\"\\f50f\"}.fa-map:before{content:\"\\f279\"}.fa-map-marked:before{content:\"\\f59f\"}.fa-map-marked-alt:before{content:\"\\f5a0\"}.fa-map-marker:before{content:\"\\f041\"}.fa-map-marker-alt:before{content:\"\\f3c5\"}.fa-map-pin:before{content:\"\\f276\"}.fa-map-signs:before{content:\"\\f277\"}.fa-markdown:before{content:\"\\f60f\"}.fa-marker:before{content:\"\\f5a1\"}.fa-mars:before{content:\"\\f222\"}.fa-mars-double:before{content:\"\\f227\"}.fa-mars-stroke:before{content:\"\\f229\"}.fa-mars-stroke-h:before{content:\"\\f22b\"}.fa-mars-stroke-v:before{content:\"\\f22a\"}.fa-mask:before{content:\"\\f6fa\"}.fa-mastodon:before{content:\"\\f4f6\"}.fa-maxcdn:before{content:\"\\f136\"}.fa-medal:before{content:\"\\f5a2\"}.fa-medapps:before{content:\"\\f3c6\"}.fa-medium:before{content:\"\\f23a\"}.fa-medium-m:before{content:\"\\f3c7\"}.fa-medkit:before{content:\"\\f0fa\"}.fa-medrt:before{content:\"\\f3c8\"}.fa-meetup:before{content:\"\\f2e0\"}.fa-megaport:before{content:\"\\f5a3\"}.fa-meh:before{content:\"\\f11a\"}.fa-meh-blank:before{content:\"\\f5a4\"}.fa-meh-rolling-eyes:before{content:\"\\f5a5\"}.fa-memory:before{content:\"\\f538\"}.fa-mendeley:before{content:\"\\f7b3\"}.fa-menorah:before{content:\"\\f676\"}.fa-mercury:before{content:\"\\f223\"}.fa-meteor:before{content:\"\\f753\"}.fa-microchip:before{content:\"\\f2db\"}.fa-microphone:before{content:\"\\f130\"}.fa-microphone-alt:before{content:\"\\f3c9\"}.fa-microphone-alt-slash:before{content:\"\\f539\"}.fa-microphone-slash:before{content:\"\\f131\"}.fa-microscope:before{content:\"\\f610\"}.fa-microsoft:before{content:\"\\f3ca\"}.fa-minus:before{content:\"\\f068\"}.fa-minus-circle:before{content:\"\\f056\"}.fa-minus-square:before{content:\"\\f146\"}.fa-mitten:before{content:\"\\f7b5\"}.fa-mix:before{content:\"\\f3cb\"}.fa-mixcloud:before{content:\"\\f289\"}.fa-mizuni:before{content:\"\\f3cc\"}.fa-mobile:before{content:\"\\f10b\"}.fa-mobile-alt:before{content:\"\\f3cd\"}.fa-modx:before{content:\"\\f285\"}.fa-monero:before{content:\"\\f3d0\"}.fa-money-bill:before{content:\"\\f0d6\"}.fa-money-bill-alt:before{content:\"\\f3d1\"}.fa-money-bill-wave:before{content:\"\\f53a\"}.fa-money-bill-wave-alt:before{content:\"\\f53b\"}.fa-money-check:before{content:\"\\f53c\"}.fa-money-check-alt:before{content:\"\\f53d\"}.fa-monument:before{content:\"\\f5a6\"}.fa-moon:before{content:\"\\f186\"}.fa-mortar-pestle:before{content:\"\\f5a7\"}.fa-mosque:before{content:\"\\f678\"}.fa-motorcycle:before{content:\"\\f21c\"}.fa-mountain:before{content:\"\\f6fc\"}.fa-mouse-pointer:before{content:\"\\f245\"}.fa-mug-hot:before{content:\"\\f7b6\"}.fa-music:before{content:\"\\f001\"}.fa-napster:before{content:\"\\f3d2\"}.fa-neos:before{content:\"\\f612\"}.fa-network-wired:before{content:\"\\f6ff\"}.fa-neuter:before{content:\"\\f22c\"}.fa-newspaper:before{content:\"\\f1ea\"}.fa-nimblr:before{content:\"\\f5a8\"}.fa-nintendo-switch:before{content:\"\\f418\"}.fa-node:before{content:\"\\f419\"}.fa-node-js:before{content:\"\\f3d3\"}.fa-not-equal:before{content:\"\\f53e\"}.fa-notes-medical:before{content:\"\\f481\"}.fa-npm:before{content:\"\\f3d4\"}.fa-ns8:before{content:\"\\f3d5\"}.fa-nutritionix:before{content:\"\\f3d6\"}.fa-object-group:before{content:\"\\f247\"}.fa-object-ungroup:before{content:\"\\f248\"}.fa-odnoklassniki:before{content:\"\\f263\"}.fa-odnoklassniki-square:before{content:\"\\f264\"}.fa-oil-can:before{content:\"\\f613\"}.fa-old-republic:before{content:\"\\f510\"}.fa-om:before{content:\"\\f679\"}.fa-opencart:before{content:\"\\f23d\"}.fa-openid:before{content:\"\\f19b\"}.fa-opera:before{content:\"\\f26a\"}.fa-optin-monster:before{content:\"\\f23c\"}.fa-osi:before{content:\"\\f41a\"}.fa-otter:before{content:\"\\f700\"}.fa-outdent:before{content:\"\\f03b\"}.fa-page4:before{content:\"\\f3d7\"}.fa-pagelines:before{content:\"\\f18c\"}.fa-pager:before{content:\"\\f815\"}.fa-paint-brush:before{content:\"\\f1fc\"}.fa-paint-roller:before{content:\"\\f5aa\"}.fa-palette:before{content:\"\\f53f\"}.fa-palfed:before{content:\"\\f3d8\"}.fa-pallet:before{content:\"\\f482\"}.fa-paper-plane:before{content:\"\\f1d8\"}.fa-paperclip:before{content:\"\\f0c6\"}.fa-parachute-box:before{content:\"\\f4cd\"}.fa-paragraph:before{content:\"\\f1dd\"}.fa-parking:before{content:\"\\f540\"}.fa-passport:before{content:\"\\f5ab\"}.fa-pastafarianism:before{content:\"\\f67b\"}.fa-paste:before{content:\"\\f0ea\"}.fa-patreon:before{content:\"\\f3d9\"}.fa-pause:before{content:\"\\f04c\"}.fa-pause-circle:before{content:\"\\f28b\"}.fa-paw:before{content:\"\\f1b0\"}.fa-paypal:before{content:\"\\f1ed\"}.fa-peace:before{content:\"\\f67c\"}.fa-pen:before{content:\"\\f304\"}.fa-pen-alt:before{content:\"\\f305\"}.fa-pen-fancy:before{content:\"\\f5ac\"}.fa-pen-nib:before{content:\"\\f5ad\"}.fa-pen-square:before{content:\"\\f14b\"}.fa-pencil-alt:before{content:\"\\f303\"}.fa-pencil-ruler:before{content:\"\\f5ae\"}.fa-penny-arcade:before{content:\"\\f704\"}.fa-people-carry:before{content:\"\\f4ce\"}.fa-pepper-hot:before{content:\"\\f816\"}.fa-percent:before{content:\"\\f295\"}.fa-percentage:before{content:\"\\f541\"}.fa-periscope:before{content:\"\\f3da\"}.fa-person-booth:before{content:\"\\f756\"}.fa-phabricator:before{content:\"\\f3db\"}.fa-phoenix-framework:before{content:\"\\f3dc\"}.fa-phoenix-squadron:before{content:\"\\f511\"}.fa-phone:before{content:\"\\f095\"}.fa-phone-slash:before{content:\"\\f3dd\"}.fa-phone-square:before{content:\"\\f098\"}.fa-phone-volume:before{content:\"\\f2a0\"}.fa-php:before{content:\"\\f457\"}.fa-pied-piper:before{content:\"\\f2ae\"}.fa-pied-piper-alt:before{content:\"\\f1a8\"}.fa-pied-piper-hat:before{content:\"\\f4e5\"}.fa-pied-piper-pp:before{content:\"\\f1a7\"}.fa-piggy-bank:before{content:\"\\f4d3\"}.fa-pills:before{content:\"\\f484\"}.fa-pinterest:before{content:\"\\f0d2\"}.fa-pinterest-p:before{content:\"\\f231\"}.fa-pinterest-square:before{content:\"\\f0d3\"}.fa-pizza-slice:before{content:\"\\f818\"}.fa-place-of-worship:before{content:\"\\f67f\"}.fa-plane:before{content:\"\\f072\"}.fa-plane-arrival:before{content:\"\\f5af\"}.fa-plane-departure:before{content:\"\\f5b0\"}.fa-play:before{content:\"\\f04b\"}.fa-play-circle:before{content:\"\\f144\"}.fa-playstation:before{content:\"\\f3df\"}.fa-plug:before{content:\"\\f1e6\"}.fa-plus:before{content:\"\\f067\"}.fa-plus-circle:before{content:\"\\f055\"}.fa-plus-square:before{content:\"\\f0fe\"}.fa-podcast:before{content:\"\\f2ce\"}.fa-poll:before{content:\"\\f681\"}.fa-poll-h:before{content:\"\\f682\"}.fa-poo:before{content:\"\\f2fe\"}.fa-poo-storm:before{content:\"\\f75a\"}.fa-poop:before{content:\"\\f619\"}.fa-portrait:before{content:\"\\f3e0\"}.fa-pound-sign:before{content:\"\\f154\"}.fa-power-off:before{content:\"\\f011\"}.fa-pray:before{content:\"\\f683\"}.fa-praying-hands:before{content:\"\\f684\"}.fa-prescription:before{content:\"\\f5b1\"}.fa-prescription-bottle:before{content:\"\\f485\"}.fa-prescription-bottle-alt:before{content:\"\\f486\"}.fa-print:before{content:\"\\f02f\"}.fa-procedures:before{content:\"\\f487\"}.fa-product-hunt:before{content:\"\\f288\"}.fa-project-diagram:before{content:\"\\f542\"}.fa-pushed:before{content:\"\\f3e1\"}.fa-puzzle-piece:before{content:\"\\f12e\"}.fa-python:before{content:\"\\f3e2\"}.fa-qq:before{content:\"\\f1d6\"}.fa-qrcode:before{content:\"\\f029\"}.fa-question:before{content:\"\\f128\"}.fa-question-circle:before{content:\"\\f059\"}.fa-quidditch:before{content:\"\\f458\"}.fa-quinscape:before{content:\"\\f459\"}.fa-quora:before{content:\"\\f2c4\"}.fa-quote-left:before{content:\"\\f10d\"}.fa-quote-right:before{content:\"\\f10e\"}.fa-quran:before{content:\"\\f687\"}.fa-r-project:before{content:\"\\f4f7\"}.fa-radiation:before{content:\"\\f7b9\"}.fa-radiation-alt:before{content:\"\\f7ba\"}.fa-rainbow:before{content:\"\\f75b\"}.fa-random:before{content:\"\\f074\"}.fa-raspberry-pi:before{content:\"\\f7bb\"}.fa-ravelry:before{content:\"\\f2d9\"}.fa-react:before{content:\"\\f41b\"}.fa-reacteurope:before{content:\"\\f75d\"}.fa-readme:before{content:\"\\f4d5\"}.fa-rebel:before{content:\"\\f1d0\"}.fa-receipt:before{content:\"\\f543\"}.fa-recycle:before{content:\"\\f1b8\"}.fa-red-river:before{content:\"\\f3e3\"}.fa-reddit:before{content:\"\\f1a1\"}.fa-reddit-alien:before{content:\"\\f281\"}.fa-reddit-square:before{content:\"\\f1a2\"}.fa-redhat:before{content:\"\\f7bc\"}.fa-redo:before{content:\"\\f01e\"}.fa-redo-alt:before{content:\"\\f2f9\"}.fa-registered:before{content:\"\\f25d\"}.fa-renren:before{content:\"\\f18b\"}.fa-reply:before{content:\"\\f3e5\"}.fa-reply-all:before{content:\"\\f122\"}.fa-replyd:before{content:\"\\f3e6\"}.fa-republican:before{content:\"\\f75e\"}.fa-researchgate:before{content:\"\\f4f8\"}.fa-resolving:before{content:\"\\f3e7\"}.fa-restroom:before{content:\"\\f7bd\"}.fa-retweet:before{content:\"\\f079\"}.fa-rev:before{content:\"\\f5b2\"}.fa-ribbon:before{content:\"\\f4d6\"}.fa-ring:before{content:\"\\f70b\"}.fa-road:before{content:\"\\f018\"}.fa-robot:before{content:\"\\f544\"}.fa-rocket:before{content:\"\\f135\"}.fa-rocketchat:before{content:\"\\f3e8\"}.fa-rockrms:before{content:\"\\f3e9\"}.fa-route:before{content:\"\\f4d7\"}.fa-rss:before{content:\"\\f09e\"}.fa-rss-square:before{content:\"\\f143\"}.fa-ruble-sign:before{content:\"\\f158\"}.fa-ruler:before{content:\"\\f545\"}.fa-ruler-combined:before{content:\"\\f546\"}.fa-ruler-horizontal:before{content:\"\\f547\"}.fa-ruler-vertical:before{content:\"\\f548\"}.fa-running:before{content:\"\\f70c\"}.fa-rupee-sign:before{content:\"\\f156\"}.fa-sad-cry:before{content:\"\\f5b3\"}.fa-sad-tear:before{content:\"\\f5b4\"}.fa-safari:before{content:\"\\f267\"}.fa-sass:before{content:\"\\f41e\"}.fa-satellite:before{content:\"\\f7bf\"}.fa-satellite-dish:before{content:\"\\f7c0\"}.fa-save:before{content:\"\\f0c7\"}.fa-schlix:before{content:\"\\f3ea\"}.fa-school:before{content:\"\\f549\"}.fa-screwdriver:before{content:\"\\f54a\"}.fa-scribd:before{content:\"\\f28a\"}.fa-scroll:before{content:\"\\f70e\"}.fa-sd-card:before{content:\"\\f7c2\"}.fa-search:before{content:\"\\f002\"}.fa-search-dollar:before{content:\"\\f688\"}.fa-search-location:before{content:\"\\f689\"}.fa-search-minus:before{content:\"\\f010\"}.fa-search-plus:before{content:\"\\f00e\"}.fa-searchengin:before{content:\"\\f3eb\"}.fa-seedling:before{content:\"\\f4d8\"}.fa-sellcast:before{content:\"\\f2da\"}.fa-sellsy:before{content:\"\\f213\"}.fa-server:before{content:\"\\f233\"}.fa-servicestack:before{content:\"\\f3ec\"}.fa-shapes:before{content:\"\\f61f\"}.fa-share:before{content:\"\\f064\"}.fa-share-alt:before{content:\"\\f1e0\"}.fa-share-alt-square:before{content:\"\\f1e1\"}.fa-share-square:before{content:\"\\f14d\"}.fa-shekel-sign:before{content:\"\\f20b\"}.fa-shield-alt:before{content:\"\\f3ed\"}.fa-ship:before{content:\"\\f21a\"}.fa-shipping-fast:before{content:\"\\f48b\"}.fa-shirtsinbulk:before{content:\"\\f214\"}.fa-shoe-prints:before{content:\"\\f54b\"}.fa-shopping-bag:before{content:\"\\f290\"}.fa-shopping-basket:before{content:\"\\f291\"}.fa-shopping-cart:before{content:\"\\f07a\"}.fa-shopware:before{content:\"\\f5b5\"}.fa-shower:before{content:\"\\f2cc\"}.fa-shuttle-van:before{content:\"\\f5b6\"}.fa-sign:before{content:\"\\f4d9\"}.fa-sign-in-alt:before{content:\"\\f2f6\"}.fa-sign-language:before{content:\"\\f2a7\"}.fa-sign-out-alt:before{content:\"\\f2f5\"}.fa-signal:before{content:\"\\f012\"}.fa-signature:before{content:\"\\f5b7\"}.fa-sim-card:before{content:\"\\f7c4\"}.fa-simplybuilt:before{content:\"\\f215\"}.fa-sistrix:before{content:\"\\f3ee\"}.fa-sitemap:before{content:\"\\f0e8\"}.fa-sith:before{content:\"\\f512\"}.fa-skating:before{content:\"\\f7c5\"}.fa-sketch:before{content:\"\\f7c6\"}.fa-skiing:before{content:\"\\f7c9\"}.fa-skiing-nordic:before{content:\"\\f7ca\"}.fa-skull:before{content:\"\\f54c\"}.fa-skull-crossbones:before{content:\"\\f714\"}.fa-skyatlas:before{content:\"\\f216\"}.fa-skype:before{content:\"\\f17e\"}.fa-slack:before{content:\"\\f198\"}.fa-slack-hash:before{content:\"\\f3ef\"}.fa-slash:before{content:\"\\f715\"}.fa-sleigh:before{content:\"\\f7cc\"}.fa-sliders-h:before{content:\"\\f1de\"}.fa-slideshare:before{content:\"\\f1e7\"}.fa-smile:before{content:\"\\f118\"}.fa-smile-beam:before{content:\"\\f5b8\"}.fa-smile-wink:before{content:\"\\f4da\"}.fa-smog:before{content:\"\\f75f\"}.fa-smoking:before{content:\"\\f48d\"}.fa-smoking-ban:before{content:\"\\f54d\"}.fa-sms:before{content:\"\\f7cd\"}.fa-snapchat:before{content:\"\\f2ab\"}.fa-snapchat-ghost:before{content:\"\\f2ac\"}.fa-snapchat-square:before{content:\"\\f2ad\"}.fa-snowboarding:before{content:\"\\f7ce\"}.fa-snowflake:before{content:\"\\f2dc\"}.fa-snowman:before{content:\"\\f7d0\"}.fa-snowplow:before{content:\"\\f7d2\"}.fa-socks:before{content:\"\\f696\"}.fa-solar-panel:before{content:\"\\f5ba\"}.fa-sort:before{content:\"\\f0dc\"}.fa-sort-alpha-down:before{content:\"\\f15d\"}.fa-sort-alpha-up:before{content:\"\\f15e\"}.fa-sort-amount-down:before{content:\"\\f160\"}.fa-sort-amount-up:before{content:\"\\f161\"}.fa-sort-down:before{content:\"\\f0dd\"}.fa-sort-numeric-down:before{content:\"\\f162\"}.fa-sort-numeric-up:before{content:\"\\f163\"}.fa-sort-up:before{content:\"\\f0de\"}.fa-soundcloud:before{content:\"\\f1be\"}.fa-sourcetree:before{content:\"\\f7d3\"}.fa-spa:before{content:\"\\f5bb\"}.fa-space-shuttle:before{content:\"\\f197\"}.fa-speakap:before{content:\"\\f3f3\"}.fa-spider:before{content:\"\\f717\"}.fa-spinner:before{content:\"\\f110\"}.fa-splotch:before{content:\"\\f5bc\"}.fa-spotify:before{content:\"\\f1bc\"}.fa-spray-can:before{content:\"\\f5bd\"}.fa-square:before{content:\"\\f0c8\"}.fa-square-full:before{content:\"\\f45c\"}.fa-square-root-alt:before{content:\"\\f698\"}.fa-squarespace:before{content:\"\\f5be\"}.fa-stack-exchange:before{content:\"\\f18d\"}.fa-stack-overflow:before{content:\"\\f16c\"}.fa-stamp:before{content:\"\\f5bf\"}.fa-star:before{content:\"\\f005\"}.fa-star-and-crescent:before{content:\"\\f699\"}.fa-star-half:before{content:\"\\f089\"}.fa-star-half-alt:before{content:\"\\f5c0\"}.fa-star-of-david:before{content:\"\\f69a\"}.fa-star-of-life:before{content:\"\\f621\"}.fa-staylinked:before{content:\"\\f3f5\"}.fa-steam:before{content:\"\\f1b6\"}.fa-steam-square:before{content:\"\\f1b7\"}.fa-steam-symbol:before{content:\"\\f3f6\"}.fa-step-backward:before{content:\"\\f048\"}.fa-step-forward:before{content:\"\\f051\"}.fa-stethoscope:before{content:\"\\f0f1\"}.fa-sticker-mule:before{content:\"\\f3f7\"}.fa-sticky-note:before{content:\"\\f249\"}.fa-stop:before{content:\"\\f04d\"}.fa-stop-circle:before{content:\"\\f28d\"}.fa-stopwatch:before{content:\"\\f2f2\"}.fa-store:before{content:\"\\f54e\"}.fa-store-alt:before{content:\"\\f54f\"}.fa-strava:before{content:\"\\f428\"}.fa-stream:before{content:\"\\f550\"}.fa-street-view:before{content:\"\\f21d\"}.fa-strikethrough:before{content:\"\\f0cc\"}.fa-stripe:before{content:\"\\f429\"}.fa-stripe-s:before{content:\"\\f42a\"}.fa-stroopwafel:before{content:\"\\f551\"}.fa-studiovinari:before{content:\"\\f3f8\"}.fa-stumbleupon:before{content:\"\\f1a4\"}.fa-stumbleupon-circle:before{content:\"\\f1a3\"}.fa-subscript:before{content:\"\\f12c\"}.fa-subway:before{content:\"\\f239\"}.fa-suitcase:before{content:\"\\f0f2\"}.fa-suitcase-rolling:before{content:\"\\f5c1\"}.fa-sun:before{content:\"\\f185\"}.fa-superpowers:before{content:\"\\f2dd\"}.fa-superscript:before{content:\"\\f12b\"}.fa-supple:before{content:\"\\f3f9\"}.fa-surprise:before{content:\"\\f5c2\"}.fa-suse:before{content:\"\\f7d6\"}.fa-swatchbook:before{content:\"\\f5c3\"}.fa-swimmer:before{content:\"\\f5c4\"}.fa-swimming-pool:before{content:\"\\f5c5\"}.fa-synagogue:before{content:\"\\f69b\"}.fa-sync:before{content:\"\\f021\"}.fa-sync-alt:before{content:\"\\f2f1\"}.fa-syringe:before{content:\"\\f48e\"}.fa-table:before{content:\"\\f0ce\"}.fa-table-tennis:before{content:\"\\f45d\"}.fa-tablet:before{content:\"\\f10a\"}.fa-tablet-alt:before{content:\"\\f3fa\"}.fa-tablets:before{content:\"\\f490\"}.fa-tachometer-alt:before{content:\"\\f3fd\"}.fa-tag:before{content:\"\\f02b\"}.fa-tags:before{content:\"\\f02c\"}.fa-tape:before{content:\"\\f4db\"}.fa-tasks:before{content:\"\\f0ae\"}.fa-taxi:before{content:\"\\f1ba\"}.fa-teamspeak:before{content:\"\\f4f9\"}.fa-teeth:before{content:\"\\f62e\"}.fa-teeth-open:before{content:\"\\f62f\"}.fa-telegram:before{content:\"\\f2c6\"}.fa-telegram-plane:before{content:\"\\f3fe\"}.fa-temperature-high:before{content:\"\\f769\"}.fa-temperature-low:before{content:\"\\f76b\"}.fa-tencent-weibo:before{content:\"\\f1d5\"}.fa-tenge:before{content:\"\\f7d7\"}.fa-terminal:before{content:\"\\f120\"}.fa-text-height:before{content:\"\\f034\"}.fa-text-width:before{content:\"\\f035\"}.fa-th:before{content:\"\\f00a\"}.fa-th-large:before{content:\"\\f009\"}.fa-th-list:before{content:\"\\f00b\"}.fa-the-red-yeti:before{content:\"\\f69d\"}.fa-theater-masks:before{content:\"\\f630\"}.fa-themeco:before{content:\"\\f5c6\"}.fa-themeisle:before{content:\"\\f2b2\"}.fa-thermometer:before{content:\"\\f491\"}.fa-thermometer-empty:before{content:\"\\f2cb\"}.fa-thermometer-full:before{content:\"\\f2c7\"}.fa-thermometer-half:before{content:\"\\f2c9\"}.fa-thermometer-quarter:before{content:\"\\f2ca\"}.fa-thermometer-three-quarters:before{content:\"\\f2c8\"}.fa-think-peaks:before{content:\"\\f731\"}.fa-thumbs-down:before{content:\"\\f165\"}.fa-thumbs-up:before{content:\"\\f164\"}.fa-thumbtack:before{content:\"\\f08d\"}.fa-ticket-alt:before{content:\"\\f3ff\"}.fa-times:before{content:\"\\f00d\"}.fa-times-circle:before{content:\"\\f057\"}.fa-tint:before{content:\"\\f043\"}.fa-tint-slash:before{content:\"\\f5c7\"}.fa-tired:before{content:\"\\f5c8\"}.fa-toggle-off:before{content:\"\\f204\"}.fa-toggle-on:before{content:\"\\f205\"}.fa-toilet:before{content:\"\\f7d8\"}.fa-toilet-paper:before{content:\"\\f71e\"}.fa-toolbox:before{content:\"\\f552\"}.fa-tools:before{content:\"\\f7d9\"}.fa-tooth:before{content:\"\\f5c9\"}.fa-torah:before{content:\"\\f6a0\"}.fa-torii-gate:before{content:\"\\f6a1\"}.fa-tractor:before{content:\"\\f722\"}.fa-trade-federation:before{content:\"\\f513\"}.fa-trademark:before{content:\"\\f25c\"}.fa-traffic-light:before{content:\"\\f637\"}.fa-train:before{content:\"\\f238\"}.fa-tram:before{content:\"\\f7da\"}.fa-transgender:before{content:\"\\f224\"}.fa-transgender-alt:before{content:\"\\f225\"}.fa-trash:before{content:\"\\f1f8\"}.fa-trash-alt:before{content:\"\\f2ed\"}.fa-trash-restore:before{content:\"\\f829\"}.fa-trash-restore-alt:before{content:\"\\f82a\"}.fa-tree:before{content:\"\\f1bb\"}.fa-trello:before{content:\"\\f181\"}.fa-tripadvisor:before{content:\"\\f262\"}.fa-trophy:before{content:\"\\f091\"}.fa-truck:before{content:\"\\f0d1\"}.fa-truck-loading:before{content:\"\\f4de\"}.fa-truck-monster:before{content:\"\\f63b\"}.fa-truck-moving:before{content:\"\\f4df\"}.fa-truck-pickup:before{content:\"\\f63c\"}.fa-tshirt:before{content:\"\\f553\"}.fa-tty:before{content:\"\\f1e4\"}.fa-tumblr:before{content:\"\\f173\"}.fa-tumblr-square:before{content:\"\\f174\"}.fa-tv:before{content:\"\\f26c\"}.fa-twitch:before{content:\"\\f1e8\"}.fa-twitter:before{content:\"\\f099\"}.fa-twitter-square:before{content:\"\\f081\"}.fa-typo3:before{content:\"\\f42b\"}.fa-uber:before{content:\"\\f402\"}.fa-ubuntu:before{content:\"\\f7df\"}.fa-uikit:before{content:\"\\f403\"}.fa-umbrella:before{content:\"\\f0e9\"}.fa-umbrella-beach:before{content:\"\\f5ca\"}.fa-underline:before{content:\"\\f0cd\"}.fa-undo:before{content:\"\\f0e2\"}.fa-undo-alt:before{content:\"\\f2ea\"}.fa-uniregistry:before{content:\"\\f404\"}.fa-universal-access:before{content:\"\\f29a\"}.fa-university:before{content:\"\\f19c\"}.fa-unlink:before{content:\"\\f127\"}.fa-unlock:before{content:\"\\f09c\"}.fa-unlock-alt:before{content:\"\\f13e\"}.fa-untappd:before{content:\"\\f405\"}.fa-upload:before{content:\"\\f093\"}.fa-ups:before{content:\"\\f7e0\"}.fa-usb:before{content:\"\\f287\"}.fa-user:before{content:\"\\f007\"}.fa-user-alt:before{content:\"\\f406\"}.fa-user-alt-slash:before{content:\"\\f4fa\"}.fa-user-astronaut:before{content:\"\\f4fb\"}.fa-user-check:before{content:\"\\f4fc\"}.fa-user-circle:before{content:\"\\f2bd\"}.fa-user-clock:before{content:\"\\f4fd\"}.fa-user-cog:before{content:\"\\f4fe\"}.fa-user-edit:before{content:\"\\f4ff\"}.fa-user-friends:before{content:\"\\f500\"}.fa-user-graduate:before{content:\"\\f501\"}.fa-user-injured:before{content:\"\\f728\"}.fa-user-lock:before{content:\"\\f502\"}.fa-user-md:before{content:\"\\f0f0\"}.fa-user-minus:before{content:\"\\f503\"}.fa-user-ninja:before{content:\"\\f504\"}.fa-user-nurse:before{content:\"\\f82f\"}.fa-user-plus:before{content:\"\\f234\"}.fa-user-secret:before{content:\"\\f21b\"}.fa-user-shield:before{content:\"\\f505\"}.fa-user-slash:before{content:\"\\f506\"}.fa-user-tag:before{content:\"\\f507\"}.fa-user-tie:before{content:\"\\f508\"}.fa-user-times:before{content:\"\\f235\"}.fa-users:before{content:\"\\f0c0\"}.fa-users-cog:before{content:\"\\f509\"}.fa-usps:before{content:\"\\f7e1\"}.fa-ussunnah:before{content:\"\\f407\"}.fa-utensil-spoon:before{content:\"\\f2e5\"}.fa-utensils:before{content:\"\\f2e7\"}.fa-vaadin:before{content:\"\\f408\"}.fa-vector-square:before{content:\"\\f5cb\"}.fa-venus:before{content:\"\\f221\"}.fa-venus-double:before{content:\"\\f226\"}.fa-venus-mars:before{content:\"\\f228\"}.fa-viacoin:before{content:\"\\f237\"}.fa-viadeo:before{content:\"\\f2a9\"}.fa-viadeo-square:before{content:\"\\f2aa\"}.fa-vial:before{content:\"\\f492\"}.fa-vials:before{content:\"\\f493\"}.fa-viber:before{content:\"\\f409\"}.fa-video:before{content:\"\\f03d\"}.fa-video-slash:before{content:\"\\f4e2\"}.fa-vihara:before{content:\"\\f6a7\"}.fa-vimeo:before{content:\"\\f40a\"}.fa-vimeo-square:before{content:\"\\f194\"}.fa-vimeo-v:before{content:\"\\f27d\"}.fa-vine:before{content:\"\\f1ca\"}.fa-vk:before{content:\"\\f189\"}.fa-vnv:before{content:\"\\f40b\"}.fa-volleyball-ball:before{content:\"\\f45f\"}.fa-volume-down:before{content:\"\\f027\"}.fa-volume-mute:before{content:\"\\f6a9\"}.fa-volume-off:before{content:\"\\f026\"}.fa-volume-up:before{content:\"\\f028\"}.fa-vote-yea:before{content:\"\\f772\"}.fa-vr-cardboard:before{content:\"\\f729\"}.fa-vuejs:before{content:\"\\f41f\"}.fa-walking:before{content:\"\\f554\"}.fa-wallet:before{content:\"\\f555\"}.fa-warehouse:before{content:\"\\f494\"}.fa-water:before{content:\"\\f773\"}.fa-weebly:before{content:\"\\f5cc\"}.fa-weibo:before{content:\"\\f18a\"}.fa-weight:before{content:\"\\f496\"}.fa-weight-hanging:before{content:\"\\f5cd\"}.fa-weixin:before{content:\"\\f1d7\"}.fa-whatsapp:before{content:\"\\f232\"}.fa-whatsapp-square:before{content:\"\\f40c\"}.fa-wheelchair:before{content:\"\\f193\"}.fa-whmcs:before{content:\"\\f40d\"}.fa-wifi:before{content:\"\\f1eb\"}.fa-wikipedia-w:before{content:\"\\f266\"}.fa-wind:before{content:\"\\f72e\"}.fa-window-close:before{content:\"\\f410\"}.fa-window-maximize:before{content:\"\\f2d0\"}.fa-window-minimize:before{content:\"\\f2d1\"}.fa-window-restore:before{content:\"\\f2d2\"}.fa-windows:before{content:\"\\f17a\"}.fa-wine-bottle:before{content:\"\\f72f\"}.fa-wine-glass:before{content:\"\\f4e3\"}.fa-wine-glass-alt:before{content:\"\\f5ce\"}.fa-wix:before{content:\"\\f5cf\"}.fa-wizards-of-the-coast:before{content:\"\\f730\"}.fa-wolf-pack-battalion:before{content:\"\\f514\"}.fa-won-sign:before{content:\"\\f159\"}.fa-wordpress:before{content:\"\\f19a\"}.fa-wordpress-simple:before{content:\"\\f411\"}.fa-wpbeginner:before{content:\"\\f297\"}.fa-wpexplorer:before{content:\"\\f2de\"}.fa-wpforms:before{content:\"\\f298\"}.fa-wpressr:before{content:\"\\f3e4\"}.fa-wrench:before{content:\"\\f0ad\"}.fa-x-ray:before{content:\"\\f497\"}.fa-xbox:before{content:\"\\f412\"}.fa-xing:before{content:\"\\f168\"}.fa-xing-square:before{content:\"\\f169\"}.fa-y-combinator:before{content:\"\\f23b\"}.fa-yahoo:before{content:\"\\f19e\"}.fa-yandex:before{content:\"\\f413\"}.fa-yandex-international:before{content:\"\\f414\"}.fa-yarn:before{content:\"\\f7e3\"}.fa-yelp:before{content:\"\\f1e9\"}.fa-yen-sign:before{content:\"\\f157\"}.fa-yin-yang:before{content:\"\\f6ad\"}.fa-yoast:before{content:\"\\f2b1\"}.fa-youtube:before{content:\"\\f167\"}.fa-youtube-square:before{content:\"\\f431\"}.fa-zhihu:before{content:\"\\f63f\"}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}@font-face{font-family:\"Font Awesome 5 Brands\";font-style:normal;font-weight:400;font-display:auto;src:url(libs/fonts/fa-brands-400.eot);src:url(libs/fonts/fa-brands-400.eot?#iefix) format(\"embedded-opentype\"),url(libs/fonts/fa-brands-400.woff2) format(\"woff2\"),url(libs/fonts/fa-brands-400.woff) format(\"woff\"),url(libs/fonts/fa-brands-400.ttf) format(\"truetype\"),url(libs/fonts/fa-brands-400.svg#fontawesome) format(\"svg\")}.fab{font-family:\"Font Awesome 5 Brands\"}@font-face{font-family:\"Font Awesome 5 Free\";font-style:normal;font-weight:400;font-display:auto;src:url(libs/fonts/fa-regular-400.eot);src:url(libs/fonts/fa-regular-400.eot?#iefix) format(\"embedded-opentype\"),url(libs/fonts/fa-regular-400.woff2) format(\"woff2\"),url(libs/fonts/fa-regular-400.woff) format(\"woff\"),url(libs/fonts/fa-regular-400.ttf) format(\"truetype\"),url(libs/fonts/fa-regular-400.svg#fontawesome) format(\"svg\")}.far{font-weight:400}@font-face{font-family:\"Font Awesome 5 Free\";font-style:normal;font-weight:900;font-display:auto;src:url(libs/fonts/fa-solid-900.eot);src:url(libs/fonts/fa-solid-900.eot?#iefix) format(\"embedded-opentype\"),url(libs/fonts/fa-solid-900.woff2) format(\"woff2\"),url(libs/fonts/fa-solid-900.woff) format(\"woff\"),url(libs/fonts/fa-solid-900.ttf) format(\"truetype\"),url(libs/fonts/fa-solid-900.svg#fontawesome) format(\"svg\")}.fa,.far,.fas{font-family:\"Font Awesome 5 Free\"}.fa,.fas{font-weight:900}:root{--colorLink:#999;--colorSidebar:#fff;--errorColor:#f44336;--main:#f44336;--sidebarBg:#292929;--sidebarHoverBg:#232323;--bg:#eee;--border:#e5e5e5;--color:#232323;--contentBg:#fff;--headerBg:#fff;--selectedBg:#ffe7e5;--bgqoute:#eee}.night-colors{--bg:#1f1f1f;--border:#191919;--color:#fff;--contentBg:#292929;--headerBg:#292929;--selectedBg:#261918;--bgqoute:#1f1f1f5e}.light-colors{--bg:#eee;--border:#e5e5e5;--color:#232323;--contentBg:#fff;--headerBg:#fff;--selectedBg:#ffe7e5;--bgqoute:#eee}body.dark{--bg:#1f1f1f;--border:#191919;--color:#fff;--contentBg:#292929;--headerBg:#292929;--selectedBg:#261918;--bgqoute:#1f1f1f5e}body.light{--bg:#eee;--border:#e5e5e5;--color:#232323;--contentBg:#fff;--headerBg:#fff;--selectedBg:#ffe7e5;--bgqoute:#eee}@media (prefers-color-scheme:dark){:root{--bg:#1f1f1f;--border:#191919;--color:#fff;--contentBg:#292929;--headerBg:#292929;--selectedBg:#261918;--bgqoute:#1f1f1f5e}}@media (prefers-color-scheme:light){:root{--bg:#eee;--border:#e5e5e5;--color:#232323;--contentBg:#fff;--headerBg:#fff;--selectedBg:#ffe7e5;--bgqoute:#eee}}select{background:var(--contentBg);border:1px solid var(--border);border-radius:4px;color:var(--color)}.login-footer{color:var(--color)}.content-footer{width:100%;padding:20px;max-width:840px;margin-bottom:16px;background-color:var(--contentBg);border:1px solid var(--border);display:flex;flex-flow:column;align-items:center}@media (display-mode:standalone),(display-mode:fullscreen),(display-mode:minimal-ui){.content-footer{padding-bottom:calc(20px + env(safe-area-inset-bottom))}}.metadata{display:flex;flex-flow:row wrap;text-align:center;font-size:16px;color:var(--colorLink)}.metadata:first-child{justify-content:flex-start}.metadata:nth-child(2){justify-content:flex-end}.metadata[v-cloak]{visibility:hidden}.links{display:flex;flex-flow:row wrap}.links a{padding:0 4px;color:var(--color);text-decoration:underline}.links a:focus,.links a:hover{color:var(--main)}*{border-width:0;box-sizing:border-box;font-family:\"Source Sans Pro\",sans-serif;margin:0;padding:0;text-decoration:none}body{background-color:var(--bg)}a{cursor:pointer}.spacer{flex:1}#login-scene{height:100%;height:100dvh;padding:16px;overflow:auto;display:flex;align-items:center;flex-flow:column nowrap;background-color:var(--bg)}#login-scene>.error-message{width:100%;max-width:400px;font-size:1em;background-color:var(--contentBg);border:1px solid var(--border);padding:16px;margin-top:auto;margin-bottom:16px;text-align:center;color:var(--errorColor)}#login-scene #login-box{width:100%;max-width:400px;margin-bottom:auto;background-color:var(--contentBg);display:flex;flex-flow:column nowrap;border:1px solid var(--border);flex-shrink:0}#login-scene #login-box:first-child{margin-top:auto}#login-scene #login-box #logo-area{display:flex;align-items:center;flex-flow:column nowrap;padding:16px;background-color:var(--main);border-bottom:1px solid var(--border);flex-shrink:0}#login-scene #login-box #logo-area #logo{font-size:3em;font-weight:100;color:var(--contentBg)}#login-scene #login-box #logo-area #logo span{margin-right:8px}#login-scene #login-box #logo-area #tagline{font-weight:500;margin-top:4px;color:var(--contentBg);text-align:center}#login-scene #login-box #input-area{padding:16px;display:grid;grid-gap:16px;grid-template-columns:auto 1fr;justify-content:baseline;align-items:center;border-bottom:1px solid var(--border)}#login-scene #login-box #input-area>label{color:var(--color)}#login-scene #login-box #input-area>input{color:var(--color);padding:8px;background-color:var(--contentBg);border:1px solid var(--border);min-width:0;font-size:1em}#login-scene #login-box #input-area .checkbox-field{grid-column:1/span 2;display:flex;flex-flow:row nowrap;align-items:center;justify-content:center;cursor:pointer}#login-scene #login-box #input-area .checkbox-field:focus,#login-scene #login-box #input-area .checkbox-field:hover{text-decoration:underline;text-decoration-color:var(--main)}#login-scene #login-box #input-area .checkbox-field>input[type=checkbox]{margin-right:8px}#login-scene #login-box #button-area{display:flex;flex-flow:row nowrap;padding:16px;justify-content:center}#login-scene #login-box #button-area a{color:var(--color);text-transform:uppercase;text-align:center;font-weight:600;cursor:default}#login-scene #login-box #button-area a.button{cursor:pointer}#login-scene #login-box #button-area a.button:focus,#login-scene #login-box #button-area a.button:hover{color:var(--main)}#main-scene{min-height:100%;min-height:100dvh;padding-top:60px;padding-left:60px;background-color:var(--bg)}@media (display-mode:standalone),(display-mode:fullscreen),(display-mode:minimal-ui){#main-scene{padding-bottom:env(safe-area-inset-bottom)}}#main-scene #main-sidebar{top:0;left:0;width:60px;height:100%;height:100dvh;position:fixed;display:flex;flex-flow:column nowrap;background-color:var(--sidebarBg);z-index:1}#main-scene #main-sidebar a{flex-shrink:0;display:block;width:60px;line-height:60px;text-align:center;font-size:1em;color:var(--colorSidebar)}#main-scene #main-sidebar a.active{cursor:default;color:var(--colorSidebar);background-color:var(--main)}#main-scene #main-sidebar a:focus,#main-scene #main-sidebar a:hover{color:var(--main);background-color:var(--sidebarHoverBg)}#main-scene .page-header{top:0;left:60px;right:0;height:60px;position:fixed;color:var(--color);background-color:var(--headerBg);border-bottom:1px solid var(--border);padding:0 16px;z-index:10}#main-scene h1.page-header{line-height:60px;font-size:1.3em;font-weight:600}#main-scene div.page-header{display:flex;flex-flow:row nowrap;align-items:center}#main-scene div.page-header p{flex:1 0;font-size:1.3em;font-weight:600;line-height:60px;color:var(--color)}#main-scene div.page-header input[type=text]{flex:1 0;min-width:0;margin-right:8px;font-size:1.1em;font-weight:500;line-height:calc(60px - 1px);color:var(--color);background-color:var(--contentBg)}#main-scene div.page-header input[type=text]::placeholder{color:var(--colorLink)}#main-scene div.page-header a{display:block;width:24px;line-height:24px;color:var(--colorLink);text-align:center}#main-scene div.page-header a:not(:last-child){margin-right:8px}#main-scene div.page-header a:hover{color:var(--main)}#main-scene .loading-overlay{display:flex;flex-flow:column nowrap;align-items:center;justify-content:center;overflow:hidden;position:fixed;top:0;left:0;width:100%;width:100dvw;height:100%;height:100dvh;z-index:10001;background-color:rgba(0,0,0,.6)}#main-scene .loading-overlay i{color:var(--colorSidebar);font-size:4em;text-align:center;width:80px;line-height:80px;position:absolute}@media (max-width:600px){#main-scene{padding-top:50px;padding-left:0;padding-bottom:50px}#main-scene #main-sidebar{top:auto;right:0;bottom:0;width:100%;width:100dvw;height:50px;flex-flow:row nowrap;border-top:1px solid var(--border)}#main-scene #main-sidebar .spacer{display:none}#main-scene #main-sidebar a{width:auto;flex:1 0;line-height:50px}#main-scene #main-sidebar a:focus,#main-scene #main-sidebar a:hover{color:var(--colorSidebar);background-color:var(--main)}#main-scene .page-header{left:0;height:50px}#main-scene h1.page-header{text-align:center;font-size:1em;line-height:50px;text-transform:uppercase}#main-scene div.page-header{flex-flow:row wrap}#main-scene div.page-header p{flex:1 0;font-size:1em;font-weight:500;line-height:3em;padding:0}#main-scene div.page-header input[type=text]{flex:1 0;font-size:1em;font-weight:500;line-height:3em}#main-scene div.page-header a{display:block;width:24px;line-height:100%}}@media (max-width:600px) and (display-mode:standalone),(display-mode:fullscreen),(display-mode:minimal-ui){#main-scene #main-sidebar{height:calc(50px + 20px)}}#content-scene{padding:20px;display:flex;color:var(--color);background-color:var(--bg);flex-flow:column nowrap;align-items:center}#content-scene #header{width:100%;padding:20px;max-width:840px;margin-bottom:16px;background-color:var(--contentBg);border:1px solid var(--border);display:flex;flex-flow:column;align-items:center}#content-scene #header #title{padding:8px 0;grid-column-start:1;grid-column-end:-1;font-size:36px;font-weight:700;word-break:break-word;hyphens:none;text-align:center}#content-scene #content{width:100%;padding:20px;max-width:840px;background-color:var(--contentBg);border:1px solid var(--border)}#content-scene #content *{font-size:18px;line-height:180%}#content-scene #content :not(:last-child){margin-bottom:20px}#content-scene #content a{color:var(--color);text-decoration:underline}#content-scene #content a:focus,#content-scene #content a:hover{color:var(--main)}#content-scene #content code,#content-scene #content pre{overflow:auto;border:1px solid var(--border);font-family:\"Ubuntu Mono\",\"Courier New\",Courier,monospace;font-size:16px}#content-scene #content pre{padding:8px}#content-scene #content pre>code{border:0}#content-scene #content ol,#content-scene #content ul{padding-left:16px}#content-scene #content img{height:auto;max-width:100%}#content-scene #content table{border:1px solid var(--border);border-collapse:collapse}#content-scene #content table td,#content-scene #content table th,#content-scene #content table tr{border:1px solid var(--border)}#content-scene #content blockquote{margin:15px;padding:15px;font-style:italic;background:var(--bgqoute)}#page-home>.empty-message{max-width:400px;font-size:1em;background-color:var(--contentBg);border:1px solid var(--border);padding:16px;margin:16px;color:var(--errorColor)}#page-home #edit-box{background-color:var(--selectedBg);border-bottom:1px solid var(--main)}#page-home #bookmarks-grid{display:grid;grid-template-rows:min-content;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));grid-gap:16px;padding:16px;overflow:auto}#page-home #bookmarks-grid .bookmark{align-self:start}#page-home #bookmarks-grid .pagination-box{grid-column-end:-1;grid-column-start:1;display:flex;flex-flow:row nowrap;align-self:start}#page-home #bookmarks-grid .pagination-box a{padding:8px;color:var(--colorLink)}#page-home #bookmarks-grid .pagination-box a:focus,#page-home #bookmarks-grid .pagination-box a:hover{color:var(--main)}#page-home #bookmarks-grid .pagination-box input{width:40px;padding:8px;text-align:center;font-size:.9em;color:var(--color);border:1px solid var(--border);background-color:var(--contentBg);margin:0 8px}#page-home #bookmarks-grid .pagination-box p{font-size:.9em;color:var(--colorLink);line-height:37px;font-weight:600}#page-home #bookmarks-grid .pagination-box p:last-of-type::before{content:\"/\";margin-right:8px}#page-home #bookmarks-grid.list{grid-gap:0;padding-bottom:0;grid-template-columns:auto}#page-home #bookmarks-grid.list .pagination-box{padding:16px 0}#page-home #bookmarks-grid.list .pagination-box:first-child{padding-top:0}@media (max-width:600px){#page-home #bookmarks-grid.list{padding:16px 0 0}#page-home #bookmarks-grid.list .pagination-box{padding:16px}}#page-home #dialog-tags .custom-dialog-body{grid-template-columns:repeat(2,minmax(0,1fr))}@media (max-width:600px){#page-home #dialog-tags .custom-dialog-body{grid-template-columns:minmax(0,1fr)}}#page-home #dialog-tags .custom-dialog-body a{font-size:1em;color:var(--color)}#page-home #dialog-tags .custom-dialog-body a span:last-child{font-size:1em;color:var(--colorLink);margin-left:4px}#page-home #dialog-tags .custom-dialog-body a span:last-child::before{content:\"(\";margin-right:2px}#page-home #dialog-tags .custom-dialog-body a span:last-child::after{content:\")\";margin-left:2px}#page-home #dialog-tags .custom-dialog-body a:focus,#page-home #dialog-tags .custom-dialog-body a:hover{color:var(--main)}#page-setting{min-height:0;max-height:100%;display:flex;flex-flow:column nowrap}#page-setting .setting-container{padding:8px;display:flex;overflow:auto;flex-flow:column nowrap;flex:1 0}#page-setting .setting-container::after{content:\"\";display:block;min-height:1px}#page-setting .setting-container details.setting-group{margin:8px;display:block;max-width:350px;color:var(--color);background-color:var(--contentBg);border:1px solid var(--border)}@media (max-width:600px){#page-setting .setting-container details.setting-group{max-width:100%}}#page-setting .setting-container details.setting-group summary{list-style:none;font-weight:600;width:100%;padding:12px 8px;font-size:1.1em;cursor:pointer}#page-setting .setting-container details.setting-group summary:hover{color:var(--main)}#page-setting .setting-container details.setting-group summary::-webkit-details-marker{display:none}#page-setting .setting-container details.setting-group summary::after{content:\"+\";margin-left:8px;font-weight:600}#page-setting .setting-container details.setting-group[open] summary{border-bottom:1px solid var(--border)}#page-setting .setting-container details.setting-group[open] summary::after{content:\"-\"!important}#page-setting .setting-container details.setting-group ul{list-style:none}#page-setting .setting-container details.setting-group ul li{padding:4px 8px;color:var(--color);display:flex;flex-flow:row nowrap;align-items:center}#page-setting .setting-container details.setting-group div.setting-group-footer{padding:4px 8px;display:flex;flex-flow:column nowrap;align-items:flex-end;border-top:1px solid var(--border)}#page-setting .setting-container details.setting-group div.setting-group-footer>a{text-transform:uppercase;padding:8px 4px;font-size:.9em;font-weight:600}#page-setting .setting-container details.setting-group div.setting-group-footer>a:hover{color:var(--main)}#page-setting .setting-container details.setting-group div.setting-group-footer>a:focus{outline:0;color:var(--main);border-bottom:1px dashed var(--main)}#page-setting #setting-bookmarks,#page-setting #setting-display{display:flex;flex-flow:column nowrap}#page-setting #setting-bookmarks[open],#page-setting #setting-display[open]{padding-bottom:8px}#page-setting #setting-bookmarks[open] summary,#page-setting #setting-display[open] summary{margin-bottom:8px}#page-setting #setting-bookmarks label,#page-setting #setting-display label{padding:4px 8px;color:var(--color);display:flex;flex-flow:row nowrap;align-items:center;cursor:pointer}#page-setting #setting-bookmarks label:focus,#page-setting #setting-bookmarks label:hover,#page-setting #setting-display label:focus,#page-setting #setting-display label:hover{text-decoration:underline;text-decoration-color:var(--main)}#page-setting #setting-bookmarks label>input[type=checkbox],#page-setting #setting-display label>input[type=checkbox]{margin-right:8px}#page-setting .setting-accounts summary{margin-bottom:0}#page-setting .setting-accounts ul{list-style:none}#page-setting .setting-accounts ul li{padding:8px;display:flex;flex-flow:row nowrap;align-items:center}#page-setting .setting-accounts ul li:not(:last-child){border-bottom:1px solid var(--border)}#page-setting .setting-accounts ul li p{font-size:1em;color:var(--color);flex:1 0}#page-setting .setting-accounts ul li p span{color:var(--colorLink)}#page-setting .setting-accounts ul li a{margin-left:8px;color:var(--colorLink)}#page-setting .setting-accounts ul li a:hover{color:var(--main)}#page-setting #setting-system-info ul{padding-top:4px;padding-bottom:4px}#page-setting #setting-system-info ul li span{margin-left:8px}:root{--dialogHeaderBg:#292929;--colorDialogHeader:#fff}.custom-dialog-overlay{display:flex;flex-flow:column nowrap;align-items:center;justify-content:center;min-width:0;min-height:0;overflow:hidden;position:fixed;top:0;left:0;width:100%;width:100dvw;height:100%;height:100dvh;z-index:10001;background-color:rgba(0,0,0,.6);padding:20px}.custom-dialog-overlay .custom-dialog{display:flex;flex-flow:column nowrap;min-height:0;max-height:100%;max-width:100%;width:400px;overflow:auto;background-color:var(--contentBg);font-size:16px;resize:both}.custom-dialog-overlay .custom-dialog .custom-dialog-header{padding:16px;color:var(--colorDialogHeader);background-color:var(--dialogHeaderBg);font-weight:600;font-size:1em;text-transform:uppercase;border-bottom:1px solid var(--border)}.custom-dialog-overlay .custom-dialog .custom-dialog-body{padding:16px 16px 0;display:grid;max-height:100%;min-height:80px;min-width:0;overflow:auto;font-size:1em;grid-template-columns:max-content 1fr;align-content:start;align-items:baseline;grid-gap:16px;flex-grow:1}.custom-dialog-overlay .custom-dialog .custom-dialog-body::after{content:\"\";display:block;min-height:1px;grid-column-end:-1;grid-column-start:1}.custom-dialog-overlay .custom-dialog .custom-dialog-body .custom-dialog-content{grid-column-end:-1;grid-column-start:1;color:var(--color);align-self:baseline}.custom-dialog-overlay .custom-dialog .custom-dialog-body>label{color:var(--color);padding:8px 0;font-size:1em}.custom-dialog-overlay .custom-dialog .custom-dialog-body>input[type=password],.custom-dialog-overlay .custom-dialog .custom-dialog-body>input[type=text],.custom-dialog-overlay .custom-dialog .custom-dialog-body>textarea{color:var(--color);padding:8px;font-size:1em;border:1px solid var(--border);background-color:var(--contentBg);min-width:0}.custom-dialog-overlay .custom-dialog .custom-dialog-body .checkbox-field{color:var(--color);font-size:1em;display:flex;flex-flow:row nowrap;padding:0;grid-column-start:1;grid-column-end:-1;cursor:pointer}.custom-dialog-overlay .custom-dialog .custom-dialog-body .checkbox-field:focus,.custom-dialog-overlay .custom-dialog .custom-dialog-body .checkbox-field:hover{text-decoration:underline;text-decoration-color:var(--main)}.custom-dialog-overlay .custom-dialog .custom-dialog-body .checkbox-field>input[type=checkbox]{margin-right:8px}.custom-dialog-overlay .custom-dialog .custom-dialog-body>textarea{height:6em;min-height:37px;resize:vertical}.custom-dialog-overlay .custom-dialog .custom-dialog-body>.suggestion{position:absolute;display:block;padding:8px;background-color:var(--contentBg);border:1px solid var(--border);color:var(--color);font-size:.9em}.custom-dialog-overlay .custom-dialog .custom-dialog-footer{padding:16px;display:flex;flex-flow:row wrap;justify-content:flex-end;border-top:1px solid var(--border)}@media (display-mode:standalone),(display-mode:fullscreen),(display-mode:minimal-ui){.custom-dialog-overlay .custom-dialog .custom-dialog-footer{padding-bottom:calc(16px + env(safe-area-inset-bottom))}}.custom-dialog-overlay .custom-dialog .custom-dialog-footer>a{padding:0 8px;font-size:.9em;font-weight:600;color:var(--color);text-transform:uppercase}.custom-dialog-overlay .custom-dialog .custom-dialog-footer>a:focus,.custom-dialog-overlay .custom-dialog .custom-dialog-footer>a:hover{outline:0;color:var(--main)}.custom-dialog-overlay .custom-dialog .custom-dialog-footer>i.fa-spinner.fa-spin{width:19px;line-height:19px;text-align:center;color:var(--color)}@media only screen and (max-width:768px){.custom-dialog-overlay{padding:0}.custom-dialog{width:100%!important;height:100%!important;resize:none!important}}.bookmark{display:flex;flex-flow:column nowrap;min-width:0;border:1px solid var(--border);background-color:var(--contentBg);height:100%;position:relative}.bookmark:focus .bookmark-menu>a,.bookmark:hover .bookmark-menu>a{display:block}.bookmark.selected{background-color:var(--selectedBg)}.bookmark .bookmark-selector{position:absolute;top:0;left:0;width:100%;height:100%;z-index:9}.bookmark .bookmark-link{display:block;cursor:default}.bookmark .bookmark-link[href]{cursor:pointer}.bookmark .bookmark-link[href]:focus .title,.bookmark .bookmark-link[href]:hover .title{color:var(--main)}.bookmark .bookmark-link span.thumbnail{width:100%;height:200px;display:block;background-size:cover;background-repeat:no-repeat;background-position:center center;margin-bottom:8px;border-bottom:1px solid var(--border)}.bookmark .bookmark-link .id{color:var(--color);border:1px solid var(--border);background-color:var(--contentBg);font-size:.7em;font-weight:700;left:-1px;top:-1px;position:absolute;padding:0 .3em;opacity:.7}.bookmark .bookmark-link .title{text-overflow:ellipsis;word-wrap:break-word;overflow:hidden;font-size:1.2em;line-height:1.3em;max-height:5.2em;font-weight:600;padding:0 16px;color:var(--color)}.bookmark .bookmark-link .title:first-child{margin-top:16px}.bookmark .bookmark-link .title i{color:var(--colorLink);margin-left:4px;font-size:14px}.bookmark .bookmark-link .excerpt{color:var(--color);margin-top:8px;padding:0 16px;text-overflow:ellipsis;word-wrap:break-word;overflow:hidden;font-size:.9em;line-height:1.5em;max-height:10.5em}.bookmark .bookmark-tags{display:flex;flex-flow:row wrap;margin:8px 0 -4px;padding:0 8px}.bookmark .bookmark-tags a{margin:4px;padding:4px 8px;font-size:.8em;font-weight:600;border:1px solid var(--border);border-radius:4px;color:var(--colorLink);background-color:var(--contentBg)}.bookmark .bookmark-tags a:focus,.bookmark .bookmark-tags a:hover{color:var(--main)}.bookmark .bookmark-menu{padding:8px 16px 16px;display:flex;flex-flow:row nowrap;min-width:0;min-height:0;align-items:center}.bookmark .bookmark-menu a{color:var(--colorLink);flex-shrink:0;opacity:.8;display:none;font-size:.9em}.bookmark .bookmark-menu a:not(:last-child){margin-right:12px}.bookmark .bookmark-menu a:focus,.bookmark .bookmark-menu a:hover{color:var(--main);opacity:1}.bookmark .bookmark-menu .url{flex:1 0;opacity:1;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:21px}.bookmark .bookmark-menu .url:not([href]){cursor:default;color:var(--colorLink)}@media (max-width:1024px){.bookmark .bookmark-menu a{display:block}}.bookmark.list{border-top-width:0;border-bottom-width:1px;padding:16px 24px 16px 100px}.bookmark.list:first-child{border-top-width:1px}.bookmark.list .bookmark-link span.thumbnail{position:absolute;top:0;left:0;width:100px;height:100%;margin-bottom:0;border-bottom:0;border-right:1px solid var(--border)}.bookmark.list .bookmark-link .title{margin:0;padding-left:24px}.bookmark.list .excerpt,.bookmark.list>.spacer{display:none}.bookmark.list .bookmark-tags{padding-left:16px;padding-right:0}.bookmark.list .bookmark-menu{padding:8px 0 0 24px;align-items:flex-end}.bookmark.list.no-thumbnail{padding-left:16px;padding-right:16px}.bookmark.list.no-thumbnail .bookmark-link .title{padding:0;margin-bottom:4px}.bookmark.list.no-thumbnail .excerpt{margin-top:0;margin-bottom:4px;padding:0;display:block}.bookmark.list.no-thumbnail .bookmark-tags{padding-left:0;margin:0 -4px 0}.bookmark.list.no-thumbnail .bookmark-menu{padding-top:0;padding-left:0}@media (max-width:600px){.bookmark.list{padding:8px 16px 8px 70px;border-width:0!important;border-bottom-width:1px!important}.bookmark.list .bookmark-link span.thumbnail{width:70px}.bookmark.list .bookmark-link .title{font-size:1.1em;font-weight:500;padding-left:16px}.bookmark.list .bookmark-tags{padding-left:8px}.bookmark.list .bookmark-menu{padding-left:16px}}"
  },
  {
    "path": "internal/view/assets/js/component/bookmark.js",
    "content": "var template = `\n<div class=\"bookmark\" :class=\"{list: ListMode, 'no-thumbnail': HideThumbnail, selected: selected}\">\n\t<a class=\"bookmark-selector\" \n\t\tv-if=\"editMode\" \n\t\t@click=\"selectBookmark\">\n\t</a>\n\t<a class=\"bookmark-link\" :href=\"mainURL\" target=\"_blank\" rel=\"noopener noreferrer\">\n\t\t<span class=\"thumbnail\" v-if=\"thumbnailVisible\" :style=\"thumbnailStyleURL\"></span>\n\t\t<p class=\"title\" dir=\"auto\">{{title}}\n\t\t\t<i v-if=\"hasContent\" class=\"fas fa-file-alt\"></i>\n\t\t\t<i v-if=\"hasArchive\" class=\"fas fa-archive\"></i>\n\t\t\t<i v-if=\"public\" class=\"fas fa-eye\"></i>\n\t\t</p>\n\t\t<p class=\"excerpt\" v-if=\"excerptVisible\">{{excerpt}}</p>\n\t\t<p class=\"id\" v-show=\"ShowId\">{{id}}</p>\n\t</a>\n\t<div class=\"bookmark-tags\" v-if=\"tags.length > 0\">\n\t\t<a v-for=\"tag in tags\" @click=\"tagClicked($event, tag.name)\">{{tag.name}}</a>\n\t</div>\n\t<div class=\"spacer\"></div>\n\t<div class=\"bookmark-menu\">\n\t\t<a class=\"url\" :href=\"url\" target=\"_blank\" rel=\"noopener noreferrer\">\n\t\t\t{{hostnameURL}}\n\t\t</a>\n\t\t<template v-if=\"!editMode && menuVisible\">\n\t\t\t<a title=\"Edit bookmark\" @click=\"editBookmark\">\n\t\t\t\t<i class=\"fas fa-fw fa-pencil-alt\"></i>\n\t\t\t</a>\n\t\t\t<a title=\"Delete bookmark\" @click=\"deleteBookmark\">\n\t\t\t\t<i class=\"fas fa-fw fa-trash-alt\"></i>\n\t\t\t</a>\n\t\t\t<a title=\"Update archive\" @click=\"updateBookmark\">\n\t\t\t\t<i class=\"fas fa-fw fa-cloud-download-alt\"></i>\n\t\t\t</a>\n            <a v-if=\"hasEbook\" title=\"Download book\" @click=\"downloadebook\">\n                <i class=\"fas fa-fw fa-book\"></i>\n            </a>\n\t\t</template>\n\t</div>\n</div>`;\n\nexport default {\n\ttemplate: template,\n\tprops: {\n\t\tid: Number,\n\t\turl: String,\n\t\ttitle: String,\n\t\texcerpt: String,\n\t\tpublic: Number,\n\t\timageURL: String,\n\t\thasContent: Boolean,\n\t\thasArchive: Boolean,\n\t\thasEbook: Boolean,\n\t\tmodifiedAt: String,\n\t\tindex: Number,\n\t\tShowId: Boolean,\n\t\teditMode: Boolean,\n\t\tListMode: Boolean,\n\t\tHideThumbnail: Boolean,\n\t\tHideExcerpt: Boolean,\n\t\tselected: Boolean,\n\t\tmenuVisible: Boolean,\n\t\ttags: {\n\t\t\ttype: Array,\n\t\t\tdefault() {\n\t\t\t\treturn [];\n\t\t\t},\n\t\t},\n\t},\n\tcomputed: {\n\t\tmainURL() {\n\t\t\tif (this.hasContent) {\n\t\t\t\treturn new URL(`bookmark/${this.id}/content`, document.baseURI);\n\t\t\t} else if (this.hasArchive) {\n\t\t\t\treturn new URL(`bookmark/${this.id}/archive`, document.baseURI);\n\t\t\t} else {\n\t\t\t\treturn this.url;\n\t\t\t}\n\t\t},\n\t\tebookURL() {\n\t\t\tif (this.hasEbook) {\n\t\t\t\treturn new URL(`bookmark/${this.id}/ebook`, document.baseURI);\n\t\t\t} else {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t},\n\t\thostnameURL() {\n\t\t\tvar url = new URL(this.url);\n\t\t\treturn url.hostname.replace(/^www\\./, \"\");\n\t\t},\n\t\tthumbnailVisible() {\n\t\t\treturn this.imageURL !== \"\" && !this.HideThumbnail;\n\t\t},\n\t\texcerptVisible() {\n\t\t\treturn this.excerpt !== \"\" && !this.thumbnailVisible && !this.HideExcerpt;\n\t\t},\n\t\tthumbnailStyleURL() {\n\t\t\treturn {\n\t\t\t\tbackgroundImage: `url(\"${this.imageURL}?modifiedAt=${this.modifiedAt}\")`,\n\t\t\t};\n\t\t},\n\t\teventItem() {\n\t\t\treturn {\n\t\t\t\tid: this.id,\n\t\t\t\tindex: this.index,\n\t\t\t};\n\t\t},\n\t},\n\tmethods: {\n\t\ttagClicked(name, event) {\n\t\t\tthis.$emit(\"tag-clicked\", name, event);\n\t\t},\n\t\tselectBookmark() {\n\t\t\tthis.$emit(\"select\", this.eventItem);\n\t\t},\n\t\teditBookmark() {\n\t\t\tthis.$emit(\"edit\", this.eventItem);\n\t\t},\n\t\tdeleteBookmark() {\n\t\t\tthis.$emit(\"delete\", this.eventItem);\n\t\t},\n\t\tupdateBookmark() {\n\t\t\tthis.$emit(\"update\", this.eventItem);\n\t\t},\n\t\tdownloadebook() {\n\t\t\tconst id = this.id;\n\t\t\tconst ebook_url = new URL(`bookmark/${id}/ebook`, document.baseURI);\n\t\t\tconst downloadLink = document.createElement(\"a\");\n\t\t\tdownloadLink.href = ebook_url.toString();\n\t\t\tdownloadLink.download = `${this.title}.epub`;\n\t\t\tdownloadLink.click();\n\t\t},\n\t},\n};\n"
  },
  {
    "path": "internal/view/assets/js/component/dialog.js",
    "content": "var template = `\n<div v-if=\"visible\" class=\"custom-dialog-overlay\" @keyup.esc=\"handleEscPressed\">\n\t<div class=\"custom-dialog\">\n\t\t<p class=\"custom-dialog-header\">{{title}}</p>\n\t\t<div class=\"custom-dialog-body\">\n\t\t\t<slot>\n\t\t\t\t<p class=\"custom-dialog-content\">{{content}}</p>\n\t\t\t\t<template v-for=\"(field,index) in formFields\">\n\t\t\t\t\t<label v-if=\"showLabel && field.type !== 'check'\">{{field.label}} :</label>\n\t\t\t\t\t<textarea v-if=\"field.type === 'area'\"\n\t\t\t\t\t\t:style=\"{gridColumnEnd: showLabel ? null : 'span 2'}\"\n\t\t\t\t\t\t:placeholder=\"field.label\"\n\t\t\t\t\t\t:tabindex=\"index+1\"\n\t\t\t\t\t\t:name=\"field.name\"\n\t\t\t\t\t\tref=\"input\"\n\t\t\t\t\t\tv-model=\"field.value\"\n\t\t\t\t\t\t@focus=\"$event.target.select()\"\n\t\t\t\t\t\t@keyup=\"handleInput(index)\">\n\t\t\t\t\t</textarea>\n\t\t\t\t\t<label v-else-if=\"field.type === 'check'\" class=\"checkbox-field\">\n\t\t\t\t\t\t<input type=\"checkbox\"\n\t\t\t\t\t\t  :name=\"field.name\"\n\t\t\t\t\t\t\tv-model=\"field.value\"\n\t\t\t\t\t\t\t:tabindex=\"index+1\">{{field.label}}\n\t\t\t\t\t</label>\n\t\t\t\t\t<input v-else\n\t\t\t\t\t\t:style=\"{gridColumnEnd: showLabel ? null : 'span 2'}\"\n\t\t\t\t\t\t:type=\"fieldType(field)\"\n\t\t\t\t\t\t:placeholder=\"field.label\"\n\t\t\t\t\t\t:tabindex=\"index+1\"\n\t\t\t\t\t\t:name=\"field.name\"\n\t\t\t\t\t\tref=\"input\"\n\t\t\t\t\t\tv-model=\"field.value\"\n\t\t\t\t\t\t@focus=\"$event.target.select()\"\n\t\t\t\t\t\t@keyup=\"handleInput(index)\"\n\t\t\t\t\t\t@keyup.enter=\"handleInputEnter(index)\">\n\t\t\t\t\t<button :ref=\"'suggestion-'+index\"\n\t\t\t\t\t\tv-if=\"field.suggestion\"\n\t\t\t\t\t\t@click=\"handleInputEnter(index)\"\n\t\t\t\t\t\tclass=\"suggestion\">{{field.suggestion}}</button>\n\t\t\t\t</template>\n\t\t\t</slot>\n\t\t</div>\n\t\t<div class=\"custom-dialog-footer\">\n\t\t\t<i v-if=\"loading\" class=\"fas fa-fw fa-spinner fa-spin\"></i>\n\t\t\t<slot v-else name=\"custom-footer\">\n\t\t\t\t<a v-if=\"secondText\"\n\t\t\t\t\t:tabindex=\"btnTabIndex+1\"\n\t\t\t\t\t@click=\"handleSecondClick\"\n\t\t\t\t\t@keyup.enter=\"handleSecondClick\"\n\t\t\t\t\tclass=\"custom-dialog-button\">{{secondText}}\n\t\t\t\t</a>\n\t\t\t\t<a :tabindex=\"btnTabIndex\"\n\t\t\t\t\tref=\"mainButton\"\n\t\t\t\t\t@click=\"handleMainClick\"\n\t\t\t\t\t@keyup.enter=\"handleMainClick\"\n\t\t\t\t\tclass=\"custom-dialog-button main\">{{mainText}}\n\t\t\t\t</a>\n\t\t\t</slot>\n\t\t</div>\n\t</div>\n</div>`;\n\nexport default {\n\ttemplate: template,\n\tprops: {\n\t\ttitle: String,\n\t\tloading: Boolean,\n\t\tvisible: Boolean,\n\t\tcontent: {\n\t\t\ttype: String,\n\t\t\tdefault: \"\",\n\t\t},\n\t\tfields: {\n\t\t\ttype: Array,\n\t\t\tdefault() {\n\t\t\t\treturn [];\n\t\t\t},\n\t\t},\n\t\tshowLabel: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\t\tmainText: {\n\t\t\ttype: String,\n\t\t\tdefault: \"OK\",\n\t\t},\n\t\tsecondText: String,\n\t\tmainClick: {\n\t\t\ttype: Function,\n\t\t\tdefault() {\n\t\t\t\tthis.visible = false;\n\t\t\t},\n\t\t},\n\t\tsecondClick: {\n\t\t\ttype: Function,\n\t\t\tdefault() {\n\t\t\t\tthis.visible = false;\n\t\t\t},\n\t\t},\n\t\tescPressed: {\n\t\t\ttype: Function,\n\t\t\tdefault() {\n\t\t\t\tthis.visible = false;\n\t\t\t},\n\t\t},\n\t},\n\tdata() {\n\t\treturn {\n\t\t\tformFields: [],\n\t\t};\n\t},\n\tcomputed: {\n\t\tbtnTabIndex() {\n\t\t\treturn this.fields.length + 1;\n\t\t},\n\t},\n\twatch: {\n\t\tfields: {\n\t\t\timmediate: true,\n\t\t\thandler() {\n\t\t\t\tthis.formFields = this.fields.map((field) => {\n\t\t\t\t\tif (typeof field === \"string\")\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tname: field,\n\t\t\t\t\t\t\tlabel: field,\n\t\t\t\t\t\t\tvalue: \"\",\n\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\tdictionary: [],\n\t\t\t\t\t\t\tseparator: \" \",\n\t\t\t\t\t\t\tsuggestion: undefined,\n\t\t\t\t\t\t};\n\n\t\t\t\t\tif (typeof field === \"object\")\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tname: field.name || \"\",\n\t\t\t\t\t\t\tlabel: field.label || \"\",\n\t\t\t\t\t\t\tvalue: field.value || \"\",\n\t\t\t\t\t\t\ttype: field.type || \"text\",\n\t\t\t\t\t\t\tdictionary:\n\t\t\t\t\t\t\t\tfield.dictionary instanceof Array ? field.dictionary : [],\n\t\t\t\t\t\t\tseparator: field.separator || \" \",\n\t\t\t\t\t\t\tsuggestion: undefined,\n\t\t\t\t\t\t};\n\t\t\t\t});\n\t\t\t},\n\t\t},\n\t\t\"fields.length\"() {\n\t\t\tthis.focus();\n\t\t},\n\t\tvisible: {\n\t\t\timmediate: true,\n\t\t\thandler() {\n\t\t\t\tthis.focus();\n\t\t\t},\n\t\t},\n\t},\n\tmethods: {\n\t\tfieldType(f) {\n\t\t\tvar type = f.type || \"text\";\n\t\t\tif (type !== \"text\" && type !== \"password\") return \"text\";\n\t\t\telse return type;\n\t\t},\n\t\thandleMainClick() {\n\t\t\tvar data = {};\n\t\t\tthis.formFields.forEach((field) => {\n\t\t\t\tvar value = field.value;\n\t\t\t\tif (field.type === \"number\") value = parseInt(value, 10) || 0;\n\t\t\t\telse if (field.type === \"float\") value = parseFloat(value) || 0.0;\n\t\t\t\telse if (field.type === \"check\") value = Boolean(value);\n\t\t\t\tdata[field.name] = value;\n\t\t\t});\n\n\t\t\tthis.mainClick(data);\n\t\t},\n\t\thandleSecondClick() {\n\t\t\tthis.secondClick();\n\t\t},\n\t\thandleEscPressed() {\n\t\t\tthis.escPressed();\n\t\t},\n\t\thandleInput(index) {\n\t\t\t// Create initial variable\n\t\t\tvar field = this.formFields[index],\n\t\t\t\tdictionary = field.dictionary;\n\n\t\t\t// Make sure dictionary is not empty\n\t\t\tif (dictionary.length === 0) return;\n\n\t\t\t// Fetch suggestion from dictionary\n\t\t\tvar words = field.value.split(field.separator),\n\t\t\t\tlastWord = words[words.length - 1].toLowerCase(),\n\t\t\t\tsuggestion;\n\n\t\t\tif (lastWord !== \"\") {\n\t\t\t\tsuggestion = dictionary.find((word) => {\n\t\t\t\t\treturn word.toLowerCase().startsWith(lastWord);\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tthis.formFields[index].suggestion = suggestion;\n\n\t\t\t// Make sure suggestion exist\n\t\t\tif (suggestion == null) return;\n\n\t\t\t// Display suggestion\n\t\t\tthis.$nextTick(() => {\n\t\t\t\tvar input = this.$refs.input[index],\n\t\t\t\t\tsuggestionNode = this.$refs[\"suggestion-\" + index][0],\n\t\t\t\t\tinputRect = input.getBoundingClientRect();\n\n\t\t\t\tsuggestionNode.style.top = inputRect.bottom - 1 + \"px\";\n\t\t\t\tsuggestionNode.style.left = inputRect.left + \"px\";\n\t\t\t});\n\t\t},\n\t\thandleInputEnter(index) {\n\t\t\tvar suggestion = this.formFields[index].suggestion;\n\n\t\t\tif (suggestion == null) {\n\t\t\t\tthis.handleMainClick();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tvar separator = this.formFields[index].separator,\n\t\t\t\twords = this.formFields[index].value.split(separator);\n\n\t\t\twords.pop();\n\t\t\twords.push(suggestion);\n\n\t\t\tthis.formFields[index].value = words.join(separator) + separator;\n\t\t\tthis.formFields[index].suggestion = undefined;\n\t\t\t// Focus input again after suggestion is accepted\n\t\t\tthis.$refs.input[index].focus();\n\t\t},\n\t\tfocus() {\n\t\t\tthis.$nextTick(() => {\n\t\t\t\tif (!this.visible) return;\n\n\t\t\t\tvar fields = this.$refs.input,\n\t\t\t\t\totherInput = this.$el.querySelectorAll(\"input\"),\n\t\t\t\t\tbutton = this.$refs.mainButton;\n\n\t\t\t\tif (fields && fields.length > 0) {\n\t\t\t\t\tthis.$refs.input[0].focus();\n\t\t\t\t\tthis.$refs.input[0].select();\n\t\t\t\t} else if (otherInput && otherInput.length > 0) {\n\t\t\t\t\totherInput[0].focus();\n\t\t\t\t\totherInput[0].select();\n\t\t\t\t} else if (button) {\n\t\t\t\t\tbutton.focus();\n\t\t\t\t}\n\t\t\t});\n\t\t},\n\t},\n};\n"
  },
  {
    "path": "internal/view/assets/js/component/eventBus.js",
    "content": "// Create a new Vue instance as the EventBus\nconst EventBus = new Vue();\n\nexport default EventBus;\n"
  },
  {
    "path": "internal/view/assets/js/component/login.js",
    "content": "import { apiRequest } from \"../utils/api.js\";\n\nconst template = `\n<div id=\"login-scene\">\n    <p class=\"error-message\" v-if=\"error !== ''\">{{error}}</p>\n    <div id=\"login-box\">\n        <form @submit.prevent=\"login\">\n            <div id=\"logo-area\">\n                <p id=\"logo\">\n                    <span>栞</span>shiori\n                </p>\n                <p id=\"tagline\">simple bookmark manager</p>\n            </div>\n            <div id=\"input-area\">\n                <label for=\"username\">Username: </label>\n                <input id=\"username\" type=\"text\" name=\"username\" placeholder=\"Username\" tabindex=\"1\" autofocus />\n                <label for=\"password\">Password: </label>\n                <input id=\"password\" type=\"password\" name=\"password\" placeholder=\"Password\" tabindex=\"2\"\n                    @keyup.enter=\"login\">\n                <label class=\"checkbox-field\"><input type=\"checkbox\" name=\"remember\" v-model=\"remember\"\n                        tabindex=\"3\">Remember me</label>\n            </div>\n            <div id=\"button-area\">\n                <a v-if=\"loading\">\n                    <i class=\"fas fa-fw fa-spinner fa-spin\"></i>\n                </a>\n                <a v-else class=\"button\" tabindex=\"4\" @click=\"login\" @keyup.enter=\"login\">Log In</a>\n            </div>\n        </form>\n    </div>\n</div>\n`;\n\nexport default {\n\tname: \"login-view\",\n\ttemplate,\n\tdata() {\n\t\treturn {\n\t\t\terror: \"\",\n\t\t\tloading: false,\n\t\t\tusername: \"\",\n\t\t\tpassword: \"\",\n\t\t\tremember: false,\n\t\t\tdestination: \"/\", // Default destination\n\t\t};\n\t},\n\temits: [\"login-success\"],\n\tmethods: {\n\t\tsanitizeDestination(dst) {\n\t\t\ttry {\n\t\t\t\t// Remove any leading/trailing whitespace\n\t\t\t\tdst = dst.trim();\n\n\t\t\t\t// Decode the URL to handle any encoded characters\n\t\t\t\tdst = decodeURIComponent(dst);\n\n\t\t\t\t// Create a URL object to parse the destination\n\t\t\t\tconst url = new URL(dst, window.location.origin);\n\n\t\t\t\t// Only allow paths from the same origin\n\t\t\t\tif (url.origin !== window.location.origin) {\n\t\t\t\t\treturn \"/\";\n\t\t\t\t}\n\n\t\t\t\t// Only return the pathname and search params\n\t\t\t\treturn url.pathname + url.search + url.hash;\n\t\t\t} catch (e) {\n\t\t\t\t// If any error occurs during parsing, return root\n\t\t\t\treturn \"/\";\n\t\t\t}\n\t\t},\n\n\t\tparseJWT(token) {\n\t\t\ttry {\n\t\t\t\treturn JSON.parse(atob(token.split(\".\")[1]));\n\t\t\t} catch (e) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t},\n\n\t\tasync login() {\n\t\t\t// Get values directly from the form\n\t\t\tconst usernameInput = document.querySelector(\"#username\");\n\t\t\tconst passwordInput = document.querySelector(\"#password\");\n\t\t\tthis.username = usernameInput ? usernameInput.value : this.username;\n\t\t\tthis.password = passwordInput ? passwordInput.value : this.password;\n\n\t\t\t// Validate input\n\t\t\tif (this.username === \"\") {\n\t\t\t\tthis.error = \"Username must not empty\";\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Remove old cookie\n\t\t\tdocument.cookie = `token=; Path=${\n\t\t\t\tnew URL(document.baseURI).pathname\n\t\t\t}; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`;\n\n\t\t\t// Send request\n\t\t\tthis.loading = true;\n\n\t\t\ttry {\n\t\t\t\tconst json = await apiRequest(\n\t\t\t\t\tnew URL(\"api/v1/auth/login\", document.baseURI),\n\t\t\t\t\t{\n\t\t\t\t\t\tmethod: \"post\",\n\t\t\t\t\t\tbody: JSON.stringify({\n\t\t\t\t\t\t\tusername: this.username,\n\t\t\t\t\t\t\tpassword: this.password,\n\t\t\t\t\t\t\tremember_me: this.remember == 1 ? true : false,\n\t\t\t\t\t\t}),\n\t\t\t\t\t},\n\t\t\t\t);\n\n\t\t\t\t// Save session id\n\t\t\t\tdocument.cookie = `token=${json.token}; Path=${\n\t\t\t\t\tnew URL(document.baseURI).pathname\n\t\t\t\t}; Expires=${new Date(json.expires * 1000).toUTCString()}`;\n\n\t\t\t\t// Save account data\n\t\t\t\tlocalStorage.setItem(\"shiori-token\", json.token);\n\t\t\t\tlocalStorage.setItem(\n\t\t\t\t\t\"shiori-account\",\n\t\t\t\t\tJSON.stringify(this.parseJWT(json.token).account),\n\t\t\t\t);\n\n\t\t\t\tthis.visible = false;\n\t\t\t\tthis.$emit(\"login-success\");\n\n\t\t\t\t// Redirect to sanitized destination\n\t\t\t\tif (this.destination !== \"/\") window.location.href = this.destination;\n\t\t\t} catch (err) {\n\t\t\t\tthis.error = err.message;\n\t\t\t} finally {\n\t\t\t\tthis.loading = false;\n\t\t\t}\n\t\t},\n\n\t\tasync checkSession() {\n\t\t\tconst token = localStorage.getItem(\"shiori-token\");\n\t\t\tif (!token) return false;\n\n\t\t\ttry {\n\t\t\t\tawait apiRequest(new URL(\"api/v1/auth/me\", document.baseURI));\n\t\t\t\treturn true;\n\t\t\t} catch (err) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t},\n\t},\n\tasync mounted() {\n\t\t// Get and sanitize destination from URL parameters\n\t\tconst urlParams = new URLSearchParams(window.location.search);\n\t\tconst dst = urlParams.get(\"dst\");\n\t\tthis.destination = dst ? this.sanitizeDestination(dst) : \"/\";\n\n\t\t// Check if there's a valid session\n\t\tif (await this.checkSession()) {\n\t\t\tthis.$emit(\"login-success\");\n\t\t\treturn;\n\t\t}\n\n\t\t// Clear session data if we reach here\n\t\tdocument.cookie = `token=; Path=${\n\t\t\tnew URL(document.baseURI).pathname\n\t\t}; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`;\n\n\t\tlocalStorage.removeItem(\"shiori-account\");\n\t\tlocalStorage.removeItem(\"shiori-token\");\n\n\t\t// Focus username input\n\t\tthis.$nextTick(() => {\n\t\t\tconst usernameInput = document.querySelector(\"#username\");\n\t\t\tif (usernameInput) {\n\t\t\t\tusernameInput.focus();\n\t\t\t}\n\t\t});\n\t},\n};\n"
  },
  {
    "path": "internal/view/assets/js/component/pagination.js",
    "content": "var template = `\n<div class=\"pagination-box\">\n\t<p>Page</p>\n\t<input type=\"text\" \n\t\tplaceholder=\"1\" \n\t\t:value=\"page\" \n\t\t@focus=\"$event.target.select()\" \n\t\t@keyup.enter=\"changePage($event.target.value)\" \n\t\t:disabled=\"editMode\">\n\t<p>{{maxPage}}</p>\n\t<div class=\"spacer\"></div>\n\t<template v-if=\"!editMode\">\n\t\t<a v-if=\"page > 2\" title=\"Go to first page\" @click=\"changePage(1)\">\n\t\t\t<i class=\"fas fa-fw fa-angle-double-left\"></i>\n\t\t</a>\n\t\t<a v-if=\"page > 1\" title=\"Go to previous page\" @click=\"changePage(page-1)\">\n\t\t\t<i class=\"fa fa-fw fa-angle-left\"></i>\n\t\t</a>\n\t\t<a v-if=\"page < maxPage\" title=\"Go to next page\" @click=\"changePage(page+1)\">\n\t\t\t<i class=\"fa fa-fw fa-angle-right\"></i>\n\t\t</a>\n\t\t<a v-if=\"page < maxPage - 1\" title=\"Go to last page\" @click=\"changePage(maxPage)\">\n\t\t\t<i class=\"fas fa-fw fa-angle-double-right\"></i>\n\t\t</a>\n\t</template>\n</div>`;\n\nexport default {\n\ttemplate: template,\n\tprops: {\n\t\tpage: Number,\n\t\tmaxPage: Number,\n\t\teditMode: Boolean,\n\t},\n\tmethods: {\n\t\tchangePage(page) {\n\t\t\tpage = parseInt(page, 10) || 0;\n\t\t\tif (page >= this.maxPage) page = this.maxPage;\n\t\t\telse if (page <= 1) page = 1;\n\n\t\t\tthis.$emit(\"change\", page);\n\t\t},\n\t},\n};\n"
  },
  {
    "path": "internal/view/assets/js/page/base.js",
    "content": "import { apiRequest } from \"../utils/api.js\";\n\nexport default {\n\tprops: {\n\t\tactiveAccount: {\n\t\t\ttype: Object,\n\t\t\tdefault() {\n\t\t\t\treturn {\n\t\t\t\t\tid: 0,\n\t\t\t\t\tusername: \"\",\n\t\t\t\t\towner: false,\n\t\t\t\t};\n\t\t\t},\n\t\t},\n\t\tappOptions: {\n\t\t\ttype: Object,\n\t\t\tdefault() {\n\t\t\t\treturn {\n\t\t\t\t\tShowId: false,\n\t\t\t\t\tListMode: false,\n\t\t\t\t\tHideThumbnail: false,\n\t\t\t\t\tHideExcerpt: false,\n\t\t\t\t\tKeepMetadata: false,\n\t\t\t\t\tUseArchive: false,\n\t\t\t\t\tCreateEbook: false,\n\t\t\t\t\tMakePublic: false,\n\t\t\t\t};\n\t\t\t},\n\t\t},\n\t},\n\tdata() {\n\t\treturn {\n\t\t\tdialog: {},\n\t\t};\n\t},\n\tmethods: {\n\t\tdefaultDialog() {\n\t\t\treturn {\n\t\t\t\tvisible: false,\n\t\t\t\tloading: false,\n\t\t\t\ttitle: \"\",\n\t\t\t\tcontent: \"\",\n\t\t\t\tfields: [],\n\t\t\t\tshowLabel: false,\n\t\t\t\tmainText: \"Yes\",\n\t\t\t\tsecondText: \"\",\n\t\t\t\tmainClick: () => {\n\t\t\t\t\tthis.dialog.visible = false;\n\t\t\t\t},\n\t\t\t\tsecondClick: () => {\n\t\t\t\t\tthis.dialog.visible = false;\n\t\t\t\t},\n\t\t\t\tescPressed: () => {\n\t\t\t\t\tif (!this.loading) this.dialog.visible = false;\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t\tshowDialog(opt) {\n\t\t\tthis.dialog = {\n\t\t\t\tvisible: true,\n\t\t\t\t...opt,\n\t\t\t};\n\t\t},\n\t\tasync getErrorMessage(err) {\n\t\t\tswitch (err.constructor) {\n\t\t\t\tcase Error:\n\t\t\t\t\treturn err.message;\n\t\t\t\tcase Response:\n\t\t\t\t\tvar text = await err.text();\n\n\t\t\t\t\t// Handle new error messages\n\t\t\t\t\tif (text[0] == \"{\") {\n\t\t\t\t\t\tvar json = JSON.parse(text);\n\t\t\t\t\t\treturn json.error;\n\t\t\t\t\t}\n\n\t\t\t\t\treturn `${text} (${err.status})`;\n\t\t\t\tdefault:\n\t\t\t\t\treturn err;\n\t\t\t}\n\t\t},\n\t\tisSessionError(err) {\n\t\t\tswitch (\n\t\t\t\terr\n\t\t\t\t\t.toString()\n\t\t\t\t\t.replace(/\\(\\d+\\)/g, \"\")\n\t\t\t\t\t.trim()\n\t\t\t\t\t.toLowerCase()\n\t\t\t) {\n\t\t\t\tcase \"session is not exist\":\n\t\t\t\tcase \"session has been expired\":\n\t\t\t\t\treturn true;\n\t\t\t\tdefault:\n\t\t\t\t\treturn false;\n\t\t\t}\n\t\t},\n\t\tthemeSwitch(theme) {\n\t\t\tswitch (theme) {\n\t\t\t\tcase \"light\":\n\t\t\t\t\tdocument.body.classList.remove(\"dark\");\n\t\t\t\t\tdocument.body.classList.add(\"light\");\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"dark\":\n\t\t\t\t\tdocument.body.classList.remove(\"light\");\n\t\t\t\t\tdocument.body.classList.add(\"dark\");\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"follow\":\n\t\t\t\t\tdocument.body.classList.remove(\"light\", \"dark\");\n\t\t\t\t\tbreak;\n\t\t\t\tdefault:\n\t\t\t\t\tconsole.error(\"Invalid theme selected\");\n\t\t\t}\n\t\t},\n\t\tshowErrorDialog(msg) {\n\t\t\tthis.showDialog({\n\t\t\t\ttitle: \"Error\",\n\t\t\t\tcontent: msg,\n\t\t\t\tmainText: \"OK\",\n\t\t\t\tmainClick: () => {\n\t\t\t\t\tthis.dialog.visible = false;\n\t\t\t\t},\n\t\t\t\tescPressed: () => {\n\t\t\t\t\tthis.dialog.visible = false;\n\t\t\t\t},\n\t\t\t});\n\t\t},\n\t\tasync saveSetting(key, value) {\n\t\t\ttry {\n\t\t\t\tawait apiRequest(new URL(\"api/v1/settings\", document.baseURI), {\n\t\t\t\t\tmethod: \"PUT\",\n\t\t\t\t\tbody: JSON.stringify({ [key]: value }),\n\t\t\t\t});\n\t\t\t} catch (err) {\n\t\t\t\tthis.showErrorDialog(err.message);\n\t\t\t}\n\t\t},\n\t\tasync logout() {\n\t\t\ttry {\n\t\t\t\tawait apiRequest(new URL(\"api/v1/auth/logout\", document.baseURI), {\n\t\t\t\t\tmethod: \"POST\",\n\t\t\t\t});\n\n\t\t\t\t// Clear session data\n\t\t\t\tdocument.cookie = `token=; Path=${\n\t\t\t\t\tnew URL(document.baseURI).pathname\n\t\t\t\t}; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`;\n\n\t\t\t\tlocalStorage.removeItem(\"shiori-account\");\n\t\t\t\tlocalStorage.removeItem(\"shiori-token\");\n\n\t\t\t\t// Reload page\n\t\t\t\twindow.location.reload();\n\t\t\t} catch (err) {\n\t\t\t\tthis.showErrorDialog(err.message);\n\t\t\t}\n\t\t},\n\t},\n};\n"
  },
  {
    "path": "internal/view/assets/js/page/home.js",
    "content": "var template = `\n<div id=\"page-home\">\n    <div class=\"page-header\">\n        <input type=\"text\" placeholder=\"Search url, keyword or tags\" v-model.trim=\"search\" @focus=\"$event.target.select()\" @keyup.enter=\"searchBookmarks\"/>\n        <a title=\"Refresh storage\" @click=\"reloadData\">\n            <i class=\"fas fa-fw fa-sync-alt\" :class=\"loading && 'fa-spin'\"></i>\n        </a>\n        <a v-if=\"activeAccount.owner\" title=\"Add new bookmark\" @click=\"showDialogAdd\">\n            <i class=\"fas fa-fw fa-plus-circle\"></i>\n        </a>\n        <a v-if=\"tags.length > 0\" title=\"Show tags\" @click=\"showDialogTags\">\n            <i class=\"fas fa-fw fa-tags\"></i>\n        </a>\n        <a v-if=\"activeAccount.owner\" title=\"Batch edit\" @click=\"toggleEditMode\">\n            <i class=\"fas fa-fw fa-pencil-alt\"></i>\n        </a>\n    </div>\n    <div class=\"page-header\" id=\"edit-box\" v-if=\"editMode\">\n        <p>{{selection.length}} items selected</p>\n        <a title=\"Delete bookmark\" @click=\"showDialogDelete(selection)\">\n            <i class=\"fas fa-fw fa-trash-alt\"></i>\n        </a>\n        <a title=\"Add tags\" @click=\"showDialogAddTags(selection)\">\n            <i class=\"fas fa-fw fa-tags\"></i>\n        </a>\n        <a title=\"Update archives\" @click=\"showDialogUpdateCache(selection)\">\n            <i class=\"fas fa-fw fa-cloud-download-alt\"></i>\n        </a>\n        <a title=\"Download ebooks\" @click=\"ebookGenerate(selection)\">\n            <i class=\"fas fa-fw fa-book\"></i>\n        </a>\n        <a title=\"Cancel\" @click=\"toggleEditMode\">\n            <i class=\"fas fa-fw fa-times\"></i>\n        </a>\n    </div>\n    <p class=\"empty-message\" v-if=\"!loading && listIsEmpty\">No saved bookmarks yet :(</p>\n    <div id=\"bookmarks-grid\" ref=\"bookmarksGrid\" :class=\"{list: appOptions.ListMode}\">\n        <pagination-box v-if=\"maxPage > 1\"\n            :page=\"page\"\n            :maxPage=\"maxPage\"\n            :editMode=\"editMode\"\n            @change=\"changePage\">\n        </pagination-box>\n        <bookmark-item v-for=\"(book, index) in bookmarks\"\n            :id=\"book.id\"\n            :url=\"book.url\"\n            :title=\"book.title\"\n            :excerpt=\"book.excerpt\"\n            :public=\"book.public\"\n            :imageURL=\"book.imageURL\"\n            :modifiedAt=\"book.modifiedAt\"\n            :hasContent=\"book.hasContent\"\n            :hasArchive=\"book.hasArchive\"\n            :hasEbook=\"book.hasEbook\"\n            :tags=\"book.tags\"\n            :index=\"index\"\n            :key=\"book.id\"\n            :editMode=\"editMode\"\n            :ShowId=\"appOptions.ShowId\"\n            :ListMode=\"appOptions.ListMode\"\n            :HideThumbnail=\"appOptions.HideThumbnail\"\n            :HideExcerpt=\"appOptions.HideExcerpt\"\n            :selected=\"isSelected(book.id)\"\n            :menuVisible=\"activeAccount.owner\"\n            @select=\"toggleSelection\"\n            @tag-clicked=\"bookmarkTagClicked\"\n            @edit=\"showDialogEdit\"\n            @delete=\"showDialogDelete\"\n            @generate-ebook=\"ebookGenerate\"\n            @update=\"showDialogUpdateCache\">\n        </bookmark-item>\n        <pagination-box v-if=\"maxPage > 1\"\n            :page=\"page\"\n            :maxPage=\"maxPage\"\n            :editMode=\"editMode\"\n            @change=\"changePage\">\n        </pagination-box>\n    </div>\n    <div class=\"loading-overlay\" v-if=\"loading\"><i class=\"fas fa-fw fa-spin fa-spinner\"></i></div>\n    <custom-dialog id=\"dialog-tags\" v-bind=\"dialogTags\">\n        <a @click=\"filterTag('*')\">(all tagged)</a>\n        <a @click=\"filterTag('*', true)\">(all untagged)</a>\n        <a v-for=\"tag in tags\" @click=\"dialogTagClicked($event, tag)\">\n            #{{tag.name}}<span>{{tag.bookmark_count}}</span>\n        </a>\n    </custom-dialog>\n    <custom-dialog v-bind=\"dialog\"/>\n</div>`;\n\nimport paginationBox from \"../component/pagination.js\";\nimport bookmarkItem from \"../component/bookmark.js\";\nimport customDialog from \"../component/dialog.js\";\nimport basePage from \"./base.js\";\nimport EventBus from \"../component/eventBus.js\";\nimport { apiRequest } from \"../utils/api.js\";\n\nVue.prototype.$bus = EventBus;\n\nexport default {\n\ttemplate: template,\n\tmixins: [basePage],\n\tcomponents: {\n\t\tbookmarkItem,\n\t\tpaginationBox,\n\t\tcustomDialog,\n\t},\n\tdata() {\n\t\treturn {\n\t\t\tloading: false,\n\t\t\teditMode: false,\n\t\t\tselection: [],\n\n\t\t\tsearch: \"\",\n\t\t\tpage: 0,\n\t\t\tmaxPage: 0,\n\t\t\tbookmarks: [],\n\t\t\ttags: [],\n\n\t\t\tdialogTags: {\n\t\t\t\tvisible: false,\n\t\t\t\teditMode: false,\n\t\t\t\ttitle: \"Existing Tags\",\n\t\t\t\tmainText: \"OK\",\n\t\t\t\tsecondText: \"Rename Tags\",\n\t\t\t\tmainClick: () => {\n\t\t\t\t\tif (this.dialogTags.editMode) {\n\t\t\t\t\t\tthis.dialogTags.editMode = false;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.dialogTags.visible = false;\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\tsecondClick: () => {\n\t\t\t\t\tthis.dialogTags.editMode = true;\n\t\t\t\t},\n\t\t\t\tescPressed: () => {\n\t\t\t\t\tthis.dialogTags.visible = false;\n\t\t\t\t\tthis.dialogTags.editMode = false;\n\t\t\t\t},\n\t\t\t},\n\t\t};\n\t},\n\tcomputed: {\n\t\tlistIsEmpty() {\n\t\t\treturn this.bookmarks.length <= 0;\n\t\t},\n\t},\n\twatch: {\n\t\t\"dialogTags.editMode\"(editMode) {\n\t\t\tif (editMode) {\n\t\t\t\tthis.dialogTags.title = \"Rename Tags\";\n\t\t\t\tthis.dialogTags.mainText = \"Cancel\";\n\t\t\t\tthis.dialogTags.secondText = \"\";\n\t\t\t} else {\n\t\t\t\tthis.dialogTags.title = \"Existing Tags\";\n\t\t\t\tthis.dialogTags.mainText = \"OK\";\n\t\t\t\tthis.dialogTags.secondText = \"Rename Tags\";\n\t\t\t}\n\t\t},\n\t},\n\tmethods: {\n\t\tclearHomePage() {\n\t\t\tthis.search = \"\";\n\t\t\tthis.searchBookmarks();\n\t\t},\n\t\treloadData() {\n\t\t\tif (this.loading) return;\n\t\t\tthis.page = 1;\n\t\t\tthis.search = \"\";\n\t\t\tthis.loadData(true, true);\n\t\t},\n\t\tasync loadData(saveState, fetchTags) {\n\t\t\tif (this.loading) return;\n\n\t\t\t// Set default args\n\t\t\tsaveState = typeof saveState === \"boolean\" ? saveState : true;\n\t\t\tfetchTags = typeof fetchTags === \"boolean\" ? fetchTags : false;\n\n\t\t\t// Parse search query\n\t\t\tvar keyword = this.search,\n\t\t\t\trxExcludeTagA = /(^|\\s)-tag:[\"']([^\"']+)[\"']/i, // -tag:\"with space\"\n\t\t\t\trxExcludeTagB = /(^|\\s)-tag:(\\S+)/i, // -tag:without-space\n\t\t\t\trxIncludeTagA = /(^|\\s)tag:[\"']([^\"']+)[\"']/i, // tag:\"with space\"\n\t\t\t\trxIncludeTagB = /(^|\\s)tag:(\\S+)/i, // tag:without-space\n\t\t\t\ttags = [],\n\t\t\t\texcludedTags = [],\n\t\t\t\trxResult;\n\n\t\t\t// Get excluded tag first, while also removing it from keyword\n\t\t\twhile ((rxResult = rxExcludeTagA.exec(keyword))) {\n\t\t\t\tkeyword = keyword.replace(rxResult[0], \"\");\n\t\t\t\texcludedTags.push(rxResult[2]);\n\t\t\t}\n\n\t\t\twhile ((rxResult = rxExcludeTagB.exec(keyword))) {\n\t\t\t\tkeyword = keyword.replace(rxResult[0], \"\");\n\t\t\t\texcludedTags.push(rxResult[2]);\n\t\t\t}\n\n\t\t\t// Get included tags\n\t\t\twhile ((rxResult = rxIncludeTagA.exec(keyword))) {\n\t\t\t\tkeyword = keyword.replace(rxResult[0], \"\");\n\t\t\t\ttags.push(rxResult[2]);\n\t\t\t}\n\n\t\t\twhile ((rxResult = rxIncludeTagB.exec(keyword))) {\n\t\t\t\tkeyword = keyword.replace(rxResult[0], \"\");\n\t\t\t\ttags.push(rxResult[2]);\n\t\t\t}\n\n\t\t\t// Trim keyword\n\t\t\tkeyword = keyword.trim().replace(/\\s+/g, \" \");\n\n\t\t\t// Prepare URL for API\n\t\t\tvar url = new URL(\"api/bookmarks\", document.baseURI);\n\t\t\turl.search = new URLSearchParams({\n\t\t\t\tkeyword: keyword,\n\t\t\t\ttags: tags.join(\",\"),\n\t\t\t\texclude: excludedTags.join(\",\"),\n\t\t\t\tpage: this.page,\n\t\t\t});\n\n\t\t\t// Fetch data from API\n\t\t\tvar skipFetchTags = Error(\"skip fetching tags\");\n\n\t\t\tthis.loading = true;\n\t\t\ttry {\n\t\t\t\tconst json = await apiRequest(url);\n\n\t\t\t\t// Set data\n\t\t\t\tthis.page = json.page;\n\t\t\t\tthis.maxPage = json.maxPage;\n\t\t\t\tthis.bookmarks = json.bookmarks;\n\n\t\t\t\t// Save state and change URL if needed\n\t\t\t\tif (saveState) {\n\t\t\t\t\tvar history = {\n\t\t\t\t\t\tactivePage: \"page-home\",\n\t\t\t\t\t\tsearch: this.search,\n\t\t\t\t\t\tpage: this.page,\n\t\t\t\t\t};\n\n\t\t\t\t\tvar url = new Url(document.baseURI);\n\t\t\t\t\turl.hash = \"home\";\n\t\t\t\t\turl.query = new URLSearchParams({\n\t\t\t\t\t\tpage: this.page,\n\t\t\t\t\t\tsearch: this.search,\n\t\t\t\t\t}).toString();\n\n\t\t\t\t\twindow.history.pushState(history, null, url.toString());\n\t\t\t\t}\n\n\t\t\t\t// Fetch tags if needed\n\t\t\t\tif (!fetchTags) throw skipFetchTags;\n\n\t\t\t\tconst tagsUrl = new URL(\"api/tags\", document.baseURI);\n\t\t\t\tconst tagsJson = await apiRequest(tagsUrl);\n\t\t\t\tthis.tags = tagsJson;\n\t\t\t} catch (err) {\n\t\t\t\tif (err !== skipFetchTags) {\n\t\t\t\t\tthis.showErrorDialog(err.message);\n\t\t\t\t}\n\t\t\t} finally {\n\t\t\t\tthis.loading = false;\n\t\t\t}\n\t\t},\n\t\tsearchBookmarks() {\n\t\t\tthis.page = 1;\n\t\t\tthis.loadData();\n\t\t},\n\t\tchangePage(page) {\n\t\t\tthis.page = page;\n\t\t\tthis.$refs.bookmarksGrid.scrollTop = 0;\n\t\t\tthis.loadData();\n\t\t},\n\t\ttoggleEditMode() {\n\t\t\tthis.selection = [];\n\t\t\tthis.editMode = !this.editMode;\n\t\t},\n\t\ttoggleSelection(item) {\n\t\t\tvar idx = this.selection.findIndex((el) => el.id === item.id);\n\t\t\tif (idx === -1) this.selection.push(item);\n\t\t\telse this.selection.splice(idx, 1);\n\t\t},\n\t\tisSelected(bookId) {\n\t\t\treturn this.selection.findIndex((el) => el.id === bookId) > -1;\n\t\t},\n\t\tdialogTagClicked(event, tag) {\n\t\t\tif (!this.dialogTags.editMode) {\n\t\t\t\tthis.filterTag(tag.name, event.altKey);\n\t\t\t} else {\n\t\t\t\tthis.dialogTags.visible = false;\n\t\t\t\tthis.showDialogRenameTag(tag);\n\t\t\t}\n\t\t},\n\t\tbookmarkTagClicked(event, tagName) {\n\t\t\tthis.filterTag(tagName, event.altKey);\n\t\t},\n\t\tfilterTag(tagName, excludeMode) {\n\t\t\t// Set default parameter\n\t\t\texcludeMode = typeof excludeMode === \"boolean\" ? excludeMode : false;\n\n\t\t\tif (this.dialogTags.editMode) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (tagName === \"*\") {\n\t\t\t\tthis.search = excludeMode ? \"-tag:*\" : \"tag:*\";\n\t\t\t\tthis.page = 1;\n\t\t\t\tthis.loadData();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tvar rxSpace = /\\s+/g,\n\t\t\t\tincludeTag = rxSpace.test(tagName)\n\t\t\t\t\t? `tag:\"${tagName}\"`\n\t\t\t\t\t: `tag:${tagName}`,\n\t\t\t\texcludeTag = \"-\" + includeTag,\n\t\t\t\trxIncludeTag = new RegExp(`(^|\\\\s)${includeTag}`, \"ig\"),\n\t\t\t\trxExcludeTag = new RegExp(`(^|\\\\s)${excludeTag}`, \"ig\"),\n\t\t\t\tsearch = this.search;\n\n\t\t\tsearch = search.replace(\"-tag:*\", \"\");\n\t\t\tsearch = search.replace(\"tag:*\", \"\");\n\t\t\tsearch = search.trim();\n\n\t\t\tif (excludeMode) {\n\t\t\t\tif (rxExcludeTag.test(search)) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (rxIncludeTag.test(search)) {\n\t\t\t\t\tthis.search = search.replace(rxIncludeTag, \"$1\" + excludeTag);\n\t\t\t\t} else {\n\t\t\t\t\tsearch += ` ${excludeTag}`;\n\t\t\t\t\tthis.search = search.trim();\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif (rxIncludeTag.test(search)) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (rxExcludeTag.test(search)) {\n\t\t\t\t\tthis.search = search.replace(rxExcludeTag, \"$1\" + includeTag);\n\t\t\t\t} else {\n\t\t\t\t\tsearch += ` ${includeTag}`;\n\t\t\t\t\tthis.search = search.trim();\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tthis.page = 1;\n\t\t\tthis.loadData();\n\t\t},\n\t\tshowDialogAdd(values) {\n\t\t\tif (values === undefined) {\n\t\t\t\tvalues = {};\n\t\t\t}\n\n\t\t\tthis.showDialog({\n\t\t\t\ttitle: \"New Bookmark\",\n\t\t\t\tcontent: \"Create a new bookmark\",\n\t\t\t\tfields: [\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"url\",\n\t\t\t\t\t\tlabel: \"Url, start with http://...\",\n\t\t\t\t\t\tvalue: values.url || \"\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"title\",\n\t\t\t\t\t\tlabel: \"Custom title (optional)\",\n\t\t\t\t\t\tvalue: values.title || \"\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"excerpt\",\n\t\t\t\t\t\tlabel: \"Custom excerpt (optional)\",\n\t\t\t\t\t\ttype: \"area\",\n\t\t\t\t\t\tvalue: values.excerpt || \"\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"tags\",\n\t\t\t\t\t\tlabel: \"Comma separated tags (optional)\",\n\t\t\t\t\t\tseparator: \",\",\n\t\t\t\t\t\tdictionary: this.tags.map((tag) => tag.name),\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"create_archive\",\n\t\t\t\t\t\tlabel: \"Create archive\",\n\t\t\t\t\t\ttype: \"check\",\n\t\t\t\t\t\tvalue: this.appOptions.UseArchive,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"create_ebook\",\n\t\t\t\t\t\tlabel: \"Create Ebook\",\n\t\t\t\t\t\ttype: \"check\",\n\t\t\t\t\t\tvalue: this.appOptions.CreateEbook,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"makePublic\",\n\t\t\t\t\t\tlabel: \"Make bookmark publicly available\",\n\t\t\t\t\t\ttype: \"check\",\n\t\t\t\t\t\tvalue: this.appOptions.MakePublic,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\tmainText: \"OK\",\n\t\t\t\tsecondText: \"Cancel\",\n\t\t\t\tmainClick: async (data) => {\n\t\t\t\t\t// Make sure URL is not empty\n\t\t\t\t\tif (data.url.trim() === \"\") {\n\t\t\t\t\t\tthis.showErrorDialog(\"URL must not empty\");\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Prepare tags\n\t\t\t\t\tvar tags = data.tags\n\t\t\t\t\t\t.toLowerCase()\n\t\t\t\t\t\t.replace(/\\s+/g, \" \")\n\t\t\t\t\t\t.split(/\\s*,\\s*/g)\n\t\t\t\t\t\t.filter((tag) => tag.trim() !== \"\")\n\t\t\t\t\t\t.map((tag) => ({\n\t\t\t\t\t\t\tname: tag.trim(),\n\t\t\t\t\t\t}));\n\n\t\t\t\t\t// Send data\n\t\t\t\t\tvar requestData = {\n\t\t\t\t\t\turl: data.url.trim(),\n\t\t\t\t\t\ttitle: data.title.trim(),\n\t\t\t\t\t\texcerpt: data.excerpt.trim(),\n\t\t\t\t\t\tpublic: data.makePublic ? 1 : 0,\n\t\t\t\t\t\ttags: tags,\n\t\t\t\t\t\tcreate_archive: data.create_archive,\n\t\t\t\t\t\tcreate_ebook: data.create_ebook,\n\t\t\t\t\t};\n\n\t\t\t\t\tthis.dialog.loading = true;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst json = await apiRequest(\n\t\t\t\t\t\t\tnew URL(\"api/bookmarks\", document.baseURI),\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tmethod: \"post\",\n\t\t\t\t\t\t\t\tbody: JSON.stringify(requestData),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.dialog.visible = false;\n\t\t\t\t\t\tthis.bookmarks.splice(0, 0, json);\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.showErrorDialog(err.message);\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t});\n\t\t},\n\t\tshowDialogEdit(item) {\n\t\t\t// Check the item\n\t\t\tif (typeof item !== \"object\") return;\n\n\t\t\tvar id = typeof item.id === \"number\" ? item.id : 0,\n\t\t\t\tindex = typeof item.index === \"number\" ? item.index : -1;\n\n\t\t\tif (id < 1 || index < 0) return;\n\n\t\t\t// Get the existing bookmark value\n\t\t\tvar book = JSON.parse(JSON.stringify(this.bookmarks[index])),\n\t\t\t\tstrTags = book.tags.map((tag) => tag.name).join(\", \");\n\n\t\t\tthis.showDialog({\n\t\t\t\ttitle: \"Edit Bookmark\",\n\t\t\t\tcontent: \"Edit the bookmark's data\",\n\t\t\t\tshowLabel: true,\n\t\t\t\tfields: [\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"url\",\n\t\t\t\t\t\tlabel: \"Url\",\n\t\t\t\t\t\tvalue: book.url,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"title\",\n\t\t\t\t\t\tlabel: \"Title\",\n\t\t\t\t\t\tvalue: book.title,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"excerpt\",\n\t\t\t\t\t\tlabel: \"Excerpt\",\n\t\t\t\t\t\ttype: \"area\",\n\t\t\t\t\t\tvalue: book.excerpt,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"tags\",\n\t\t\t\t\t\tlabel: \"Tags\",\n\t\t\t\t\t\tvalue: strTags,\n\t\t\t\t\t\tseparator: \",\",\n\t\t\t\t\t\tdictionary: this.tags.map((tag) => tag.name),\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"makePublic\",\n\t\t\t\t\t\tlabel: \"Make bookmark publicly available\",\n\t\t\t\t\t\ttype: \"check\",\n\t\t\t\t\t\tvalue: book.public >= 1,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\tmainText: \"OK\",\n\t\t\t\tsecondText: \"Cancel\",\n\t\t\t\tmainClick: async (data) => {\n\t\t\t\t\t// Validate input\n\t\t\t\t\tif (data.title.trim() === \"\") return;\n\n\t\t\t\t\t// Prepare tags\n\t\t\t\t\tvar tags = data.tags\n\t\t\t\t\t\t.toLowerCase()\n\t\t\t\t\t\t.replace(/\\s+/g, \" \")\n\t\t\t\t\t\t.split(/\\s*,\\s*/g)\n\t\t\t\t\t\t.filter((tag) => tag.trim() !== \"\")\n\t\t\t\t\t\t.map((tag) => ({\n\t\t\t\t\t\t\tname: tag.trim(),\n\t\t\t\t\t\t}));\n\n\t\t\t\t\t// Set new data\n\t\t\t\t\tbook.url = data.url.trim();\n\t\t\t\t\tbook.title = data.title.trim();\n\t\t\t\t\tbook.excerpt = data.excerpt.trim();\n\t\t\t\t\tbook.public = data.makePublic ? 1 : 0;\n\t\t\t\t\tbook.tags = tags;\n\n\t\t\t\t\t// Send data\n\t\t\t\t\tthis.dialog.loading = true;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst json = await apiRequest(\n\t\t\t\t\t\t\tnew URL(\"api/bookmarks\", document.baseURI),\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tmethod: \"put\",\n\t\t\t\t\t\t\t\tbody: JSON.stringify(book),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.dialog.visible = false;\n\t\t\t\t\t\tthis.bookmarks.splice(index, 1, json);\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.showErrorDialog(err.message);\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t});\n\t\t},\n\t\tshowDialogDelete(items) {\n\t\t\t// Check and filter items\n\t\t\tif (typeof items !== \"object\") return;\n\t\t\tif (!Array.isArray(items)) items = [items];\n\n\t\t\titems = items.filter((item) => {\n\t\t\t\tvar id = typeof item.id === \"number\" ? item.id : 0,\n\t\t\t\t\tindex = typeof item.index === \"number\" ? item.index : -1;\n\n\t\t\t\treturn id > 0 && index > -1;\n\t\t\t});\n\n\t\t\tif (items.length === 0) return;\n\n\t\t\t// Split ids and indices\n\t\t\tvar ids = items.map((item) => item.id),\n\t\t\t\tindices = items.map((item) => item.index).sort((a, b) => b - a);\n\n\t\t\t// Create title and content\n\t\t\tvar title = \"Delete Bookmarks\",\n\t\t\t\tcontent =\n\t\t\t\t\t\"Delete the selected bookmarks ? This action is irreversible.\";\n\n\t\t\tif (items.length === 1) {\n\t\t\t\ttitle = \"Delete Bookmark\";\n\t\t\t\tcontent = \"Are you sure ? This action is irreversible.\";\n\t\t\t}\n\n\t\t\t// Show dialog\n\t\t\tthis.showDialog({\n\t\t\t\ttitle: title,\n\t\t\t\tcontent: content,\n\t\t\t\tmainText: \"Yes\",\n\t\t\t\tsecondText: \"No\",\n\t\t\t\tmainClick: async () => {\n\t\t\t\t\tthis.dialog.loading = true;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait apiRequest(new URL(\"api/bookmarks\", document.baseURI), {\n\t\t\t\t\t\t\tmethod: \"delete\",\n\t\t\t\t\t\t\tbody: JSON.stringify(ids),\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tthis.selection = [];\n\t\t\t\t\t\tthis.editMode = false;\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.dialog.visible = false;\n\t\t\t\t\t\tindices.forEach((index) => this.bookmarks.splice(index, 1));\n\n\t\t\t\t\t\tif (this.bookmarks.length < 20) {\n\t\t\t\t\t\t\tthis.loadData(false);\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tthis.selection = [];\n\t\t\t\t\t\tthis.editMode = false;\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.showErrorDialog(err.message);\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t});\n\t\t},\n\t\tebookGenerate(items) {\n\t\t\t// Check and filter items\n\t\t\tif (typeof items !== \"object\") return;\n\t\t\tif (!Array.isArray(items)) items = [items];\n\n\t\t\titems = items.filter((item) => {\n\t\t\t\tvar id = typeof item.id === \"number\" ? item.id : 0,\n\t\t\t\t\tindex = typeof item.index === \"number\" ? item.index : -1;\n\n\t\t\t\treturn id > 0 && index > -1;\n\t\t\t});\n\n\t\t\tif (items.length === 0) return;\n\n\t\t\t// define variable and send request\n\t\t\tvar ids = items.map((item) => item.id);\n\t\t\tvar data = {\n\t\t\t\tids: ids,\n\t\t\t\tcreate_archive: false,\n\t\t\t\tkeep_metadata: true,\n\t\t\t\tcreate_ebook: true,\n\t\t\t\tskip_exist: true,\n\t\t\t};\n\t\t\tthis.loading = true;\n\t\t\tfetch(new URL(\"api/v1/bookmarks/cache\", document.baseURI), {\n\t\t\t\tmethod: \"put\",\n\t\t\t\tbody: JSON.stringify(data),\n\t\t\t\theaders: {\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t\tAuthorization: \"Bearer \" + localStorage.getItem(\"shiori-token\"),\n\t\t\t\t},\n\t\t\t})\n\t\t\t\t.then((response) => {\n\t\t\t\t\tif (!response.ok) throw response;\n\t\t\t\t\treturn response.json();\n\t\t\t\t})\n\t\t\t\t.then((json) => {\n\t\t\t\t\tthis.selection = [];\n\t\t\t\t\tthis.editMode = false;\n\t\t\t\t\tjson.forEach((book) => {\n\t\t\t\t\t\t// download ebooks\n\t\t\t\t\t\tconst id = book.id;\n\t\t\t\t\t\tif (book.hasEbook) {\n\t\t\t\t\t\t\tconst ebook_url = new URL(\n\t\t\t\t\t\t\t\t`bookmark/${id}/ebook`,\n\t\t\t\t\t\t\t\tdocument.baseURI,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tconst downloadLink = document.createElement(\"a\");\n\t\t\t\t\t\t\tdownloadLink.href = ebook_url.toString();\n\t\t\t\t\t\t\tdownloadLink.download = `${book.title}.epub`;\n\t\t\t\t\t\t\tdownloadLink.click();\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tvar item = items.find((el) => el.id === book.id);\n\t\t\t\t\t\tthis.bookmarks.splice(item.index, 1, book);\n\t\t\t\t\t});\n\t\t\t\t})\n\t\t\t\t.catch((err) => {\n\t\t\t\t\tthis.selection = [];\n\t\t\t\t\tthis.editMode = false;\n\t\t\t\t\tthis.getErrorMessage(err).then((msg) => {\n\t\t\t\t\t\tthis.showErrorDialog(msg);\n\t\t\t\t\t});\n\t\t\t\t})\n\t\t\t\t.finally(() => {\n\t\t\t\t\tthis.loading = false;\n\t\t\t\t});\n\t\t},\n\t\tshowDialogUpdateCache(items) {\n\t\t\t// Check and filter items\n\t\t\tif (typeof items !== \"object\") return;\n\t\t\tif (!Array.isArray(items)) items = [items];\n\n\t\t\titems = items.filter((item) => {\n\t\t\t\tvar id = typeof item.id === \"number\" ? item.id : 0,\n\t\t\t\t\tindex = typeof item.index === \"number\" ? item.index : -1;\n\n\t\t\t\treturn id > 0 && index > -1;\n\t\t\t});\n\n\t\t\tif (items.length === 0) return;\n\n\t\t\t// Show dialog\n\t\t\tvar ids = items.map((item) => item.id);\n\n\t\t\tthis.showDialog({\n\t\t\t\ttitle: \"Update Cache\",\n\t\t\t\tcontent:\n\t\t\t\t\t\"Update cache for selected bookmarks ? This action is irreversible.\",\n\t\t\t\tfields: [\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"keep_metadata\",\n\t\t\t\t\t\tlabel: \"Keep the old title and excerpt\",\n\t\t\t\t\t\ttype: \"check\",\n\t\t\t\t\t\tvalue: this.appOptions.KeepMetadata,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"create_archive\",\n\t\t\t\t\t\tlabel: \"Update archive as well\",\n\t\t\t\t\t\ttype: \"check\",\n\t\t\t\t\t\tvalue: this.appOptions.UseArchive,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"create_ebook\",\n\t\t\t\t\t\tlabel: \"Update Ebook as well\",\n\t\t\t\t\t\ttype: \"check\",\n\t\t\t\t\t\tvalue: this.appOptions.CreateEbook,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\tmainText: \"Yes\",\n\t\t\t\tsecondText: \"No\",\n\t\t\t\tmainClick: async (data) => {\n\t\t\t\t\tvar requestData = {\n\t\t\t\t\t\tids: ids,\n\t\t\t\t\t\tcreate_archive: data.create_archive,\n\t\t\t\t\t\tkeep_metadata: data.keep_metadata,\n\t\t\t\t\t\tcreate_ebook: data.create_ebook,\n\t\t\t\t\t\tskip_exist: false,\n\t\t\t\t\t};\n\n\t\t\t\t\tthis.dialog.loading = true;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst json = await apiRequest(\n\t\t\t\t\t\t\tnew URL(\"api/v1/bookmarks/cache\", document.baseURI),\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tmethod: \"put\",\n\t\t\t\t\t\t\t\tbody: JSON.stringify(requestData),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\tthis.selection = [];\n\t\t\t\t\t\tthis.editMode = false;\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.dialog.visible = false;\n\n\t\t\t\t\t\tlet faildedUpdateArchives = [];\n\t\t\t\t\t\tlet faildedCreateEbook = [];\n\t\t\t\t\t\tjson.forEach((book) => {\n\t\t\t\t\t\t\tvar item = items.find((el) => el.id === book.id);\n\t\t\t\t\t\t\tthis.bookmarks.splice(item.index, 1, book);\n\n\t\t\t\t\t\t\tif (data.create_archive && !book.hasArchive) {\n\t\t\t\t\t\t\t\tfaildedUpdateArchives.push(book.id);\n\t\t\t\t\t\t\t\tconsole.error(\"can't update archive for bookmark id\", book.id);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif (data.create_ebook && !book.hasEbook) {\n\t\t\t\t\t\t\t\tfaildedCreateEbook.push(book.id);\n\t\t\t\t\t\t\t\tconsole.error(\"can't update ebook for bookmark id:\", book.id);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\tfaildedCreateEbook.length > 0 ||\n\t\t\t\t\t\t\tfaildedUpdateArchives.length > 0\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\tthis.showDialog({\n\t\t\t\t\t\t\t\ttitle: `Bookmarks Id that Update Action Faild`,\n\t\t\t\t\t\t\t\tcontent: `Not all bookmarks could have their contents updated, but no files were overwritten.`,\n\t\t\t\t\t\t\t\tmainText: \"OK\",\n\t\t\t\t\t\t\t\tmainClick: () => {\n\t\t\t\t\t\t\t\t\tthis.dialog.visible = false;\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tthis.selection = [];\n\t\t\t\t\t\tthis.editMode = false;\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.showErrorDialog(err.message);\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t});\n\t\t},\n\t\tshowDialogAddTags(items) {\n\t\t\t// Check and filter items\n\t\t\tif (typeof items !== \"object\") return;\n\t\t\tif (!Array.isArray(items)) items = [items];\n\n\t\t\titems = items.filter((item) => {\n\t\t\t\tvar id = typeof item.id === \"number\" ? item.id : 0,\n\t\t\t\t\tindex = typeof item.index === \"number\" ? item.index : -1;\n\n\t\t\t\treturn id > 0 && index > -1;\n\t\t\t});\n\n\t\t\tif (items.length === 0) return;\n\n\t\t\t// Show dialog\n\t\t\tthis.showDialog({\n\t\t\t\ttitle: \"Add New Tags\",\n\t\t\t\tcontent: \"Add new tags to selected bookmarks\",\n\t\t\t\tfields: [\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"tags\",\n\t\t\t\t\t\tlabel: \"Comma separated tags\",\n\t\t\t\t\t\tvalue: \"\",\n\t\t\t\t\t\tseparator: \",\",\n\t\t\t\t\t\tdictionary: this.tags.map((tag) => tag.name),\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\tmainText: \"OK\",\n\t\t\t\tsecondText: \"Cancel\",\n\t\t\t\tmainClick: async (data) => {\n\t\t\t\t\t// Validate input\n\t\t\t\t\tvar tags = data.tags\n\t\t\t\t\t\t.toLowerCase()\n\t\t\t\t\t\t.replace(/\\s+/g, \" \")\n\t\t\t\t\t\t.split(/\\s*,\\s*/g)\n\t\t\t\t\t\t.filter((tag) => tag.trim() !== \"\")\n\t\t\t\t\t\t.map((tag) => ({\n\t\t\t\t\t\t\tname: tag.trim(),\n\t\t\t\t\t\t}));\n\n\t\t\t\t\tif (tags.length === 0) return;\n\n\t\t\t\t\t// Send data\n\t\t\t\t\tvar request = {\n\t\t\t\t\t\tids: items.map((item) => item.id),\n\t\t\t\t\t\ttags: tags,\n\t\t\t\t\t};\n\n\t\t\t\t\tthis.dialog.loading = true;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst json = await apiRequest(\n\t\t\t\t\t\t\tnew URL(\"api/v1/bookmarks/tags\", document.baseURI),\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tmethod: \"put\",\n\t\t\t\t\t\t\t\tbody: JSON.stringify(request),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\tthis.selection = [];\n\t\t\t\t\t\tthis.editMode = false;\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.dialog.visible = false;\n\n\t\t\t\t\t\tjson.forEach((book) => {\n\t\t\t\t\t\t\tvar item = items.find((el) => el.id === book.id);\n\t\t\t\t\t\t\tthis.bookmarks.splice(item.index, 1, book);\n\t\t\t\t\t\t});\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tthis.selection = [];\n\t\t\t\t\t\tthis.editMode = false;\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.showErrorDialog(err.message);\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t});\n\t\t},\n\t\tshowDialogTags() {\n\t\t\tthis.dialogTags.visible = true;\n\t\t\tthis.dialogTags.editMode = false;\n\t\t\tthis.dialogTags.secondText = this.activeAccount.owner\n\t\t\t\t? \"Rename Tags\"\n\t\t\t\t: \"\";\n\t\t},\n\t\tshowDialogRenameTag(tag) {\n\t\t\tthis.showDialog({\n\t\t\t\ttitle: \"Rename Tag\",\n\t\t\t\tcontent: `Change the name for tag \"#${tag.name}\"`,\n\t\t\t\tfields: [\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"newName\",\n\t\t\t\t\t\tlabel: \"New tag name\",\n\t\t\t\t\t\tvalue: tag.name,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\tmainText: \"OK\",\n\t\t\t\tsecondText: \"Cancel\",\n\t\t\t\tsecondClick: () => {\n\t\t\t\t\tthis.dialog.visible = false;\n\t\t\t\t\tthis.dialogTags.visible = true;\n\t\t\t\t},\n\t\t\t\tescPressed: () => {\n\t\t\t\t\tthis.dialog.visible = false;\n\t\t\t\t\tthis.dialogTags.visible = true;\n\t\t\t\t},\n\t\t\t\tmainClick: async (data) => {\n\t\t\t\t\t// Save the old query\n\t\t\t\t\tvar rxSpace = /\\s+/g,\n\t\t\t\t\t\toldTagQuery = rxSpace.test(tag.name)\n\t\t\t\t\t\t\t? `\"#${tag.name}\"`\n\t\t\t\t\t\t\t: `#${tag.name}`,\n\t\t\t\t\t\tnewTagQuery = rxSpace.test(data.newName)\n\t\t\t\t\t\t\t? `\"#${data.newName}\"`\n\t\t\t\t\t\t\t: `#${data.newName}`;\n\n\t\t\t\t\tthis.dialog.loading = true;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait apiRequest(\n\t\t\t\t\t\t\tnew URL(\"api/v1/tags/\" + tag.id, document.baseURI),\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tmethod: \"PUT\",\n\t\t\t\t\t\t\t\tbody: JSON.stringify({ name: data.newName }),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\ttag.name = data.newName;\n\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.dialog.visible = false;\n\t\t\t\t\t\tthis.dialogTags.visible = true;\n\t\t\t\t\t\tthis.dialogTags.editMode = false;\n\t\t\t\t\t\tthis.tags.sort((a, b) => {\n\t\t\t\t\t\t\tvar aName = a.name.toLowerCase(),\n\t\t\t\t\t\t\t\tbName = b.name.toLowerCase();\n\n\t\t\t\t\t\t\tif (aName < bName) return -1;\n\t\t\t\t\t\t\telse if (aName > bName) return 1;\n\t\t\t\t\t\t\telse return 0;\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tif (this.search.includes(oldTagQuery)) {\n\t\t\t\t\t\t\tthis.search = this.search.replace(oldTagQuery, newTagQuery);\n\t\t\t\t\t\t\tthis.loadData();\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.dialogTags.visible = false;\n\t\t\t\t\t\tthis.dialogTags.editMode = false;\n\t\t\t\t\t\tthis.showErrorDialog(err.message);\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t});\n\t\t},\n\t},\n\tmounted() {\n\t\tthis.$bus.$on(\"clearHomePage\", () => {\n\t\t\tthis.clearHomePage();\n\t\t});\n\t\t// Prepare history state watcher\n\t\tvar stateWatcher = (e) => {\n\t\t\tvar state = e.state || {},\n\t\t\t\tactivePage = state.activePage || \"page-home\",\n\t\t\t\tsearch = state.search || \"\",\n\t\t\t\tpage = state.page || 1;\n\n\t\t\tif (activePage !== \"page-home\") return;\n\n\t\t\tthis.page = page;\n\t\t\tthis.search = search;\n\t\t\tthis.loadData(false);\n\t\t};\n\n\t\twindow.addEventListener(\"popstate\", stateWatcher);\n\t\tthis.$once(\"hook:beforeDestroy\", () => {\n\t\t\twindow.removeEventListener(\"popstate\", stateWatcher);\n\t\t});\n\n\t\t// Set initial parameter\n\t\tvar url = new Url();\n\t\tthis.search = url.query.search || \"\";\n\t\tthis.page = url.query.page || 1;\n\n\t\tvar isSharing =\n\t\t\turl.query.url !== undefined || url.query.excerpt !== undefined;\n\t\tif (isSharing) {\n\t\t\t// this is what the spec says\n\t\t\tvar shareData = {\n\t\t\t\turl: url.query.url,\n\t\t\t\texcerpt: url.query.excerpt,\n\t\t\t\ttitle: url.query.title,\n\t\t\t};\n\n\t\t\t// In my testing sharing from chrome and ff focus, this is how data arrives\n\t\t\tif (shareData.url === undefined) {\n\t\t\t\tshareData.url = url.query.excerpt;\n\t\t\t\tshareData.title = url.query.title;\n\t\t\t\tshareData.excerpt = \"\";\n\t\t\t}\n\n\t\t\tthis.showDialogAdd(shareData);\n\t\t\tvar history = {\n\t\t\t\tactivePage: \"page-home\",\n\t\t\t\tsearch: this.search,\n\t\t\t\tpage: this.page,\n\t\t\t};\n\n\t\t\tvar url = new Url(document.baseURI);\n\t\t\turl.hash = \"home\";\n\t\t\turl.clearQuery();\n\t\t\twindow.history.replaceState(history, \"page-home\", url);\n\t\t}\n\n\t\tthis.loadData(false, true);\n\t},\n};\n"
  },
  {
    "path": "internal/view/assets/js/page/setting.js",
    "content": "var template = `\n<div id=\"page-setting\">\n    <h1 class=\"page-header\">Settings</h1>\n    <div class=\"setting-container\">\n        <details open class=\"setting-group\" id=\"setting-display\">\n            <summary>Display</summary>\n            <label>\n                Theme &nbsp;\n                <select v-model=\"appOptions.Theme\" @change=\"saveSetting\">\n                <option value=\"follow\">Follow system</option>\n                <option value=\"light\">Light theme</option>\n                <option value=\"dark\">Dark theme</option>\n                </select>\n            </label>\n            <label>\n                <input type=\"checkbox\" v-model=\"appOptions.ShowId\" @change=\"saveSetting\">\n                Show bookmark's ID\n            </label>\n            <label>\n                <input type=\"checkbox\" v-model=\"appOptions.ListMode\" @change=\"saveSetting\">\n                Display bookmarks as list\n            </label>\n            <label>\n                <input type=\"checkbox\" v-model=\"appOptions.HideThumbnail\" @change=\"saveSetting\">\n                Hide thumbnail image\n            </label>\n            <label>\n                <input type=\"checkbox\" v-model=\"appOptions.HideExcerpt\" @change=\"saveSetting\">\n                Hide bookmark's excerpt\n            </label>\n        </details>\n        <details v-if=\"activeAccount.owner\" open class=\"setting-group\" id=\"setting-bookmarks\">\n            <summary>Bookmarks</summary>\n            <label>\n                <input type=\"checkbox\" v-model=\"appOptions.KeepMetadata\" @change=\"saveSetting\">\n                Keep bookmark's metadata when updating\n            </label>\n            <label>\n                <input type=\"checkbox\" v-model=\"appOptions.UseArchive\" @change=\"saveSetting\">\n                Create archive by default\n            </label>\n            <label>\n                <input type=\"checkbox\" v-model=\"appOptions.CreateEbook\" @change=\"saveSetting\">\n                Create ebook by default\n            </label>\n            <label>\n                <input type=\"checkbox\" v-model=\"appOptions.MakePublic\" @change=\"saveSetting\">\n                Make bookmark publicly available by default\n            </label>\n        </details>\n        <details v-if=\"activeAccount.owner\" open class=\"setting-group setting-accounts\" id=\"setting-accounts\">\n            <summary>Accounts</summary>\n            <ul class=\"accounts-list\">\n                <li v-if=\"accounts.length === 0\">No accounts registered</li>\n                <li v-for=\"(account, idx) in accounts\" :shiori-username=\"account.username\">\n                    <p>{{account.username}}\n                        <span v-if=\"account.owner\" class=\"account-level\">(owner)</span>\n                    </p>\n                    <a title=\"Change password\" @click=\"showDialogChangePassword(account)\">\n                        <i class=\"fa fas fa-fw fa-key\"></i>\n                    </a>\n                    <a title=\"Delete account\" @click=\"showDialogDeleteAccount(account, idx)\">\n                        <i class=\"fa fas fa-fw fa-trash-alt\"></i>\n                    </a>\n                </li>\n            </ul>\n            <div class=\"setting-group-footer\">\n                <a @click=\"loadAccounts\" title=\"Refresh accounts\">Refresh accounts</a>\n                <a v-if=\"activeAccount.owner\" @click=\"showDialogNewAccount\" title=\"Add new account\">Add new account</a>\n            </div>\n        </details>\n        <details v-if=\"!activeAccount.owner\" open class=\"setting-group setting-accounts\" id=\"setting-my-account\">\n            <summary>My account</summary>\n            <ul>\n                <li v-for=\"(account, idx) in [this.activeAccount]\" :shiori-username=\"account.username\">\n                    <p>{{account.username}}\n                        <span v-if=\"account.owner\" class=\"account-level\">(owner)</span>\n                    </p>\n                    <a title=\"Change password\" @click=\"showDialogChangePassword(account)\">\n                        <i class=\"fa fas fa-fw fa-key\"></i>\n                    </a>\n                </li>\n            </ul>\n            <div class=\"setting-group-footer\">\n                <a @click=\"showDialogChangePassword(this.activeAccount)\" title=\"Change password\">Change password</a>\n            </div>\n        </details>\n\t\t<details v-if=\"activeAccount.owner\" class=\"setting-group\" id=\"setting-system-info\">\n\t\t\t<summary>System info</summary>\n\t\t\t<ul>\n\t\t\t\t<li><b>Shiori version:</b> <span>{{system.version?.tag}}<span></li>\n\t\t\t\t<li><b>Database engine:</b> <span>{{system.database}}</span></li>\n\t\t\t\t<li><b>Operating system:</b> <span>{{system.os}}</span></li>\n\t\t\t</ul>\n\t\t</details>\n    </div>\n    <div class=\"loading-overlay\" v-if=\"loading\"><i class=\"fas fa-fw fa-spin fa-spinner\"></i></div>\n    <custom-dialog v-bind=\"dialog\"/>\n</div>`;\n\nimport customDialog from \"../component/dialog.js\";\nimport basePage from \"./base.js\";\nimport { apiRequest } from \"../utils/api.js\";\n\nexport default {\n\ttemplate: template,\n\tmixins: [basePage],\n\tcomponents: {\n\t\tcustomDialog,\n\t},\n\tdata() {\n\t\treturn {\n\t\t\tloading: false,\n\t\t\taccounts: [],\n\t\t\tsystem: {},\n\t\t};\n\t},\n\tmethods: {\n\t\tsaveSetting() {\n\t\t\tlet options = {\n\t\t\t\tShowId: this.appOptions.ShowId,\n\t\t\t\tListMode: this.appOptions.ListMode,\n\t\t\t\tHideThumbnail: this.appOptions.HideThumbnail,\n\t\t\t\tHideExcerpt: this.appOptions.HideExcerpt,\n\t\t\t\tTheme: this.appOptions.Theme,\n\t\t\t};\n\n\t\t\tif (this.activeAccount.owner) {\n\t\t\t\toptions = {\n\t\t\t\t\t...options,\n\t\t\t\t\tKeepMetadata: this.appOptions.KeepMetadata,\n\t\t\t\t\tUseArchive: this.appOptions.UseArchive,\n\t\t\t\t\tCreateEbook: this.appOptions.CreateEbook,\n\t\t\t\t\tMakePublic: this.appOptions.MakePublic,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tthis.$emit(\"setting-changed\", options);\n\t\t\t//request\n\t\t\tfetch(new URL(\"api/v1/auth/account\", document.baseURI), {\n\t\t\t\tmethod: \"PATCH\",\n\t\t\t\tbody: JSON.stringify({\n\t\t\t\t\tconfig: this.appOptions,\n\t\t\t\t}),\n\t\t\t\theaders: {\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t\tAuthorization: \"Bearer \" + localStorage.getItem(\"shiori-token\"),\n\t\t\t\t},\n\t\t\t})\n\t\t\t\t.then((response) => {\n\t\t\t\t\tif (!response.ok) throw response;\n\t\t\t\t\treturn response.json();\n\t\t\t\t})\n\t\t\t\t.then((responseData) => {\n\t\t\t\t\tconst responseString = JSON.stringify(responseData);\n\t\t\t\t\tlocalStorage.setItem(\"shiori-account\", responseString);\n\t\t\t\t})\n\t\t\t\t.catch((err) => {\n\t\t\t\t\tthis.getErrorMessage(err).then((msg) => {\n\t\t\t\t\t\tthis.showErrorDialog(msg);\n\t\t\t\t\t});\n\t\t\t\t});\n\t\t},\n\t\tasync loadAccounts() {\n\t\t\tif (this.loading) return;\n\n\t\t\tthis.loading = true;\n\t\t\ttry {\n\t\t\t\tconst json = await apiRequest(\n\t\t\t\t\tnew URL(\"api/v1/accounts\", document.baseURI),\n\t\t\t\t);\n\t\t\t\tthis.loading = false;\n\t\t\t\tthis.accounts = json;\n\t\t\t} catch (err) {\n\t\t\t\tthis.loading = false;\n\t\t\t\tthis.showErrorDialog(err.message);\n\t\t\t}\n\t\t},\n\t\tasync loadSystemInfo() {\n\t\t\tif (this.system.version !== undefined) return;\n\n\t\t\ttry {\n\t\t\t\tconst json = await apiRequest(\n\t\t\t\t\tnew URL(\"api/v1/system/info\", document.baseURI),\n\t\t\t\t);\n\t\t\t\tthis.system = json;\n\t\t\t} catch (err) {\n\t\t\t\tthis.showErrorDialog(err.message);\n\t\t\t}\n\t\t},\n\t\tshowDialogNewAccount() {\n\t\t\tthis.showDialog({\n\t\t\t\ttitle: \"New Account\",\n\t\t\t\tcontent: \"Input new account's data :\",\n\t\t\t\tfields: [\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"username\",\n\t\t\t\t\t\tlabel: \"Username\",\n\t\t\t\t\t\tvalue: \"\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"password\",\n\t\t\t\t\t\tlabel: \"Password\",\n\t\t\t\t\t\ttype: \"password\",\n\t\t\t\t\t\tvalue: \"\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"repeat_password\",\n\t\t\t\t\t\tlabel: \"Repeat password\",\n\t\t\t\t\t\ttype: \"password\",\n\t\t\t\t\t\tvalue: \"\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tname: \"admin\",\n\t\t\t\t\t\tlabel: \"This account is an admin account\",\n\t\t\t\t\t\ttype: \"check\",\n\t\t\t\t\t\tvalue: false,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\tmainText: \"OK\",\n\t\t\t\tsecondText: \"Cancel\",\n\t\t\t\tmainClick: async (data) => {\n\t\t\t\t\tif (data.username === \"\") {\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tvar request = {\n\t\t\t\t\t\tusername: data.username,\n\t\t\t\t\t\tpassword: data.password,\n\t\t\t\t\t\towner: data.admin,\n\t\t\t\t\t};\n\n\t\t\t\t\tthis.dialog.loading = true;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst json = await apiRequest(\n\t\t\t\t\t\t\tnew URL(\"api/v1/accounts\", document.baseURI),\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tmethod: \"post\",\n\t\t\t\t\t\t\t\tbody: JSON.stringify(request),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.dialog.visible = false;\n\n\t\t\t\t\t\tthis.accounts.push(json);\n\t\t\t\t\t\tthis.accounts.sort((a, b) => {\n\t\t\t\t\t\t\tvar nameA = a.username.toLowerCase(),\n\t\t\t\t\t\t\t\tnameB = b.username.toLowerCase();\n\n\t\t\t\t\t\t\tif (nameA < nameB) {\n\t\t\t\t\t\t\t\treturn -1;\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\tif (nameA > nameB) {\n\t\t\t\t\t\t\t\treturn 1;\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\treturn 0;\n\t\t\t\t\t\t});\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.showErrorDialog(err.message);\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t});\n\t\t},\n\t\tshowDialogChangePassword(account) {\n\t\t\tlet fields = [\n\t\t\t\t{\n\t\t\t\t\tname: \"new_password\",\n\t\t\t\t\tlabel: \"New password\",\n\t\t\t\t\ttype: \"password\",\n\t\t\t\t\tvalue: \"\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname: \"repeat_password\",\n\t\t\t\t\tlabel: \"Repeat password\",\n\t\t\t\t\ttype: \"password\",\n\t\t\t\t\tvalue: \"\",\n\t\t\t\t},\n\t\t\t];\n\n\t\t\tconst requiresOldPassword =\n\t\t\t\t!this.activeAccount.owner || this.activeAccount.id === account.id;\n\n\t\t\t// Only owners can update user passwords without\n\t\t\t// providing the old password\n\n\t\t\tif (requiresOldPassword) {\n\t\t\t\tfields.unshift({\n\t\t\t\t\tname: \"old_password\",\n\t\t\t\t\tlabel: \"The current password\",\n\t\t\t\t\ttype: \"password\",\n\t\t\t\t\tvalue: \"\",\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tthis.showDialog({\n\t\t\t\ttitle: \"Change Password\",\n\t\t\t\tcontent: \"\",\n\t\t\t\tfields: fields,\n\t\t\t\tmainText: \"OK\",\n\t\t\t\tsecondText: \"Cancel\",\n\t\t\t\tmainClick: async (data) => {\n\t\t\t\t\tif (requiresOldPassword) {\n\t\t\t\t\t\tif (data.old_password === \"\") {\n\t\t\t\t\t\t\tthis.showErrorDialog(\"You must provide the current password.\");\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tif (data.new_password === \"\") {\n\t\t\t\t\t\tthis.showErrorDialog(\"New password must not empt.\");\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (data.new_password !== data.repeat_password) {\n\t\t\t\t\t\tthis.showErrorDialog(\"Password does not match.\");\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tvar request = {\n\t\t\t\t\t\told_password: data.old_password,\n\t\t\t\t\t\tnew_password: data.new_password,\n\t\t\t\t\t};\n\n\t\t\t\t\t// Determine which URL to use depending if the user is updating its own\n\t\t\t\t\t// account or another user's account.\n\t\t\t\t\tlet url = `api/v1/accounts/${account.id}`;\n\t\t\t\t\tif (this.activeAccount.id === account.id) {\n\t\t\t\t\t\turl = \"api/v1/auth/account\";\n\t\t\t\t\t}\n\n\t\t\t\t\tthis.dialog.loading = true;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait apiRequest(new URL(url, document.baseURI), {\n\t\t\t\t\t\t\tmethod: \"PATCH\",\n\t\t\t\t\t\t\tbody: JSON.stringify(request),\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tthis.showDialog({\n\t\t\t\t\t\t\ttitle: \"Password Changed\",\n\t\t\t\t\t\t\tcontent: \"Password has been changed.\",\n\t\t\t\t\t\t\tmainText: \"OK\",\n\t\t\t\t\t\t\tmainClick: () => {\n\t\t\t\t\t\t\t\tthis.dialog.visible = false;\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t});\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.showErrorDialog(err.message);\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t});\n\t\t},\n\t\tshowDialogDeleteAccount(account, idx) {\n\t\t\tthis.showDialog({\n\t\t\t\ttitle: \"Delete Account\",\n\t\t\t\tcontent: `Delete account \"${account.username}\" ?`,\n\t\t\t\tmainText: \"Yes\",\n\t\t\t\tsecondText: \"No\",\n\t\t\t\tmainClick: async () => {\n\t\t\t\t\tthis.dialog.loading = true;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait apiRequest(`api/v1/accounts/${account.id}`, {\n\t\t\t\t\t\t\tmethod: \"DELETE\",\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.dialog.visible = false;\n\t\t\t\t\t\tthis.accounts.splice(idx, 1);\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\tthis.showErrorDialog(err.message);\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t});\n\t\t},\n\t},\n\tmounted() {\n\t\tif (this.activeAccount.owner) {\n\t\t\tthis.loadAccounts();\n\t\t\tthis.loadSystemInfo();\n\t\t}\n\t},\n};\n"
  },
  {
    "path": "internal/view/assets/js/url.js",
    "content": "/*!\n * Lightweight URL manipulation with JavaScript\n * This library is independent of any other libraries and has pretty simple\n * interface and lightweight code-base.\n * Some ideas of query string parsing had been taken from Jan Wolter\n * @see http://unixpapa.com/js/querystring.html\n *\n * @license MIT\n * @author Mykhailo Stadnyk <mikhus@gmail.com>\n */\n(function (ns) {\n\t'use strict';\n\n\tvar RX_PROTOCOL = /^[a-z]+:/;\n\tvar RX_PORT = /[-a-z0-9]+(\\.[-a-z0-9])*:\\d+/i;\n\tvar RX_CREDS = /\\/\\/(.*?)(?::(.*?))?@/;\n\tvar RX_WIN = /^win/i;\n\tvar RX_PROTOCOL_REPL = /:$/;\n\tvar RX_QUERY_REPL = /^\\?/;\n\tvar RX_HASH_REPL = /^#/;\n\tvar RX_PATH = /(.*\\/)/;\n\tvar RX_PATH_FIX = /^\\/{2,}/;\n\tvar RX_SINGLE_QUOTE = /'/g;\n\tvar RX_DECODE_1 = /%([ef][0-9a-f])%([89ab][0-9a-f])%([89ab][0-9a-f])/gi;\n\tvar RX_DECODE_2 = /%([cd][0-9a-f])%([89ab][0-9a-f])/gi;\n\tvar RX_DECODE_3 = /%([0-7][0-9a-f])/gi;\n\tvar RX_PLUS = /\\+/g;\n\tvar RX_PATH_SEMI = /^\\w:$/;\n\tvar RX_URL_TEST = /[^/#?]/;\n\n\t// configure given url options\n\tfunction urlConfig(url) {\n\t\tvar config = {\n\t\t\tpath: true,\n\t\t\tquery: true,\n\t\t\thash: true\n\t\t};\n\n\t\tif (!url) {\n\t\t\treturn config;\n\t\t}\n\n\t\tif (RX_PROTOCOL.test(url)) {\n\t\t\tconfig.protocol = true;\n\t\t\tconfig.host = true;\n\n\t\t\tif (RX_PORT.test(url)) {\n\t\t\t\tconfig.port = true;\n\t\t\t}\n\n\t\t\tif (RX_CREDS.test(url)) {\n\t\t\t\tconfig.user = true;\n\t\t\t\tconfig.pass = true;\n\t\t\t}\n\t\t}\n\n\t\treturn config;\n\t}\n\n\tvar isNode = typeof window === 'undefined' &&\n\t\ttypeof global !== 'undefined' &&\n\t\ttypeof require === 'function';\n\n\t// Trick to bypass Webpack's require at compile time\n\tvar nodeRequire = isNode ? ns['require'] : null;\n\n\t// mapping between what we want and <a> element properties\n\tvar map = {\n\t\tprotocol: 'protocol',\n\t\thost: 'hostname',\n\t\tport: 'port',\n\t\tpath: 'pathname',\n\t\tquery: 'search',\n\t\thash: 'hash'\n\t};\n\n\t// jscs: disable\n    /**\n     * default ports as defined by http://url.spec.whatwg.org/#default-port\n     * We need them to fix IE behavior, @see https://github.com/Mikhus/jsurl/issues/2\n     */\n\t// jscs: enable\n\tvar defaultPorts = {\n\t\tftp: 21,\n\t\tgopher: 70,\n\t\thttp: 80,\n\t\thttps: 443,\n\t\tws: 80,\n\t\twss: 443\n\t};\n\n\tvar _currNodeUrl;\n\tfunction getCurrUrl() {\n\t\tif (isNode) {\n\t\t\tif (!_currNodeUrl) {\n\t\t\t\t_currNodeUrl = ('file://' +\n\t\t\t\t\t(process.platform.match(RX_WIN) ? '/' : '') +\n\t\t\t\t\tnodeRequire('fs').realpathSync('.')\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn _currNodeUrl;\n\t\t} else {\n\t\t\treturn document.location.href;\n\t\t}\n\t}\n\n\tfunction parse(self, url, absolutize) {\n\t\tvar link, i, auth;\n\n\t\tif (!url) {\n\t\t\turl = getCurrUrl();\n\t\t}\n\n\t\tif (isNode) {\n\t\t\tlink = nodeRequire('url').parse(url);\n\t\t}\n\n\t\telse {\n\t\t\tlink = document.createElement('a');\n\t\t\tlink.href = url;\n\t\t}\n\n\t\tvar config = urlConfig(url);\n\n\t\tauth = url.match(RX_CREDS) || [];\n\n\t\tfor (i in map) {\n\t\t\tif (config[i]) {\n\t\t\t\tself[i] = link[map[i]] || '';\n\t\t\t}\n\n\t\t\telse {\n\t\t\t\tself[i] = '';\n\t\t\t}\n\t\t}\n\n\t\t// fix-up some parts\n\t\tself.protocol = self.protocol.replace(RX_PROTOCOL_REPL, '');\n\t\tself.query = self.query.replace(RX_QUERY_REPL, '');\n\t\tself.hash = decode(self.hash.replace(RX_HASH_REPL, ''));\n\t\tself.user = decode(auth[1] || '');\n\t\tself.pass = decode(auth[2] || '');\n\t\t/* jshint ignore:start */\n\t\tself.port = (\n\t\t\t// loosely compare because port can be a string\n\t\t\tdefaultPorts[self.protocol] == self.port || self.port == 0\n\t\t) ? '' : self.port; // IE fix, Android browser fix\n\t\t/* jshint ignore:end */\n\n\t\tif (!config.protocol && RX_URL_TEST.test(url.charAt(0))) {\n\t\t\tself.path = url.split('?')[0].split('#')[0];\n\t\t}\n\n\t\tif (!config.protocol && absolutize) {\n\t\t\t// is IE and path is relative\n\t\t\tvar base = new Url(getCurrUrl().match(RX_PATH)[0]);\n\t\t\tvar basePath = base.path.split('/');\n\t\t\tvar selfPath = self.path.split('/');\n\t\t\tvar props = ['protocol', 'user', 'pass', 'host', 'port'];\n\t\t\tvar s = props.length;\n\n\t\t\tbasePath.pop();\n\n\t\t\tfor (i = 0; i < s; i++) {\n\t\t\t\tself[props[i]] = base[props[i]];\n\t\t\t}\n\n\t\t\twhile (selfPath[0] === '..') { // skip all \"../\n\t\t\t\tbasePath.pop();\n\t\t\t\tselfPath.shift();\n\t\t\t}\n\n\t\t\tself.path =\n\t\t\t\t(url.charAt(0) !== '/' ? basePath.join('/') : '') +\n\t\t\t\t'/' + selfPath.join('/')\n\t\t\t\t;\n\t\t}\n\n\t\tself.path = self.path.replace(RX_PATH_FIX, '/');\n\n\t\tself.paths(self.paths());\n\n\t\tself.query = new QueryString(self.query);\n\t}\n\n\tfunction encode(s) {\n\t\treturn encodeURIComponent(s).replace(RX_SINGLE_QUOTE, '%27');\n\t}\n\n\tfunction decode(s) {\n\t\ts = s.replace(RX_PLUS, ' ');\n\t\ts = s.replace(RX_DECODE_1, function (code, hex1, hex2, hex3) {\n\t\t\tvar n1 = parseInt(hex1, 16) - 0xE0;\n\t\t\tvar n2 = parseInt(hex2, 16) - 0x80;\n\n\t\t\tif (n1 === 0 && n2 < 32) {\n\t\t\t\treturn code;\n\t\t\t}\n\n\t\t\tvar n3 = parseInt(hex3, 16) - 0x80;\n\t\t\tvar n = (n1 << 12) + (n2 << 6) + n3;\n\n\t\t\tif (n > 0xFFFF) {\n\t\t\t\treturn code;\n\t\t\t}\n\n\t\t\treturn String.fromCharCode(n);\n\t\t});\n\t\ts = s.replace(RX_DECODE_2, function (code, hex1, hex2) {\n\t\t\tvar n1 = parseInt(hex1, 16) - 0xC0;\n\n\t\t\tif (n1 < 2) {\n\t\t\t\treturn code;\n\t\t\t}\n\n\t\t\tvar n2 = parseInt(hex2, 16) - 0x80;\n\n\t\t\treturn String.fromCharCode((n1 << 6) + n2);\n\t\t});\n\n\t\treturn s.replace(RX_DECODE_3, function (code, hex) {\n\t\t\treturn String.fromCharCode(parseInt(hex, 16));\n\t\t});\n\t}\n\n    /**\n     * Class QueryString\n     *\n     * @param {string} qs - string representation of QueryString\n     * @constructor\n     */\n\tfunction QueryString(qs) {\n\t\tvar parts = qs.split('&');\n\n\t\tfor (var i = 0, s = parts.length; i < s; i++) {\n\t\t\tvar keyVal = parts[i].split('=');\n\t\t\tvar key = decodeURIComponent(keyVal[0].replace(RX_PLUS, ' '));\n\n\t\t\tif (!key) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar value = keyVal[1] !== undefined ? decode(keyVal[1]) : null;\n\n\t\t\tif (typeof this[key] === 'undefined') {\n\t\t\t\tthis[key] = value;\n\t\t\t} else {\n\t\t\t\tif (!(this[key] instanceof Array)) {\n\t\t\t\t\tthis[key] = [this[key]];\n\t\t\t\t}\n\n\t\t\t\tthis[key].push(value);\n\t\t\t}\n\t\t}\n\t}\n\n    /**\n     * Converts QueryString object back to string representation\n     *\n     * @returns {string}\n     */\n\tQueryString.prototype.toString = function () {\n\t\tvar s = '';\n\t\tvar e = encode;\n\t\tvar i, ii;\n\n\t\tfor (i in this) {\n\t\t\tvar w = this[i];\n\n\t\t\tif (w instanceof Function || w === null) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (w instanceof Array) {\n\t\t\t\tvar len = w.length;\n\n\t\t\t\tif (len) {\n\t\t\t\t\tfor (ii = 0; ii < len; ii++) {\n\t\t\t\t\t\tvar v = w[ii];\n\t\t\t\t\t\ts += s ? '&' : '';\n\t\t\t\t\t\ts += e(i) + (v === undefined || v === null\n\t\t\t\t\t\t\t? ''\n\t\t\t\t\t\t\t: '=' + e(v));\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\telse {\n\t\t\t\t\t// parameter is an empty array, so treat as\n\t\t\t\t\t// an empty argument\n\t\t\t\t\ts += (s ? '&' : '') + e(i) + '=';\n\t\t\t\t}\n\t\t\t}\n\n\t\t\telse {\n\t\t\t\ts += s ? '&' : '';\n\t\t\t\ts += e(i) + (w === undefined ? '' : '=' + e(w));\n\t\t\t}\n\t\t}\n\n\t\treturn s;\n\t};\n\n    /**\n     * Class Url\n     *\n     * @param {string} [url] - string URL representation\n     * @param {boolean} [noTransform] - do not transform to absolute URL\n     * @constructor\n     */\n\tfunction Url(url, noTransform) {\n\t\tparse(this, url, !noTransform);\n\t}\n\n    /**\n     * Clears QueryString, making it contain no params at all\n     *\n     * @returns {Url}\n     */\n\tUrl.prototype.clearQuery = function () {\n\t\tfor (var key in this.query) {\n\t\t\tif (!(this.query[key] instanceof Function)) {\n\t\t\t\tdelete this.query[key];\n\t\t\t}\n\t\t}\n\n\t\treturn this;\n\t};\n\n    /**\n     * Returns total number of parameters in QueryString\n     *\n     * @returns {number}\n     */\n\tUrl.prototype.queryLength = function () {\n\t\tvar count = 0;\n\n\t\tfor (var key in this.query) {\n\t\t\tif (!(this.query[key] instanceof Function)) {\n\t\t\t\tcount++;\n\t\t\t}\n\t\t}\n\n\t\treturn count;\n\t};\n\n    /**\n     * Returns true if QueryString contains no parameters, false otherwise\n     *\n     * @returns {boolean}\n     */\n\tUrl.prototype.isEmptyQuery = function () {\n\t\treturn this.queryLength() === 0;\n\t};\n\n    /**\n     *\n     * @param {Array} [paths] - an array pf path parts (if given will modify\n     *                          Url.path property\n     * @returns {Array} - an array representation of the Url.path property\n     */\n\tUrl.prototype.paths = function (paths) {\n\t\tvar prefix = '';\n\t\tvar i = 0;\n\t\tvar s;\n\n\t\tif (paths && paths.length && paths + '' !== paths) {\n\t\t\tif (this.isAbsolute()) {\n\t\t\t\tprefix = '/';\n\t\t\t}\n\n\t\t\tfor (s = paths.length; i < s; i++) {\n\t\t\t\tpaths[i] = !i && RX_PATH_SEMI.test(paths[i])\n\t\t\t\t\t? paths[i]\n\t\t\t\t\t: encode(paths[i]);\n\t\t\t}\n\n\t\t\tthis.path = prefix + paths.join('/');\n\t\t}\n\n\t\tpaths = (this.path.charAt(0) === '/' ?\n\t\t\tthis.path.slice(1) : this.path).split('/');\n\n\t\tfor (i = 0, s = paths.length; i < s; i++) {\n\t\t\tpaths[i] = decode(paths[i]);\n\t\t}\n\n\t\treturn paths;\n\t};\n\n    /**\n     * Performs URL-specific encoding of the given string\n     *\n     * @method Url#encode\n     * @param {string} s - string to encode\n     * @returns {string}\n     */\n\tUrl.prototype.encode = encode;\n\n    /**\n     * Performs URL-specific decoding of the given encoded string\n     *\n     * @method Url#decode\n     * @param {string} s - string to decode\n     * @returns {string}\n     */\n\tUrl.prototype.decode = decode;\n\n    /**\n     * Checks if current URL is an absolute resource locator (globally absolute\n     * or absolute path to current server)\n     *\n     * @returns {boolean}\n     */\n\tUrl.prototype.isAbsolute = function () {\n\t\treturn this.protocol || this.path.charAt(0) === '/';\n\t};\n\n    /**\n     * Returns string representation of current Url object\n     *\n     * @returns {string}\n     */\n\tUrl.prototype.toString = function () {\n\t\treturn (\n\t\t\t(this.protocol && (this.protocol + '://')) +\n\t\t\t(this.user && (\n\t\t\t\tencode(this.user) + (this.pass && (':' + encode(this.pass))\n\t\t\t\t) + '@')) +\n\t\t\t(this.host && this.host) +\n\t\t\t(this.port && (':' + this.port)) +\n\t\t\t(this.path && this.path) +\n\t\t\t(this.query.toString() && ('?' + this.query)) +\n\t\t\t(this.hash && ('#' + encode(this.hash)))\n\t\t);\n\t};\n\n\tns[ns.exports ? 'exports' : 'Url'] = Url;\n}(typeof module !== 'undefined' && module.exports ? module : window));\n"
  },
  {
    "path": "internal/view/assets/js/utils/api.js",
    "content": "// Handles API responses in both legacy and new message formats\nexport async function handleApiResponse(response) {\n\tif (!response.ok) throw response;\n\n\t// Return early for 204 No Content responses\n\tif (response.status === 204) {\n\t\treturn null;\n\t}\n\n\tconst contentType = response.headers.get(\"Content-Type\");\n\tif (!contentType || !contentType.includes(\"application/json\")) {\n\t\treturn response;\n\t}\n\n\tconst data = await response.json();\n\n\t// Check if response is in the new message format\n\tif (data && typeof data === \"object\" && \"ok\" in data && \"message\" in data) {\n\t\tif (!data.ok) {\n\t\t\tthrow new Error(data.message?.error || \"Unknown error\");\n\t\t}\n\t\treturn data.message;\n\t}\n\n\t// Legacy format - return as is\n\treturn data;\n}\n\n// Handles API errors and returns a user-friendly error message\nexport async function handleApiError(error) {\n\tif (error instanceof Response) {\n\t\tconst data = await error.json();\n\n\t\tif (data && typeof data === \"object\" && \"error\" in data) {\n\t\t\treturn data.error;\n\t\t} else if (\n\t\t\tdata &&\n\t\t\ttypeof data === \"object\" &&\n\t\t\t\"message\" in data &&\n\t\t\t\"error\" in data.message\n\t\t) {\n\t\t\treturn data.message.error;\n\t\t} else {\n\t\t\treturn error.statusText;\n\t\t}\n\t}\n\n\treturn \"Unknown error occurred\";\n}\n\n// Makes an API request with proper error handling\nexport async function apiRequest(url, options = {}) {\n\ttry {\n\t\tconst response = await fetch(url, {\n\t\t\t...options,\n\t\t\theaders: {\n\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\tAuthorization: \"Bearer \" + localStorage.getItem(\"shiori-token\"),\n\t\t\t\t...(options.headers || {}),\n\t\t\t},\n\t\t});\n\n\t\treturn await handleApiResponse(response);\n\t} catch (error) {\n\t\tthrow new Error(await handleApiError(error));\n\t}\n}\n"
  },
  {
    "path": "internal/view/assets/js/vue.js",
    "content": "/*!\n * Vue.js v2.6.8\n * (c) 2014-2019 Evan You\n * Released under the MIT License.\n */\n(function (global, factory) {\n  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :\n  typeof define === 'function' && define.amd ? define(factory) :\n  (global = global || self, global.Vue = factory());\n}(this, function () { 'use strict';\n\n  /*  */\n\n  var emptyObject = Object.freeze({});\n\n  // These helpers produce better VM code in JS engines due to their\n  // explicitness and function inlining.\n  function isUndef (v) {\n    return v === undefined || v === null\n  }\n\n  function isDef (v) {\n    return v !== undefined && v !== null\n  }\n\n  function isTrue (v) {\n    return v === true\n  }\n\n  function isFalse (v) {\n    return v === false\n  }\n\n  /**\n   * Check if value is primitive.\n   */\n  function isPrimitive (value) {\n    return (\n      typeof value === 'string' ||\n      typeof value === 'number' ||\n      // $flow-disable-line\n      typeof value === 'symbol' ||\n      typeof value === 'boolean'\n    )\n  }\n\n  /**\n   * Quick object check - this is primarily used to tell\n   * Objects from primitive values when we know the value\n   * is a JSON-compliant type.\n   */\n  function isObject (obj) {\n    return obj !== null && typeof obj === 'object'\n  }\n\n  /**\n   * Get the raw type string of a value, e.g., [object Object].\n   */\n  var _toString = Object.prototype.toString;\n\n  function toRawType (value) {\n    return _toString.call(value).slice(8, -1)\n  }\n\n  /**\n   * Strict object type check. Only returns true\n   * for plain JavaScript objects.\n   */\n  function isPlainObject (obj) {\n    return _toString.call(obj) === '[object Object]'\n  }\n\n  function isRegExp (v) {\n    return _toString.call(v) === '[object RegExp]'\n  }\n\n  /**\n   * Check if val is a valid array index.\n   */\n  function isValidArrayIndex (val) {\n    var n = parseFloat(String(val));\n    return n >= 0 && Math.floor(n) === n && isFinite(val)\n  }\n\n  function isPromise (val) {\n    return (\n      isDef(val) &&\n      typeof val.then === 'function' &&\n      typeof val.catch === 'function'\n    )\n  }\n\n  /**\n   * Convert a value to a string that is actually rendered.\n   */\n  function toString (val) {\n    return val == null\n      ? ''\n      : Array.isArray(val) || (isPlainObject(val) && val.toString === _toString)\n        ? JSON.stringify(val, null, 2)\n        : String(val)\n  }\n\n  /**\n   * Convert an input value to a number for persistence.\n   * If the conversion fails, return original string.\n   */\n  function toNumber (val) {\n    var n = parseFloat(val);\n    return isNaN(n) ? val : n\n  }\n\n  /**\n   * Make a map and return a function for checking if a key\n   * is in that map.\n   */\n  function makeMap (\n    str,\n    expectsLowerCase\n  ) {\n    var map = Object.create(null);\n    var list = str.split(',');\n    for (var i = 0; i < list.length; i++) {\n      map[list[i]] = true;\n    }\n    return expectsLowerCase\n      ? function (val) { return map[val.toLowerCase()]; }\n      : function (val) { return map[val]; }\n  }\n\n  /**\n   * Check if a tag is a built-in tag.\n   */\n  var isBuiltInTag = makeMap('slot,component', true);\n\n  /**\n   * Check if an attribute is a reserved attribute.\n   */\n  var isReservedAttribute = makeMap('key,ref,slot,slot-scope,is');\n\n  /**\n   * Remove an item from an array.\n   */\n  function remove (arr, item) {\n    if (arr.length) {\n      var index = arr.indexOf(item);\n      if (index > -1) {\n        return arr.splice(index, 1)\n      }\n    }\n  }\n\n  /**\n   * Check whether an object has the property.\n   */\n  var hasOwnProperty = Object.prototype.hasOwnProperty;\n  function hasOwn (obj, key) {\n    return hasOwnProperty.call(obj, key)\n  }\n\n  /**\n   * Create a cached version of a pure function.\n   */\n  function cached (fn) {\n    var cache = Object.create(null);\n    return (function cachedFn (str) {\n      var hit = cache[str];\n      return hit || (cache[str] = fn(str))\n    })\n  }\n\n  /**\n   * Camelize a hyphen-delimited string.\n   */\n  var camelizeRE = /-(\\w)/g;\n  var camelize = cached(function (str) {\n    return str.replace(camelizeRE, function (_, c) { return c ? c.toUpperCase() : ''; })\n  });\n\n  /**\n   * Capitalize a string.\n   */\n  var capitalize = cached(function (str) {\n    return str.charAt(0).toUpperCase() + str.slice(1)\n  });\n\n  /**\n   * Hyphenate a camelCase string.\n   */\n  var hyphenateRE = /\\B([A-Z])/g;\n  var hyphenate = cached(function (str) {\n    return str.replace(hyphenateRE, '-$1').toLowerCase()\n  });\n\n  /**\n   * Simple bind polyfill for environments that do not support it,\n   * e.g., PhantomJS 1.x. Technically, we don't need this anymore\n   * since native bind is now performant enough in most browsers.\n   * But removing it would mean breaking code that was able to run in\n   * PhantomJS 1.x, so this must be kept for backward compatibility.\n   */\n\n  /* istanbul ignore next */\n  function polyfillBind (fn, ctx) {\n    function boundFn (a) {\n      var l = arguments.length;\n      return l\n        ? l > 1\n          ? fn.apply(ctx, arguments)\n          : fn.call(ctx, a)\n        : fn.call(ctx)\n    }\n\n    boundFn._length = fn.length;\n    return boundFn\n  }\n\n  function nativeBind (fn, ctx) {\n    return fn.bind(ctx)\n  }\n\n  var bind = Function.prototype.bind\n    ? nativeBind\n    : polyfillBind;\n\n  /**\n   * Convert an Array-like object to a real Array.\n   */\n  function toArray (list, start) {\n    start = start || 0;\n    var i = list.length - start;\n    var ret = new Array(i);\n    while (i--) {\n      ret[i] = list[i + start];\n    }\n    return ret\n  }\n\n  /**\n   * Mix properties into target object.\n   */\n  function extend (to, _from) {\n    for (var key in _from) {\n      to[key] = _from[key];\n    }\n    return to\n  }\n\n  /**\n   * Merge an Array of Objects into a single Object.\n   */\n  function toObject (arr) {\n    var res = {};\n    for (var i = 0; i < arr.length; i++) {\n      if (arr[i]) {\n        extend(res, arr[i]);\n      }\n    }\n    return res\n  }\n\n  /* eslint-disable no-unused-vars */\n\n  /**\n   * Perform no operation.\n   * Stubbing args to make Flow happy without leaving useless transpiled code\n   * with ...rest (https://flow.org/blog/2017/05/07/Strict-Function-Call-Arity/).\n   */\n  function noop (a, b, c) {}\n\n  /**\n   * Always return false.\n   */\n  var no = function (a, b, c) { return false; };\n\n  /* eslint-enable no-unused-vars */\n\n  /**\n   * Return the same value.\n   */\n  var identity = function (_) { return _; };\n\n  /**\n   * Generate a string containing static keys from compiler modules.\n   */\n  function genStaticKeys (modules) {\n    return modules.reduce(function (keys, m) {\n      return keys.concat(m.staticKeys || [])\n    }, []).join(',')\n  }\n\n  /**\n   * Check if two values are loosely equal - that is,\n   * if they are plain objects, do they have the same shape?\n   */\n  function looseEqual (a, b) {\n    if (a === b) { return true }\n    var isObjectA = isObject(a);\n    var isObjectB = isObject(b);\n    if (isObjectA && isObjectB) {\n      try {\n        var isArrayA = Array.isArray(a);\n        var isArrayB = Array.isArray(b);\n        if (isArrayA && isArrayB) {\n          return a.length === b.length && a.every(function (e, i) {\n            return looseEqual(e, b[i])\n          })\n        } else if (a instanceof Date && b instanceof Date) {\n          return a.getTime() === b.getTime()\n        } else if (!isArrayA && !isArrayB) {\n          var keysA = Object.keys(a);\n          var keysB = Object.keys(b);\n          return keysA.length === keysB.length && keysA.every(function (key) {\n            return looseEqual(a[key], b[key])\n          })\n        } else {\n          /* istanbul ignore next */\n          return false\n        }\n      } catch (e) {\n        /* istanbul ignore next */\n        return false\n      }\n    } else if (!isObjectA && !isObjectB) {\n      return String(a) === String(b)\n    } else {\n      return false\n    }\n  }\n\n  /**\n   * Return the first index at which a loosely equal value can be\n   * found in the array (if value is a plain object, the array must\n   * contain an object of the same shape), or -1 if it is not present.\n   */\n  function looseIndexOf (arr, val) {\n    for (var i = 0; i < arr.length; i++) {\n      if (looseEqual(arr[i], val)) { return i }\n    }\n    return -1\n  }\n\n  /**\n   * Ensure a function is called only once.\n   */\n  function once (fn) {\n    var called = false;\n    return function () {\n      if (!called) {\n        called = true;\n        fn.apply(this, arguments);\n      }\n    }\n  }\n\n  var SSR_ATTR = 'data-server-rendered';\n\n  var ASSET_TYPES = [\n    'component',\n    'directive',\n    'filter'\n  ];\n\n  var LIFECYCLE_HOOKS = [\n    'beforeCreate',\n    'created',\n    'beforeMount',\n    'mounted',\n    'beforeUpdate',\n    'updated',\n    'beforeDestroy',\n    'destroyed',\n    'activated',\n    'deactivated',\n    'errorCaptured',\n    'serverPrefetch'\n  ];\n\n  /*  */\n\n\n\n  var config = ({\n    /**\n     * Option merge strategies (used in core/util/options)\n     */\n    // $flow-disable-line\n    optionMergeStrategies: Object.create(null),\n\n    /**\n     * Whether to suppress warnings.\n     */\n    silent: false,\n\n    /**\n     * Show production mode tip message on boot?\n     */\n    productionTip: \"development\" !== 'production',\n\n    /**\n     * Whether to enable devtools\n     */\n    devtools: \"development\" !== 'production',\n\n    /**\n     * Whether to record perf\n     */\n    performance: false,\n\n    /**\n     * Error handler for watcher errors\n     */\n    errorHandler: null,\n\n    /**\n     * Warn handler for watcher warns\n     */\n    warnHandler: null,\n\n    /**\n     * Ignore certain custom elements\n     */\n    ignoredElements: [],\n\n    /**\n     * Custom user key aliases for v-on\n     */\n    // $flow-disable-line\n    keyCodes: Object.create(null),\n\n    /**\n     * Check if a tag is reserved so that it cannot be registered as a\n     * component. This is platform-dependent and may be overwritten.\n     */\n    isReservedTag: no,\n\n    /**\n     * Check if an attribute is reserved so that it cannot be used as a component\n     * prop. This is platform-dependent and may be overwritten.\n     */\n    isReservedAttr: no,\n\n    /**\n     * Check if a tag is an unknown element.\n     * Platform-dependent.\n     */\n    isUnknownElement: no,\n\n    /**\n     * Get the namespace of an element\n     */\n    getTagNamespace: noop,\n\n    /**\n     * Parse the real tag name for the specific platform.\n     */\n    parsePlatformTagName: identity,\n\n    /**\n     * Check if an attribute must be bound using property, e.g. value\n     * Platform-dependent.\n     */\n    mustUseProp: no,\n\n    /**\n     * Perform updates asynchronously. Intended to be used by Vue Test Utils\n     * This will significantly reduce performance if set to false.\n     */\n    async: true,\n\n    /**\n     * Exposed for legacy reasons\n     */\n    _lifecycleHooks: LIFECYCLE_HOOKS\n  });\n\n  /*  */\n\n  /**\n   * unicode letters used for parsing html tags, component names and property paths.\n   * using https://www.w3.org/TR/html53/semantics-scripting.html#potentialcustomelementname\n   * skipping \\u10000-\\uEFFFF due to it freezing up PhantomJS\n   */\n  var unicodeRegExp = /a-zA-Z\\u00B7\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u203F-\\u2040\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD/;\n\n  /**\n   * Check if a string starts with $ or _\n   */\n  function isReserved (str) {\n    var c = (str + '').charCodeAt(0);\n    return c === 0x24 || c === 0x5F\n  }\n\n  /**\n   * Define a property.\n   */\n  function def (obj, key, val, enumerable) {\n    Object.defineProperty(obj, key, {\n      value: val,\n      enumerable: !!enumerable,\n      writable: true,\n      configurable: true\n    });\n  }\n\n  /**\n   * Parse simple path.\n   */\n  var bailRE = new RegExp((\"[^\" + (unicodeRegExp.source) + \".$_\\\\d]\"));\n  function parsePath (path) {\n    if (bailRE.test(path)) {\n      return\n    }\n    var segments = path.split('.');\n    return function (obj) {\n      for (var i = 0; i < segments.length; i++) {\n        if (!obj) { return }\n        obj = obj[segments[i]];\n      }\n      return obj\n    }\n  }\n\n  /*  */\n\n  // can we use __proto__?\n  var hasProto = '__proto__' in {};\n\n  // Browser environment sniffing\n  var inBrowser = typeof window !== 'undefined';\n  var inWeex = typeof WXEnvironment !== 'undefined' && !!WXEnvironment.platform;\n  var weexPlatform = inWeex && WXEnvironment.platform.toLowerCase();\n  var UA = inBrowser && window.navigator.userAgent.toLowerCase();\n  var isIE = UA && /msie|trident/.test(UA);\n  var isIE9 = UA && UA.indexOf('msie 9.0') > 0;\n  var isEdge = UA && UA.indexOf('edge/') > 0;\n  var isAndroid = (UA && UA.indexOf('android') > 0) || (weexPlatform === 'android');\n  var isIOS = (UA && /iphone|ipad|ipod|ios/.test(UA)) || (weexPlatform === 'ios');\n  var isChrome = UA && /chrome\\/\\d+/.test(UA) && !isEdge;\n  var isPhantomJS = UA && /phantomjs/.test(UA);\n  var isFF = UA && UA.match(/firefox\\/(\\d+)/);\n\n  // Firefox has a \"watch\" function on Object.prototype...\n  var nativeWatch = ({}).watch;\n\n  var supportsPassive = false;\n  if (inBrowser) {\n    try {\n      var opts = {};\n      Object.defineProperty(opts, 'passive', ({\n        get: function get () {\n          /* istanbul ignore next */\n          supportsPassive = true;\n        }\n      })); // https://github.com/facebook/flow/issues/285\n      window.addEventListener('test-passive', null, opts);\n    } catch (e) {}\n  }\n\n  // this needs to be lazy-evaled because vue may be required before\n  // vue-server-renderer can set VUE_ENV\n  var _isServer;\n  var isServerRendering = function () {\n    if (_isServer === undefined) {\n      /* istanbul ignore if */\n      if (!inBrowser && !inWeex && typeof global !== 'undefined') {\n        // detect presence of vue-server-renderer and avoid\n        // Webpack shimming the process\n        _isServer = global['process'] && global['process'].env.VUE_ENV === 'server';\n      } else {\n        _isServer = false;\n      }\n    }\n    return _isServer\n  };\n\n  // detect devtools\n  var devtools = inBrowser && window.__VUE_DEVTOOLS_GLOBAL_HOOK__;\n\n  /* istanbul ignore next */\n  function isNative (Ctor) {\n    return typeof Ctor === 'function' && /native code/.test(Ctor.toString())\n  }\n\n  var hasSymbol =\n    typeof Symbol !== 'undefined' && isNative(Symbol) &&\n    typeof Reflect !== 'undefined' && isNative(Reflect.ownKeys);\n\n  var _Set;\n  /* istanbul ignore if */ // $flow-disable-line\n  if (typeof Set !== 'undefined' && isNative(Set)) {\n    // use native Set when available.\n    _Set = Set;\n  } else {\n    // a non-standard Set polyfill that only works with primitive keys.\n    _Set = /*@__PURE__*/(function () {\n      function Set () {\n        this.set = Object.create(null);\n      }\n      Set.prototype.has = function has (key) {\n        return this.set[key] === true\n      };\n      Set.prototype.add = function add (key) {\n        this.set[key] = true;\n      };\n      Set.prototype.clear = function clear () {\n        this.set = Object.create(null);\n      };\n\n      return Set;\n    }());\n  }\n\n  /*  */\n\n  var warn = noop;\n  var tip = noop;\n  var generateComponentTrace = (noop); // work around flow check\n  var formatComponentName = (noop);\n\n  {\n    var hasConsole = typeof console !== 'undefined';\n    var classifyRE = /(?:^|[-_])(\\w)/g;\n    var classify = function (str) { return str\n      .replace(classifyRE, function (c) { return c.toUpperCase(); })\n      .replace(/[-_]/g, ''); };\n\n    warn = function (msg, vm) {\n      var trace = vm ? generateComponentTrace(vm) : '';\n\n      if (config.warnHandler) {\n        config.warnHandler.call(null, msg, vm, trace);\n      } else if (hasConsole && (!config.silent)) {\n        console.error((\"[Vue warn]: \" + msg + trace));\n      }\n    };\n\n    tip = function (msg, vm) {\n      if (hasConsole && (!config.silent)) {\n        console.warn(\"[Vue tip]: \" + msg + (\n          vm ? generateComponentTrace(vm) : ''\n        ));\n      }\n    };\n\n    formatComponentName = function (vm, includeFile) {\n      if (vm.$root === vm) {\n        return '<Root>'\n      }\n      var options = typeof vm === 'function' && vm.cid != null\n        ? vm.options\n        : vm._isVue\n          ? vm.$options || vm.constructor.options\n          : vm;\n      var name = options.name || options._componentTag;\n      var file = options.__file;\n      if (!name && file) {\n        var match = file.match(/([^/\\\\]+)\\.vue$/);\n        name = match && match[1];\n      }\n\n      return (\n        (name ? (\"<\" + (classify(name)) + \">\") : \"<Anonymous>\") +\n        (file && includeFile !== false ? (\" at \" + file) : '')\n      )\n    };\n\n    var repeat = function (str, n) {\n      var res = '';\n      while (n) {\n        if (n % 2 === 1) { res += str; }\n        if (n > 1) { str += str; }\n        n >>= 1;\n      }\n      return res\n    };\n\n    generateComponentTrace = function (vm) {\n      if (vm._isVue && vm.$parent) {\n        var tree = [];\n        var currentRecursiveSequence = 0;\n        while (vm) {\n          if (tree.length > 0) {\n            var last = tree[tree.length - 1];\n            if (last.constructor === vm.constructor) {\n              currentRecursiveSequence++;\n              vm = vm.$parent;\n              continue\n            } else if (currentRecursiveSequence > 0) {\n              tree[tree.length - 1] = [last, currentRecursiveSequence];\n              currentRecursiveSequence = 0;\n            }\n          }\n          tree.push(vm);\n          vm = vm.$parent;\n        }\n        return '\\n\\nfound in\\n\\n' + tree\n          .map(function (vm, i) { return (\"\" + (i === 0 ? '---> ' : repeat(' ', 5 + i * 2)) + (Array.isArray(vm)\n              ? ((formatComponentName(vm[0])) + \"... (\" + (vm[1]) + \" recursive calls)\")\n              : formatComponentName(vm))); })\n          .join('\\n')\n      } else {\n        return (\"\\n\\n(found in \" + (formatComponentName(vm)) + \")\")\n      }\n    };\n  }\n\n  /*  */\n\n  var uid = 0;\n\n  /**\n   * A dep is an observable that can have multiple\n   * directives subscribing to it.\n   */\n  var Dep = function Dep () {\n    this.id = uid++;\n    this.subs = [];\n  };\n\n  Dep.prototype.addSub = function addSub (sub) {\n    this.subs.push(sub);\n  };\n\n  Dep.prototype.removeSub = function removeSub (sub) {\n    remove(this.subs, sub);\n  };\n\n  Dep.prototype.depend = function depend () {\n    if (Dep.target) {\n      Dep.target.addDep(this);\n    }\n  };\n\n  Dep.prototype.notify = function notify () {\n    // stabilize the subscriber list first\n    var subs = this.subs.slice();\n    if (!config.async) {\n      // subs aren't sorted in scheduler if not running async\n      // we need to sort them now to make sure they fire in correct\n      // order\n      subs.sort(function (a, b) { return a.id - b.id; });\n    }\n    for (var i = 0, l = subs.length; i < l; i++) {\n      subs[i].update();\n    }\n  };\n\n  // The current target watcher being evaluated.\n  // This is globally unique because only one watcher\n  // can be evaluated at a time.\n  Dep.target = null;\n  var targetStack = [];\n\n  function pushTarget (target) {\n    targetStack.push(target);\n    Dep.target = target;\n  }\n\n  function popTarget () {\n    targetStack.pop();\n    Dep.target = targetStack[targetStack.length - 1];\n  }\n\n  /*  */\n\n  var VNode = function VNode (\n    tag,\n    data,\n    children,\n    text,\n    elm,\n    context,\n    componentOptions,\n    asyncFactory\n  ) {\n    this.tag = tag;\n    this.data = data;\n    this.children = children;\n    this.text = text;\n    this.elm = elm;\n    this.ns = undefined;\n    this.context = context;\n    this.fnContext = undefined;\n    this.fnOptions = undefined;\n    this.fnScopeId = undefined;\n    this.key = data && data.key;\n    this.componentOptions = componentOptions;\n    this.componentInstance = undefined;\n    this.parent = undefined;\n    this.raw = false;\n    this.isStatic = false;\n    this.isRootInsert = true;\n    this.isComment = false;\n    this.isCloned = false;\n    this.isOnce = false;\n    this.asyncFactory = asyncFactory;\n    this.asyncMeta = undefined;\n    this.isAsyncPlaceholder = false;\n  };\n\n  var prototypeAccessors = { child: { configurable: true } };\n\n  // DEPRECATED: alias for componentInstance for backwards compat.\n  /* istanbul ignore next */\n  prototypeAccessors.child.get = function () {\n    return this.componentInstance\n  };\n\n  Object.defineProperties( VNode.prototype, prototypeAccessors );\n\n  var createEmptyVNode = function (text) {\n    if ( text === void 0 ) text = '';\n\n    var node = new VNode();\n    node.text = text;\n    node.isComment = true;\n    return node\n  };\n\n  function createTextVNode (val) {\n    return new VNode(undefined, undefined, undefined, String(val))\n  }\n\n  // optimized shallow clone\n  // used for static nodes and slot nodes because they may be reused across\n  // multiple renders, cloning them avoids errors when DOM manipulations rely\n  // on their elm reference.\n  function cloneVNode (vnode) {\n    var cloned = new VNode(\n      vnode.tag,\n      vnode.data,\n      // #7975\n      // clone children array to avoid mutating original in case of cloning\n      // a child.\n      vnode.children && vnode.children.slice(),\n      vnode.text,\n      vnode.elm,\n      vnode.context,\n      vnode.componentOptions,\n      vnode.asyncFactory\n    );\n    cloned.ns = vnode.ns;\n    cloned.isStatic = vnode.isStatic;\n    cloned.key = vnode.key;\n    cloned.isComment = vnode.isComment;\n    cloned.fnContext = vnode.fnContext;\n    cloned.fnOptions = vnode.fnOptions;\n    cloned.fnScopeId = vnode.fnScopeId;\n    cloned.asyncMeta = vnode.asyncMeta;\n    cloned.isCloned = true;\n    return cloned\n  }\n\n  /*\n   * not type checking this file because flow doesn't play well with\n   * dynamically accessing methods on Array prototype\n   */\n\n  var arrayProto = Array.prototype;\n  var arrayMethods = Object.create(arrayProto);\n\n  var methodsToPatch = [\n    'push',\n    'pop',\n    'shift',\n    'unshift',\n    'splice',\n    'sort',\n    'reverse'\n  ];\n\n  /**\n   * Intercept mutating methods and emit events\n   */\n  methodsToPatch.forEach(function (method) {\n    // cache original method\n    var original = arrayProto[method];\n    def(arrayMethods, method, function mutator () {\n      var args = [], len = arguments.length;\n      while ( len-- ) args[ len ] = arguments[ len ];\n\n      var result = original.apply(this, args);\n      var ob = this.__ob__;\n      var inserted;\n      switch (method) {\n        case 'push':\n        case 'unshift':\n          inserted = args;\n          break\n        case 'splice':\n          inserted = args.slice(2);\n          break\n      }\n      if (inserted) { ob.observeArray(inserted); }\n      // notify change\n      ob.dep.notify();\n      return result\n    });\n  });\n\n  /*  */\n\n  var arrayKeys = Object.getOwnPropertyNames(arrayMethods);\n\n  /**\n   * In some cases we may want to disable observation inside a component's\n   * update computation.\n   */\n  var shouldObserve = true;\n\n  function toggleObserving (value) {\n    shouldObserve = value;\n  }\n\n  /**\n   * Observer class that is attached to each observed\n   * object. Once attached, the observer converts the target\n   * object's property keys into getter/setters that\n   * collect dependencies and dispatch updates.\n   */\n  var Observer = function Observer (value) {\n    this.value = value;\n    this.dep = new Dep();\n    this.vmCount = 0;\n    def(value, '__ob__', this);\n    if (Array.isArray(value)) {\n      if (hasProto) {\n        protoAugment(value, arrayMethods);\n      } else {\n        copyAugment(value, arrayMethods, arrayKeys);\n      }\n      this.observeArray(value);\n    } else {\n      this.walk(value);\n    }\n  };\n\n  /**\n   * Walk through all properties and convert them into\n   * getter/setters. This method should only be called when\n   * value type is Object.\n   */\n  Observer.prototype.walk = function walk (obj) {\n    var keys = Object.keys(obj);\n    for (var i = 0; i < keys.length; i++) {\n      defineReactive$$1(obj, keys[i]);\n    }\n  };\n\n  /**\n   * Observe a list of Array items.\n   */\n  Observer.prototype.observeArray = function observeArray (items) {\n    for (var i = 0, l = items.length; i < l; i++) {\n      observe(items[i]);\n    }\n  };\n\n  // helpers\n\n  /**\n   * Augment a target Object or Array by intercepting\n   * the prototype chain using __proto__\n   */\n  function protoAugment (target, src) {\n    /* eslint-disable no-proto */\n    target.__proto__ = src;\n    /* eslint-enable no-proto */\n  }\n\n  /**\n   * Augment a target Object or Array by defining\n   * hidden properties.\n   */\n  /* istanbul ignore next */\n  function copyAugment (target, src, keys) {\n    for (var i = 0, l = keys.length; i < l; i++) {\n      var key = keys[i];\n      def(target, key, src[key]);\n    }\n  }\n\n  /**\n   * Attempt to create an observer instance for a value,\n   * returns the new observer if successfully observed,\n   * or the existing observer if the value already has one.\n   */\n  function observe (value, asRootData) {\n    if (!isObject(value) || value instanceof VNode) {\n      return\n    }\n    var ob;\n    if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {\n      ob = value.__ob__;\n    } else if (\n      shouldObserve &&\n      !isServerRendering() &&\n      (Array.isArray(value) || isPlainObject(value)) &&\n      Object.isExtensible(value) &&\n      !value._isVue\n    ) {\n      ob = new Observer(value);\n    }\n    if (asRootData && ob) {\n      ob.vmCount++;\n    }\n    return ob\n  }\n\n  /**\n   * Define a reactive property on an Object.\n   */\n  function defineReactive$$1 (\n    obj,\n    key,\n    val,\n    customSetter,\n    shallow\n  ) {\n    var dep = new Dep();\n\n    var property = Object.getOwnPropertyDescriptor(obj, key);\n    if (property && property.configurable === false) {\n      return\n    }\n\n    // cater for pre-defined getter/setters\n    var getter = property && property.get;\n    var setter = property && property.set;\n    if ((!getter || setter) && arguments.length === 2) {\n      val = obj[key];\n    }\n\n    var childOb = !shallow && observe(val);\n    Object.defineProperty(obj, key, {\n      enumerable: true,\n      configurable: true,\n      get: function reactiveGetter () {\n        var value = getter ? getter.call(obj) : val;\n        if (Dep.target) {\n          dep.depend();\n          if (childOb) {\n            childOb.dep.depend();\n            if (Array.isArray(value)) {\n              dependArray(value);\n            }\n          }\n        }\n        return value\n      },\n      set: function reactiveSetter (newVal) {\n        var value = getter ? getter.call(obj) : val;\n        /* eslint-disable no-self-compare */\n        if (newVal === value || (newVal !== newVal && value !== value)) {\n          return\n        }\n        /* eslint-enable no-self-compare */\n        if (customSetter) {\n          customSetter();\n        }\n        // #7981: for accessor properties without setter\n        if (getter && !setter) { return }\n        if (setter) {\n          setter.call(obj, newVal);\n        } else {\n          val = newVal;\n        }\n        childOb = !shallow && observe(newVal);\n        dep.notify();\n      }\n    });\n  }\n\n  /**\n   * Set a property on an object. Adds the new property and\n   * triggers change notification if the property doesn't\n   * already exist.\n   */\n  function set (target, key, val) {\n    if (isUndef(target) || isPrimitive(target)\n    ) {\n      warn((\"Cannot set reactive property on undefined, null, or primitive value: \" + ((target))));\n    }\n    if (Array.isArray(target) && isValidArrayIndex(key)) {\n      target.length = Math.max(target.length, key);\n      target.splice(key, 1, val);\n      return val\n    }\n    if (key in target && !(key in Object.prototype)) {\n      target[key] = val;\n      return val\n    }\n    var ob = (target).__ob__;\n    if (target._isVue || (ob && ob.vmCount)) {\n      warn(\n        'Avoid adding reactive properties to a Vue instance or its root $data ' +\n        'at runtime - declare it upfront in the data option.'\n      );\n      return val\n    }\n    if (!ob) {\n      target[key] = val;\n      return val\n    }\n    defineReactive$$1(ob.value, key, val);\n    ob.dep.notify();\n    return val\n  }\n\n  /**\n   * Delete a property and trigger change if necessary.\n   */\n  function del (target, key) {\n    if (isUndef(target) || isPrimitive(target)\n    ) {\n      warn((\"Cannot delete reactive property on undefined, null, or primitive value: \" + ((target))));\n    }\n    if (Array.isArray(target) && isValidArrayIndex(key)) {\n      target.splice(key, 1);\n      return\n    }\n    var ob = (target).__ob__;\n    if (target._isVue || (ob && ob.vmCount)) {\n      warn(\n        'Avoid deleting properties on a Vue instance or its root $data ' +\n        '- just set it to null.'\n      );\n      return\n    }\n    if (!hasOwn(target, key)) {\n      return\n    }\n    delete target[key];\n    if (!ob) {\n      return\n    }\n    ob.dep.notify();\n  }\n\n  /**\n   * Collect dependencies on array elements when the array is touched, since\n   * we cannot intercept array element access like property getters.\n   */\n  function dependArray (value) {\n    for (var e = (void 0), i = 0, l = value.length; i < l; i++) {\n      e = value[i];\n      e && e.__ob__ && e.__ob__.dep.depend();\n      if (Array.isArray(e)) {\n        dependArray(e);\n      }\n    }\n  }\n\n  /*  */\n\n  /**\n   * Option overwriting strategies are functions that handle\n   * how to merge a parent option value and a child option\n   * value into the final value.\n   */\n  var strats = config.optionMergeStrategies;\n\n  /**\n   * Options with restrictions\n   */\n  {\n    strats.el = strats.propsData = function (parent, child, vm, key) {\n      if (!vm) {\n        warn(\n          \"option \\\"\" + key + \"\\\" can only be used during instance \" +\n          'creation with the `new` keyword.'\n        );\n      }\n      return defaultStrat(parent, child)\n    };\n  }\n\n  /**\n   * Helper that recursively merges two data objects together.\n   */\n  function mergeData (to, from) {\n    if (!from) { return to }\n    var key, toVal, fromVal;\n\n    var keys = hasSymbol\n      ? Reflect.ownKeys(from)\n      : Object.keys(from);\n\n    for (var i = 0; i < keys.length; i++) {\n      key = keys[i];\n      // in case the object is already observed...\n      if (key === '__ob__') { continue }\n      toVal = to[key];\n      fromVal = from[key];\n      if (!hasOwn(to, key)) {\n        set(to, key, fromVal);\n      } else if (\n        toVal !== fromVal &&\n        isPlainObject(toVal) &&\n        isPlainObject(fromVal)\n      ) {\n        mergeData(toVal, fromVal);\n      }\n    }\n    return to\n  }\n\n  /**\n   * Data\n   */\n  function mergeDataOrFn (\n    parentVal,\n    childVal,\n    vm\n  ) {\n    if (!vm) {\n      // in a Vue.extend merge, both should be functions\n      if (!childVal) {\n        return parentVal\n      }\n      if (!parentVal) {\n        return childVal\n      }\n      // when parentVal & childVal are both present,\n      // we need to return a function that returns the\n      // merged result of both functions... no need to\n      // check if parentVal is a function here because\n      // it has to be a function to pass previous merges.\n      return function mergedDataFn () {\n        return mergeData(\n          typeof childVal === 'function' ? childVal.call(this, this) : childVal,\n          typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal\n        )\n      }\n    } else {\n      return function mergedInstanceDataFn () {\n        // instance merge\n        var instanceData = typeof childVal === 'function'\n          ? childVal.call(vm, vm)\n          : childVal;\n        var defaultData = typeof parentVal === 'function'\n          ? parentVal.call(vm, vm)\n          : parentVal;\n        if (instanceData) {\n          return mergeData(instanceData, defaultData)\n        } else {\n          return defaultData\n        }\n      }\n    }\n  }\n\n  strats.data = function (\n    parentVal,\n    childVal,\n    vm\n  ) {\n    if (!vm) {\n      if (childVal && typeof childVal !== 'function') {\n        warn(\n          'The \"data\" option should be a function ' +\n          'that returns a per-instance value in component ' +\n          'definitions.',\n          vm\n        );\n\n        return parentVal\n      }\n      return mergeDataOrFn(parentVal, childVal)\n    }\n\n    return mergeDataOrFn(parentVal, childVal, vm)\n  };\n\n  /**\n   * Hooks and props are merged as arrays.\n   */\n  function mergeHook (\n    parentVal,\n    childVal\n  ) {\n    var res = childVal\n      ? parentVal\n        ? parentVal.concat(childVal)\n        : Array.isArray(childVal)\n          ? childVal\n          : [childVal]\n      : parentVal;\n    return res\n      ? dedupeHooks(res)\n      : res\n  }\n\n  function dedupeHooks (hooks) {\n    var res = [];\n    for (var i = 0; i < hooks.length; i++) {\n      if (res.indexOf(hooks[i]) === -1) {\n        res.push(hooks[i]);\n      }\n    }\n    return res\n  }\n\n  LIFECYCLE_HOOKS.forEach(function (hook) {\n    strats[hook] = mergeHook;\n  });\n\n  /**\n   * Assets\n   *\n   * When a vm is present (instance creation), we need to do\n   * a three-way merge between constructor options, instance\n   * options and parent options.\n   */\n  function mergeAssets (\n    parentVal,\n    childVal,\n    vm,\n    key\n  ) {\n    var res = Object.create(parentVal || null);\n    if (childVal) {\n      assertObjectType(key, childVal, vm);\n      return extend(res, childVal)\n    } else {\n      return res\n    }\n  }\n\n  ASSET_TYPES.forEach(function (type) {\n    strats[type + 's'] = mergeAssets;\n  });\n\n  /**\n   * Watchers.\n   *\n   * Watchers hashes should not overwrite one\n   * another, so we merge them as arrays.\n   */\n  strats.watch = function (\n    parentVal,\n    childVal,\n    vm,\n    key\n  ) {\n    // work around Firefox's Object.prototype.watch...\n    if (parentVal === nativeWatch) { parentVal = undefined; }\n    if (childVal === nativeWatch) { childVal = undefined; }\n    /* istanbul ignore if */\n    if (!childVal) { return Object.create(parentVal || null) }\n    {\n      assertObjectType(key, childVal, vm);\n    }\n    if (!parentVal) { return childVal }\n    var ret = {};\n    extend(ret, parentVal);\n    for (var key$1 in childVal) {\n      var parent = ret[key$1];\n      var child = childVal[key$1];\n      if (parent && !Array.isArray(parent)) {\n        parent = [parent];\n      }\n      ret[key$1] = parent\n        ? parent.concat(child)\n        : Array.isArray(child) ? child : [child];\n    }\n    return ret\n  };\n\n  /**\n   * Other object hashes.\n   */\n  strats.props =\n  strats.methods =\n  strats.inject =\n  strats.computed = function (\n    parentVal,\n    childVal,\n    vm,\n    key\n  ) {\n    if (childVal && \"development\" !== 'production') {\n      assertObjectType(key, childVal, vm);\n    }\n    if (!parentVal) { return childVal }\n    var ret = Object.create(null);\n    extend(ret, parentVal);\n    if (childVal) { extend(ret, childVal); }\n    return ret\n  };\n  strats.provide = mergeDataOrFn;\n\n  /**\n   * Default strategy.\n   */\n  var defaultStrat = function (parentVal, childVal) {\n    return childVal === undefined\n      ? parentVal\n      : childVal\n  };\n\n  /**\n   * Validate component names\n   */\n  function checkComponents (options) {\n    for (var key in options.components) {\n      validateComponentName(key);\n    }\n  }\n\n  function validateComponentName (name) {\n    if (!new RegExp((\"^[a-zA-Z][\\\\-\\\\.0-9_\" + (unicodeRegExp.source) + \"]*$\")).test(name)) {\n      warn(\n        'Invalid component name: \"' + name + '\". Component names ' +\n        'should conform to valid custom element name in html5 specification.'\n      );\n    }\n    if (isBuiltInTag(name) || config.isReservedTag(name)) {\n      warn(\n        'Do not use built-in or reserved HTML elements as component ' +\n        'id: ' + name\n      );\n    }\n  }\n\n  /**\n   * Ensure all props option syntax are normalized into the\n   * Object-based format.\n   */\n  function normalizeProps (options, vm) {\n    var props = options.props;\n    if (!props) { return }\n    var res = {};\n    var i, val, name;\n    if (Array.isArray(props)) {\n      i = props.length;\n      while (i--) {\n        val = props[i];\n        if (typeof val === 'string') {\n          name = camelize(val);\n          res[name] = { type: null };\n        } else {\n          warn('props must be strings when using array syntax.');\n        }\n      }\n    } else if (isPlainObject(props)) {\n      for (var key in props) {\n        val = props[key];\n        name = camelize(key);\n        res[name] = isPlainObject(val)\n          ? val\n          : { type: val };\n      }\n    } else {\n      warn(\n        \"Invalid value for option \\\"props\\\": expected an Array or an Object, \" +\n        \"but got \" + (toRawType(props)) + \".\",\n        vm\n      );\n    }\n    options.props = res;\n  }\n\n  /**\n   * Normalize all injections into Object-based format\n   */\n  function normalizeInject (options, vm) {\n    var inject = options.inject;\n    if (!inject) { return }\n    var normalized = options.inject = {};\n    if (Array.isArray(inject)) {\n      for (var i = 0; i < inject.length; i++) {\n        normalized[inject[i]] = { from: inject[i] };\n      }\n    } else if (isPlainObject(inject)) {\n      for (var key in inject) {\n        var val = inject[key];\n        normalized[key] = isPlainObject(val)\n          ? extend({ from: key }, val)\n          : { from: val };\n      }\n    } else {\n      warn(\n        \"Invalid value for option \\\"inject\\\": expected an Array or an Object, \" +\n        \"but got \" + (toRawType(inject)) + \".\",\n        vm\n      );\n    }\n  }\n\n  /**\n   * Normalize raw function directives into object format.\n   */\n  function normalizeDirectives (options) {\n    var dirs = options.directives;\n    if (dirs) {\n      for (var key in dirs) {\n        var def$$1 = dirs[key];\n        if (typeof def$$1 === 'function') {\n          dirs[key] = { bind: def$$1, update: def$$1 };\n        }\n      }\n    }\n  }\n\n  function assertObjectType (name, value, vm) {\n    if (!isPlainObject(value)) {\n      warn(\n        \"Invalid value for option \\\"\" + name + \"\\\": expected an Object, \" +\n        \"but got \" + (toRawType(value)) + \".\",\n        vm\n      );\n    }\n  }\n\n  /**\n   * Merge two option objects into a new one.\n   * Core utility used in both instantiation and inheritance.\n   */\n  function mergeOptions (\n    parent,\n    child,\n    vm\n  ) {\n    {\n      checkComponents(child);\n    }\n\n    if (typeof child === 'function') {\n      child = child.options;\n    }\n\n    normalizeProps(child, vm);\n    normalizeInject(child, vm);\n    normalizeDirectives(child);\n\n    // Apply extends and mixins on the child options,\n    // but only if it is a raw options object that isn't\n    // the result of another mergeOptions call.\n    // Only merged options has the _base property.\n    if (!child._base) {\n      if (child.extends) {\n        parent = mergeOptions(parent, child.extends, vm);\n      }\n      if (child.mixins) {\n        for (var i = 0, l = child.mixins.length; i < l; i++) {\n          parent = mergeOptions(parent, child.mixins[i], vm);\n        }\n      }\n    }\n\n    var options = {};\n    var key;\n    for (key in parent) {\n      mergeField(key);\n    }\n    for (key in child) {\n      if (!hasOwn(parent, key)) {\n        mergeField(key);\n      }\n    }\n    function mergeField (key) {\n      var strat = strats[key] || defaultStrat;\n      options[key] = strat(parent[key], child[key], vm, key);\n    }\n    return options\n  }\n\n  /**\n   * Resolve an asset.\n   * This function is used because child instances need access\n   * to assets defined in its ancestor chain.\n   */\n  function resolveAsset (\n    options,\n    type,\n    id,\n    warnMissing\n  ) {\n    /* istanbul ignore if */\n    if (typeof id !== 'string') {\n      return\n    }\n    var assets = options[type];\n    // check local registration variations first\n    if (hasOwn(assets, id)) { return assets[id] }\n    var camelizedId = camelize(id);\n    if (hasOwn(assets, camelizedId)) { return assets[camelizedId] }\n    var PascalCaseId = capitalize(camelizedId);\n    if (hasOwn(assets, PascalCaseId)) { return assets[PascalCaseId] }\n    // fallback to prototype chain\n    var res = assets[id] || assets[camelizedId] || assets[PascalCaseId];\n    if (warnMissing && !res) {\n      warn(\n        'Failed to resolve ' + type.slice(0, -1) + ': ' + id,\n        options\n      );\n    }\n    return res\n  }\n\n  /*  */\n\n\n\n  function validateProp (\n    key,\n    propOptions,\n    propsData,\n    vm\n  ) {\n    var prop = propOptions[key];\n    var absent = !hasOwn(propsData, key);\n    var value = propsData[key];\n    // boolean casting\n    var booleanIndex = getTypeIndex(Boolean, prop.type);\n    if (booleanIndex > -1) {\n      if (absent && !hasOwn(prop, 'default')) {\n        value = false;\n      } else if (value === '' || value === hyphenate(key)) {\n        // only cast empty string / same name to boolean if\n        // boolean has higher priority\n        var stringIndex = getTypeIndex(String, prop.type);\n        if (stringIndex < 0 || booleanIndex < stringIndex) {\n          value = true;\n        }\n      }\n    }\n    // check default value\n    if (value === undefined) {\n      value = getPropDefaultValue(vm, prop, key);\n      // since the default value is a fresh copy,\n      // make sure to observe it.\n      var prevShouldObserve = shouldObserve;\n      toggleObserving(true);\n      observe(value);\n      toggleObserving(prevShouldObserve);\n    }\n    {\n      assertProp(prop, key, value, vm, absent);\n    }\n    return value\n  }\n\n  /**\n   * Get the default value of a prop.\n   */\n  function getPropDefaultValue (vm, prop, key) {\n    // no default, return undefined\n    if (!hasOwn(prop, 'default')) {\n      return undefined\n    }\n    var def = prop.default;\n    // warn against non-factory defaults for Object & Array\n    if (isObject(def)) {\n      warn(\n        'Invalid default value for prop \"' + key + '\": ' +\n        'Props with type Object/Array must use a factory function ' +\n        'to return the default value.',\n        vm\n      );\n    }\n    // the raw prop value was also undefined from previous render,\n    // return previous default value to avoid unnecessary watcher trigger\n    if (vm && vm.$options.propsData &&\n      vm.$options.propsData[key] === undefined &&\n      vm._props[key] !== undefined\n    ) {\n      return vm._props[key]\n    }\n    // call factory function for non-Function types\n    // a value is Function if its prototype is function even across different execution context\n    return typeof def === 'function' && getType(prop.type) !== 'Function'\n      ? def.call(vm)\n      : def\n  }\n\n  /**\n   * Assert whether a prop is valid.\n   */\n  function assertProp (\n    prop,\n    name,\n    value,\n    vm,\n    absent\n  ) {\n    if (prop.required && absent) {\n      warn(\n        'Missing required prop: \"' + name + '\"',\n        vm\n      );\n      return\n    }\n    if (value == null && !prop.required) {\n      return\n    }\n    var type = prop.type;\n    var valid = !type || type === true;\n    var expectedTypes = [];\n    if (type) {\n      if (!Array.isArray(type)) {\n        type = [type];\n      }\n      for (var i = 0; i < type.length && !valid; i++) {\n        var assertedType = assertType(value, type[i]);\n        expectedTypes.push(assertedType.expectedType || '');\n        valid = assertedType.valid;\n      }\n    }\n\n    if (!valid) {\n      warn(\n        getInvalidTypeMessage(name, value, expectedTypes),\n        vm\n      );\n      return\n    }\n    var validator = prop.validator;\n    if (validator) {\n      if (!validator(value)) {\n        warn(\n          'Invalid prop: custom validator check failed for prop \"' + name + '\".',\n          vm\n        );\n      }\n    }\n  }\n\n  var simpleCheckRE = /^(String|Number|Boolean|Function|Symbol)$/;\n\n  function assertType (value, type) {\n    var valid;\n    var expectedType = getType(type);\n    if (simpleCheckRE.test(expectedType)) {\n      var t = typeof value;\n      valid = t === expectedType.toLowerCase();\n      // for primitive wrapper objects\n      if (!valid && t === 'object') {\n        valid = value instanceof type;\n      }\n    } else if (expectedType === 'Object') {\n      valid = isPlainObject(value);\n    } else if (expectedType === 'Array') {\n      valid = Array.isArray(value);\n    } else {\n      valid = value instanceof type;\n    }\n    return {\n      valid: valid,\n      expectedType: expectedType\n    }\n  }\n\n  /**\n   * Use function string name to check built-in types,\n   * because a simple equality check will fail when running\n   * across different vms / iframes.\n   */\n  function getType (fn) {\n    var match = fn && fn.toString().match(/^\\s*function (\\w+)/);\n    return match ? match[1] : ''\n  }\n\n  function isSameType (a, b) {\n    return getType(a) === getType(b)\n  }\n\n  function getTypeIndex (type, expectedTypes) {\n    if (!Array.isArray(expectedTypes)) {\n      return isSameType(expectedTypes, type) ? 0 : -1\n    }\n    for (var i = 0, len = expectedTypes.length; i < len; i++) {\n      if (isSameType(expectedTypes[i], type)) {\n        return i\n      }\n    }\n    return -1\n  }\n\n  function getInvalidTypeMessage (name, value, expectedTypes) {\n    var message = \"Invalid prop: type check failed for prop \\\"\" + name + \"\\\".\" +\n      \" Expected \" + (expectedTypes.map(capitalize).join(', '));\n    var expectedType = expectedTypes[0];\n    var receivedType = toRawType(value);\n    var expectedValue = styleValue(value, expectedType);\n    var receivedValue = styleValue(value, receivedType);\n    // check if we need to specify expected value\n    if (expectedTypes.length === 1 &&\n        isExplicable(expectedType) &&\n        !isBoolean(expectedType, receivedType)) {\n      message += \" with value \" + expectedValue;\n    }\n    message += \", got \" + receivedType + \" \";\n    // check if we need to specify received value\n    if (isExplicable(receivedType)) {\n      message += \"with value \" + receivedValue + \".\";\n    }\n    return message\n  }\n\n  function styleValue (value, type) {\n    if (type === 'String') {\n      return (\"\\\"\" + value + \"\\\"\")\n    } else if (type === 'Number') {\n      return (\"\" + (Number(value)))\n    } else {\n      return (\"\" + value)\n    }\n  }\n\n  function isExplicable (value) {\n    var explicitTypes = ['string', 'number', 'boolean'];\n    return explicitTypes.some(function (elem) { return value.toLowerCase() === elem; })\n  }\n\n  function isBoolean () {\n    var args = [], len = arguments.length;\n    while ( len-- ) args[ len ] = arguments[ len ];\n\n    return args.some(function (elem) { return elem.toLowerCase() === 'boolean'; })\n  }\n\n  /*  */\n\n  function handleError (err, vm, info) {\n    // Deactivate deps tracking while processing error handler to avoid possible infinite rendering.\n    // See: https://github.com/vuejs/vuex/issues/1505\n    pushTarget();\n    try {\n      if (vm) {\n        var cur = vm;\n        while ((cur = cur.$parent)) {\n          var hooks = cur.$options.errorCaptured;\n          if (hooks) {\n            for (var i = 0; i < hooks.length; i++) {\n              try {\n                var capture = hooks[i].call(cur, err, vm, info) === false;\n                if (capture) { return }\n              } catch (e) {\n                globalHandleError(e, cur, 'errorCaptured hook');\n              }\n            }\n          }\n        }\n      }\n      globalHandleError(err, vm, info);\n    } finally {\n      popTarget();\n    }\n  }\n\n  function invokeWithErrorHandling (\n    handler,\n    context,\n    args,\n    vm,\n    info\n  ) {\n    var res;\n    try {\n      res = args ? handler.apply(context, args) : handler.call(context);\n      if (res && !res._isVue && isPromise(res)) {\n        // issue #9511\n        // reassign to res to avoid catch triggering multiple times when nested calls\n        res = res.catch(function (e) { return handleError(e, vm, info + \" (Promise/async)\"); });\n      }\n    } catch (e) {\n      handleError(e, vm, info);\n    }\n    return res\n  }\n\n  function globalHandleError (err, vm, info) {\n    if (config.errorHandler) {\n      try {\n        return config.errorHandler.call(null, err, vm, info)\n      } catch (e) {\n        // if the user intentionally throws the original error in the handler,\n        // do not log it twice\n        if (e !== err) {\n          logError(e, null, 'config.errorHandler');\n        }\n      }\n    }\n    logError(err, vm, info);\n  }\n\n  function logError (err, vm, info) {\n    {\n      warn((\"Error in \" + info + \": \\\"\" + (err.toString()) + \"\\\"\"), vm);\n    }\n    /* istanbul ignore else */\n    if ((inBrowser || inWeex) && typeof console !== 'undefined') {\n      console.error(err);\n    } else {\n      throw err\n    }\n  }\n\n  /*  */\n\n  var isUsingMicroTask = false;\n\n  var callbacks = [];\n  var pending = false;\n\n  function flushCallbacks () {\n    pending = false;\n    var copies = callbacks.slice(0);\n    callbacks.length = 0;\n    for (var i = 0; i < copies.length; i++) {\n      copies[i]();\n    }\n  }\n\n  // Here we have async deferring wrappers using microtasks.\n  // In 2.5 we used (macro) tasks (in combination with microtasks).\n  // However, it has subtle problems when state is changed right before repaint\n  // (e.g. #6813, out-in transitions).\n  // Also, using (macro) tasks in event handler would cause some weird behaviors\n  // that cannot be circumvented (e.g. #7109, #7153, #7546, #7834, #8109).\n  // So we now use microtasks everywhere, again.\n  // A major drawback of this tradeoff is that there are some scenarios\n  // where microtasks have too high a priority and fire in between supposedly\n  // sequential events (e.g. #4521, #6690, which have workarounds)\n  // or even between bubbling of the same event (#6566).\n  var timerFunc;\n\n  // The nextTick behavior leverages the microtask queue, which can be accessed\n  // via either native Promise.then or MutationObserver.\n  // MutationObserver has wider support, however it is seriously bugged in\n  // UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It\n  // completely stops working after triggering a few times... so, if native\n  // Promise is available, we will use it:\n  /* istanbul ignore next, $flow-disable-line */\n  if (typeof Promise !== 'undefined' && isNative(Promise)) {\n    var p = Promise.resolve();\n    timerFunc = function () {\n      p.then(flushCallbacks);\n      // In problematic UIWebViews, Promise.then doesn't completely break, but\n      // it can get stuck in a weird state where callbacks are pushed into the\n      // microtask queue but the queue isn't being flushed, until the browser\n      // needs to do some other work, e.g. handle a timer. Therefore we can\n      // \"force\" the microtask queue to be flushed by adding an empty timer.\n      if (isIOS) { setTimeout(noop); }\n    };\n    isUsingMicroTask = true;\n  } else if (!isIE && typeof MutationObserver !== 'undefined' && (\n    isNative(MutationObserver) ||\n    // PhantomJS and iOS 7.x\n    MutationObserver.toString() === '[object MutationObserverConstructor]'\n  )) {\n    // Use MutationObserver where native Promise is not available,\n    // e.g. PhantomJS, iOS7, Android 4.4\n    // (#6466 MutationObserver is unreliable in IE11)\n    var counter = 1;\n    var observer = new MutationObserver(flushCallbacks);\n    var textNode = document.createTextNode(String(counter));\n    observer.observe(textNode, {\n      characterData: true\n    });\n    timerFunc = function () {\n      counter = (counter + 1) % 2;\n      textNode.data = String(counter);\n    };\n    isUsingMicroTask = true;\n  } else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {\n    // Fallback to setImmediate.\n    // Techinically it leverages the (macro) task queue,\n    // but it is still a better choice than setTimeout.\n    timerFunc = function () {\n      setImmediate(flushCallbacks);\n    };\n  } else {\n    // Fallback to setTimeout.\n    timerFunc = function () {\n      setTimeout(flushCallbacks, 0);\n    };\n  }\n\n  function nextTick (cb, ctx) {\n    var _resolve;\n    callbacks.push(function () {\n      if (cb) {\n        try {\n          cb.call(ctx);\n        } catch (e) {\n          handleError(e, ctx, 'nextTick');\n        }\n      } else if (_resolve) {\n        _resolve(ctx);\n      }\n    });\n    if (!pending) {\n      pending = true;\n      timerFunc();\n    }\n    // $flow-disable-line\n    if (!cb && typeof Promise !== 'undefined') {\n      return new Promise(function (resolve) {\n        _resolve = resolve;\n      })\n    }\n  }\n\n  /*  */\n\n  var mark;\n  var measure;\n\n  {\n    var perf = inBrowser && window.performance;\n    /* istanbul ignore if */\n    if (\n      perf &&\n      perf.mark &&\n      perf.measure &&\n      perf.clearMarks &&\n      perf.clearMeasures\n    ) {\n      mark = function (tag) { return perf.mark(tag); };\n      measure = function (name, startTag, endTag) {\n        perf.measure(name, startTag, endTag);\n        perf.clearMarks(startTag);\n        perf.clearMarks(endTag);\n        // perf.clearMeasures(name)\n      };\n    }\n  }\n\n  /* not type checking this file because flow doesn't play well with Proxy */\n\n  var initProxy;\n\n  {\n    var allowedGlobals = makeMap(\n      'Infinity,undefined,NaN,isFinite,isNaN,' +\n      'parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,' +\n      'Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,' +\n      'require' // for Webpack/Browserify\n    );\n\n    var warnNonPresent = function (target, key) {\n      warn(\n        \"Property or method \\\"\" + key + \"\\\" is not defined on the instance but \" +\n        'referenced during render. Make sure that this property is reactive, ' +\n        'either in the data option, or for class-based components, by ' +\n        'initializing the property. ' +\n        'See: https://vuejs.org/v2/guide/reactivity.html#Declaring-Reactive-Properties.',\n        target\n      );\n    };\n\n    var warnReservedPrefix = function (target, key) {\n      warn(\n        \"Property \\\"\" + key + \"\\\" must be accessed with \\\"$data.\" + key + \"\\\" because \" +\n        'properties starting with \"$\" or \"_\" are not proxied in the Vue instance to ' +\n        'prevent conflicts with Vue internals' +\n        'See: https://vuejs.org/v2/api/#data',\n        target\n      );\n    };\n\n    var hasProxy =\n      typeof Proxy !== 'undefined' && isNative(Proxy);\n\n    if (hasProxy) {\n      var isBuiltInModifier = makeMap('stop,prevent,self,ctrl,shift,alt,meta,exact');\n      config.keyCodes = new Proxy(config.keyCodes, {\n        set: function set (target, key, value) {\n          if (isBuiltInModifier(key)) {\n            warn((\"Avoid overwriting built-in modifier in config.keyCodes: .\" + key));\n            return false\n          } else {\n            target[key] = value;\n            return true\n          }\n        }\n      });\n    }\n\n    var hasHandler = {\n      has: function has (target, key) {\n        var has = key in target;\n        var isAllowed = allowedGlobals(key) ||\n          (typeof key === 'string' && key.charAt(0) === '_' && !(key in target.$data));\n        if (!has && !isAllowed) {\n          if (key in target.$data) { warnReservedPrefix(target, key); }\n          else { warnNonPresent(target, key); }\n        }\n        return has || !isAllowed\n      }\n    };\n\n    var getHandler = {\n      get: function get (target, key) {\n        if (typeof key === 'string' && !(key in target)) {\n          if (key in target.$data) { warnReservedPrefix(target, key); }\n          else { warnNonPresent(target, key); }\n        }\n        return target[key]\n      }\n    };\n\n    initProxy = function initProxy (vm) {\n      if (hasProxy) {\n        // determine which proxy handler to use\n        var options = vm.$options;\n        var handlers = options.render && options.render._withStripped\n          ? getHandler\n          : hasHandler;\n        vm._renderProxy = new Proxy(vm, handlers);\n      } else {\n        vm._renderProxy = vm;\n      }\n    };\n  }\n\n  /*  */\n\n  var seenObjects = new _Set();\n\n  /**\n   * Recursively traverse an object to evoke all converted\n   * getters, so that every nested property inside the object\n   * is collected as a \"deep\" dependency.\n   */\n  function traverse (val) {\n    _traverse(val, seenObjects);\n    seenObjects.clear();\n  }\n\n  function _traverse (val, seen) {\n    var i, keys;\n    var isA = Array.isArray(val);\n    if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {\n      return\n    }\n    if (val.__ob__) {\n      var depId = val.__ob__.dep.id;\n      if (seen.has(depId)) {\n        return\n      }\n      seen.add(depId);\n    }\n    if (isA) {\n      i = val.length;\n      while (i--) { _traverse(val[i], seen); }\n    } else {\n      keys = Object.keys(val);\n      i = keys.length;\n      while (i--) { _traverse(val[keys[i]], seen); }\n    }\n  }\n\n  /*  */\n\n  var normalizeEvent = cached(function (name) {\n    var passive = name.charAt(0) === '&';\n    name = passive ? name.slice(1) : name;\n    var once$$1 = name.charAt(0) === '~'; // Prefixed last, checked first\n    name = once$$1 ? name.slice(1) : name;\n    var capture = name.charAt(0) === '!';\n    name = capture ? name.slice(1) : name;\n    return {\n      name: name,\n      once: once$$1,\n      capture: capture,\n      passive: passive\n    }\n  });\n\n  function createFnInvoker (fns, vm) {\n    function invoker () {\n      var arguments$1 = arguments;\n\n      var fns = invoker.fns;\n      if (Array.isArray(fns)) {\n        var cloned = fns.slice();\n        for (var i = 0; i < cloned.length; i++) {\n          invokeWithErrorHandling(cloned[i], null, arguments$1, vm, \"v-on handler\");\n        }\n      } else {\n        // return handler return value for single handlers\n        return invokeWithErrorHandling(fns, null, arguments, vm, \"v-on handler\")\n      }\n    }\n    invoker.fns = fns;\n    return invoker\n  }\n\n  function updateListeners (\n    on,\n    oldOn,\n    add,\n    remove$$1,\n    createOnceHandler,\n    vm\n  ) {\n    var name, def$$1, cur, old, event;\n    for (name in on) {\n      def$$1 = cur = on[name];\n      old = oldOn[name];\n      event = normalizeEvent(name);\n      if (isUndef(cur)) {\n        warn(\n          \"Invalid handler for event \\\"\" + (event.name) + \"\\\": got \" + String(cur),\n          vm\n        );\n      } else if (isUndef(old)) {\n        if (isUndef(cur.fns)) {\n          cur = on[name] = createFnInvoker(cur, vm);\n        }\n        if (isTrue(event.once)) {\n          cur = on[name] = createOnceHandler(event.name, cur, event.capture);\n        }\n        add(event.name, cur, event.capture, event.passive, event.params);\n      } else if (cur !== old) {\n        old.fns = cur;\n        on[name] = old;\n      }\n    }\n    for (name in oldOn) {\n      if (isUndef(on[name])) {\n        event = normalizeEvent(name);\n        remove$$1(event.name, oldOn[name], event.capture);\n      }\n    }\n  }\n\n  /*  */\n\n  function mergeVNodeHook (def, hookKey, hook) {\n    if (def instanceof VNode) {\n      def = def.data.hook || (def.data.hook = {});\n    }\n    var invoker;\n    var oldHook = def[hookKey];\n\n    function wrappedHook () {\n      hook.apply(this, arguments);\n      // important: remove merged hook to ensure it's called only once\n      // and prevent memory leak\n      remove(invoker.fns, wrappedHook);\n    }\n\n    if (isUndef(oldHook)) {\n      // no existing hook\n      invoker = createFnInvoker([wrappedHook]);\n    } else {\n      /* istanbul ignore if */\n      if (isDef(oldHook.fns) && isTrue(oldHook.merged)) {\n        // already a merged invoker\n        invoker = oldHook;\n        invoker.fns.push(wrappedHook);\n      } else {\n        // existing plain hook\n        invoker = createFnInvoker([oldHook, wrappedHook]);\n      }\n    }\n\n    invoker.merged = true;\n    def[hookKey] = invoker;\n  }\n\n  /*  */\n\n  function extractPropsFromVNodeData (\n    data,\n    Ctor,\n    tag\n  ) {\n    // we are only extracting raw values here.\n    // validation and default values are handled in the child\n    // component itself.\n    var propOptions = Ctor.options.props;\n    if (isUndef(propOptions)) {\n      return\n    }\n    var res = {};\n    var attrs = data.attrs;\n    var props = data.props;\n    if (isDef(attrs) || isDef(props)) {\n      for (var key in propOptions) {\n        var altKey = hyphenate(key);\n        {\n          var keyInLowerCase = key.toLowerCase();\n          if (\n            key !== keyInLowerCase &&\n            attrs && hasOwn(attrs, keyInLowerCase)\n          ) {\n            tip(\n              \"Prop \\\"\" + keyInLowerCase + \"\\\" is passed to component \" +\n              (formatComponentName(tag || Ctor)) + \", but the declared prop name is\" +\n              \" \\\"\" + key + \"\\\". \" +\n              \"Note that HTML attributes are case-insensitive and camelCased \" +\n              \"props need to use their kebab-case equivalents when using in-DOM \" +\n              \"templates. You should probably use \\\"\" + altKey + \"\\\" instead of \\\"\" + key + \"\\\".\"\n            );\n          }\n        }\n        checkProp(res, props, key, altKey, true) ||\n        checkProp(res, attrs, key, altKey, false);\n      }\n    }\n    return res\n  }\n\n  function checkProp (\n    res,\n    hash,\n    key,\n    altKey,\n    preserve\n  ) {\n    if (isDef(hash)) {\n      if (hasOwn(hash, key)) {\n        res[key] = hash[key];\n        if (!preserve) {\n          delete hash[key];\n        }\n        return true\n      } else if (hasOwn(hash, altKey)) {\n        res[key] = hash[altKey];\n        if (!preserve) {\n          delete hash[altKey];\n        }\n        return true\n      }\n    }\n    return false\n  }\n\n  /*  */\n\n  // The template compiler attempts to minimize the need for normalization by\n  // statically analyzing the template at compile time.\n  //\n  // For plain HTML markup, normalization can be completely skipped because the\n  // generated render function is guaranteed to return Array<VNode>. There are\n  // two cases where extra normalization is needed:\n\n  // 1. When the children contains components - because a functional component\n  // may return an Array instead of a single root. In this case, just a simple\n  // normalization is needed - if any child is an Array, we flatten the whole\n  // thing with Array.prototype.concat. It is guaranteed to be only 1-level deep\n  // because functional components already normalize their own children.\n  function simpleNormalizeChildren (children) {\n    for (var i = 0; i < children.length; i++) {\n      if (Array.isArray(children[i])) {\n        return Array.prototype.concat.apply([], children)\n      }\n    }\n    return children\n  }\n\n  // 2. When the children contains constructs that always generated nested Arrays,\n  // e.g. <template>, <slot>, v-for, or when the children is provided by user\n  // with hand-written render functions / JSX. In such cases a full normalization\n  // is needed to cater to all possible types of children values.\n  function normalizeChildren (children) {\n    return isPrimitive(children)\n      ? [createTextVNode(children)]\n      : Array.isArray(children)\n        ? normalizeArrayChildren(children)\n        : undefined\n  }\n\n  function isTextNode (node) {\n    return isDef(node) && isDef(node.text) && isFalse(node.isComment)\n  }\n\n  function normalizeArrayChildren (children, nestedIndex) {\n    var res = [];\n    var i, c, lastIndex, last;\n    for (i = 0; i < children.length; i++) {\n      c = children[i];\n      if (isUndef(c) || typeof c === 'boolean') { continue }\n      lastIndex = res.length - 1;\n      last = res[lastIndex];\n      //  nested\n      if (Array.isArray(c)) {\n        if (c.length > 0) {\n          c = normalizeArrayChildren(c, ((nestedIndex || '') + \"_\" + i));\n          // merge adjacent text nodes\n          if (isTextNode(c[0]) && isTextNode(last)) {\n            res[lastIndex] = createTextVNode(last.text + (c[0]).text);\n            c.shift();\n          }\n          res.push.apply(res, c);\n        }\n      } else if (isPrimitive(c)) {\n        if (isTextNode(last)) {\n          // merge adjacent text nodes\n          // this is necessary for SSR hydration because text nodes are\n          // essentially merged when rendered to HTML strings\n          res[lastIndex] = createTextVNode(last.text + c);\n        } else if (c !== '') {\n          // convert primitive to vnode\n          res.push(createTextVNode(c));\n        }\n      } else {\n        if (isTextNode(c) && isTextNode(last)) {\n          // merge adjacent text nodes\n          res[lastIndex] = createTextVNode(last.text + c.text);\n        } else {\n          // default key for nested array children (likely generated by v-for)\n          if (isTrue(children._isVList) &&\n            isDef(c.tag) &&\n            isUndef(c.key) &&\n            isDef(nestedIndex)) {\n            c.key = \"__vlist\" + nestedIndex + \"_\" + i + \"__\";\n          }\n          res.push(c);\n        }\n      }\n    }\n    return res\n  }\n\n  /*  */\n\n  function initProvide (vm) {\n    var provide = vm.$options.provide;\n    if (provide) {\n      vm._provided = typeof provide === 'function'\n        ? provide.call(vm)\n        : provide;\n    }\n  }\n\n  function initInjections (vm) {\n    var result = resolveInject(vm.$options.inject, vm);\n    if (result) {\n      toggleObserving(false);\n      Object.keys(result).forEach(function (key) {\n        /* istanbul ignore else */\n        {\n          defineReactive$$1(vm, key, result[key], function () {\n            warn(\n              \"Avoid mutating an injected value directly since the changes will be \" +\n              \"overwritten whenever the provided component re-renders. \" +\n              \"injection being mutated: \\\"\" + key + \"\\\"\",\n              vm\n            );\n          });\n        }\n      });\n      toggleObserving(true);\n    }\n  }\n\n  function resolveInject (inject, vm) {\n    if (inject) {\n      // inject is :any because flow is not smart enough to figure out cached\n      var result = Object.create(null);\n      var keys = hasSymbol\n        ? Reflect.ownKeys(inject)\n        : Object.keys(inject);\n\n      for (var i = 0; i < keys.length; i++) {\n        var key = keys[i];\n        // #6574 in case the inject object is observed...\n        if (key === '__ob__') { continue }\n        var provideKey = inject[key].from;\n        var source = vm;\n        while (source) {\n          if (source._provided && hasOwn(source._provided, provideKey)) {\n            result[key] = source._provided[provideKey];\n            break\n          }\n          source = source.$parent;\n        }\n        if (!source) {\n          if ('default' in inject[key]) {\n            var provideDefault = inject[key].default;\n            result[key] = typeof provideDefault === 'function'\n              ? provideDefault.call(vm)\n              : provideDefault;\n          } else {\n            warn((\"Injection \\\"\" + key + \"\\\" not found\"), vm);\n          }\n        }\n      }\n      return result\n    }\n  }\n\n  /*  */\n\n\n\n  /**\n   * Runtime helper for resolving raw children VNodes into a slot object.\n   */\n  function resolveSlots (\n    children,\n    context\n  ) {\n    if (!children || !children.length) {\n      return {}\n    }\n    var slots = {};\n    for (var i = 0, l = children.length; i < l; i++) {\n      var child = children[i];\n      var data = child.data;\n      // remove slot attribute if the node is resolved as a Vue slot node\n      if (data && data.attrs && data.attrs.slot) {\n        delete data.attrs.slot;\n      }\n      // named slots should only be respected if the vnode was rendered in the\n      // same context.\n      if ((child.context === context || child.fnContext === context) &&\n        data && data.slot != null\n      ) {\n        var name = data.slot;\n        var slot = (slots[name] || (slots[name] = []));\n        if (child.tag === 'template') {\n          slot.push.apply(slot, child.children || []);\n        } else {\n          slot.push(child);\n        }\n      } else {\n        (slots.default || (slots.default = [])).push(child);\n      }\n    }\n    // ignore slots that contains only whitespace\n    for (var name$1 in slots) {\n      if (slots[name$1].every(isWhitespace)) {\n        delete slots[name$1];\n      }\n    }\n    return slots\n  }\n\n  function isWhitespace (node) {\n    return (node.isComment && !node.asyncFactory) || node.text === ' '\n  }\n\n  /*  */\n\n  function normalizeScopedSlots (\n    slots,\n    normalSlots,\n    prevSlots\n  ) {\n    var res;\n    var isStable = slots ? !!slots.$stable : true;\n    var key = slots && slots.$key;\n    if (!slots) {\n      res = {};\n    } else if (slots._normalized) {\n      // fast path 1: child component re-render only, parent did not change\n      return slots._normalized\n    } else if (\n      isStable &&\n      prevSlots &&\n      prevSlots !== emptyObject &&\n      key === prevSlots.$key &&\n      Object.keys(normalSlots).length === 0\n    ) {\n      // fast path 2: stable scoped slots w/ no normal slots to proxy,\n      // only need to normalize once\n      return prevSlots\n    } else {\n      res = {};\n      for (var key$1 in slots) {\n        if (slots[key$1] && key$1[0] !== '$') {\n          res[key$1] = normalizeScopedSlot(normalSlots, key$1, slots[key$1]);\n        }\n      }\n    }\n    // expose normal slots on scopedSlots\n    for (var key$2 in normalSlots) {\n      if (!(key$2 in res)) {\n        res[key$2] = proxyNormalSlot(normalSlots, key$2);\n      }\n    }\n    // avoriaz seems to mock a non-extensible $scopedSlots object\n    // and when that is passed down this would cause an error\n    if (slots && Object.isExtensible(slots)) {\n      (slots)._normalized = res;\n    }\n    def(res, '$stable', isStable);\n    def(res, '$key', key);\n    return res\n  }\n\n  function normalizeScopedSlot(normalSlots, key, fn) {\n    var normalized = function () {\n      var res = arguments.length ? fn.apply(null, arguments) : fn({});\n      res = res && typeof res === 'object' && !Array.isArray(res)\n        ? [res] // single vnode\n        : normalizeChildren(res);\n      return res && res.length === 0\n        ? undefined\n        : res\n    };\n    // this is a slot using the new v-slot syntax without scope. although it is\n    // compiled as a scoped slot, render fn users would expect it to be present\n    // on this.$slots because the usage is semantically a normal slot.\n    if (fn.proxy) {\n      Object.defineProperty(normalSlots, key, {\n        get: normalized,\n        enumerable: true,\n        configurable: true\n      });\n    }\n    return normalized\n  }\n\n  function proxyNormalSlot(slots, key) {\n    return function () { return slots[key]; }\n  }\n\n  /*  */\n\n  /**\n   * Runtime helper for rendering v-for lists.\n   */\n  function renderList (\n    val,\n    render\n  ) {\n    var ret, i, l, keys, key;\n    if (Array.isArray(val) || typeof val === 'string') {\n      ret = new Array(val.length);\n      for (i = 0, l = val.length; i < l; i++) {\n        ret[i] = render(val[i], i);\n      }\n    } else if (typeof val === 'number') {\n      ret = new Array(val);\n      for (i = 0; i < val; i++) {\n        ret[i] = render(i + 1, i);\n      }\n    } else if (isObject(val)) {\n      if (hasSymbol && val[Symbol.iterator]) {\n        ret = [];\n        var iterator = val[Symbol.iterator]();\n        var result = iterator.next();\n        while (!result.done) {\n          ret.push(render(result.value, ret.length));\n          result = iterator.next();\n        }\n      } else {\n        keys = Object.keys(val);\n        ret = new Array(keys.length);\n        for (i = 0, l = keys.length; i < l; i++) {\n          key = keys[i];\n          ret[i] = render(val[key], key, i);\n        }\n      }\n    }\n    if (!isDef(ret)) {\n      ret = [];\n    }\n    (ret)._isVList = true;\n    return ret\n  }\n\n  /*  */\n\n  /**\n   * Runtime helper for rendering <slot>\n   */\n  function renderSlot (\n    name,\n    fallback,\n    props,\n    bindObject\n  ) {\n    var scopedSlotFn = this.$scopedSlots[name];\n    var nodes;\n    if (scopedSlotFn) { // scoped slot\n      props = props || {};\n      if (bindObject) {\n        if (!isObject(bindObject)) {\n          warn(\n            'slot v-bind without argument expects an Object',\n            this\n          );\n        }\n        props = extend(extend({}, bindObject), props);\n      }\n      nodes = scopedSlotFn(props) || fallback;\n    } else {\n      nodes = this.$slots[name] || fallback;\n    }\n\n    var target = props && props.slot;\n    if (target) {\n      return this.$createElement('template', { slot: target }, nodes)\n    } else {\n      return nodes\n    }\n  }\n\n  /*  */\n\n  /**\n   * Runtime helper for resolving filters\n   */\n  function resolveFilter (id) {\n    return resolveAsset(this.$options, 'filters', id, true) || identity\n  }\n\n  /*  */\n\n  function isKeyNotMatch (expect, actual) {\n    if (Array.isArray(expect)) {\n      return expect.indexOf(actual) === -1\n    } else {\n      return expect !== actual\n    }\n  }\n\n  /**\n   * Runtime helper for checking keyCodes from config.\n   * exposed as Vue.prototype._k\n   * passing in eventKeyName as last argument separately for backwards compat\n   */\n  function checkKeyCodes (\n    eventKeyCode,\n    key,\n    builtInKeyCode,\n    eventKeyName,\n    builtInKeyName\n  ) {\n    var mappedKeyCode = config.keyCodes[key] || builtInKeyCode;\n    if (builtInKeyName && eventKeyName && !config.keyCodes[key]) {\n      return isKeyNotMatch(builtInKeyName, eventKeyName)\n    } else if (mappedKeyCode) {\n      return isKeyNotMatch(mappedKeyCode, eventKeyCode)\n    } else if (eventKeyName) {\n      return hyphenate(eventKeyName) !== key\n    }\n  }\n\n  /*  */\n\n  /**\n   * Runtime helper for merging v-bind=\"object\" into a VNode's data.\n   */\n  function bindObjectProps (\n    data,\n    tag,\n    value,\n    asProp,\n    isSync\n  ) {\n    if (value) {\n      if (!isObject(value)) {\n        warn(\n          'v-bind without argument expects an Object or Array value',\n          this\n        );\n      } else {\n        if (Array.isArray(value)) {\n          value = toObject(value);\n        }\n        var hash;\n        var loop = function ( key ) {\n          if (\n            key === 'class' ||\n            key === 'style' ||\n            isReservedAttribute(key)\n          ) {\n            hash = data;\n          } else {\n            var type = data.attrs && data.attrs.type;\n            hash = asProp || config.mustUseProp(tag, type, key)\n              ? data.domProps || (data.domProps = {})\n              : data.attrs || (data.attrs = {});\n          }\n          var camelizedKey = camelize(key);\n          if (!(key in hash) && !(camelizedKey in hash)) {\n            hash[key] = value[key];\n\n            if (isSync) {\n              var on = data.on || (data.on = {});\n              on[(\"update:\" + camelizedKey)] = function ($event) {\n                value[key] = $event;\n              };\n            }\n          }\n        };\n\n        for (var key in value) loop( key );\n      }\n    }\n    return data\n  }\n\n  /*  */\n\n  /**\n   * Runtime helper for rendering static trees.\n   */\n  function renderStatic (\n    index,\n    isInFor\n  ) {\n    var cached = this._staticTrees || (this._staticTrees = []);\n    var tree = cached[index];\n    // if has already-rendered static tree and not inside v-for,\n    // we can reuse the same tree.\n    if (tree && !isInFor) {\n      return tree\n    }\n    // otherwise, render a fresh tree.\n    tree = cached[index] = this.$options.staticRenderFns[index].call(\n      this._renderProxy,\n      null,\n      this // for render fns generated for functional component templates\n    );\n    markStatic(tree, (\"__static__\" + index), false);\n    return tree\n  }\n\n  /**\n   * Runtime helper for v-once.\n   * Effectively it means marking the node as static with a unique key.\n   */\n  function markOnce (\n    tree,\n    index,\n    key\n  ) {\n    markStatic(tree, (\"__once__\" + index + (key ? (\"_\" + key) : \"\")), true);\n    return tree\n  }\n\n  function markStatic (\n    tree,\n    key,\n    isOnce\n  ) {\n    if (Array.isArray(tree)) {\n      for (var i = 0; i < tree.length; i++) {\n        if (tree[i] && typeof tree[i] !== 'string') {\n          markStaticNode(tree[i], (key + \"_\" + i), isOnce);\n        }\n      }\n    } else {\n      markStaticNode(tree, key, isOnce);\n    }\n  }\n\n  function markStaticNode (node, key, isOnce) {\n    node.isStatic = true;\n    node.key = key;\n    node.isOnce = isOnce;\n  }\n\n  /*  */\n\n  function bindObjectListeners (data, value) {\n    if (value) {\n      if (!isPlainObject(value)) {\n        warn(\n          'v-on without argument expects an Object value',\n          this\n        );\n      } else {\n        var on = data.on = data.on ? extend({}, data.on) : {};\n        for (var key in value) {\n          var existing = on[key];\n          var ours = value[key];\n          on[key] = existing ? [].concat(existing, ours) : ours;\n        }\n      }\n    }\n    return data\n  }\n\n  /*  */\n\n  function resolveScopedSlots (\n    fns, // see flow/vnode\n    res,\n    // the following are added in 2.6\n    hasDynamicKeys,\n    contentHashKey\n  ) {\n    res = res || { $stable: !hasDynamicKeys };\n    for (var i = 0; i < fns.length; i++) {\n      var slot = fns[i];\n      if (Array.isArray(slot)) {\n        resolveScopedSlots(slot, res, hasDynamicKeys);\n      } else if (slot) {\n        // marker for reverse proxying v-slot without scope on this.$slots\n        if (slot.proxy) {\n          slot.fn.proxy = true;\n        }\n        res[slot.key] = slot.fn;\n      }\n    }\n    if (contentHashKey) {\n      (res).$key = contentHashKey;\n    }\n    return res\n  }\n\n  /*  */\n\n  function bindDynamicKeys (baseObj, values) {\n    for (var i = 0; i < values.length; i += 2) {\n      var key = values[i];\n      if (typeof key === 'string' && key) {\n        baseObj[values[i]] = values[i + 1];\n      } else if (key !== '' && key !== null) {\n        // null is a speical value for explicitly removing a binding\n        warn(\n          (\"Invalid value for dynamic directive argument (expected string or null): \" + key),\n          this\n        );\n      }\n    }\n    return baseObj\n  }\n\n  // helper to dynamically append modifier runtime markers to event names.\n  // ensure only append when value is already string, otherwise it will be cast\n  // to string and cause the type check to miss.\n  function prependModifier (value, symbol) {\n    return typeof value === 'string' ? symbol + value : value\n  }\n\n  /*  */\n\n  function installRenderHelpers (target) {\n    target._o = markOnce;\n    target._n = toNumber;\n    target._s = toString;\n    target._l = renderList;\n    target._t = renderSlot;\n    target._q = looseEqual;\n    target._i = looseIndexOf;\n    target._m = renderStatic;\n    target._f = resolveFilter;\n    target._k = checkKeyCodes;\n    target._b = bindObjectProps;\n    target._v = createTextVNode;\n    target._e = createEmptyVNode;\n    target._u = resolveScopedSlots;\n    target._g = bindObjectListeners;\n    target._d = bindDynamicKeys;\n    target._p = prependModifier;\n  }\n\n  /*  */\n\n  function FunctionalRenderContext (\n    data,\n    props,\n    children,\n    parent,\n    Ctor\n  ) {\n    var this$1 = this;\n\n    var options = Ctor.options;\n    // ensure the createElement function in functional components\n    // gets a unique context - this is necessary for correct named slot check\n    var contextVm;\n    if (hasOwn(parent, '_uid')) {\n      contextVm = Object.create(parent);\n      // $flow-disable-line\n      contextVm._original = parent;\n    } else {\n      // the context vm passed in is a functional context as well.\n      // in this case we want to make sure we are able to get a hold to the\n      // real context instance.\n      contextVm = parent;\n      // $flow-disable-line\n      parent = parent._original;\n    }\n    var isCompiled = isTrue(options._compiled);\n    var needNormalization = !isCompiled;\n\n    this.data = data;\n    this.props = props;\n    this.children = children;\n    this.parent = parent;\n    this.listeners = data.on || emptyObject;\n    this.injections = resolveInject(options.inject, parent);\n    this.slots = function () {\n      if (!this$1.$slots) {\n        normalizeScopedSlots(\n          data.scopedSlots,\n          this$1.$slots = resolveSlots(children, parent)\n        );\n      }\n      return this$1.$slots\n    };\n\n    Object.defineProperty(this, 'scopedSlots', ({\n      enumerable: true,\n      get: function get () {\n        return normalizeScopedSlots(data.scopedSlots, this.slots())\n      }\n    }));\n\n    // support for compiled functional template\n    if (isCompiled) {\n      // exposing $options for renderStatic()\n      this.$options = options;\n      // pre-resolve slots for renderSlot()\n      this.$slots = this.slots();\n      this.$scopedSlots = normalizeScopedSlots(data.scopedSlots, this.$slots);\n    }\n\n    if (options._scopeId) {\n      this._c = function (a, b, c, d) {\n        var vnode = createElement(contextVm, a, b, c, d, needNormalization);\n        if (vnode && !Array.isArray(vnode)) {\n          vnode.fnScopeId = options._scopeId;\n          vnode.fnContext = parent;\n        }\n        return vnode\n      };\n    } else {\n      this._c = function (a, b, c, d) { return createElement(contextVm, a, b, c, d, needNormalization); };\n    }\n  }\n\n  installRenderHelpers(FunctionalRenderContext.prototype);\n\n  function createFunctionalComponent (\n    Ctor,\n    propsData,\n    data,\n    contextVm,\n    children\n  ) {\n    var options = Ctor.options;\n    var props = {};\n    var propOptions = options.props;\n    if (isDef(propOptions)) {\n      for (var key in propOptions) {\n        props[key] = validateProp(key, propOptions, propsData || emptyObject);\n      }\n    } else {\n      if (isDef(data.attrs)) { mergeProps(props, data.attrs); }\n      if (isDef(data.props)) { mergeProps(props, data.props); }\n    }\n\n    var renderContext = new FunctionalRenderContext(\n      data,\n      props,\n      children,\n      contextVm,\n      Ctor\n    );\n\n    var vnode = options.render.call(null, renderContext._c, renderContext);\n\n    if (vnode instanceof VNode) {\n      return cloneAndMarkFunctionalResult(vnode, data, renderContext.parent, options, renderContext)\n    } else if (Array.isArray(vnode)) {\n      var vnodes = normalizeChildren(vnode) || [];\n      var res = new Array(vnodes.length);\n      for (var i = 0; i < vnodes.length; i++) {\n        res[i] = cloneAndMarkFunctionalResult(vnodes[i], data, renderContext.parent, options, renderContext);\n      }\n      return res\n    }\n  }\n\n  function cloneAndMarkFunctionalResult (vnode, data, contextVm, options, renderContext) {\n    // #7817 clone node before setting fnContext, otherwise if the node is reused\n    // (e.g. it was from a cached normal slot) the fnContext causes named slots\n    // that should not be matched to match.\n    var clone = cloneVNode(vnode);\n    clone.fnContext = contextVm;\n    clone.fnOptions = options;\n    {\n      (clone.devtoolsMeta = clone.devtoolsMeta || {}).renderContext = renderContext;\n    }\n    if (data.slot) {\n      (clone.data || (clone.data = {})).slot = data.slot;\n    }\n    return clone\n  }\n\n  function mergeProps (to, from) {\n    for (var key in from) {\n      to[camelize(key)] = from[key];\n    }\n  }\n\n  /*  */\n\n  /*  */\n\n  /*  */\n\n  /*  */\n\n  // inline hooks to be invoked on component VNodes during patch\n  var componentVNodeHooks = {\n    init: function init (vnode, hydrating) {\n      if (\n        vnode.componentInstance &&\n        !vnode.componentInstance._isDestroyed &&\n        vnode.data.keepAlive\n      ) {\n        // kept-alive components, treat as a patch\n        var mountedNode = vnode; // work around flow\n        componentVNodeHooks.prepatch(mountedNode, mountedNode);\n      } else {\n        var child = vnode.componentInstance = createComponentInstanceForVnode(\n          vnode,\n          activeInstance\n        );\n        child.$mount(hydrating ? vnode.elm : undefined, hydrating);\n      }\n    },\n\n    prepatch: function prepatch (oldVnode, vnode) {\n      var options = vnode.componentOptions;\n      var child = vnode.componentInstance = oldVnode.componentInstance;\n      updateChildComponent(\n        child,\n        options.propsData, // updated props\n        options.listeners, // updated listeners\n        vnode, // new parent vnode\n        options.children // new children\n      );\n    },\n\n    insert: function insert (vnode) {\n      var context = vnode.context;\n      var componentInstance = vnode.componentInstance;\n      if (!componentInstance._isMounted) {\n        componentInstance._isMounted = true;\n        callHook(componentInstance, 'mounted');\n      }\n      if (vnode.data.keepAlive) {\n        if (context._isMounted) {\n          // vue-router#1212\n          // During updates, a kept-alive component's child components may\n          // change, so directly walking the tree here may call activated hooks\n          // on incorrect children. Instead we push them into a queue which will\n          // be processed after the whole patch process ended.\n          queueActivatedComponent(componentInstance);\n        } else {\n          activateChildComponent(componentInstance, true /* direct */);\n        }\n      }\n    },\n\n    destroy: function destroy (vnode) {\n      var componentInstance = vnode.componentInstance;\n      if (!componentInstance._isDestroyed) {\n        if (!vnode.data.keepAlive) {\n          componentInstance.$destroy();\n        } else {\n          deactivateChildComponent(componentInstance, true /* direct */);\n        }\n      }\n    }\n  };\n\n  var hooksToMerge = Object.keys(componentVNodeHooks);\n\n  function createComponent (\n    Ctor,\n    data,\n    context,\n    children,\n    tag\n  ) {\n    if (isUndef(Ctor)) {\n      return\n    }\n\n    var baseCtor = context.$options._base;\n\n    // plain options object: turn it into a constructor\n    if (isObject(Ctor)) {\n      Ctor = baseCtor.extend(Ctor);\n    }\n\n    // if at this stage it's not a constructor or an async component factory,\n    // reject.\n    if (typeof Ctor !== 'function') {\n      {\n        warn((\"Invalid Component definition: \" + (String(Ctor))), context);\n      }\n      return\n    }\n\n    // async component\n    var asyncFactory;\n    if (isUndef(Ctor.cid)) {\n      asyncFactory = Ctor;\n      Ctor = resolveAsyncComponent(asyncFactory, baseCtor);\n      if (Ctor === undefined) {\n        // return a placeholder node for async component, which is rendered\n        // as a comment node but preserves all the raw information for the node.\n        // the information will be used for async server-rendering and hydration.\n        return createAsyncPlaceholder(\n          asyncFactory,\n          data,\n          context,\n          children,\n          tag\n        )\n      }\n    }\n\n    data = data || {};\n\n    // resolve constructor options in case global mixins are applied after\n    // component constructor creation\n    resolveConstructorOptions(Ctor);\n\n    // transform component v-model data into props & events\n    if (isDef(data.model)) {\n      transformModel(Ctor.options, data);\n    }\n\n    // extract props\n    var propsData = extractPropsFromVNodeData(data, Ctor, tag);\n\n    // functional component\n    if (isTrue(Ctor.options.functional)) {\n      return createFunctionalComponent(Ctor, propsData, data, context, children)\n    }\n\n    // extract listeners, since these needs to be treated as\n    // child component listeners instead of DOM listeners\n    var listeners = data.on;\n    // replace with listeners with .native modifier\n    // so it gets processed during parent component patch.\n    data.on = data.nativeOn;\n\n    if (isTrue(Ctor.options.abstract)) {\n      // abstract components do not keep anything\n      // other than props & listeners & slot\n\n      // work around flow\n      var slot = data.slot;\n      data = {};\n      if (slot) {\n        data.slot = slot;\n      }\n    }\n\n    // install component management hooks onto the placeholder node\n    installComponentHooks(data);\n\n    // return a placeholder vnode\n    var name = Ctor.options.name || tag;\n    var vnode = new VNode(\n      (\"vue-component-\" + (Ctor.cid) + (name ? (\"-\" + name) : '')),\n      data, undefined, undefined, undefined, context,\n      { Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },\n      asyncFactory\n    );\n\n    return vnode\n  }\n\n  function createComponentInstanceForVnode (\n    vnode, // we know it's MountedComponentVNode but flow doesn't\n    parent // activeInstance in lifecycle state\n  ) {\n    var options = {\n      _isComponent: true,\n      _parentVnode: vnode,\n      parent: parent\n    };\n    // check inline-template render functions\n    var inlineTemplate = vnode.data.inlineTemplate;\n    if (isDef(inlineTemplate)) {\n      options.render = inlineTemplate.render;\n      options.staticRenderFns = inlineTemplate.staticRenderFns;\n    }\n    return new vnode.componentOptions.Ctor(options)\n  }\n\n  function installComponentHooks (data) {\n    var hooks = data.hook || (data.hook = {});\n    for (var i = 0; i < hooksToMerge.length; i++) {\n      var key = hooksToMerge[i];\n      var existing = hooks[key];\n      var toMerge = componentVNodeHooks[key];\n      if (existing !== toMerge && !(existing && existing._merged)) {\n        hooks[key] = existing ? mergeHook$1(toMerge, existing) : toMerge;\n      }\n    }\n  }\n\n  function mergeHook$1 (f1, f2) {\n    var merged = function (a, b) {\n      // flow complains about extra args which is why we use any\n      f1(a, b);\n      f2(a, b);\n    };\n    merged._merged = true;\n    return merged\n  }\n\n  // transform component v-model info (value and callback) into\n  // prop and event handler respectively.\n  function transformModel (options, data) {\n    var prop = (options.model && options.model.prop) || 'value';\n    var event = (options.model && options.model.event) || 'input'\n    ;(data.attrs || (data.attrs = {}))[prop] = data.model.value;\n    var on = data.on || (data.on = {});\n    var existing = on[event];\n    var callback = data.model.callback;\n    if (isDef(existing)) {\n      if (\n        Array.isArray(existing)\n          ? existing.indexOf(callback) === -1\n          : existing !== callback\n      ) {\n        on[event] = [callback].concat(existing);\n      }\n    } else {\n      on[event] = callback;\n    }\n  }\n\n  /*  */\n\n  var SIMPLE_NORMALIZE = 1;\n  var ALWAYS_NORMALIZE = 2;\n\n  // wrapper function for providing a more flexible interface\n  // without getting yelled at by flow\n  function createElement (\n    context,\n    tag,\n    data,\n    children,\n    normalizationType,\n    alwaysNormalize\n  ) {\n    if (Array.isArray(data) || isPrimitive(data)) {\n      normalizationType = children;\n      children = data;\n      data = undefined;\n    }\n    if (isTrue(alwaysNormalize)) {\n      normalizationType = ALWAYS_NORMALIZE;\n    }\n    return _createElement(context, tag, data, children, normalizationType)\n  }\n\n  function _createElement (\n    context,\n    tag,\n    data,\n    children,\n    normalizationType\n  ) {\n    if (isDef(data) && isDef((data).__ob__)) {\n      warn(\n        \"Avoid using observed data object as vnode data: \" + (JSON.stringify(data)) + \"\\n\" +\n        'Always create fresh vnode data objects in each render!',\n        context\n      );\n      return createEmptyVNode()\n    }\n    // object syntax in v-bind\n    if (isDef(data) && isDef(data.is)) {\n      tag = data.is;\n    }\n    if (!tag) {\n      // in case of component :is set to falsy value\n      return createEmptyVNode()\n    }\n    // warn against non-primitive key\n    if (isDef(data) && isDef(data.key) && !isPrimitive(data.key)\n    ) {\n      {\n        warn(\n          'Avoid using non-primitive value as key, ' +\n          'use string/number value instead.',\n          context\n        );\n      }\n    }\n    // support single function children as default scoped slot\n    if (Array.isArray(children) &&\n      typeof children[0] === 'function'\n    ) {\n      data = data || {};\n      data.scopedSlots = { default: children[0] };\n      children.length = 0;\n    }\n    if (normalizationType === ALWAYS_NORMALIZE) {\n      children = normalizeChildren(children);\n    } else if (normalizationType === SIMPLE_NORMALIZE) {\n      children = simpleNormalizeChildren(children);\n    }\n    var vnode, ns;\n    if (typeof tag === 'string') {\n      var Ctor;\n      ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag);\n      if (config.isReservedTag(tag)) {\n        // platform built-in elements\n        vnode = new VNode(\n          config.parsePlatformTagName(tag), data, children,\n          undefined, undefined, context\n        );\n      } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {\n        // component\n        vnode = createComponent(Ctor, data, context, children, tag);\n      } else {\n        // unknown or unlisted namespaced elements\n        // check at runtime because it may get assigned a namespace when its\n        // parent normalizes children\n        vnode = new VNode(\n          tag, data, children,\n          undefined, undefined, context\n        );\n      }\n    } else {\n      // direct component options / constructor\n      vnode = createComponent(tag, data, context, children);\n    }\n    if (Array.isArray(vnode)) {\n      return vnode\n    } else if (isDef(vnode)) {\n      if (isDef(ns)) { applyNS(vnode, ns); }\n      if (isDef(data)) { registerDeepBindings(data); }\n      return vnode\n    } else {\n      return createEmptyVNode()\n    }\n  }\n\n  function applyNS (vnode, ns, force) {\n    vnode.ns = ns;\n    if (vnode.tag === 'foreignObject') {\n      // use default namespace inside foreignObject\n      ns = undefined;\n      force = true;\n    }\n    if (isDef(vnode.children)) {\n      for (var i = 0, l = vnode.children.length; i < l; i++) {\n        var child = vnode.children[i];\n        if (isDef(child.tag) && (\n          isUndef(child.ns) || (isTrue(force) && child.tag !== 'svg'))) {\n          applyNS(child, ns, force);\n        }\n      }\n    }\n  }\n\n  // ref #5318\n  // necessary to ensure parent re-render when deep bindings like :style and\n  // :class are used on slot nodes\n  function registerDeepBindings (data) {\n    if (isObject(data.style)) {\n      traverse(data.style);\n    }\n    if (isObject(data.class)) {\n      traverse(data.class);\n    }\n  }\n\n  /*  */\n\n  function initRender (vm) {\n    vm._vnode = null; // the root of the child tree\n    vm._staticTrees = null; // v-once cached trees\n    var options = vm.$options;\n    var parentVnode = vm.$vnode = options._parentVnode; // the placeholder node in parent tree\n    var renderContext = parentVnode && parentVnode.context;\n    vm.$slots = resolveSlots(options._renderChildren, renderContext);\n    vm.$scopedSlots = emptyObject;\n    // bind the createElement fn to this instance\n    // so that we get proper render context inside it.\n    // args order: tag, data, children, normalizationType, alwaysNormalize\n    // internal version is used by render functions compiled from templates\n    vm._c = function (a, b, c, d) { return createElement(vm, a, b, c, d, false); };\n    // normalization is always applied for the public version, used in\n    // user-written render functions.\n    vm.$createElement = function (a, b, c, d) { return createElement(vm, a, b, c, d, true); };\n\n    // $attrs & $listeners are exposed for easier HOC creation.\n    // they need to be reactive so that HOCs using them are always updated\n    var parentData = parentVnode && parentVnode.data;\n\n    /* istanbul ignore else */\n    {\n      defineReactive$$1(vm, '$attrs', parentData && parentData.attrs || emptyObject, function () {\n        !isUpdatingChildComponent && warn(\"$attrs is readonly.\", vm);\n      }, true);\n      defineReactive$$1(vm, '$listeners', options._parentListeners || emptyObject, function () {\n        !isUpdatingChildComponent && warn(\"$listeners is readonly.\", vm);\n      }, true);\n    }\n  }\n\n  var currentRenderingInstance = null;\n\n  function renderMixin (Vue) {\n    // install runtime convenience helpers\n    installRenderHelpers(Vue.prototype);\n\n    Vue.prototype.$nextTick = function (fn) {\n      return nextTick(fn, this)\n    };\n\n    Vue.prototype._render = function () {\n      var vm = this;\n      var ref = vm.$options;\n      var render = ref.render;\n      var _parentVnode = ref._parentVnode;\n\n      if (_parentVnode) {\n        vm.$scopedSlots = normalizeScopedSlots(\n          _parentVnode.data.scopedSlots,\n          vm.$slots,\n          vm.$scopedSlots\n        );\n      }\n\n      // set parent vnode. this allows render functions to have access\n      // to the data on the placeholder node.\n      vm.$vnode = _parentVnode;\n      // render self\n      var vnode;\n      try {\n        // There's no need to maintain a stack becaues all render fns are called\n        // separately from one another. Nested component's render fns are called\n        // when parent component is patched.\n        currentRenderingInstance = vm;\n        vnode = render.call(vm._renderProxy, vm.$createElement);\n      } catch (e) {\n        handleError(e, vm, \"render\");\n        // return error render result,\n        // or previous vnode to prevent render error causing blank component\n        /* istanbul ignore else */\n        if (vm.$options.renderError) {\n          try {\n            vnode = vm.$options.renderError.call(vm._renderProxy, vm.$createElement, e);\n          } catch (e) {\n            handleError(e, vm, \"renderError\");\n            vnode = vm._vnode;\n          }\n        } else {\n          vnode = vm._vnode;\n        }\n      } finally {\n        currentRenderingInstance = null;\n      }\n      // if the returned array contains only a single node, allow it\n      if (Array.isArray(vnode) && vnode.length === 1) {\n        vnode = vnode[0];\n      }\n      // return empty vnode in case the render function errored out\n      if (!(vnode instanceof VNode)) {\n        if (Array.isArray(vnode)) {\n          warn(\n            'Multiple root nodes returned from render function. Render function ' +\n            'should return a single root node.',\n            vm\n          );\n        }\n        vnode = createEmptyVNode();\n      }\n      // set parent\n      vnode.parent = _parentVnode;\n      return vnode\n    };\n  }\n\n  /*  */\n\n  function ensureCtor (comp, base) {\n    if (\n      comp.__esModule ||\n      (hasSymbol && comp[Symbol.toStringTag] === 'Module')\n    ) {\n      comp = comp.default;\n    }\n    return isObject(comp)\n      ? base.extend(comp)\n      : comp\n  }\n\n  function createAsyncPlaceholder (\n    factory,\n    data,\n    context,\n    children,\n    tag\n  ) {\n    var node = createEmptyVNode();\n    node.asyncFactory = factory;\n    node.asyncMeta = { data: data, context: context, children: children, tag: tag };\n    return node\n  }\n\n  function resolveAsyncComponent (\n    factory,\n    baseCtor\n  ) {\n    if (isTrue(factory.error) && isDef(factory.errorComp)) {\n      return factory.errorComp\n    }\n\n    if (isDef(factory.resolved)) {\n      return factory.resolved\n    }\n\n    var owner = currentRenderingInstance;\n    if (isDef(factory.owners) && factory.owners.indexOf(owner) === -1) {\n      // already pending\n      factory.owners.push(owner);\n    }\n\n    if (isTrue(factory.loading) && isDef(factory.loadingComp)) {\n      return factory.loadingComp\n    }\n\n    if (!isDef(factory.owners)) {\n      var owners = factory.owners = [owner];\n      var sync = true\n\n      ;(owner).$on('hook:destroyed', function () { return remove(owners, owner); });\n\n      var forceRender = function (renderCompleted) {\n        for (var i = 0, l = owners.length; i < l; i++) {\n          (owners[i]).$forceUpdate();\n        }\n\n        if (renderCompleted) {\n          owners.length = 0;\n        }\n      };\n\n      var resolve = once(function (res) {\n        // cache resolved\n        factory.resolved = ensureCtor(res, baseCtor);\n        // invoke callbacks only if this is not a synchronous resolve\n        // (async resolves are shimmed as synchronous during SSR)\n        if (!sync) {\n          forceRender(true);\n        } else {\n          owners.length = 0;\n        }\n      });\n\n      var reject = once(function (reason) {\n        warn(\n          \"Failed to resolve async component: \" + (String(factory)) +\n          (reason ? (\"\\nReason: \" + reason) : '')\n        );\n        if (isDef(factory.errorComp)) {\n          factory.error = true;\n          forceRender(true);\n        }\n      });\n\n      var res = factory(resolve, reject);\n\n      if (isObject(res)) {\n        if (isPromise(res)) {\n          // () => Promise\n          if (isUndef(factory.resolved)) {\n            res.then(resolve, reject);\n          }\n        } else if (isPromise(res.component)) {\n          res.component.then(resolve, reject);\n\n          if (isDef(res.error)) {\n            factory.errorComp = ensureCtor(res.error, baseCtor);\n          }\n\n          if (isDef(res.loading)) {\n            factory.loadingComp = ensureCtor(res.loading, baseCtor);\n            if (res.delay === 0) {\n              factory.loading = true;\n            } else {\n              setTimeout(function () {\n                if (isUndef(factory.resolved) && isUndef(factory.error)) {\n                  factory.loading = true;\n                  forceRender(false);\n                }\n              }, res.delay || 200);\n            }\n          }\n\n          if (isDef(res.timeout)) {\n            setTimeout(function () {\n              if (isUndef(factory.resolved)) {\n                reject(\n                  \"timeout (\" + (res.timeout) + \"ms)\"\n                );\n              }\n            }, res.timeout);\n          }\n        }\n      }\n\n      sync = false;\n      // return in case resolved synchronously\n      return factory.loading\n        ? factory.loadingComp\n        : factory.resolved\n    }\n  }\n\n  /*  */\n\n  function isAsyncPlaceholder (node) {\n    return node.isComment && node.asyncFactory\n  }\n\n  /*  */\n\n  function getFirstComponentChild (children) {\n    if (Array.isArray(children)) {\n      for (var i = 0; i < children.length; i++) {\n        var c = children[i];\n        if (isDef(c) && (isDef(c.componentOptions) || isAsyncPlaceholder(c))) {\n          return c\n        }\n      }\n    }\n  }\n\n  /*  */\n\n  /*  */\n\n  function initEvents (vm) {\n    vm._events = Object.create(null);\n    vm._hasHookEvent = false;\n    // init parent attached events\n    var listeners = vm.$options._parentListeners;\n    if (listeners) {\n      updateComponentListeners(vm, listeners);\n    }\n  }\n\n  var target;\n\n  function add (event, fn) {\n    target.$on(event, fn);\n  }\n\n  function remove$1 (event, fn) {\n    target.$off(event, fn);\n  }\n\n  function createOnceHandler (event, fn) {\n    var _target = target;\n    return function onceHandler () {\n      var res = fn.apply(null, arguments);\n      if (res !== null) {\n        _target.$off(event, onceHandler);\n      }\n    }\n  }\n\n  function updateComponentListeners (\n    vm,\n    listeners,\n    oldListeners\n  ) {\n    target = vm;\n    updateListeners(listeners, oldListeners || {}, add, remove$1, createOnceHandler, vm);\n    target = undefined;\n  }\n\n  function eventsMixin (Vue) {\n    var hookRE = /^hook:/;\n    Vue.prototype.$on = function (event, fn) {\n      var vm = this;\n      if (Array.isArray(event)) {\n        for (var i = 0, l = event.length; i < l; i++) {\n          vm.$on(event[i], fn);\n        }\n      } else {\n        (vm._events[event] || (vm._events[event] = [])).push(fn);\n        // optimize hook:event cost by using a boolean flag marked at registration\n        // instead of a hash lookup\n        if (hookRE.test(event)) {\n          vm._hasHookEvent = true;\n        }\n      }\n      return vm\n    };\n\n    Vue.prototype.$once = function (event, fn) {\n      var vm = this;\n      function on () {\n        vm.$off(event, on);\n        fn.apply(vm, arguments);\n      }\n      on.fn = fn;\n      vm.$on(event, on);\n      return vm\n    };\n\n    Vue.prototype.$off = function (event, fn) {\n      var vm = this;\n      // all\n      if (!arguments.length) {\n        vm._events = Object.create(null);\n        return vm\n      }\n      // array of events\n      if (Array.isArray(event)) {\n        for (var i$1 = 0, l = event.length; i$1 < l; i$1++) {\n          vm.$off(event[i$1], fn);\n        }\n        return vm\n      }\n      // specific event\n      var cbs = vm._events[event];\n      if (!cbs) {\n        return vm\n      }\n      if (!fn) {\n        vm._events[event] = null;\n        return vm\n      }\n      // specific handler\n      var cb;\n      var i = cbs.length;\n      while (i--) {\n        cb = cbs[i];\n        if (cb === fn || cb.fn === fn) {\n          cbs.splice(i, 1);\n          break\n        }\n      }\n      return vm\n    };\n\n    Vue.prototype.$emit = function (event) {\n      var vm = this;\n      {\n        var lowerCaseEvent = event.toLowerCase();\n        if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {\n          tip(\n            \"Event \\\"\" + lowerCaseEvent + \"\\\" is emitted in component \" +\n            (formatComponentName(vm)) + \" but the handler is registered for \\\"\" + event + \"\\\". \" +\n            \"Note that HTML attributes are case-insensitive and you cannot use \" +\n            \"v-on to listen to camelCase events when using in-DOM templates. \" +\n            \"You should probably use \\\"\" + (hyphenate(event)) + \"\\\" instead of \\\"\" + event + \"\\\".\"\n          );\n        }\n      }\n      var cbs = vm._events[event];\n      if (cbs) {\n        cbs = cbs.length > 1 ? toArray(cbs) : cbs;\n        var args = toArray(arguments, 1);\n        var info = \"event handler for \\\"\" + event + \"\\\"\";\n        for (var i = 0, l = cbs.length; i < l; i++) {\n          invokeWithErrorHandling(cbs[i], vm, args, vm, info);\n        }\n      }\n      return vm\n    };\n  }\n\n  /*  */\n\n  var activeInstance = null;\n  var isUpdatingChildComponent = false;\n\n  function setActiveInstance(vm) {\n    var prevActiveInstance = activeInstance;\n    activeInstance = vm;\n    return function () {\n      activeInstance = prevActiveInstance;\n    }\n  }\n\n  function initLifecycle (vm) {\n    var options = vm.$options;\n\n    // locate first non-abstract parent\n    var parent = options.parent;\n    if (parent && !options.abstract) {\n      while (parent.$options.abstract && parent.$parent) {\n        parent = parent.$parent;\n      }\n      parent.$children.push(vm);\n    }\n\n    vm.$parent = parent;\n    vm.$root = parent ? parent.$root : vm;\n\n    vm.$children = [];\n    vm.$refs = {};\n\n    vm._watcher = null;\n    vm._inactive = null;\n    vm._directInactive = false;\n    vm._isMounted = false;\n    vm._isDestroyed = false;\n    vm._isBeingDestroyed = false;\n  }\n\n  function lifecycleMixin (Vue) {\n    Vue.prototype._update = function (vnode, hydrating) {\n      var vm = this;\n      var prevEl = vm.$el;\n      var prevVnode = vm._vnode;\n      var restoreActiveInstance = setActiveInstance(vm);\n      vm._vnode = vnode;\n      // Vue.prototype.__patch__ is injected in entry points\n      // based on the rendering backend used.\n      if (!prevVnode) {\n        // initial render\n        vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);\n      } else {\n        // updates\n        vm.$el = vm.__patch__(prevVnode, vnode);\n      }\n      restoreActiveInstance();\n      // update __vue__ reference\n      if (prevEl) {\n        prevEl.__vue__ = null;\n      }\n      if (vm.$el) {\n        vm.$el.__vue__ = vm;\n      }\n      // if parent is an HOC, update its $el as well\n      if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {\n        vm.$parent.$el = vm.$el;\n      }\n      // updated hook is called by the scheduler to ensure that children are\n      // updated in a parent's updated hook.\n    };\n\n    Vue.prototype.$forceUpdate = function () {\n      var vm = this;\n      if (vm._watcher) {\n        vm._watcher.update();\n      }\n    };\n\n    Vue.prototype.$destroy = function () {\n      var vm = this;\n      if (vm._isBeingDestroyed) {\n        return\n      }\n      callHook(vm, 'beforeDestroy');\n      vm._isBeingDestroyed = true;\n      // remove self from parent\n      var parent = vm.$parent;\n      if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {\n        remove(parent.$children, vm);\n      }\n      // teardown watchers\n      if (vm._watcher) {\n        vm._watcher.teardown();\n      }\n      var i = vm._watchers.length;\n      while (i--) {\n        vm._watchers[i].teardown();\n      }\n      // remove reference from data ob\n      // frozen object may not have observer.\n      if (vm._data.__ob__) {\n        vm._data.__ob__.vmCount--;\n      }\n      // call the last hook...\n      vm._isDestroyed = true;\n      // invoke destroy hooks on current rendered tree\n      vm.__patch__(vm._vnode, null);\n      // fire destroyed hook\n      callHook(vm, 'destroyed');\n      // turn off all instance listeners.\n      vm.$off();\n      // remove __vue__ reference\n      if (vm.$el) {\n        vm.$el.__vue__ = null;\n      }\n      // release circular reference (#6759)\n      if (vm.$vnode) {\n        vm.$vnode.parent = null;\n      }\n    };\n  }\n\n  function mountComponent (\n    vm,\n    el,\n    hydrating\n  ) {\n    vm.$el = el;\n    if (!vm.$options.render) {\n      vm.$options.render = createEmptyVNode;\n      {\n        /* istanbul ignore if */\n        if ((vm.$options.template && vm.$options.template.charAt(0) !== '#') ||\n          vm.$options.el || el) {\n          warn(\n            'You are using the runtime-only build of Vue where the template ' +\n            'compiler is not available. Either pre-compile the templates into ' +\n            'render functions, or use the compiler-included build.',\n            vm\n          );\n        } else {\n          warn(\n            'Failed to mount component: template or render function not defined.',\n            vm\n          );\n        }\n      }\n    }\n    callHook(vm, 'beforeMount');\n\n    var updateComponent;\n    /* istanbul ignore if */\n    if (config.performance && mark) {\n      updateComponent = function () {\n        var name = vm._name;\n        var id = vm._uid;\n        var startTag = \"vue-perf-start:\" + id;\n        var endTag = \"vue-perf-end:\" + id;\n\n        mark(startTag);\n        var vnode = vm._render();\n        mark(endTag);\n        measure((\"vue \" + name + \" render\"), startTag, endTag);\n\n        mark(startTag);\n        vm._update(vnode, hydrating);\n        mark(endTag);\n        measure((\"vue \" + name + \" patch\"), startTag, endTag);\n      };\n    } else {\n      updateComponent = function () {\n        vm._update(vm._render(), hydrating);\n      };\n    }\n\n    // we set this to vm._watcher inside the watcher's constructor\n    // since the watcher's initial patch may call $forceUpdate (e.g. inside child\n    // component's mounted hook), which relies on vm._watcher being already defined\n    new Watcher(vm, updateComponent, noop, {\n      before: function before () {\n        if (vm._isMounted && !vm._isDestroyed) {\n          callHook(vm, 'beforeUpdate');\n        }\n      }\n    }, true /* isRenderWatcher */);\n    hydrating = false;\n\n    // manually mounted instance, call mounted on self\n    // mounted is called for render-created child components in its inserted hook\n    if (vm.$vnode == null) {\n      vm._isMounted = true;\n      callHook(vm, 'mounted');\n    }\n    return vm\n  }\n\n  function updateChildComponent (\n    vm,\n    propsData,\n    listeners,\n    parentVnode,\n    renderChildren\n  ) {\n    {\n      isUpdatingChildComponent = true;\n    }\n\n    // determine whether component has slot children\n    // we need to do this before overwriting $options._renderChildren.\n\n    // check if there are dynamic scopedSlots (hand-written or compiled but with\n    // dynamic slot names). Static scoped slots compiled from template has the\n    // \"$stable\" marker.\n    var newScopedSlots = parentVnode.data.scopedSlots;\n    var oldScopedSlots = vm.$scopedSlots;\n    var hasDynamicScopedSlot = !!(\n      (newScopedSlots && !newScopedSlots.$stable) ||\n      (oldScopedSlots !== emptyObject && !oldScopedSlots.$stable) ||\n      (newScopedSlots && vm.$scopedSlots.$key !== newScopedSlots.$key)\n    );\n\n    // Any static slot children from the parent may have changed during parent's\n    // update. Dynamic scoped slots may also have changed. In such cases, a forced\n    // update is necessary to ensure correctness.\n    var needsForceUpdate = !!(\n      renderChildren ||               // has new static slots\n      vm.$options._renderChildren ||  // has old static slots\n      hasDynamicScopedSlot\n    );\n\n    vm.$options._parentVnode = parentVnode;\n    vm.$vnode = parentVnode; // update vm's placeholder node without re-render\n\n    if (vm._vnode) { // update child tree's parent\n      vm._vnode.parent = parentVnode;\n    }\n    vm.$options._renderChildren = renderChildren;\n\n    // update $attrs and $listeners hash\n    // these are also reactive so they may trigger child update if the child\n    // used them during render\n    vm.$attrs = parentVnode.data.attrs || emptyObject;\n    vm.$listeners = listeners || emptyObject;\n\n    // update props\n    if (propsData && vm.$options.props) {\n      toggleObserving(false);\n      var props = vm._props;\n      var propKeys = vm.$options._propKeys || [];\n      for (var i = 0; i < propKeys.length; i++) {\n        var key = propKeys[i];\n        var propOptions = vm.$options.props; // wtf flow?\n        props[key] = validateProp(key, propOptions, propsData, vm);\n      }\n      toggleObserving(true);\n      // keep a copy of raw propsData\n      vm.$options.propsData = propsData;\n    }\n\n    // update listeners\n    listeners = listeners || emptyObject;\n    var oldListeners = vm.$options._parentListeners;\n    vm.$options._parentListeners = listeners;\n    updateComponentListeners(vm, listeners, oldListeners);\n\n    // resolve slots + force update if has children\n    if (needsForceUpdate) {\n      vm.$slots = resolveSlots(renderChildren, parentVnode.context);\n      vm.$forceUpdate();\n    }\n\n    {\n      isUpdatingChildComponent = false;\n    }\n  }\n\n  function isInInactiveTree (vm) {\n    while (vm && (vm = vm.$parent)) {\n      if (vm._inactive) { return true }\n    }\n    return false\n  }\n\n  function activateChildComponent (vm, direct) {\n    if (direct) {\n      vm._directInactive = false;\n      if (isInInactiveTree(vm)) {\n        return\n      }\n    } else if (vm._directInactive) {\n      return\n    }\n    if (vm._inactive || vm._inactive === null) {\n      vm._inactive = false;\n      for (var i = 0; i < vm.$children.length; i++) {\n        activateChildComponent(vm.$children[i]);\n      }\n      callHook(vm, 'activated');\n    }\n  }\n\n  function deactivateChildComponent (vm, direct) {\n    if (direct) {\n      vm._directInactive = true;\n      if (isInInactiveTree(vm)) {\n        return\n      }\n    }\n    if (!vm._inactive) {\n      vm._inactive = true;\n      for (var i = 0; i < vm.$children.length; i++) {\n        deactivateChildComponent(vm.$children[i]);\n      }\n      callHook(vm, 'deactivated');\n    }\n  }\n\n  function callHook (vm, hook) {\n    // #7573 disable dep collection when invoking lifecycle hooks\n    pushTarget();\n    var handlers = vm.$options[hook];\n    var info = hook + \" hook\";\n    if (handlers) {\n      for (var i = 0, j = handlers.length; i < j; i++) {\n        invokeWithErrorHandling(handlers[i], vm, null, vm, info);\n      }\n    }\n    if (vm._hasHookEvent) {\n      vm.$emit('hook:' + hook);\n    }\n    popTarget();\n  }\n\n  /*  */\n\n  var MAX_UPDATE_COUNT = 100;\n\n  var queue = [];\n  var activatedChildren = [];\n  var has = {};\n  var circular = {};\n  var waiting = false;\n  var flushing = false;\n  var index = 0;\n\n  /**\n   * Reset the scheduler's state.\n   */\n  function resetSchedulerState () {\n    index = queue.length = activatedChildren.length = 0;\n    has = {};\n    {\n      circular = {};\n    }\n    waiting = flushing = false;\n  }\n\n  // Async edge case #6566 requires saving the timestamp when event listeners are\n  // attached. However, calling performance.now() has a perf overhead especially\n  // if the page has thousands of event listeners. Instead, we take a timestamp\n  // every time the scheduler flushes and use that for all event listeners\n  // attached during that flush.\n  var currentFlushTimestamp = 0;\n\n  // Async edge case fix requires storing an event listener's attach timestamp.\n  var getNow = Date.now;\n\n  // Determine what event timestamp the browser is using. Annoyingly, the\n  // timestamp can either be hi-res (relative to page load) or low-res\n  // (relative to UNIX epoch), so in order to compare time we have to use the\n  // same timestamp type when saving the flush timestamp.\n  if (inBrowser && getNow() > document.createEvent('Event').timeStamp) {\n    // if the low-res timestamp which is bigger than the event timestamp\n    // (which is evaluated AFTER) it means the event is using a hi-res timestamp,\n    // and we need to use the hi-res version for event listeners as well.\n    getNow = function () { return performance.now(); };\n  }\n\n  /**\n   * Flush both queues and run the watchers.\n   */\n  function flushSchedulerQueue () {\n    currentFlushTimestamp = getNow();\n    flushing = true;\n    var watcher, id;\n\n    // Sort queue before flush.\n    // This ensures that:\n    // 1. Components are updated from parent to child. (because parent is always\n    //    created before the child)\n    // 2. A component's user watchers are run before its render watcher (because\n    //    user watchers are created before the render watcher)\n    // 3. If a component is destroyed during a parent component's watcher run,\n    //    its watchers can be skipped.\n    queue.sort(function (a, b) { return a.id - b.id; });\n\n    // do not cache length because more watchers might be pushed\n    // as we run existing watchers\n    for (index = 0; index < queue.length; index++) {\n      watcher = queue[index];\n      if (watcher.before) {\n        watcher.before();\n      }\n      id = watcher.id;\n      has[id] = null;\n      watcher.run();\n      // in dev build, check and stop circular updates.\n      if (has[id] != null) {\n        circular[id] = (circular[id] || 0) + 1;\n        if (circular[id] > MAX_UPDATE_COUNT) {\n          warn(\n            'You may have an infinite update loop ' + (\n              watcher.user\n                ? (\"in watcher with expression \\\"\" + (watcher.expression) + \"\\\"\")\n                : \"in a component render function.\"\n            ),\n            watcher.vm\n          );\n          break\n        }\n      }\n    }\n\n    // keep copies of post queues before resetting state\n    var activatedQueue = activatedChildren.slice();\n    var updatedQueue = queue.slice();\n\n    resetSchedulerState();\n\n    // call component updated and activated hooks\n    callActivatedHooks(activatedQueue);\n    callUpdatedHooks(updatedQueue);\n\n    // devtool hook\n    /* istanbul ignore if */\n    if (devtools && config.devtools) {\n      devtools.emit('flush');\n    }\n  }\n\n  function callUpdatedHooks (queue) {\n    var i = queue.length;\n    while (i--) {\n      var watcher = queue[i];\n      var vm = watcher.vm;\n      if (vm._watcher === watcher && vm._isMounted && !vm._isDestroyed) {\n        callHook(vm, 'updated');\n      }\n    }\n  }\n\n  /**\n   * Queue a kept-alive component that was activated during patch.\n   * The queue will be processed after the entire tree has been patched.\n   */\n  function queueActivatedComponent (vm) {\n    // setting _inactive to false here so that a render function can\n    // rely on checking whether it's in an inactive tree (e.g. router-view)\n    vm._inactive = false;\n    activatedChildren.push(vm);\n  }\n\n  function callActivatedHooks (queue) {\n    for (var i = 0; i < queue.length; i++) {\n      queue[i]._inactive = true;\n      activateChildComponent(queue[i], true /* true */);\n    }\n  }\n\n  /**\n   * Push a watcher into the watcher queue.\n   * Jobs with duplicate IDs will be skipped unless it's\n   * pushed when the queue is being flushed.\n   */\n  function queueWatcher (watcher) {\n    var id = watcher.id;\n    if (has[id] == null) {\n      has[id] = true;\n      if (!flushing) {\n        queue.push(watcher);\n      } else {\n        // if already flushing, splice the watcher based on its id\n        // if already past its id, it will be run next immediately.\n        var i = queue.length - 1;\n        while (i > index && queue[i].id > watcher.id) {\n          i--;\n        }\n        queue.splice(i + 1, 0, watcher);\n      }\n      // queue the flush\n      if (!waiting) {\n        waiting = true;\n\n        if (!config.async) {\n          flushSchedulerQueue();\n          return\n        }\n        nextTick(flushSchedulerQueue);\n      }\n    }\n  }\n\n  /*  */\n\n\n\n  var uid$2 = 0;\n\n  /**\n   * A watcher parses an expression, collects dependencies,\n   * and fires callback when the expression value changes.\n   * This is used for both the $watch() api and directives.\n   */\n  var Watcher = function Watcher (\n    vm,\n    expOrFn,\n    cb,\n    options,\n    isRenderWatcher\n  ) {\n    this.vm = vm;\n    if (isRenderWatcher) {\n      vm._watcher = this;\n    }\n    vm._watchers.push(this);\n    // options\n    if (options) {\n      this.deep = !!options.deep;\n      this.user = !!options.user;\n      this.lazy = !!options.lazy;\n      this.sync = !!options.sync;\n      this.before = options.before;\n    } else {\n      this.deep = this.user = this.lazy = this.sync = false;\n    }\n    this.cb = cb;\n    this.id = ++uid$2; // uid for batching\n    this.active = true;\n    this.dirty = this.lazy; // for lazy watchers\n    this.deps = [];\n    this.newDeps = [];\n    this.depIds = new _Set();\n    this.newDepIds = new _Set();\n    this.expression = expOrFn.toString();\n    // parse expression for getter\n    if (typeof expOrFn === 'function') {\n      this.getter = expOrFn;\n    } else {\n      this.getter = parsePath(expOrFn);\n      if (!this.getter) {\n        this.getter = noop;\n        warn(\n          \"Failed watching path: \\\"\" + expOrFn + \"\\\" \" +\n          'Watcher only accepts simple dot-delimited paths. ' +\n          'For full control, use a function instead.',\n          vm\n        );\n      }\n    }\n    this.value = this.lazy\n      ? undefined\n      : this.get();\n  };\n\n  /**\n   * Evaluate the getter, and re-collect dependencies.\n   */\n  Watcher.prototype.get = function get () {\n    pushTarget(this);\n    var value;\n    var vm = this.vm;\n    try {\n      value = this.getter.call(vm, vm);\n    } catch (e) {\n      if (this.user) {\n        handleError(e, vm, (\"getter for watcher \\\"\" + (this.expression) + \"\\\"\"));\n      } else {\n        throw e\n      }\n    } finally {\n      // \"touch\" every property so they are all tracked as\n      // dependencies for deep watching\n      if (this.deep) {\n        traverse(value);\n      }\n      popTarget();\n      this.cleanupDeps();\n    }\n    return value\n  };\n\n  /**\n   * Add a dependency to this directive.\n   */\n  Watcher.prototype.addDep = function addDep (dep) {\n    var id = dep.id;\n    if (!this.newDepIds.has(id)) {\n      this.newDepIds.add(id);\n      this.newDeps.push(dep);\n      if (!this.depIds.has(id)) {\n        dep.addSub(this);\n      }\n    }\n  };\n\n  /**\n   * Clean up for dependency collection.\n   */\n  Watcher.prototype.cleanupDeps = function cleanupDeps () {\n    var i = this.deps.length;\n    while (i--) {\n      var dep = this.deps[i];\n      if (!this.newDepIds.has(dep.id)) {\n        dep.removeSub(this);\n      }\n    }\n    var tmp = this.depIds;\n    this.depIds = this.newDepIds;\n    this.newDepIds = tmp;\n    this.newDepIds.clear();\n    tmp = this.deps;\n    this.deps = this.newDeps;\n    this.newDeps = tmp;\n    this.newDeps.length = 0;\n  };\n\n  /**\n   * Subscriber interface.\n   * Will be called when a dependency changes.\n   */\n  Watcher.prototype.update = function update () {\n    /* istanbul ignore else */\n    if (this.lazy) {\n      this.dirty = true;\n    } else if (this.sync) {\n      this.run();\n    } else {\n      queueWatcher(this);\n    }\n  };\n\n  /**\n   * Scheduler job interface.\n   * Will be called by the scheduler.\n   */\n  Watcher.prototype.run = function run () {\n    if (this.active) {\n      var value = this.get();\n      if (\n        value !== this.value ||\n        // Deep watchers and watchers on Object/Arrays should fire even\n        // when the value is the same, because the value may\n        // have mutated.\n        isObject(value) ||\n        this.deep\n      ) {\n        // set new value\n        var oldValue = this.value;\n        this.value = value;\n        if (this.user) {\n          try {\n            this.cb.call(this.vm, value, oldValue);\n          } catch (e) {\n            handleError(e, this.vm, (\"callback for watcher \\\"\" + (this.expression) + \"\\\"\"));\n          }\n        } else {\n          this.cb.call(this.vm, value, oldValue);\n        }\n      }\n    }\n  };\n\n  /**\n   * Evaluate the value of the watcher.\n   * This only gets called for lazy watchers.\n   */\n  Watcher.prototype.evaluate = function evaluate () {\n    this.value = this.get();\n    this.dirty = false;\n  };\n\n  /**\n   * Depend on all deps collected by this watcher.\n   */\n  Watcher.prototype.depend = function depend () {\n    var i = this.deps.length;\n    while (i--) {\n      this.deps[i].depend();\n    }\n  };\n\n  /**\n   * Remove self from all dependencies' subscriber list.\n   */\n  Watcher.prototype.teardown = function teardown () {\n    if (this.active) {\n      // remove self from vm's watcher list\n      // this is a somewhat expensive operation so we skip it\n      // if the vm is being destroyed.\n      if (!this.vm._isBeingDestroyed) {\n        remove(this.vm._watchers, this);\n      }\n      var i = this.deps.length;\n      while (i--) {\n        this.deps[i].removeSub(this);\n      }\n      this.active = false;\n    }\n  };\n\n  /*  */\n\n  var sharedPropertyDefinition = {\n    enumerable: true,\n    configurable: true,\n    get: noop,\n    set: noop\n  };\n\n  function proxy (target, sourceKey, key) {\n    sharedPropertyDefinition.get = function proxyGetter () {\n      return this[sourceKey][key]\n    };\n    sharedPropertyDefinition.set = function proxySetter (val) {\n      this[sourceKey][key] = val;\n    };\n    Object.defineProperty(target, key, sharedPropertyDefinition);\n  }\n\n  function initState (vm) {\n    vm._watchers = [];\n    var opts = vm.$options;\n    if (opts.props) { initProps(vm, opts.props); }\n    if (opts.methods) { initMethods(vm, opts.methods); }\n    if (opts.data) {\n      initData(vm);\n    } else {\n      observe(vm._data = {}, true /* asRootData */);\n    }\n    if (opts.computed) { initComputed(vm, opts.computed); }\n    if (opts.watch && opts.watch !== nativeWatch) {\n      initWatch(vm, opts.watch);\n    }\n  }\n\n  function initProps (vm, propsOptions) {\n    var propsData = vm.$options.propsData || {};\n    var props = vm._props = {};\n    // cache prop keys so that future props updates can iterate using Array\n    // instead of dynamic object key enumeration.\n    var keys = vm.$options._propKeys = [];\n    var isRoot = !vm.$parent;\n    // root instance props should be converted\n    if (!isRoot) {\n      toggleObserving(false);\n    }\n    var loop = function ( key ) {\n      keys.push(key);\n      var value = validateProp(key, propsOptions, propsData, vm);\n      /* istanbul ignore else */\n      {\n        var hyphenatedKey = hyphenate(key);\n        if (isReservedAttribute(hyphenatedKey) ||\n            config.isReservedAttr(hyphenatedKey)) {\n          warn(\n            (\"\\\"\" + hyphenatedKey + \"\\\" is a reserved attribute and cannot be used as component prop.\"),\n            vm\n          );\n        }\n        defineReactive$$1(props, key, value, function () {\n          if (!isRoot && !isUpdatingChildComponent) {\n            warn(\n              \"Avoid mutating a prop directly since the value will be \" +\n              \"overwritten whenever the parent component re-renders. \" +\n              \"Instead, use a data or computed property based on the prop's \" +\n              \"value. Prop being mutated: \\\"\" + key + \"\\\"\",\n              vm\n            );\n          }\n        });\n      }\n      // static props are already proxied on the component's prototype\n      // during Vue.extend(). We only need to proxy props defined at\n      // instantiation here.\n      if (!(key in vm)) {\n        proxy(vm, \"_props\", key);\n      }\n    };\n\n    for (var key in propsOptions) loop( key );\n    toggleObserving(true);\n  }\n\n  function initData (vm) {\n    var data = vm.$options.data;\n    data = vm._data = typeof data === 'function'\n      ? getData(data, vm)\n      : data || {};\n    if (!isPlainObject(data)) {\n      data = {};\n      warn(\n        'data functions should return an object:\\n' +\n        'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',\n        vm\n      );\n    }\n    // proxy data on instance\n    var keys = Object.keys(data);\n    var props = vm.$options.props;\n    var methods = vm.$options.methods;\n    var i = keys.length;\n    while (i--) {\n      var key = keys[i];\n      {\n        if (methods && hasOwn(methods, key)) {\n          warn(\n            (\"Method \\\"\" + key + \"\\\" has already been defined as a data property.\"),\n            vm\n          );\n        }\n      }\n      if (props && hasOwn(props, key)) {\n        warn(\n          \"The data property \\\"\" + key + \"\\\" is already declared as a prop. \" +\n          \"Use prop default value instead.\",\n          vm\n        );\n      } else if (!isReserved(key)) {\n        proxy(vm, \"_data\", key);\n      }\n    }\n    // observe data\n    observe(data, true /* asRootData */);\n  }\n\n  function getData (data, vm) {\n    // #7573 disable dep collection when invoking data getters\n    pushTarget();\n    try {\n      return data.call(vm, vm)\n    } catch (e) {\n      handleError(e, vm, \"data()\");\n      return {}\n    } finally {\n      popTarget();\n    }\n  }\n\n  var computedWatcherOptions = { lazy: true };\n\n  function initComputed (vm, computed) {\n    // $flow-disable-line\n    var watchers = vm._computedWatchers = Object.create(null);\n    // computed properties are just getters during SSR\n    var isSSR = isServerRendering();\n\n    for (var key in computed) {\n      var userDef = computed[key];\n      var getter = typeof userDef === 'function' ? userDef : userDef.get;\n      if (getter == null) {\n        warn(\n          (\"Getter is missing for computed property \\\"\" + key + \"\\\".\"),\n          vm\n        );\n      }\n\n      if (!isSSR) {\n        // create internal watcher for the computed property.\n        watchers[key] = new Watcher(\n          vm,\n          getter || noop,\n          noop,\n          computedWatcherOptions\n        );\n      }\n\n      // component-defined computed properties are already defined on the\n      // component prototype. We only need to define computed properties defined\n      // at instantiation here.\n      if (!(key in vm)) {\n        defineComputed(vm, key, userDef);\n      } else {\n        if (key in vm.$data) {\n          warn((\"The computed property \\\"\" + key + \"\\\" is already defined in data.\"), vm);\n        } else if (vm.$options.props && key in vm.$options.props) {\n          warn((\"The computed property \\\"\" + key + \"\\\" is already defined as a prop.\"), vm);\n        }\n      }\n    }\n  }\n\n  function defineComputed (\n    target,\n    key,\n    userDef\n  ) {\n    var shouldCache = !isServerRendering();\n    if (typeof userDef === 'function') {\n      sharedPropertyDefinition.get = shouldCache\n        ? createComputedGetter(key)\n        : createGetterInvoker(userDef);\n      sharedPropertyDefinition.set = noop;\n    } else {\n      sharedPropertyDefinition.get = userDef.get\n        ? shouldCache && userDef.cache !== false\n          ? createComputedGetter(key)\n          : createGetterInvoker(userDef.get)\n        : noop;\n      sharedPropertyDefinition.set = userDef.set || noop;\n    }\n    if (sharedPropertyDefinition.set === noop) {\n      sharedPropertyDefinition.set = function () {\n        warn(\n          (\"Computed property \\\"\" + key + \"\\\" was assigned to but it has no setter.\"),\n          this\n        );\n      };\n    }\n    Object.defineProperty(target, key, sharedPropertyDefinition);\n  }\n\n  function createComputedGetter (key) {\n    return function computedGetter () {\n      var watcher = this._computedWatchers && this._computedWatchers[key];\n      if (watcher) {\n        if (watcher.dirty) {\n          watcher.evaluate();\n        }\n        if (Dep.target) {\n          watcher.depend();\n        }\n        return watcher.value\n      }\n    }\n  }\n\n  function createGetterInvoker(fn) {\n    return function computedGetter () {\n      return fn.call(this, this)\n    }\n  }\n\n  function initMethods (vm, methods) {\n    var props = vm.$options.props;\n    for (var key in methods) {\n      {\n        if (typeof methods[key] !== 'function') {\n          warn(\n            \"Method \\\"\" + key + \"\\\" has type \\\"\" + (typeof methods[key]) + \"\\\" in the component definition. \" +\n            \"Did you reference the function correctly?\",\n            vm\n          );\n        }\n        if (props && hasOwn(props, key)) {\n          warn(\n            (\"Method \\\"\" + key + \"\\\" has already been defined as a prop.\"),\n            vm\n          );\n        }\n        if ((key in vm) && isReserved(key)) {\n          warn(\n            \"Method \\\"\" + key + \"\\\" conflicts with an existing Vue instance method. \" +\n            \"Avoid defining component methods that start with _ or $.\"\n          );\n        }\n      }\n      vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm);\n    }\n  }\n\n  function initWatch (vm, watch) {\n    for (var key in watch) {\n      var handler = watch[key];\n      if (Array.isArray(handler)) {\n        for (var i = 0; i < handler.length; i++) {\n          createWatcher(vm, key, handler[i]);\n        }\n      } else {\n        createWatcher(vm, key, handler);\n      }\n    }\n  }\n\n  function createWatcher (\n    vm,\n    expOrFn,\n    handler,\n    options\n  ) {\n    if (isPlainObject(handler)) {\n      options = handler;\n      handler = handler.handler;\n    }\n    if (typeof handler === 'string') {\n      handler = vm[handler];\n    }\n    return vm.$watch(expOrFn, handler, options)\n  }\n\n  function stateMixin (Vue) {\n    // flow somehow has problems with directly declared definition object\n    // when using Object.defineProperty, so we have to procedurally build up\n    // the object here.\n    var dataDef = {};\n    dataDef.get = function () { return this._data };\n    var propsDef = {};\n    propsDef.get = function () { return this._props };\n    {\n      dataDef.set = function () {\n        warn(\n          'Avoid replacing instance root $data. ' +\n          'Use nested data properties instead.',\n          this\n        );\n      };\n      propsDef.set = function () {\n        warn(\"$props is readonly.\", this);\n      };\n    }\n    Object.defineProperty(Vue.prototype, '$data', dataDef);\n    Object.defineProperty(Vue.prototype, '$props', propsDef);\n\n    Vue.prototype.$set = set;\n    Vue.prototype.$delete = del;\n\n    Vue.prototype.$watch = function (\n      expOrFn,\n      cb,\n      options\n    ) {\n      var vm = this;\n      if (isPlainObject(cb)) {\n        return createWatcher(vm, expOrFn, cb, options)\n      }\n      options = options || {};\n      options.user = true;\n      var watcher = new Watcher(vm, expOrFn, cb, options);\n      if (options.immediate) {\n        try {\n          cb.call(vm, watcher.value);\n        } catch (error) {\n          handleError(error, vm, (\"callback for immediate watcher \\\"\" + (watcher.expression) + \"\\\"\"));\n        }\n      }\n      return function unwatchFn () {\n        watcher.teardown();\n      }\n    };\n  }\n\n  /*  */\n\n  var uid$3 = 0;\n\n  function initMixin (Vue) {\n    Vue.prototype._init = function (options) {\n      var vm = this;\n      // a uid\n      vm._uid = uid$3++;\n\n      var startTag, endTag;\n      /* istanbul ignore if */\n      if (config.performance && mark) {\n        startTag = \"vue-perf-start:\" + (vm._uid);\n        endTag = \"vue-perf-end:\" + (vm._uid);\n        mark(startTag);\n      }\n\n      // a flag to avoid this being observed\n      vm._isVue = true;\n      // merge options\n      if (options && options._isComponent) {\n        // optimize internal component instantiation\n        // since dynamic options merging is pretty slow, and none of the\n        // internal component options needs special treatment.\n        initInternalComponent(vm, options);\n      } else {\n        vm.$options = mergeOptions(\n          resolveConstructorOptions(vm.constructor),\n          options || {},\n          vm\n        );\n      }\n      /* istanbul ignore else */\n      {\n        initProxy(vm);\n      }\n      // expose real self\n      vm._self = vm;\n      initLifecycle(vm);\n      initEvents(vm);\n      initRender(vm);\n      callHook(vm, 'beforeCreate');\n      initInjections(vm); // resolve injections before data/props\n      initState(vm);\n      initProvide(vm); // resolve provide after data/props\n      callHook(vm, 'created');\n\n      /* istanbul ignore if */\n      if (config.performance && mark) {\n        vm._name = formatComponentName(vm, false);\n        mark(endTag);\n        measure((\"vue \" + (vm._name) + \" init\"), startTag, endTag);\n      }\n\n      if (vm.$options.el) {\n        vm.$mount(vm.$options.el);\n      }\n    };\n  }\n\n  function initInternalComponent (vm, options) {\n    var opts = vm.$options = Object.create(vm.constructor.options);\n    // doing this because it's faster than dynamic enumeration.\n    var parentVnode = options._parentVnode;\n    opts.parent = options.parent;\n    opts._parentVnode = parentVnode;\n\n    var vnodeComponentOptions = parentVnode.componentOptions;\n    opts.propsData = vnodeComponentOptions.propsData;\n    opts._parentListeners = vnodeComponentOptions.listeners;\n    opts._renderChildren = vnodeComponentOptions.children;\n    opts._componentTag = vnodeComponentOptions.tag;\n\n    if (options.render) {\n      opts.render = options.render;\n      opts.staticRenderFns = options.staticRenderFns;\n    }\n  }\n\n  function resolveConstructorOptions (Ctor) {\n    var options = Ctor.options;\n    if (Ctor.super) {\n      var superOptions = resolveConstructorOptions(Ctor.super);\n      var cachedSuperOptions = Ctor.superOptions;\n      if (superOptions !== cachedSuperOptions) {\n        // super option changed,\n        // need to resolve new options.\n        Ctor.superOptions = superOptions;\n        // check if there are any late-modified/attached options (#4976)\n        var modifiedOptions = resolveModifiedOptions(Ctor);\n        // update base extend options\n        if (modifiedOptions) {\n          extend(Ctor.extendOptions, modifiedOptions);\n        }\n        options = Ctor.options = mergeOptions(superOptions, Ctor.extendOptions);\n        if (options.name) {\n          options.components[options.name] = Ctor;\n        }\n      }\n    }\n    return options\n  }\n\n  function resolveModifiedOptions (Ctor) {\n    var modified;\n    var latest = Ctor.options;\n    var sealed = Ctor.sealedOptions;\n    for (var key in latest) {\n      if (latest[key] !== sealed[key]) {\n        if (!modified) { modified = {}; }\n        modified[key] = latest[key];\n      }\n    }\n    return modified\n  }\n\n  function Vue (options) {\n    if (!(this instanceof Vue)\n    ) {\n      warn('Vue is a constructor and should be called with the `new` keyword');\n    }\n    this._init(options);\n  }\n\n  initMixin(Vue);\n  stateMixin(Vue);\n  eventsMixin(Vue);\n  lifecycleMixin(Vue);\n  renderMixin(Vue);\n\n  /*  */\n\n  function initUse (Vue) {\n    Vue.use = function (plugin) {\n      var installedPlugins = (this._installedPlugins || (this._installedPlugins = []));\n      if (installedPlugins.indexOf(plugin) > -1) {\n        return this\n      }\n\n      // additional parameters\n      var args = toArray(arguments, 1);\n      args.unshift(this);\n      if (typeof plugin.install === 'function') {\n        plugin.install.apply(plugin, args);\n      } else if (typeof plugin === 'function') {\n        plugin.apply(null, args);\n      }\n      installedPlugins.push(plugin);\n      return this\n    };\n  }\n\n  /*  */\n\n  function initMixin$1 (Vue) {\n    Vue.mixin = function (mixin) {\n      this.options = mergeOptions(this.options, mixin);\n      return this\n    };\n  }\n\n  /*  */\n\n  function initExtend (Vue) {\n    /**\n     * Each instance constructor, including Vue, has a unique\n     * cid. This enables us to create wrapped \"child\n     * constructors\" for prototypal inheritance and cache them.\n     */\n    Vue.cid = 0;\n    var cid = 1;\n\n    /**\n     * Class inheritance\n     */\n    Vue.extend = function (extendOptions) {\n      extendOptions = extendOptions || {};\n      var Super = this;\n      var SuperId = Super.cid;\n      var cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {});\n      if (cachedCtors[SuperId]) {\n        return cachedCtors[SuperId]\n      }\n\n      var name = extendOptions.name || Super.options.name;\n      if (name) {\n        validateComponentName(name);\n      }\n\n      var Sub = function VueComponent (options) {\n        this._init(options);\n      };\n      Sub.prototype = Object.create(Super.prototype);\n      Sub.prototype.constructor = Sub;\n      Sub.cid = cid++;\n      Sub.options = mergeOptions(\n        Super.options,\n        extendOptions\n      );\n      Sub['super'] = Super;\n\n      // For props and computed properties, we define the proxy getters on\n      // the Vue instances at extension time, on the extended prototype. This\n      // avoids Object.defineProperty calls for each instance created.\n      if (Sub.options.props) {\n        initProps$1(Sub);\n      }\n      if (Sub.options.computed) {\n        initComputed$1(Sub);\n      }\n\n      // allow further extension/mixin/plugin usage\n      Sub.extend = Super.extend;\n      Sub.mixin = Super.mixin;\n      Sub.use = Super.use;\n\n      // create asset registers, so extended classes\n      // can have their private assets too.\n      ASSET_TYPES.forEach(function (type) {\n        Sub[type] = Super[type];\n      });\n      // enable recursive self-lookup\n      if (name) {\n        Sub.options.components[name] = Sub;\n      }\n\n      // keep a reference to the super options at extension time.\n      // later at instantiation we can check if Super's options have\n      // been updated.\n      Sub.superOptions = Super.options;\n      Sub.extendOptions = extendOptions;\n      Sub.sealedOptions = extend({}, Sub.options);\n\n      // cache constructor\n      cachedCtors[SuperId] = Sub;\n      return Sub\n    };\n  }\n\n  function initProps$1 (Comp) {\n    var props = Comp.options.props;\n    for (var key in props) {\n      proxy(Comp.prototype, \"_props\", key);\n    }\n  }\n\n  function initComputed$1 (Comp) {\n    var computed = Comp.options.computed;\n    for (var key in computed) {\n      defineComputed(Comp.prototype, key, computed[key]);\n    }\n  }\n\n  /*  */\n\n  function initAssetRegisters (Vue) {\n    /**\n     * Create asset registration methods.\n     */\n    ASSET_TYPES.forEach(function (type) {\n      Vue[type] = function (\n        id,\n        definition\n      ) {\n        if (!definition) {\n          return this.options[type + 's'][id]\n        } else {\n          /* istanbul ignore if */\n          if (type === 'component') {\n            validateComponentName(id);\n          }\n          if (type === 'component' && isPlainObject(definition)) {\n            definition.name = definition.name || id;\n            definition = this.options._base.extend(definition);\n          }\n          if (type === 'directive' && typeof definition === 'function') {\n            definition = { bind: definition, update: definition };\n          }\n          this.options[type + 's'][id] = definition;\n          return definition\n        }\n      };\n    });\n  }\n\n  /*  */\n\n\n\n  function getComponentName (opts) {\n    return opts && (opts.Ctor.options.name || opts.tag)\n  }\n\n  function matches (pattern, name) {\n    if (Array.isArray(pattern)) {\n      return pattern.indexOf(name) > -1\n    } else if (typeof pattern === 'string') {\n      return pattern.split(',').indexOf(name) > -1\n    } else if (isRegExp(pattern)) {\n      return pattern.test(name)\n    }\n    /* istanbul ignore next */\n    return false\n  }\n\n  function pruneCache (keepAliveInstance, filter) {\n    var cache = keepAliveInstance.cache;\n    var keys = keepAliveInstance.keys;\n    var _vnode = keepAliveInstance._vnode;\n    for (var key in cache) {\n      var cachedNode = cache[key];\n      if (cachedNode) {\n        var name = getComponentName(cachedNode.componentOptions);\n        if (name && !filter(name)) {\n          pruneCacheEntry(cache, key, keys, _vnode);\n        }\n      }\n    }\n  }\n\n  function pruneCacheEntry (\n    cache,\n    key,\n    keys,\n    current\n  ) {\n    var cached$$1 = cache[key];\n    if (cached$$1 && (!current || cached$$1.tag !== current.tag)) {\n      cached$$1.componentInstance.$destroy();\n    }\n    cache[key] = null;\n    remove(keys, key);\n  }\n\n  var patternTypes = [String, RegExp, Array];\n\n  var KeepAlive = {\n    name: 'keep-alive',\n    abstract: true,\n\n    props: {\n      include: patternTypes,\n      exclude: patternTypes,\n      max: [String, Number]\n    },\n\n    created: function created () {\n      this.cache = Object.create(null);\n      this.keys = [];\n    },\n\n    destroyed: function destroyed () {\n      for (var key in this.cache) {\n        pruneCacheEntry(this.cache, key, this.keys);\n      }\n    },\n\n    mounted: function mounted () {\n      var this$1 = this;\n\n      this.$watch('include', function (val) {\n        pruneCache(this$1, function (name) { return matches(val, name); });\n      });\n      this.$watch('exclude', function (val) {\n        pruneCache(this$1, function (name) { return !matches(val, name); });\n      });\n    },\n\n    render: function render () {\n      var slot = this.$slots.default;\n      var vnode = getFirstComponentChild(slot);\n      var componentOptions = vnode && vnode.componentOptions;\n      if (componentOptions) {\n        // check pattern\n        var name = getComponentName(componentOptions);\n        var ref = this;\n        var include = ref.include;\n        var exclude = ref.exclude;\n        if (\n          // not included\n          (include && (!name || !matches(include, name))) ||\n          // excluded\n          (exclude && name && matches(exclude, name))\n        ) {\n          return vnode\n        }\n\n        var ref$1 = this;\n        var cache = ref$1.cache;\n        var keys = ref$1.keys;\n        var key = vnode.key == null\n          // same constructor may get registered as different local components\n          // so cid alone is not enough (#3269)\n          ? componentOptions.Ctor.cid + (componentOptions.tag ? (\"::\" + (componentOptions.tag)) : '')\n          : vnode.key;\n        if (cache[key]) {\n          vnode.componentInstance = cache[key].componentInstance;\n          // make current key freshest\n          remove(keys, key);\n          keys.push(key);\n        } else {\n          cache[key] = vnode;\n          keys.push(key);\n          // prune oldest entry\n          if (this.max && keys.length > parseInt(this.max)) {\n            pruneCacheEntry(cache, keys[0], keys, this._vnode);\n          }\n        }\n\n        vnode.data.keepAlive = true;\n      }\n      return vnode || (slot && slot[0])\n    }\n  };\n\n  var builtInComponents = {\n    KeepAlive: KeepAlive\n  };\n\n  /*  */\n\n  function initGlobalAPI (Vue) {\n    // config\n    var configDef = {};\n    configDef.get = function () { return config; };\n    {\n      configDef.set = function () {\n        warn(\n          'Do not replace the Vue.config object, set individual fields instead.'\n        );\n      };\n    }\n    Object.defineProperty(Vue, 'config', configDef);\n\n    // exposed util methods.\n    // NOTE: these are not considered part of the public API - avoid relying on\n    // them unless you are aware of the risk.\n    Vue.util = {\n      warn: warn,\n      extend: extend,\n      mergeOptions: mergeOptions,\n      defineReactive: defineReactive$$1\n    };\n\n    Vue.set = set;\n    Vue.delete = del;\n    Vue.nextTick = nextTick;\n\n    // 2.6 explicit observable API\n    Vue.observable = function (obj) {\n      observe(obj);\n      return obj\n    };\n\n    Vue.options = Object.create(null);\n    ASSET_TYPES.forEach(function (type) {\n      Vue.options[type + 's'] = Object.create(null);\n    });\n\n    // this is used to identify the \"base\" constructor to extend all plain-object\n    // components with in Weex's multi-instance scenarios.\n    Vue.options._base = Vue;\n\n    extend(Vue.options.components, builtInComponents);\n\n    initUse(Vue);\n    initMixin$1(Vue);\n    initExtend(Vue);\n    initAssetRegisters(Vue);\n  }\n\n  initGlobalAPI(Vue);\n\n  Object.defineProperty(Vue.prototype, '$isServer', {\n    get: isServerRendering\n  });\n\n  Object.defineProperty(Vue.prototype, '$ssrContext', {\n    get: function get () {\n      /* istanbul ignore next */\n      return this.$vnode && this.$vnode.ssrContext\n    }\n  });\n\n  // expose FunctionalRenderContext for ssr runtime helper installation\n  Object.defineProperty(Vue, 'FunctionalRenderContext', {\n    value: FunctionalRenderContext\n  });\n\n  Vue.version = '2.6.8';\n\n  /*  */\n\n  // these are reserved for web because they are directly compiled away\n  // during template compilation\n  var isReservedAttr = makeMap('style,class');\n\n  // attributes that should be using props for binding\n  var acceptValue = makeMap('input,textarea,option,select,progress');\n  var mustUseProp = function (tag, type, attr) {\n    return (\n      (attr === 'value' && acceptValue(tag)) && type !== 'button' ||\n      (attr === 'selected' && tag === 'option') ||\n      (attr === 'checked' && tag === 'input') ||\n      (attr === 'muted' && tag === 'video')\n    )\n  };\n\n  var isEnumeratedAttr = makeMap('contenteditable,draggable,spellcheck');\n\n  var isValidContentEditableValue = makeMap('events,caret,typing,plaintext-only');\n\n  var convertEnumeratedValue = function (key, value) {\n    return isFalsyAttrValue(value) || value === 'false'\n      ? 'false'\n      // allow arbitrary string value for contenteditable\n      : key === 'contenteditable' && isValidContentEditableValue(value)\n        ? value\n        : 'true'\n  };\n\n  var isBooleanAttr = makeMap(\n    'allowfullscreen,async,autofocus,autoplay,checked,compact,controls,declare,' +\n    'default,defaultchecked,defaultmuted,defaultselected,defer,disabled,' +\n    'enabled,formnovalidate,hidden,indeterminate,inert,ismap,itemscope,loop,multiple,' +\n    'muted,nohref,noresize,noshade,novalidate,nowrap,open,pauseonexit,readonly,' +\n    'required,reversed,scoped,seamless,selected,sortable,translate,' +\n    'truespeed,typemustmatch,visible'\n  );\n\n  var xlinkNS = 'http://www.w3.org/1999/xlink';\n\n  var isXlink = function (name) {\n    return name.charAt(5) === ':' && name.slice(0, 5) === 'xlink'\n  };\n\n  var getXlinkProp = function (name) {\n    return isXlink(name) ? name.slice(6, name.length) : ''\n  };\n\n  var isFalsyAttrValue = function (val) {\n    return val == null || val === false\n  };\n\n  /*  */\n\n  function genClassForVnode (vnode) {\n    var data = vnode.data;\n    var parentNode = vnode;\n    var childNode = vnode;\n    while (isDef(childNode.componentInstance)) {\n      childNode = childNode.componentInstance._vnode;\n      if (childNode && childNode.data) {\n        data = mergeClassData(childNode.data, data);\n      }\n    }\n    while (isDef(parentNode = parentNode.parent)) {\n      if (parentNode && parentNode.data) {\n        data = mergeClassData(data, parentNode.data);\n      }\n    }\n    return renderClass(data.staticClass, data.class)\n  }\n\n  function mergeClassData (child, parent) {\n    return {\n      staticClass: concat(child.staticClass, parent.staticClass),\n      class: isDef(child.class)\n        ? [child.class, parent.class]\n        : parent.class\n    }\n  }\n\n  function renderClass (\n    staticClass,\n    dynamicClass\n  ) {\n    if (isDef(staticClass) || isDef(dynamicClass)) {\n      return concat(staticClass, stringifyClass(dynamicClass))\n    }\n    /* istanbul ignore next */\n    return ''\n  }\n\n  function concat (a, b) {\n    return a ? b ? (a + ' ' + b) : a : (b || '')\n  }\n\n  function stringifyClass (value) {\n    if (Array.isArray(value)) {\n      return stringifyArray(value)\n    }\n    if (isObject(value)) {\n      return stringifyObject(value)\n    }\n    if (typeof value === 'string') {\n      return value\n    }\n    /* istanbul ignore next */\n    return ''\n  }\n\n  function stringifyArray (value) {\n    var res = '';\n    var stringified;\n    for (var i = 0, l = value.length; i < l; i++) {\n      if (isDef(stringified = stringifyClass(value[i])) && stringified !== '') {\n        if (res) { res += ' '; }\n        res += stringified;\n      }\n    }\n    return res\n  }\n\n  function stringifyObject (value) {\n    var res = '';\n    for (var key in value) {\n      if (value[key]) {\n        if (res) { res += ' '; }\n        res += key;\n      }\n    }\n    return res\n  }\n\n  /*  */\n\n  var namespaceMap = {\n    svg: 'http://www.w3.org/2000/svg',\n    math: 'http://www.w3.org/1998/Math/MathML'\n  };\n\n  var isHTMLTag = makeMap(\n    'html,body,base,head,link,meta,style,title,' +\n    'address,article,aside,footer,header,h1,h2,h3,h4,h5,h6,hgroup,nav,section,' +\n    'div,dd,dl,dt,figcaption,figure,picture,hr,img,li,main,ol,p,pre,ul,' +\n    'a,b,abbr,bdi,bdo,br,cite,code,data,dfn,em,i,kbd,mark,q,rp,rt,rtc,ruby,' +\n    's,samp,small,span,strong,sub,sup,time,u,var,wbr,area,audio,map,track,video,' +\n    'embed,object,param,source,canvas,script,noscript,del,ins,' +\n    'caption,col,colgroup,table,thead,tbody,td,th,tr,' +\n    'button,datalist,fieldset,form,input,label,legend,meter,optgroup,option,' +\n    'output,progress,select,textarea,' +\n    'details,dialog,menu,menuitem,summary,' +\n    'content,element,shadow,template,blockquote,iframe,tfoot'\n  );\n\n  // this map is intentionally selective, only covering SVG elements that may\n  // contain child elements.\n  var isSVG = makeMap(\n    'svg,animate,circle,clippath,cursor,defs,desc,ellipse,filter,font-face,' +\n    'foreignObject,g,glyph,image,line,marker,mask,missing-glyph,path,pattern,' +\n    'polygon,polyline,rect,switch,symbol,text,textpath,tspan,use,view',\n    true\n  );\n\n  var isPreTag = function (tag) { return tag === 'pre'; };\n\n  var isReservedTag = function (tag) {\n    return isHTMLTag(tag) || isSVG(tag)\n  };\n\n  function getTagNamespace (tag) {\n    if (isSVG(tag)) {\n      return 'svg'\n    }\n    // basic support for MathML\n    // note it doesn't support other MathML elements being component roots\n    if (tag === 'math') {\n      return 'math'\n    }\n  }\n\n  var unknownElementCache = Object.create(null);\n  function isUnknownElement (tag) {\n    /* istanbul ignore if */\n    if (!inBrowser) {\n      return true\n    }\n    if (isReservedTag(tag)) {\n      return false\n    }\n    tag = tag.toLowerCase();\n    /* istanbul ignore if */\n    if (unknownElementCache[tag] != null) {\n      return unknownElementCache[tag]\n    }\n    var el = document.createElement(tag);\n    if (tag.indexOf('-') > -1) {\n      // http://stackoverflow.com/a/28210364/1070244\n      return (unknownElementCache[tag] = (\n        el.constructor === window.HTMLUnknownElement ||\n        el.constructor === window.HTMLElement\n      ))\n    } else {\n      return (unknownElementCache[tag] = /HTMLUnknownElement/.test(el.toString()))\n    }\n  }\n\n  var isTextInputType = makeMap('text,number,password,search,email,tel,url');\n\n  /*  */\n\n  /**\n   * Query an element selector if it's not an element already.\n   */\n  function query (el) {\n    if (typeof el === 'string') {\n      var selected = document.querySelector(el);\n      if (!selected) {\n        warn(\n          'Cannot find element: ' + el\n        );\n        return document.createElement('div')\n      }\n      return selected\n    } else {\n      return el\n    }\n  }\n\n  /*  */\n\n  function createElement$1 (tagName, vnode) {\n    var elm = document.createElement(tagName);\n    if (tagName !== 'select') {\n      return elm\n    }\n    // false or null will remove the attribute but undefined will not\n    if (vnode.data && vnode.data.attrs && vnode.data.attrs.multiple !== undefined) {\n      elm.setAttribute('multiple', 'multiple');\n    }\n    return elm\n  }\n\n  function createElementNS (namespace, tagName) {\n    return document.createElementNS(namespaceMap[namespace], tagName)\n  }\n\n  function createTextNode (text) {\n    return document.createTextNode(text)\n  }\n\n  function createComment (text) {\n    return document.createComment(text)\n  }\n\n  function insertBefore (parentNode, newNode, referenceNode) {\n    parentNode.insertBefore(newNode, referenceNode);\n  }\n\n  function removeChild (node, child) {\n    node.removeChild(child);\n  }\n\n  function appendChild (node, child) {\n    node.appendChild(child);\n  }\n\n  function parentNode (node) {\n    return node.parentNode\n  }\n\n  function nextSibling (node) {\n    return node.nextSibling\n  }\n\n  function tagName (node) {\n    return node.tagName\n  }\n\n  function setTextContent (node, text) {\n    node.textContent = text;\n  }\n\n  function setStyleScope (node, scopeId) {\n    node.setAttribute(scopeId, '');\n  }\n\n  var nodeOps = /*#__PURE__*/Object.freeze({\n    createElement: createElement$1,\n    createElementNS: createElementNS,\n    createTextNode: createTextNode,\n    createComment: createComment,\n    insertBefore: insertBefore,\n    removeChild: removeChild,\n    appendChild: appendChild,\n    parentNode: parentNode,\n    nextSibling: nextSibling,\n    tagName: tagName,\n    setTextContent: setTextContent,\n    setStyleScope: setStyleScope\n  });\n\n  /*  */\n\n  var ref = {\n    create: function create (_, vnode) {\n      registerRef(vnode);\n    },\n    update: function update (oldVnode, vnode) {\n      if (oldVnode.data.ref !== vnode.data.ref) {\n        registerRef(oldVnode, true);\n        registerRef(vnode);\n      }\n    },\n    destroy: function destroy (vnode) {\n      registerRef(vnode, true);\n    }\n  };\n\n  function registerRef (vnode, isRemoval) {\n    var key = vnode.data.ref;\n    if (!isDef(key)) { return }\n\n    var vm = vnode.context;\n    var ref = vnode.componentInstance || vnode.elm;\n    var refs = vm.$refs;\n    if (isRemoval) {\n      if (Array.isArray(refs[key])) {\n        remove(refs[key], ref);\n      } else if (refs[key] === ref) {\n        refs[key] = undefined;\n      }\n    } else {\n      if (vnode.data.refInFor) {\n        if (!Array.isArray(refs[key])) {\n          refs[key] = [ref];\n        } else if (refs[key].indexOf(ref) < 0) {\n          // $flow-disable-line\n          refs[key].push(ref);\n        }\n      } else {\n        refs[key] = ref;\n      }\n    }\n  }\n\n  /**\n   * Virtual DOM patching algorithm based on Snabbdom by\n   * Simon Friis Vindum (@paldepind)\n   * Licensed under the MIT License\n   * https://github.com/paldepind/snabbdom/blob/master/LICENSE\n   *\n   * modified by Evan You (@yyx990803)\n   *\n   * Not type-checking this because this file is perf-critical and the cost\n   * of making flow understand it is not worth it.\n   */\n\n  var emptyNode = new VNode('', {}, []);\n\n  var hooks = ['create', 'activate', 'update', 'remove', 'destroy'];\n\n  function sameVnode (a, b) {\n    return (\n      a.key === b.key && (\n        (\n          a.tag === b.tag &&\n          a.isComment === b.isComment &&\n          isDef(a.data) === isDef(b.data) &&\n          sameInputType(a, b)\n        ) || (\n          isTrue(a.isAsyncPlaceholder) &&\n          a.asyncFactory === b.asyncFactory &&\n          isUndef(b.asyncFactory.error)\n        )\n      )\n    )\n  }\n\n  function sameInputType (a, b) {\n    if (a.tag !== 'input') { return true }\n    var i;\n    var typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type;\n    var typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type;\n    return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB)\n  }\n\n  function createKeyToOldIdx (children, beginIdx, endIdx) {\n    var i, key;\n    var map = {};\n    for (i = beginIdx; i <= endIdx; ++i) {\n      key = children[i].key;\n      if (isDef(key)) { map[key] = i; }\n    }\n    return map\n  }\n\n  function createPatchFunction (backend) {\n    var i, j;\n    var cbs = {};\n\n    var modules = backend.modules;\n    var nodeOps = backend.nodeOps;\n\n    for (i = 0; i < hooks.length; ++i) {\n      cbs[hooks[i]] = [];\n      for (j = 0; j < modules.length; ++j) {\n        if (isDef(modules[j][hooks[i]])) {\n          cbs[hooks[i]].push(modules[j][hooks[i]]);\n        }\n      }\n    }\n\n    function emptyNodeAt (elm) {\n      return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)\n    }\n\n    function createRmCb (childElm, listeners) {\n      function remove$$1 () {\n        if (--remove$$1.listeners === 0) {\n          removeNode(childElm);\n        }\n      }\n      remove$$1.listeners = listeners;\n      return remove$$1\n    }\n\n    function removeNode (el) {\n      var parent = nodeOps.parentNode(el);\n      // element may have already been removed due to v-html / v-text\n      if (isDef(parent)) {\n        nodeOps.removeChild(parent, el);\n      }\n    }\n\n    function isUnknownElement$$1 (vnode, inVPre) {\n      return (\n        !inVPre &&\n        !vnode.ns &&\n        !(\n          config.ignoredElements.length &&\n          config.ignoredElements.some(function (ignore) {\n            return isRegExp(ignore)\n              ? ignore.test(vnode.tag)\n              : ignore === vnode.tag\n          })\n        ) &&\n        config.isUnknownElement(vnode.tag)\n      )\n    }\n\n    var creatingElmInVPre = 0;\n\n    function createElm (\n      vnode,\n      insertedVnodeQueue,\n      parentElm,\n      refElm,\n      nested,\n      ownerArray,\n      index\n    ) {\n      if (isDef(vnode.elm) && isDef(ownerArray)) {\n        // This vnode was used in a previous render!\n        // now it's used as a new node, overwriting its elm would cause\n        // potential patch errors down the road when it's used as an insertion\n        // reference node. Instead, we clone the node on-demand before creating\n        // associated DOM element for it.\n        vnode = ownerArray[index] = cloneVNode(vnode);\n      }\n\n      vnode.isRootInsert = !nested; // for transition enter check\n      if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {\n        return\n      }\n\n      var data = vnode.data;\n      var children = vnode.children;\n      var tag = vnode.tag;\n      if (isDef(tag)) {\n        {\n          if (data && data.pre) {\n            creatingElmInVPre++;\n          }\n          if (isUnknownElement$$1(vnode, creatingElmInVPre)) {\n            warn(\n              'Unknown custom element: <' + tag + '> - did you ' +\n              'register the component correctly? For recursive components, ' +\n              'make sure to provide the \"name\" option.',\n              vnode.context\n            );\n          }\n        }\n\n        vnode.elm = vnode.ns\n          ? nodeOps.createElementNS(vnode.ns, tag)\n          : nodeOps.createElement(tag, vnode);\n        setScope(vnode);\n\n        /* istanbul ignore if */\n        {\n          createChildren(vnode, children, insertedVnodeQueue);\n          if (isDef(data)) {\n            invokeCreateHooks(vnode, insertedVnodeQueue);\n          }\n          insert(parentElm, vnode.elm, refElm);\n        }\n\n        if (data && data.pre) {\n          creatingElmInVPre--;\n        }\n      } else if (isTrue(vnode.isComment)) {\n        vnode.elm = nodeOps.createComment(vnode.text);\n        insert(parentElm, vnode.elm, refElm);\n      } else {\n        vnode.elm = nodeOps.createTextNode(vnode.text);\n        insert(parentElm, vnode.elm, refElm);\n      }\n    }\n\n    function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {\n      var i = vnode.data;\n      if (isDef(i)) {\n        var isReactivated = isDef(vnode.componentInstance) && i.keepAlive;\n        if (isDef(i = i.hook) && isDef(i = i.init)) {\n          i(vnode, false /* hydrating */);\n        }\n        // after calling the init hook, if the vnode is a child component\n        // it should've created a child instance and mounted it. the child\n        // component also has set the placeholder vnode's elm.\n        // in that case we can just return the element and be done.\n        if (isDef(vnode.componentInstance)) {\n          initComponent(vnode, insertedVnodeQueue);\n          insert(parentElm, vnode.elm, refElm);\n          if (isTrue(isReactivated)) {\n            reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);\n          }\n          return true\n        }\n      }\n    }\n\n    function initComponent (vnode, insertedVnodeQueue) {\n      if (isDef(vnode.data.pendingInsert)) {\n        insertedVnodeQueue.push.apply(insertedVnodeQueue, vnode.data.pendingInsert);\n        vnode.data.pendingInsert = null;\n      }\n      vnode.elm = vnode.componentInstance.$el;\n      if (isPatchable(vnode)) {\n        invokeCreateHooks(vnode, insertedVnodeQueue);\n        setScope(vnode);\n      } else {\n        // empty component root.\n        // skip all element-related modules except for ref (#3455)\n        registerRef(vnode);\n        // make sure to invoke the insert hook\n        insertedVnodeQueue.push(vnode);\n      }\n    }\n\n    function reactivateComponent (vnode, insertedVnodeQueue, parentElm, refElm) {\n      var i;\n      // hack for #4339: a reactivated component with inner transition\n      // does not trigger because the inner node's created hooks are not called\n      // again. It's not ideal to involve module-specific logic in here but\n      // there doesn't seem to be a better way to do it.\n      var innerNode = vnode;\n      while (innerNode.componentInstance) {\n        innerNode = innerNode.componentInstance._vnode;\n        if (isDef(i = innerNode.data) && isDef(i = i.transition)) {\n          for (i = 0; i < cbs.activate.length; ++i) {\n            cbs.activate[i](emptyNode, innerNode);\n          }\n          insertedVnodeQueue.push(innerNode);\n          break\n        }\n      }\n      // unlike a newly created component,\n      // a reactivated keep-alive component doesn't insert itself\n      insert(parentElm, vnode.elm, refElm);\n    }\n\n    function insert (parent, elm, ref$$1) {\n      if (isDef(parent)) {\n        if (isDef(ref$$1)) {\n          if (nodeOps.parentNode(ref$$1) === parent) {\n            nodeOps.insertBefore(parent, elm, ref$$1);\n          }\n        } else {\n          nodeOps.appendChild(parent, elm);\n        }\n      }\n    }\n\n    function createChildren (vnode, children, insertedVnodeQueue) {\n      if (Array.isArray(children)) {\n        {\n          checkDuplicateKeys(children);\n        }\n        for (var i = 0; i < children.length; ++i) {\n          createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i);\n        }\n      } else if (isPrimitive(vnode.text)) {\n        nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)));\n      }\n    }\n\n    function isPatchable (vnode) {\n      while (vnode.componentInstance) {\n        vnode = vnode.componentInstance._vnode;\n      }\n      return isDef(vnode.tag)\n    }\n\n    function invokeCreateHooks (vnode, insertedVnodeQueue) {\n      for (var i$1 = 0; i$1 < cbs.create.length; ++i$1) {\n        cbs.create[i$1](emptyNode, vnode);\n      }\n      i = vnode.data.hook; // Reuse variable\n      if (isDef(i)) {\n        if (isDef(i.create)) { i.create(emptyNode, vnode); }\n        if (isDef(i.insert)) { insertedVnodeQueue.push(vnode); }\n      }\n    }\n\n    // set scope id attribute for scoped CSS.\n    // this is implemented as a special case to avoid the overhead\n    // of going through the normal attribute patching process.\n    function setScope (vnode) {\n      var i;\n      if (isDef(i = vnode.fnScopeId)) {\n        nodeOps.setStyleScope(vnode.elm, i);\n      } else {\n        var ancestor = vnode;\n        while (ancestor) {\n          if (isDef(i = ancestor.context) && isDef(i = i.$options._scopeId)) {\n            nodeOps.setStyleScope(vnode.elm, i);\n          }\n          ancestor = ancestor.parent;\n        }\n      }\n      // for slot content they should also get the scopeId from the host instance.\n      if (isDef(i = activeInstance) &&\n        i !== vnode.context &&\n        i !== vnode.fnContext &&\n        isDef(i = i.$options._scopeId)\n      ) {\n        nodeOps.setStyleScope(vnode.elm, i);\n      }\n    }\n\n    function addVnodes (parentElm, refElm, vnodes, startIdx, endIdx, insertedVnodeQueue) {\n      for (; startIdx <= endIdx; ++startIdx) {\n        createElm(vnodes[startIdx], insertedVnodeQueue, parentElm, refElm, false, vnodes, startIdx);\n      }\n    }\n\n    function invokeDestroyHook (vnode) {\n      var i, j;\n      var data = vnode.data;\n      if (isDef(data)) {\n        if (isDef(i = data.hook) && isDef(i = i.destroy)) { i(vnode); }\n        for (i = 0; i < cbs.destroy.length; ++i) { cbs.destroy[i](vnode); }\n      }\n      if (isDef(i = vnode.children)) {\n        for (j = 0; j < vnode.children.length; ++j) {\n          invokeDestroyHook(vnode.children[j]);\n        }\n      }\n    }\n\n    function removeVnodes (parentElm, vnodes, startIdx, endIdx) {\n      for (; startIdx <= endIdx; ++startIdx) {\n        var ch = vnodes[startIdx];\n        if (isDef(ch)) {\n          if (isDef(ch.tag)) {\n            removeAndInvokeRemoveHook(ch);\n            invokeDestroyHook(ch);\n          } else { // Text node\n            removeNode(ch.elm);\n          }\n        }\n      }\n    }\n\n    function removeAndInvokeRemoveHook (vnode, rm) {\n      if (isDef(rm) || isDef(vnode.data)) {\n        var i;\n        var listeners = cbs.remove.length + 1;\n        if (isDef(rm)) {\n          // we have a recursively passed down rm callback\n          // increase the listeners count\n          rm.listeners += listeners;\n        } else {\n          // directly removing\n          rm = createRmCb(vnode.elm, listeners);\n        }\n        // recursively invoke hooks on child component root node\n        if (isDef(i = vnode.componentInstance) && isDef(i = i._vnode) && isDef(i.data)) {\n          removeAndInvokeRemoveHook(i, rm);\n        }\n        for (i = 0; i < cbs.remove.length; ++i) {\n          cbs.remove[i](vnode, rm);\n        }\n        if (isDef(i = vnode.data.hook) && isDef(i = i.remove)) {\n          i(vnode, rm);\n        } else {\n          rm();\n        }\n      } else {\n        removeNode(vnode.elm);\n      }\n    }\n\n    function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {\n      var oldStartIdx = 0;\n      var newStartIdx = 0;\n      var oldEndIdx = oldCh.length - 1;\n      var oldStartVnode = oldCh[0];\n      var oldEndVnode = oldCh[oldEndIdx];\n      var newEndIdx = newCh.length - 1;\n      var newStartVnode = newCh[0];\n      var newEndVnode = newCh[newEndIdx];\n      var oldKeyToIdx, idxInOld, vnodeToMove, refElm;\n\n      // removeOnly is a special flag used only by <transition-group>\n      // to ensure removed elements stay in correct relative positions\n      // during leaving transitions\n      var canMove = !removeOnly;\n\n      {\n        checkDuplicateKeys(newCh);\n      }\n\n      while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {\n        if (isUndef(oldStartVnode)) {\n          oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left\n        } else if (isUndef(oldEndVnode)) {\n          oldEndVnode = oldCh[--oldEndIdx];\n        } else if (sameVnode(oldStartVnode, newStartVnode)) {\n          patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);\n          oldStartVnode = oldCh[++oldStartIdx];\n          newStartVnode = newCh[++newStartIdx];\n        } else if (sameVnode(oldEndVnode, newEndVnode)) {\n          patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);\n          oldEndVnode = oldCh[--oldEndIdx];\n          newEndVnode = newCh[--newEndIdx];\n        } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right\n          patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);\n          canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));\n          oldStartVnode = oldCh[++oldStartIdx];\n          newEndVnode = newCh[--newEndIdx];\n        } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left\n          patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);\n          canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);\n          oldEndVnode = oldCh[--oldEndIdx];\n          newStartVnode = newCh[++newStartIdx];\n        } else {\n          if (isUndef(oldKeyToIdx)) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); }\n          idxInOld = isDef(newStartVnode.key)\n            ? oldKeyToIdx[newStartVnode.key]\n            : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);\n          if (isUndef(idxInOld)) { // New element\n            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);\n          } else {\n            vnodeToMove = oldCh[idxInOld];\n            if (sameVnode(vnodeToMove, newStartVnode)) {\n              patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);\n              oldCh[idxInOld] = undefined;\n              canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);\n            } else {\n              // same key but different element. treat as new element\n              createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);\n            }\n          }\n          newStartVnode = newCh[++newStartIdx];\n        }\n      }\n      if (oldStartIdx > oldEndIdx) {\n        refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;\n        addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);\n      } else if (newStartIdx > newEndIdx) {\n        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);\n      }\n    }\n\n    function checkDuplicateKeys (children) {\n      var seenKeys = {};\n      for (var i = 0; i < children.length; i++) {\n        var vnode = children[i];\n        var key = vnode.key;\n        if (isDef(key)) {\n          if (seenKeys[key]) {\n            warn(\n              (\"Duplicate keys detected: '\" + key + \"'. This may cause an update error.\"),\n              vnode.context\n            );\n          } else {\n            seenKeys[key] = true;\n          }\n        }\n      }\n    }\n\n    function findIdxInOld (node, oldCh, start, end) {\n      for (var i = start; i < end; i++) {\n        var c = oldCh[i];\n        if (isDef(c) && sameVnode(node, c)) { return i }\n      }\n    }\n\n    function patchVnode (\n      oldVnode,\n      vnode,\n      insertedVnodeQueue,\n      ownerArray,\n      index,\n      removeOnly\n    ) {\n      if (oldVnode === vnode) {\n        return\n      }\n\n      if (isDef(vnode.elm) && isDef(ownerArray)) {\n        // clone reused vnode\n        vnode = ownerArray[index] = cloneVNode(vnode);\n      }\n\n      var elm = vnode.elm = oldVnode.elm;\n\n      if (isTrue(oldVnode.isAsyncPlaceholder)) {\n        if (isDef(vnode.asyncFactory.resolved)) {\n          hydrate(oldVnode.elm, vnode, insertedVnodeQueue);\n        } else {\n          vnode.isAsyncPlaceholder = true;\n        }\n        return\n      }\n\n      // reuse element for static trees.\n      // note we only do this if the vnode is cloned -\n      // if the new node is not cloned it means the render functions have been\n      // reset by the hot-reload-api and we need to do a proper re-render.\n      if (isTrue(vnode.isStatic) &&\n        isTrue(oldVnode.isStatic) &&\n        vnode.key === oldVnode.key &&\n        (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))\n      ) {\n        vnode.componentInstance = oldVnode.componentInstance;\n        return\n      }\n\n      var i;\n      var data = vnode.data;\n      if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {\n        i(oldVnode, vnode);\n      }\n\n      var oldCh = oldVnode.children;\n      var ch = vnode.children;\n      if (isDef(data) && isPatchable(vnode)) {\n        for (i = 0; i < cbs.update.length; ++i) { cbs.update[i](oldVnode, vnode); }\n        if (isDef(i = data.hook) && isDef(i = i.update)) { i(oldVnode, vnode); }\n      }\n      if (isUndef(vnode.text)) {\n        if (isDef(oldCh) && isDef(ch)) {\n          if (oldCh !== ch) { updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly); }\n        } else if (isDef(ch)) {\n          {\n            checkDuplicateKeys(ch);\n          }\n          if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, ''); }\n          addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);\n        } else if (isDef(oldCh)) {\n          removeVnodes(elm, oldCh, 0, oldCh.length - 1);\n        } else if (isDef(oldVnode.text)) {\n          nodeOps.setTextContent(elm, '');\n        }\n      } else if (oldVnode.text !== vnode.text) {\n        nodeOps.setTextContent(elm, vnode.text);\n      }\n      if (isDef(data)) {\n        if (isDef(i = data.hook) && isDef(i = i.postpatch)) { i(oldVnode, vnode); }\n      }\n    }\n\n    function invokeInsertHook (vnode, queue, initial) {\n      // delay insert hooks for component root nodes, invoke them after the\n      // element is really inserted\n      if (isTrue(initial) && isDef(vnode.parent)) {\n        vnode.parent.data.pendingInsert = queue;\n      } else {\n        for (var i = 0; i < queue.length; ++i) {\n          queue[i].data.hook.insert(queue[i]);\n        }\n      }\n    }\n\n    var hydrationBailed = false;\n    // list of modules that can skip create hook during hydration because they\n    // are already rendered on the client or has no need for initialization\n    // Note: style is excluded because it relies on initial clone for future\n    // deep updates (#7063).\n    var isRenderedModule = makeMap('attrs,class,staticClass,staticStyle,key');\n\n    // Note: this is a browser-only function so we can assume elms are DOM nodes.\n    function hydrate (elm, vnode, insertedVnodeQueue, inVPre) {\n      var i;\n      var tag = vnode.tag;\n      var data = vnode.data;\n      var children = vnode.children;\n      inVPre = inVPre || (data && data.pre);\n      vnode.elm = elm;\n\n      if (isTrue(vnode.isComment) && isDef(vnode.asyncFactory)) {\n        vnode.isAsyncPlaceholder = true;\n        return true\n      }\n      // assert node match\n      {\n        if (!assertNodeMatch(elm, vnode, inVPre)) {\n          return false\n        }\n      }\n      if (isDef(data)) {\n        if (isDef(i = data.hook) && isDef(i = i.init)) { i(vnode, true /* hydrating */); }\n        if (isDef(i = vnode.componentInstance)) {\n          // child component. it should have hydrated its own tree.\n          initComponent(vnode, insertedVnodeQueue);\n          return true\n        }\n      }\n      if (isDef(tag)) {\n        if (isDef(children)) {\n          // empty element, allow client to pick up and populate children\n          if (!elm.hasChildNodes()) {\n            createChildren(vnode, children, insertedVnodeQueue);\n          } else {\n            // v-html and domProps: innerHTML\n            if (isDef(i = data) && isDef(i = i.domProps) && isDef(i = i.innerHTML)) {\n              if (i !== elm.innerHTML) {\n                /* istanbul ignore if */\n                if (typeof console !== 'undefined' &&\n                  !hydrationBailed\n                ) {\n                  hydrationBailed = true;\n                  console.warn('Parent: ', elm);\n                  console.warn('server innerHTML: ', i);\n                  console.warn('client innerHTML: ', elm.innerHTML);\n                }\n                return false\n              }\n            } else {\n              // iterate and compare children lists\n              var childrenMatch = true;\n              var childNode = elm.firstChild;\n              for (var i$1 = 0; i$1 < children.length; i$1++) {\n                if (!childNode || !hydrate(childNode, children[i$1], insertedVnodeQueue, inVPre)) {\n                  childrenMatch = false;\n                  break\n                }\n                childNode = childNode.nextSibling;\n              }\n              // if childNode is not null, it means the actual childNodes list is\n              // longer than the virtual children list.\n              if (!childrenMatch || childNode) {\n                /* istanbul ignore if */\n                if (typeof console !== 'undefined' &&\n                  !hydrationBailed\n                ) {\n                  hydrationBailed = true;\n                  console.warn('Parent: ', elm);\n                  console.warn('Mismatching childNodes vs. VNodes: ', elm.childNodes, children);\n                }\n                return false\n              }\n            }\n          }\n        }\n        if (isDef(data)) {\n          var fullInvoke = false;\n          for (var key in data) {\n            if (!isRenderedModule(key)) {\n              fullInvoke = true;\n              invokeCreateHooks(vnode, insertedVnodeQueue);\n              break\n            }\n          }\n          if (!fullInvoke && data['class']) {\n            // ensure collecting deps for deep class bindings for future updates\n            traverse(data['class']);\n          }\n        }\n      } else if (elm.data !== vnode.text) {\n        elm.data = vnode.text;\n      }\n      return true\n    }\n\n    function assertNodeMatch (node, vnode, inVPre) {\n      if (isDef(vnode.tag)) {\n        return vnode.tag.indexOf('vue-component') === 0 || (\n          !isUnknownElement$$1(vnode, inVPre) &&\n          vnode.tag.toLowerCase() === (node.tagName && node.tagName.toLowerCase())\n        )\n      } else {\n        return node.nodeType === (vnode.isComment ? 8 : 3)\n      }\n    }\n\n    return function patch (oldVnode, vnode, hydrating, removeOnly) {\n      if (isUndef(vnode)) {\n        if (isDef(oldVnode)) { invokeDestroyHook(oldVnode); }\n        return\n      }\n\n      var isInitialPatch = false;\n      var insertedVnodeQueue = [];\n\n      if (isUndef(oldVnode)) {\n        // empty mount (likely as component), create new root element\n        isInitialPatch = true;\n        createElm(vnode, insertedVnodeQueue);\n      } else {\n        var isRealElement = isDef(oldVnode.nodeType);\n        if (!isRealElement && sameVnode(oldVnode, vnode)) {\n          // patch existing root node\n          patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);\n        } else {\n          if (isRealElement) {\n            // mounting to a real element\n            // check if this is server-rendered content and if we can perform\n            // a successful hydration.\n            if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {\n              oldVnode.removeAttribute(SSR_ATTR);\n              hydrating = true;\n            }\n            if (isTrue(hydrating)) {\n              if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {\n                invokeInsertHook(vnode, insertedVnodeQueue, true);\n                return oldVnode\n              } else {\n                warn(\n                  'The client-side rendered virtual DOM tree is not matching ' +\n                  'server-rendered content. This is likely caused by incorrect ' +\n                  'HTML markup, for example nesting block-level elements inside ' +\n                  '<p>, or missing <tbody>. Bailing hydration and performing ' +\n                  'full client-side render.'\n                );\n              }\n            }\n            // either not server-rendered, or hydration failed.\n            // create an empty node and replace it\n            oldVnode = emptyNodeAt(oldVnode);\n          }\n\n          // replacing existing element\n          var oldElm = oldVnode.elm;\n          var parentElm = nodeOps.parentNode(oldElm);\n\n          // create new node\n          createElm(\n            vnode,\n            insertedVnodeQueue,\n            // extremely rare edge case: do not insert if old element is in a\n            // leaving transition. Only happens when combining transition +\n            // keep-alive + HOCs. (#4590)\n            oldElm._leaveCb ? null : parentElm,\n            nodeOps.nextSibling(oldElm)\n          );\n\n          // update parent placeholder node element, recursively\n          if (isDef(vnode.parent)) {\n            var ancestor = vnode.parent;\n            var patchable = isPatchable(vnode);\n            while (ancestor) {\n              for (var i = 0; i < cbs.destroy.length; ++i) {\n                cbs.destroy[i](ancestor);\n              }\n              ancestor.elm = vnode.elm;\n              if (patchable) {\n                for (var i$1 = 0; i$1 < cbs.create.length; ++i$1) {\n                  cbs.create[i$1](emptyNode, ancestor);\n                }\n                // #6513\n                // invoke insert hooks that may have been merged by create hooks.\n                // e.g. for directives that uses the \"inserted\" hook.\n                var insert = ancestor.data.hook.insert;\n                if (insert.merged) {\n                  // start at index 1 to avoid re-invoking component mounted hook\n                  for (var i$2 = 1; i$2 < insert.fns.length; i$2++) {\n                    insert.fns[i$2]();\n                  }\n                }\n              } else {\n                registerRef(ancestor);\n              }\n              ancestor = ancestor.parent;\n            }\n          }\n\n          // destroy old node\n          if (isDef(parentElm)) {\n            removeVnodes(parentElm, [oldVnode], 0, 0);\n          } else if (isDef(oldVnode.tag)) {\n            invokeDestroyHook(oldVnode);\n          }\n        }\n      }\n\n      invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);\n      return vnode.elm\n    }\n  }\n\n  /*  */\n\n  var directives = {\n    create: updateDirectives,\n    update: updateDirectives,\n    destroy: function unbindDirectives (vnode) {\n      updateDirectives(vnode, emptyNode);\n    }\n  };\n\n  function updateDirectives (oldVnode, vnode) {\n    if (oldVnode.data.directives || vnode.data.directives) {\n      _update(oldVnode, vnode);\n    }\n  }\n\n  function _update (oldVnode, vnode) {\n    var isCreate = oldVnode === emptyNode;\n    var isDestroy = vnode === emptyNode;\n    var oldDirs = normalizeDirectives$1(oldVnode.data.directives, oldVnode.context);\n    var newDirs = normalizeDirectives$1(vnode.data.directives, vnode.context);\n\n    var dirsWithInsert = [];\n    var dirsWithPostpatch = [];\n\n    var key, oldDir, dir;\n    for (key in newDirs) {\n      oldDir = oldDirs[key];\n      dir = newDirs[key];\n      if (!oldDir) {\n        // new directive, bind\n        callHook$1(dir, 'bind', vnode, oldVnode);\n        if (dir.def && dir.def.inserted) {\n          dirsWithInsert.push(dir);\n        }\n      } else {\n        // existing directive, update\n        dir.oldValue = oldDir.value;\n        dir.oldArg = oldDir.arg;\n        callHook$1(dir, 'update', vnode, oldVnode);\n        if (dir.def && dir.def.componentUpdated) {\n          dirsWithPostpatch.push(dir);\n        }\n      }\n    }\n\n    if (dirsWithInsert.length) {\n      var callInsert = function () {\n        for (var i = 0; i < dirsWithInsert.length; i++) {\n          callHook$1(dirsWithInsert[i], 'inserted', vnode, oldVnode);\n        }\n      };\n      if (isCreate) {\n        mergeVNodeHook(vnode, 'insert', callInsert);\n      } else {\n        callInsert();\n      }\n    }\n\n    if (dirsWithPostpatch.length) {\n      mergeVNodeHook(vnode, 'postpatch', function () {\n        for (var i = 0; i < dirsWithPostpatch.length; i++) {\n          callHook$1(dirsWithPostpatch[i], 'componentUpdated', vnode, oldVnode);\n        }\n      });\n    }\n\n    if (!isCreate) {\n      for (key in oldDirs) {\n        if (!newDirs[key]) {\n          // no longer present, unbind\n          callHook$1(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestroy);\n        }\n      }\n    }\n  }\n\n  var emptyModifiers = Object.create(null);\n\n  function normalizeDirectives$1 (\n    dirs,\n    vm\n  ) {\n    var res = Object.create(null);\n    if (!dirs) {\n      // $flow-disable-line\n      return res\n    }\n    var i, dir;\n    for (i = 0; i < dirs.length; i++) {\n      dir = dirs[i];\n      if (!dir.modifiers) {\n        // $flow-disable-line\n        dir.modifiers = emptyModifiers;\n      }\n      res[getRawDirName(dir)] = dir;\n      dir.def = resolveAsset(vm.$options, 'directives', dir.name, true);\n    }\n    // $flow-disable-line\n    return res\n  }\n\n  function getRawDirName (dir) {\n    return dir.rawName || ((dir.name) + \".\" + (Object.keys(dir.modifiers || {}).join('.')))\n  }\n\n  function callHook$1 (dir, hook, vnode, oldVnode, isDestroy) {\n    var fn = dir.def && dir.def[hook];\n    if (fn) {\n      try {\n        fn(vnode.elm, dir, vnode, oldVnode, isDestroy);\n      } catch (e) {\n        handleError(e, vnode.context, (\"directive \" + (dir.name) + \" \" + hook + \" hook\"));\n      }\n    }\n  }\n\n  var baseModules = [\n    ref,\n    directives\n  ];\n\n  /*  */\n\n  function updateAttrs (oldVnode, vnode) {\n    var opts = vnode.componentOptions;\n    if (isDef(opts) && opts.Ctor.options.inheritAttrs === false) {\n      return\n    }\n    if (isUndef(oldVnode.data.attrs) && isUndef(vnode.data.attrs)) {\n      return\n    }\n    var key, cur, old;\n    var elm = vnode.elm;\n    var oldAttrs = oldVnode.data.attrs || {};\n    var attrs = vnode.data.attrs || {};\n    // clone observed objects, as the user probably wants to mutate it\n    if (isDef(attrs.__ob__)) {\n      attrs = vnode.data.attrs = extend({}, attrs);\n    }\n\n    for (key in attrs) {\n      cur = attrs[key];\n      old = oldAttrs[key];\n      if (old !== cur) {\n        setAttr(elm, key, cur);\n      }\n    }\n    // #4391: in IE9, setting type can reset value for input[type=radio]\n    // #6666: IE/Edge forces progress value down to 1 before setting a max\n    /* istanbul ignore if */\n    if ((isIE || isEdge) && attrs.value !== oldAttrs.value) {\n      setAttr(elm, 'value', attrs.value);\n    }\n    for (key in oldAttrs) {\n      if (isUndef(attrs[key])) {\n        if (isXlink(key)) {\n          elm.removeAttributeNS(xlinkNS, getXlinkProp(key));\n        } else if (!isEnumeratedAttr(key)) {\n          elm.removeAttribute(key);\n        }\n      }\n    }\n  }\n\n  function setAttr (el, key, value) {\n    if (el.tagName.indexOf('-') > -1) {\n      baseSetAttr(el, key, value);\n    } else if (isBooleanAttr(key)) {\n      // set attribute for blank value\n      // e.g. <option disabled>Select one</option>\n      if (isFalsyAttrValue(value)) {\n        el.removeAttribute(key);\n      } else {\n        // technically allowfullscreen is a boolean attribute for <iframe>,\n        // but Flash expects a value of \"true\" when used on <embed> tag\n        value = key === 'allowfullscreen' && el.tagName === 'EMBED'\n          ? 'true'\n          : key;\n        el.setAttribute(key, value);\n      }\n    } else if (isEnumeratedAttr(key)) {\n      el.setAttribute(key, convertEnumeratedValue(key, value));\n    } else if (isXlink(key)) {\n      if (isFalsyAttrValue(value)) {\n        el.removeAttributeNS(xlinkNS, getXlinkProp(key));\n      } else {\n        el.setAttributeNS(xlinkNS, key, value);\n      }\n    } else {\n      baseSetAttr(el, key, value);\n    }\n  }\n\n  function baseSetAttr (el, key, value) {\n    if (isFalsyAttrValue(value)) {\n      el.removeAttribute(key);\n    } else {\n      // #7138: IE10 & 11 fires input event when setting placeholder on\n      // <textarea>... block the first input event and remove the blocker\n      // immediately.\n      /* istanbul ignore if */\n      if (\n        isIE && !isIE9 &&\n        el.tagName === 'TEXTAREA' &&\n        key === 'placeholder' && value !== '' && !el.__ieph\n      ) {\n        var blocker = function (e) {\n          e.stopImmediatePropagation();\n          el.removeEventListener('input', blocker);\n        };\n        el.addEventListener('input', blocker);\n        // $flow-disable-line\n        el.__ieph = true; /* IE placeholder patched */\n      }\n      el.setAttribute(key, value);\n    }\n  }\n\n  var attrs = {\n    create: updateAttrs,\n    update: updateAttrs\n  };\n\n  /*  */\n\n  function updateClass (oldVnode, vnode) {\n    var el = vnode.elm;\n    var data = vnode.data;\n    var oldData = oldVnode.data;\n    if (\n      isUndef(data.staticClass) &&\n      isUndef(data.class) && (\n        isUndef(oldData) || (\n          isUndef(oldData.staticClass) &&\n          isUndef(oldData.class)\n        )\n      )\n    ) {\n      return\n    }\n\n    var cls = genClassForVnode(vnode);\n\n    // handle transition classes\n    var transitionClass = el._transitionClasses;\n    if (isDef(transitionClass)) {\n      cls = concat(cls, stringifyClass(transitionClass));\n    }\n\n    // set the class\n    if (cls !== el._prevClass) {\n      el.setAttribute('class', cls);\n      el._prevClass = cls;\n    }\n  }\n\n  var klass = {\n    create: updateClass,\n    update: updateClass\n  };\n\n  /*  */\n\n  var validDivisionCharRE = /[\\w).+\\-_$\\]]/;\n\n  function parseFilters (exp) {\n    var inSingle = false;\n    var inDouble = false;\n    var inTemplateString = false;\n    var inRegex = false;\n    var curly = 0;\n    var square = 0;\n    var paren = 0;\n    var lastFilterIndex = 0;\n    var c, prev, i, expression, filters;\n\n    for (i = 0; i < exp.length; i++) {\n      prev = c;\n      c = exp.charCodeAt(i);\n      if (inSingle) {\n        if (c === 0x27 && prev !== 0x5C) { inSingle = false; }\n      } else if (inDouble) {\n        if (c === 0x22 && prev !== 0x5C) { inDouble = false; }\n      } else if (inTemplateString) {\n        if (c === 0x60 && prev !== 0x5C) { inTemplateString = false; }\n      } else if (inRegex) {\n        if (c === 0x2f && prev !== 0x5C) { inRegex = false; }\n      } else if (\n        c === 0x7C && // pipe\n        exp.charCodeAt(i + 1) !== 0x7C &&\n        exp.charCodeAt(i - 1) !== 0x7C &&\n        !curly && !square && !paren\n      ) {\n        if (expression === undefined) {\n          // first filter, end of expression\n          lastFilterIndex = i + 1;\n          expression = exp.slice(0, i).trim();\n        } else {\n          pushFilter();\n        }\n      } else {\n        switch (c) {\n          case 0x22: inDouble = true; break         // \"\n          case 0x27: inSingle = true; break         // '\n          case 0x60: inTemplateString = true; break // `\n          case 0x28: paren++; break                 // (\n          case 0x29: paren--; break                 // )\n          case 0x5B: square++; break                // [\n          case 0x5D: square--; break                // ]\n          case 0x7B: curly++; break                 // {\n          case 0x7D: curly--; break                 // }\n        }\n        if (c === 0x2f) { // /\n          var j = i - 1;\n          var p = (void 0);\n          // find first non-whitespace prev char\n          for (; j >= 0; j--) {\n            p = exp.charAt(j);\n            if (p !== ' ') { break }\n          }\n          if (!p || !validDivisionCharRE.test(p)) {\n            inRegex = true;\n          }\n        }\n      }\n    }\n\n    if (expression === undefined) {\n      expression = exp.slice(0, i).trim();\n    } else if (lastFilterIndex !== 0) {\n      pushFilter();\n    }\n\n    function pushFilter () {\n      (filters || (filters = [])).push(exp.slice(lastFilterIndex, i).trim());\n      lastFilterIndex = i + 1;\n    }\n\n    if (filters) {\n      for (i = 0; i < filters.length; i++) {\n        expression = wrapFilter(expression, filters[i]);\n      }\n    }\n\n    return expression\n  }\n\n  function wrapFilter (exp, filter) {\n    var i = filter.indexOf('(');\n    if (i < 0) {\n      // _f: resolveFilter\n      return (\"_f(\\\"\" + filter + \"\\\")(\" + exp + \")\")\n    } else {\n      var name = filter.slice(0, i);\n      var args = filter.slice(i + 1);\n      return (\"_f(\\\"\" + name + \"\\\")(\" + exp + (args !== ')' ? ',' + args : args))\n    }\n  }\n\n  /*  */\n\n\n\n  /* eslint-disable no-unused-vars */\n  function baseWarn (msg, range) {\n    console.error((\"[Vue compiler]: \" + msg));\n  }\n  /* eslint-enable no-unused-vars */\n\n  function pluckModuleFunction (\n    modules,\n    key\n  ) {\n    return modules\n      ? modules.map(function (m) { return m[key]; }).filter(function (_) { return _; })\n      : []\n  }\n\n  function addProp (el, name, value, range, dynamic) {\n    (el.props || (el.props = [])).push(rangeSetItem({ name: name, value: value, dynamic: dynamic }, range));\n    el.plain = false;\n  }\n\n  function addAttr (el, name, value, range, dynamic) {\n    var attrs = dynamic\n      ? (el.dynamicAttrs || (el.dynamicAttrs = []))\n      : (el.attrs || (el.attrs = []));\n    attrs.push(rangeSetItem({ name: name, value: value, dynamic: dynamic }, range));\n    el.plain = false;\n  }\n\n  // add a raw attr (use this in preTransforms)\n  function addRawAttr (el, name, value, range) {\n    el.attrsMap[name] = value;\n    el.attrsList.push(rangeSetItem({ name: name, value: value }, range));\n  }\n\n  function addDirective (\n    el,\n    name,\n    rawName,\n    value,\n    arg,\n    isDynamicArg,\n    modifiers,\n    range\n  ) {\n    (el.directives || (el.directives = [])).push(rangeSetItem({\n      name: name,\n      rawName: rawName,\n      value: value,\n      arg: arg,\n      isDynamicArg: isDynamicArg,\n      modifiers: modifiers\n    }, range));\n    el.plain = false;\n  }\n\n  function prependModifierMarker (symbol, name, dynamic) {\n    return dynamic\n      ? (\"_p(\" + name + \",\\\"\" + symbol + \"\\\")\")\n      : symbol + name // mark the event as captured\n  }\n\n  function addHandler (\n    el,\n    name,\n    value,\n    modifiers,\n    important,\n    warn,\n    range,\n    dynamic\n  ) {\n    modifiers = modifiers || emptyObject;\n    // warn prevent and passive modifier\n    /* istanbul ignore if */\n    if (\n      warn &&\n      modifiers.prevent && modifiers.passive\n    ) {\n      warn(\n        'passive and prevent can\\'t be used together. ' +\n        'Passive handler can\\'t prevent default event.',\n        range\n      );\n    }\n\n    // normalize click.right and click.middle since they don't actually fire\n    // this is technically browser-specific, but at least for now browsers are\n    // the only target envs that have right/middle clicks.\n    if (modifiers.right) {\n      if (dynamic) {\n        name = \"(\" + name + \")==='click'?'contextmenu':(\" + name + \")\";\n      } else if (name === 'click') {\n        name = 'contextmenu';\n        delete modifiers.right;\n      }\n    } else if (modifiers.middle) {\n      if (dynamic) {\n        name = \"(\" + name + \")==='click'?'mouseup':(\" + name + \")\";\n      } else if (name === 'click') {\n        name = 'mouseup';\n      }\n    }\n\n    // check capture modifier\n    if (modifiers.capture) {\n      delete modifiers.capture;\n      name = prependModifierMarker('!', name, dynamic);\n    }\n    if (modifiers.once) {\n      delete modifiers.once;\n      name = prependModifierMarker('~', name, dynamic);\n    }\n    /* istanbul ignore if */\n    if (modifiers.passive) {\n      delete modifiers.passive;\n      name = prependModifierMarker('&', name, dynamic);\n    }\n\n    var events;\n    if (modifiers.native) {\n      delete modifiers.native;\n      events = el.nativeEvents || (el.nativeEvents = {});\n    } else {\n      events = el.events || (el.events = {});\n    }\n\n    var newHandler = rangeSetItem({ value: value.trim(), dynamic: dynamic }, range);\n    if (modifiers !== emptyObject) {\n      newHandler.modifiers = modifiers;\n    }\n\n    var handlers = events[name];\n    /* istanbul ignore if */\n    if (Array.isArray(handlers)) {\n      important ? handlers.unshift(newHandler) : handlers.push(newHandler);\n    } else if (handlers) {\n      events[name] = important ? [newHandler, handlers] : [handlers, newHandler];\n    } else {\n      events[name] = newHandler;\n    }\n\n    el.plain = false;\n  }\n\n  function getRawBindingAttr (\n    el,\n    name\n  ) {\n    return el.rawAttrsMap[':' + name] ||\n      el.rawAttrsMap['v-bind:' + name] ||\n      el.rawAttrsMap[name]\n  }\n\n  function getBindingAttr (\n    el,\n    name,\n    getStatic\n  ) {\n    var dynamicValue =\n      getAndRemoveAttr(el, ':' + name) ||\n      getAndRemoveAttr(el, 'v-bind:' + name);\n    if (dynamicValue != null) {\n      return parseFilters(dynamicValue)\n    } else if (getStatic !== false) {\n      var staticValue = getAndRemoveAttr(el, name);\n      if (staticValue != null) {\n        return JSON.stringify(staticValue)\n      }\n    }\n  }\n\n  // note: this only removes the attr from the Array (attrsList) so that it\n  // doesn't get processed by processAttrs.\n  // By default it does NOT remove it from the map (attrsMap) because the map is\n  // needed during codegen.\n  function getAndRemoveAttr (\n    el,\n    name,\n    removeFromMap\n  ) {\n    var val;\n    if ((val = el.attrsMap[name]) != null) {\n      var list = el.attrsList;\n      for (var i = 0, l = list.length; i < l; i++) {\n        if (list[i].name === name) {\n          list.splice(i, 1);\n          break\n        }\n      }\n    }\n    if (removeFromMap) {\n      delete el.attrsMap[name];\n    }\n    return val\n  }\n\n  function getAndRemoveAttrByRegex (\n    el,\n    name\n  ) {\n    var list = el.attrsList;\n    for (var i = 0, l = list.length; i < l; i++) {\n      var attr = list[i];\n      if (name.test(attr.name)) {\n        list.splice(i, 1);\n        return attr\n      }\n    }\n  }\n\n  function rangeSetItem (\n    item,\n    range\n  ) {\n    if (range) {\n      if (range.start != null) {\n        item.start = range.start;\n      }\n      if (range.end != null) {\n        item.end = range.end;\n      }\n    }\n    return item\n  }\n\n  /*  */\n\n  /**\n   * Cross-platform code generation for component v-model\n   */\n  function genComponentModel (\n    el,\n    value,\n    modifiers\n  ) {\n    var ref = modifiers || {};\n    var number = ref.number;\n    var trim = ref.trim;\n\n    var baseValueExpression = '$$v';\n    var valueExpression = baseValueExpression;\n    if (trim) {\n      valueExpression =\n        \"(typeof \" + baseValueExpression + \" === 'string'\" +\n        \"? \" + baseValueExpression + \".trim()\" +\n        \": \" + baseValueExpression + \")\";\n    }\n    if (number) {\n      valueExpression = \"_n(\" + valueExpression + \")\";\n    }\n    var assignment = genAssignmentCode(value, valueExpression);\n\n    el.model = {\n      value: (\"(\" + value + \")\"),\n      expression: JSON.stringify(value),\n      callback: (\"function (\" + baseValueExpression + \") {\" + assignment + \"}\")\n    };\n  }\n\n  /**\n   * Cross-platform codegen helper for generating v-model value assignment code.\n   */\n  function genAssignmentCode (\n    value,\n    assignment\n  ) {\n    var res = parseModel(value);\n    if (res.key === null) {\n      return (value + \"=\" + assignment)\n    } else {\n      return (\"$set(\" + (res.exp) + \", \" + (res.key) + \", \" + assignment + \")\")\n    }\n  }\n\n  /**\n   * Parse a v-model expression into a base path and a final key segment.\n   * Handles both dot-path and possible square brackets.\n   *\n   * Possible cases:\n   *\n   * - test\n   * - test[key]\n   * - test[test1[key]]\n   * - test[\"a\"][key]\n   * - xxx.test[a[a].test1[key]]\n   * - test.xxx.a[\"asa\"][test1[key]]\n   *\n   */\n\n  var len, str, chr, index$1, expressionPos, expressionEndPos;\n\n\n\n  function parseModel (val) {\n    // Fix https://github.com/vuejs/vue/pull/7730\n    // allow v-model=\"obj.val \" (trailing whitespace)\n    val = val.trim();\n    len = val.length;\n\n    if (val.indexOf('[') < 0 || val.lastIndexOf(']') < len - 1) {\n      index$1 = val.lastIndexOf('.');\n      if (index$1 > -1) {\n        return {\n          exp: val.slice(0, index$1),\n          key: '\"' + val.slice(index$1 + 1) + '\"'\n        }\n      } else {\n        return {\n          exp: val,\n          key: null\n        }\n      }\n    }\n\n    str = val;\n    index$1 = expressionPos = expressionEndPos = 0;\n\n    while (!eof()) {\n      chr = next();\n      /* istanbul ignore if */\n      if (isStringStart(chr)) {\n        parseString(chr);\n      } else if (chr === 0x5B) {\n        parseBracket(chr);\n      }\n    }\n\n    return {\n      exp: val.slice(0, expressionPos),\n      key: val.slice(expressionPos + 1, expressionEndPos)\n    }\n  }\n\n  function next () {\n    return str.charCodeAt(++index$1)\n  }\n\n  function eof () {\n    return index$1 >= len\n  }\n\n  function isStringStart (chr) {\n    return chr === 0x22 || chr === 0x27\n  }\n\n  function parseBracket (chr) {\n    var inBracket = 1;\n    expressionPos = index$1;\n    while (!eof()) {\n      chr = next();\n      if (isStringStart(chr)) {\n        parseString(chr);\n        continue\n      }\n      if (chr === 0x5B) { inBracket++; }\n      if (chr === 0x5D) { inBracket--; }\n      if (inBracket === 0) {\n        expressionEndPos = index$1;\n        break\n      }\n    }\n  }\n\n  function parseString (chr) {\n    var stringQuote = chr;\n    while (!eof()) {\n      chr = next();\n      if (chr === stringQuote) {\n        break\n      }\n    }\n  }\n\n  /*  */\n\n  var warn$1;\n\n  // in some cases, the event used has to be determined at runtime\n  // so we used some reserved tokens during compile.\n  var RANGE_TOKEN = '__r';\n  var CHECKBOX_RADIO_TOKEN = '__c';\n\n  function model (\n    el,\n    dir,\n    _warn\n  ) {\n    warn$1 = _warn;\n    var value = dir.value;\n    var modifiers = dir.modifiers;\n    var tag = el.tag;\n    var type = el.attrsMap.type;\n\n    {\n      // inputs with type=\"file\" are read only and setting the input's\n      // value will throw an error.\n      if (tag === 'input' && type === 'file') {\n        warn$1(\n          \"<\" + (el.tag) + \" v-model=\\\"\" + value + \"\\\" type=\\\"file\\\">:\\n\" +\n          \"File inputs are read only. Use a v-on:change listener instead.\",\n          el.rawAttrsMap['v-model']\n        );\n      }\n    }\n\n    if (el.component) {\n      genComponentModel(el, value, modifiers);\n      // component v-model doesn't need extra runtime\n      return false\n    } else if (tag === 'select') {\n      genSelect(el, value, modifiers);\n    } else if (tag === 'input' && type === 'checkbox') {\n      genCheckboxModel(el, value, modifiers);\n    } else if (tag === 'input' && type === 'radio') {\n      genRadioModel(el, value, modifiers);\n    } else if (tag === 'input' || tag === 'textarea') {\n      genDefaultModel(el, value, modifiers);\n    } else if (!config.isReservedTag(tag)) {\n      genComponentModel(el, value, modifiers);\n      // component v-model doesn't need extra runtime\n      return false\n    } else {\n      warn$1(\n        \"<\" + (el.tag) + \" v-model=\\\"\" + value + \"\\\">: \" +\n        \"v-model is not supported on this element type. \" +\n        'If you are working with contenteditable, it\\'s recommended to ' +\n        'wrap a library dedicated for that purpose inside a custom component.',\n        el.rawAttrsMap['v-model']\n      );\n    }\n\n    // ensure runtime directive metadata\n    return true\n  }\n\n  function genCheckboxModel (\n    el,\n    value,\n    modifiers\n  ) {\n    var number = modifiers && modifiers.number;\n    var valueBinding = getBindingAttr(el, 'value') || 'null';\n    var trueValueBinding = getBindingAttr(el, 'true-value') || 'true';\n    var falseValueBinding = getBindingAttr(el, 'false-value') || 'false';\n    addProp(el, 'checked',\n      \"Array.isArray(\" + value + \")\" +\n      \"?_i(\" + value + \",\" + valueBinding + \")>-1\" + (\n        trueValueBinding === 'true'\n          ? (\":(\" + value + \")\")\n          : (\":_q(\" + value + \",\" + trueValueBinding + \")\")\n      )\n    );\n    addHandler(el, 'change',\n      \"var $$a=\" + value + \",\" +\n          '$$el=$event.target,' +\n          \"$$c=$$el.checked?(\" + trueValueBinding + \"):(\" + falseValueBinding + \");\" +\n      'if(Array.isArray($$a)){' +\n        \"var $$v=\" + (number ? '_n(' + valueBinding + ')' : valueBinding) + \",\" +\n            '$$i=_i($$a,$$v);' +\n        \"if($$el.checked){$$i<0&&(\" + (genAssignmentCode(value, '$$a.concat([$$v])')) + \")}\" +\n        \"else{$$i>-1&&(\" + (genAssignmentCode(value, '$$a.slice(0,$$i).concat($$a.slice($$i+1))')) + \")}\" +\n      \"}else{\" + (genAssignmentCode(value, '$$c')) + \"}\",\n      null, true\n    );\n  }\n\n  function genRadioModel (\n    el,\n    value,\n    modifiers\n  ) {\n    var number = modifiers && modifiers.number;\n    var valueBinding = getBindingAttr(el, 'value') || 'null';\n    valueBinding = number ? (\"_n(\" + valueBinding + \")\") : valueBinding;\n    addProp(el, 'checked', (\"_q(\" + value + \",\" + valueBinding + \")\"));\n    addHandler(el, 'change', genAssignmentCode(value, valueBinding), null, true);\n  }\n\n  function genSelect (\n    el,\n    value,\n    modifiers\n  ) {\n    var number = modifiers && modifiers.number;\n    var selectedVal = \"Array.prototype.filter\" +\n      \".call($event.target.options,function(o){return o.selected})\" +\n      \".map(function(o){var val = \\\"_value\\\" in o ? o._value : o.value;\" +\n      \"return \" + (number ? '_n(val)' : 'val') + \"})\";\n\n    var assignment = '$event.target.multiple ? $$selectedVal : $$selectedVal[0]';\n    var code = \"var $$selectedVal = \" + selectedVal + \";\";\n    code = code + \" \" + (genAssignmentCode(value, assignment));\n    addHandler(el, 'change', code, null, true);\n  }\n\n  function genDefaultModel (\n    el,\n    value,\n    modifiers\n  ) {\n    var type = el.attrsMap.type;\n\n    // warn if v-bind:value conflicts with v-model\n    // except for inputs with v-bind:type\n    {\n      var value$1 = el.attrsMap['v-bind:value'] || el.attrsMap[':value'];\n      var typeBinding = el.attrsMap['v-bind:type'] || el.attrsMap[':type'];\n      if (value$1 && !typeBinding) {\n        var binding = el.attrsMap['v-bind:value'] ? 'v-bind:value' : ':value';\n        warn$1(\n          binding + \"=\\\"\" + value$1 + \"\\\" conflicts with v-model on the same element \" +\n          'because the latter already expands to a value binding internally',\n          el.rawAttrsMap[binding]\n        );\n      }\n    }\n\n    var ref = modifiers || {};\n    var lazy = ref.lazy;\n    var number = ref.number;\n    var trim = ref.trim;\n    var needCompositionGuard = !lazy && type !== 'range';\n    var event = lazy\n      ? 'change'\n      : type === 'range'\n        ? RANGE_TOKEN\n        : 'input';\n\n    var valueExpression = '$event.target.value';\n    if (trim) {\n      valueExpression = \"$event.target.value.trim()\";\n    }\n    if (number) {\n      valueExpression = \"_n(\" + valueExpression + \")\";\n    }\n\n    var code = genAssignmentCode(value, valueExpression);\n    if (needCompositionGuard) {\n      code = \"if($event.target.composing)return;\" + code;\n    }\n\n    addProp(el, 'value', (\"(\" + value + \")\"));\n    addHandler(el, event, code, null, true);\n    if (trim || number) {\n      addHandler(el, 'blur', '$forceUpdate()');\n    }\n  }\n\n  /*  */\n\n  // normalize v-model event tokens that can only be determined at runtime.\n  // it's important to place the event as the first in the array because\n  // the whole point is ensuring the v-model callback gets called before\n  // user-attached handlers.\n  function normalizeEvents (on) {\n    /* istanbul ignore if */\n    if (isDef(on[RANGE_TOKEN])) {\n      // IE input[type=range] only supports `change` event\n      var event = isIE ? 'change' : 'input';\n      on[event] = [].concat(on[RANGE_TOKEN], on[event] || []);\n      delete on[RANGE_TOKEN];\n    }\n    // This was originally intended to fix #4521 but no longer necessary\n    // after 2.5. Keeping it for backwards compat with generated code from < 2.4\n    /* istanbul ignore if */\n    if (isDef(on[CHECKBOX_RADIO_TOKEN])) {\n      on.change = [].concat(on[CHECKBOX_RADIO_TOKEN], on.change || []);\n      delete on[CHECKBOX_RADIO_TOKEN];\n    }\n  }\n\n  var target$1;\n\n  function createOnceHandler$1 (event, handler, capture) {\n    var _target = target$1; // save current target element in closure\n    return function onceHandler () {\n      var res = handler.apply(null, arguments);\n      if (res !== null) {\n        remove$2(event, onceHandler, capture, _target);\n      }\n    }\n  }\n\n  // #9446: Firefox <= 53 (in particular, ESR 52) has incorrect Event.timeStamp\n  // implementation and does not fire microtasks in between event propagation, so\n  // safe to exclude.\n  var useMicrotaskFix = isUsingMicroTask && !(isFF && Number(isFF[1]) <= 53);\n\n  function add$1 (\n    name,\n    handler,\n    capture,\n    passive\n  ) {\n    // async edge case #6566: inner click event triggers patch, event handler\n    // attached to outer element during patch, and triggered again. This\n    // happens because browsers fire microtask ticks between event propagation.\n    // the solution is simple: we save the timestamp when a handler is attached,\n    // and the handler would only fire if the event passed to it was fired\n    // AFTER it was attached.\n    if (useMicrotaskFix) {\n      var attachedTimestamp = currentFlushTimestamp;\n      var original = handler;\n      handler = original._wrapper = function (e) {\n        if (\n          // no bubbling, should always fire.\n          // this is just a safety net in case event.timeStamp is unreliable in\n          // certain weird environments...\n          e.target === e.currentTarget ||\n          // event is fired after handler attachment\n          e.timeStamp >= attachedTimestamp ||\n          // #9462 bail for iOS 9 bug: event.timeStamp is 0 after history.pushState\n          e.timeStamp === 0 ||\n          // #9448 bail if event is fired in another document in a multi-page\n          // electron/nw.js app, since event.timeStamp will be using a different\n          // starting reference\n          e.target.ownerDocument !== document\n        ) {\n          return original.apply(this, arguments)\n        }\n      };\n    }\n    target$1.addEventListener(\n      name,\n      handler,\n      supportsPassive\n        ? { capture: capture, passive: passive }\n        : capture\n    );\n  }\n\n  function remove$2 (\n    name,\n    handler,\n    capture,\n    _target\n  ) {\n    (_target || target$1).removeEventListener(\n      name,\n      handler._wrapper || handler,\n      capture\n    );\n  }\n\n  function updateDOMListeners (oldVnode, vnode) {\n    if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {\n      return\n    }\n    var on = vnode.data.on || {};\n    var oldOn = oldVnode.data.on || {};\n    target$1 = vnode.elm;\n    normalizeEvents(on);\n    updateListeners(on, oldOn, add$1, remove$2, createOnceHandler$1, vnode.context);\n    target$1 = undefined;\n  }\n\n  var events = {\n    create: updateDOMListeners,\n    update: updateDOMListeners\n  };\n\n  /*  */\n\n  var svgContainer;\n\n  function updateDOMProps (oldVnode, vnode) {\n    if (isUndef(oldVnode.data.domProps) && isUndef(vnode.data.domProps)) {\n      return\n    }\n    var key, cur;\n    var elm = vnode.elm;\n    var oldProps = oldVnode.data.domProps || {};\n    var props = vnode.data.domProps || {};\n    // clone observed objects, as the user probably wants to mutate it\n    if (isDef(props.__ob__)) {\n      props = vnode.data.domProps = extend({}, props);\n    }\n\n    for (key in oldProps) {\n      if (isUndef(props[key])) {\n        elm[key] = '';\n      }\n    }\n    for (key in props) {\n      cur = props[key];\n      // ignore children if the node has textContent or innerHTML,\n      // as these will throw away existing DOM nodes and cause removal errors\n      // on subsequent patches (#3360)\n      if (key === 'textContent' || key === 'innerHTML') {\n        if (vnode.children) { vnode.children.length = 0; }\n        if (cur === oldProps[key]) { continue }\n        // #6601 work around Chrome version <= 55 bug where single textNode\n        // replaced by innerHTML/textContent retains its parentNode property\n        if (elm.childNodes.length === 1) {\n          elm.removeChild(elm.childNodes[0]);\n        }\n      }\n\n      if (key === 'value' && elm.tagName !== 'PROGRESS') {\n        // store value as _value as well since\n        // non-string values will be stringified\n        elm._value = cur;\n        // avoid resetting cursor position when value is the same\n        var strCur = isUndef(cur) ? '' : String(cur);\n        if (shouldUpdateValue(elm, strCur)) {\n          elm.value = strCur;\n        }\n      } else if (key === 'innerHTML' && isSVG(elm.tagName) && isUndef(elm.innerHTML)) {\n        // IE doesn't support innerHTML for SVG elements\n        svgContainer = svgContainer || document.createElement('div');\n        svgContainer.innerHTML = \"<svg>\" + cur + \"</svg>\";\n        var svg = svgContainer.firstChild;\n        while (elm.firstChild) {\n          elm.removeChild(elm.firstChild);\n        }\n        while (svg.firstChild) {\n          elm.appendChild(svg.firstChild);\n        }\n      } else if (\n        // skip the update if old and new VDOM state is the same.\n        // `value` is handled separately because the DOM value may be temporarily\n        // out of sync with VDOM state due to focus, composition and modifiers.\n        // This  #4521 by skipping the unnecesarry `checked` update.\n        cur !== oldProps[key]\n      ) {\n        // some property updates can throw\n        // e.g. `value` on <progress> w/ non-finite value\n        try {\n          elm[key] = cur;\n        } catch (e) {}\n      }\n    }\n  }\n\n  // check platforms/web/util/attrs.js acceptValue\n\n\n  function shouldUpdateValue (elm, checkVal) {\n    return (!elm.composing && (\n      elm.tagName === 'OPTION' ||\n      isNotInFocusAndDirty(elm, checkVal) ||\n      isDirtyWithModifiers(elm, checkVal)\n    ))\n  }\n\n  function isNotInFocusAndDirty (elm, checkVal) {\n    // return true when textbox (.number and .trim) loses focus and its value is\n    // not equal to the updated value\n    var notInFocus = true;\n    // #6157\n    // work around IE bug when accessing document.activeElement in an iframe\n    try { notInFocus = document.activeElement !== elm; } catch (e) {}\n    return notInFocus && elm.value !== checkVal\n  }\n\n  function isDirtyWithModifiers (elm, newVal) {\n    var value = elm.value;\n    var modifiers = elm._vModifiers; // injected by v-model runtime\n    if (isDef(modifiers)) {\n      if (modifiers.number) {\n        return toNumber(value) !== toNumber(newVal)\n      }\n      if (modifiers.trim) {\n        return value.trim() !== newVal.trim()\n      }\n    }\n    return value !== newVal\n  }\n\n  var domProps = {\n    create: updateDOMProps,\n    update: updateDOMProps\n  };\n\n  /*  */\n\n  var parseStyleText = cached(function (cssText) {\n    var res = {};\n    var listDelimiter = /;(?![^(]*\\))/g;\n    var propertyDelimiter = /:(.+)/;\n    cssText.split(listDelimiter).forEach(function (item) {\n      if (item) {\n        var tmp = item.split(propertyDelimiter);\n        tmp.length > 1 && (res[tmp[0].trim()] = tmp[1].trim());\n      }\n    });\n    return res\n  });\n\n  // merge static and dynamic style data on the same vnode\n  function normalizeStyleData (data) {\n    var style = normalizeStyleBinding(data.style);\n    // static style is pre-processed into an object during compilation\n    // and is always a fresh object, so it's safe to merge into it\n    return data.staticStyle\n      ? extend(data.staticStyle, style)\n      : style\n  }\n\n  // normalize possible array / string values into Object\n  function normalizeStyleBinding (bindingStyle) {\n    if (Array.isArray(bindingStyle)) {\n      return toObject(bindingStyle)\n    }\n    if (typeof bindingStyle === 'string') {\n      return parseStyleText(bindingStyle)\n    }\n    return bindingStyle\n  }\n\n  /**\n   * parent component style should be after child's\n   * so that parent component's style could override it\n   */\n  function getStyle (vnode, checkChild) {\n    var res = {};\n    var styleData;\n\n    if (checkChild) {\n      var childNode = vnode;\n      while (childNode.componentInstance) {\n        childNode = childNode.componentInstance._vnode;\n        if (\n          childNode && childNode.data &&\n          (styleData = normalizeStyleData(childNode.data))\n        ) {\n          extend(res, styleData);\n        }\n      }\n    }\n\n    if ((styleData = normalizeStyleData(vnode.data))) {\n      extend(res, styleData);\n    }\n\n    var parentNode = vnode;\n    while ((parentNode = parentNode.parent)) {\n      if (parentNode.data && (styleData = normalizeStyleData(parentNode.data))) {\n        extend(res, styleData);\n      }\n    }\n    return res\n  }\n\n  /*  */\n\n  var cssVarRE = /^--/;\n  var importantRE = /\\s*!important$/;\n  var setProp = function (el, name, val) {\n    /* istanbul ignore if */\n    if (cssVarRE.test(name)) {\n      el.style.setProperty(name, val);\n    } else if (importantRE.test(val)) {\n      el.style.setProperty(hyphenate(name), val.replace(importantRE, ''), 'important');\n    } else {\n      var normalizedName = normalize(name);\n      if (Array.isArray(val)) {\n        // Support values array created by autoprefixer, e.g.\n        // {display: [\"-webkit-box\", \"-ms-flexbox\", \"flex\"]}\n        // Set them one by one, and the browser will only set those it can recognize\n        for (var i = 0, len = val.length; i < len; i++) {\n          el.style[normalizedName] = val[i];\n        }\n      } else {\n        el.style[normalizedName] = val;\n      }\n    }\n  };\n\n  var vendorNames = ['Webkit', 'Moz', 'ms'];\n\n  var emptyStyle;\n  var normalize = cached(function (prop) {\n    emptyStyle = emptyStyle || document.createElement('div').style;\n    prop = camelize(prop);\n    if (prop !== 'filter' && (prop in emptyStyle)) {\n      return prop\n    }\n    var capName = prop.charAt(0).toUpperCase() + prop.slice(1);\n    for (var i = 0; i < vendorNames.length; i++) {\n      var name = vendorNames[i] + capName;\n      if (name in emptyStyle) {\n        return name\n      }\n    }\n  });\n\n  function updateStyle (oldVnode, vnode) {\n    var data = vnode.data;\n    var oldData = oldVnode.data;\n\n    if (isUndef(data.staticStyle) && isUndef(data.style) &&\n      isUndef(oldData.staticStyle) && isUndef(oldData.style)\n    ) {\n      return\n    }\n\n    var cur, name;\n    var el = vnode.elm;\n    var oldStaticStyle = oldData.staticStyle;\n    var oldStyleBinding = oldData.normalizedStyle || oldData.style || {};\n\n    // if static style exists, stylebinding already merged into it when doing normalizeStyleData\n    var oldStyle = oldStaticStyle || oldStyleBinding;\n\n    var style = normalizeStyleBinding(vnode.data.style) || {};\n\n    // store normalized style under a different key for next diff\n    // make sure to clone it if it's reactive, since the user likely wants\n    // to mutate it.\n    vnode.data.normalizedStyle = isDef(style.__ob__)\n      ? extend({}, style)\n      : style;\n\n    var newStyle = getStyle(vnode, true);\n\n    for (name in oldStyle) {\n      if (isUndef(newStyle[name])) {\n        setProp(el, name, '');\n      }\n    }\n    for (name in newStyle) {\n      cur = newStyle[name];\n      if (cur !== oldStyle[name]) {\n        // ie9 setting to null has no effect, must use empty string\n        setProp(el, name, cur == null ? '' : cur);\n      }\n    }\n  }\n\n  var style = {\n    create: updateStyle,\n    update: updateStyle\n  };\n\n  /*  */\n\n  var whitespaceRE = /\\s+/;\n\n  /**\n   * Add class with compatibility for SVG since classList is not supported on\n   * SVG elements in IE\n   */\n  function addClass (el, cls) {\n    /* istanbul ignore if */\n    if (!cls || !(cls = cls.trim())) {\n      return\n    }\n\n    /* istanbul ignore else */\n    if (el.classList) {\n      if (cls.indexOf(' ') > -1) {\n        cls.split(whitespaceRE).forEach(function (c) { return el.classList.add(c); });\n      } else {\n        el.classList.add(cls);\n      }\n    } else {\n      var cur = \" \" + (el.getAttribute('class') || '') + \" \";\n      if (cur.indexOf(' ' + cls + ' ') < 0) {\n        el.setAttribute('class', (cur + cls).trim());\n      }\n    }\n  }\n\n  /**\n   * Remove class with compatibility for SVG since classList is not supported on\n   * SVG elements in IE\n   */\n  function removeClass (el, cls) {\n    /* istanbul ignore if */\n    if (!cls || !(cls = cls.trim())) {\n      return\n    }\n\n    /* istanbul ignore else */\n    if (el.classList) {\n      if (cls.indexOf(' ') > -1) {\n        cls.split(whitespaceRE).forEach(function (c) { return el.classList.remove(c); });\n      } else {\n        el.classList.remove(cls);\n      }\n      if (!el.classList.length) {\n        el.removeAttribute('class');\n      }\n    } else {\n      var cur = \" \" + (el.getAttribute('class') || '') + \" \";\n      var tar = ' ' + cls + ' ';\n      while (cur.indexOf(tar) >= 0) {\n        cur = cur.replace(tar, ' ');\n      }\n      cur = cur.trim();\n      if (cur) {\n        el.setAttribute('class', cur);\n      } else {\n        el.removeAttribute('class');\n      }\n    }\n  }\n\n  /*  */\n\n  function resolveTransition (def$$1) {\n    if (!def$$1) {\n      return\n    }\n    /* istanbul ignore else */\n    if (typeof def$$1 === 'object') {\n      var res = {};\n      if (def$$1.css !== false) {\n        extend(res, autoCssTransition(def$$1.name || 'v'));\n      }\n      extend(res, def$$1);\n      return res\n    } else if (typeof def$$1 === 'string') {\n      return autoCssTransition(def$$1)\n    }\n  }\n\n  var autoCssTransition = cached(function (name) {\n    return {\n      enterClass: (name + \"-enter\"),\n      enterToClass: (name + \"-enter-to\"),\n      enterActiveClass: (name + \"-enter-active\"),\n      leaveClass: (name + \"-leave\"),\n      leaveToClass: (name + \"-leave-to\"),\n      leaveActiveClass: (name + \"-leave-active\")\n    }\n  });\n\n  var hasTransition = inBrowser && !isIE9;\n  var TRANSITION = 'transition';\n  var ANIMATION = 'animation';\n\n  // Transition property/event sniffing\n  var transitionProp = 'transition';\n  var transitionEndEvent = 'transitionend';\n  var animationProp = 'animation';\n  var animationEndEvent = 'animationend';\n  if (hasTransition) {\n    /* istanbul ignore if */\n    if (window.ontransitionend === undefined &&\n      window.onwebkittransitionend !== undefined\n    ) {\n      transitionProp = 'WebkitTransition';\n      transitionEndEvent = 'webkitTransitionEnd';\n    }\n    if (window.onanimationend === undefined &&\n      window.onwebkitanimationend !== undefined\n    ) {\n      animationProp = 'WebkitAnimation';\n      animationEndEvent = 'webkitAnimationEnd';\n    }\n  }\n\n  // binding to window is necessary to make hot reload work in IE in strict mode\n  var raf = inBrowser\n    ? window.requestAnimationFrame\n      ? window.requestAnimationFrame.bind(window)\n      : setTimeout\n    : /* istanbul ignore next */ function (fn) { return fn(); };\n\n  function nextFrame (fn) {\n    raf(function () {\n      raf(fn);\n    });\n  }\n\n  function addTransitionClass (el, cls) {\n    var transitionClasses = el._transitionClasses || (el._transitionClasses = []);\n    if (transitionClasses.indexOf(cls) < 0) {\n      transitionClasses.push(cls);\n      addClass(el, cls);\n    }\n  }\n\n  function removeTransitionClass (el, cls) {\n    if (el._transitionClasses) {\n      remove(el._transitionClasses, cls);\n    }\n    removeClass(el, cls);\n  }\n\n  function whenTransitionEnds (\n    el,\n    expectedType,\n    cb\n  ) {\n    var ref = getTransitionInfo(el, expectedType);\n    var type = ref.type;\n    var timeout = ref.timeout;\n    var propCount = ref.propCount;\n    if (!type) { return cb() }\n    var event = type === TRANSITION ? transitionEndEvent : animationEndEvent;\n    var ended = 0;\n    var end = function () {\n      el.removeEventListener(event, onEnd);\n      cb();\n    };\n    var onEnd = function (e) {\n      if (e.target === el) {\n        if (++ended >= propCount) {\n          end();\n        }\n      }\n    };\n    setTimeout(function () {\n      if (ended < propCount) {\n        end();\n      }\n    }, timeout + 1);\n    el.addEventListener(event, onEnd);\n  }\n\n  var transformRE = /\\b(transform|all)(,|$)/;\n\n  function getTransitionInfo (el, expectedType) {\n    var styles = window.getComputedStyle(el);\n    // JSDOM may return undefined for transition properties\n    var transitionDelays = (styles[transitionProp + 'Delay'] || '').split(', ');\n    var transitionDurations = (styles[transitionProp + 'Duration'] || '').split(', ');\n    var transitionTimeout = getTimeout(transitionDelays, transitionDurations);\n    var animationDelays = (styles[animationProp + 'Delay'] || '').split(', ');\n    var animationDurations = (styles[animationProp + 'Duration'] || '').split(', ');\n    var animationTimeout = getTimeout(animationDelays, animationDurations);\n\n    var type;\n    var timeout = 0;\n    var propCount = 0;\n    /* istanbul ignore if */\n    if (expectedType === TRANSITION) {\n      if (transitionTimeout > 0) {\n        type = TRANSITION;\n        timeout = transitionTimeout;\n        propCount = transitionDurations.length;\n      }\n    } else if (expectedType === ANIMATION) {\n      if (animationTimeout > 0) {\n        type = ANIMATION;\n        timeout = animationTimeout;\n        propCount = animationDurations.length;\n      }\n    } else {\n      timeout = Math.max(transitionTimeout, animationTimeout);\n      type = timeout > 0\n        ? transitionTimeout > animationTimeout\n          ? TRANSITION\n          : ANIMATION\n        : null;\n      propCount = type\n        ? type === TRANSITION\n          ? transitionDurations.length\n          : animationDurations.length\n        : 0;\n    }\n    var hasTransform =\n      type === TRANSITION &&\n      transformRE.test(styles[transitionProp + 'Property']);\n    return {\n      type: type,\n      timeout: timeout,\n      propCount: propCount,\n      hasTransform: hasTransform\n    }\n  }\n\n  function getTimeout (delays, durations) {\n    /* istanbul ignore next */\n    while (delays.length < durations.length) {\n      delays = delays.concat(delays);\n    }\n\n    return Math.max.apply(null, durations.map(function (d, i) {\n      return toMs(d) + toMs(delays[i])\n    }))\n  }\n\n  // Old versions of Chromium (below 61.0.3163.100) formats floating pointer numbers\n  // in a locale-dependent way, using a comma instead of a dot.\n  // If comma is not replaced with a dot, the input will be rounded down (i.e. acting\n  // as a floor function) causing unexpected behaviors\n  function toMs (s) {\n    return Number(s.slice(0, -1).replace(',', '.')) * 1000\n  }\n\n  /*  */\n\n  function enter (vnode, toggleDisplay) {\n    var el = vnode.elm;\n\n    // call leave callback now\n    if (isDef(el._leaveCb)) {\n      el._leaveCb.cancelled = true;\n      el._leaveCb();\n    }\n\n    var data = resolveTransition(vnode.data.transition);\n    if (isUndef(data)) {\n      return\n    }\n\n    /* istanbul ignore if */\n    if (isDef(el._enterCb) || el.nodeType !== 1) {\n      return\n    }\n\n    var css = data.css;\n    var type = data.type;\n    var enterClass = data.enterClass;\n    var enterToClass = data.enterToClass;\n    var enterActiveClass = data.enterActiveClass;\n    var appearClass = data.appearClass;\n    var appearToClass = data.appearToClass;\n    var appearActiveClass = data.appearActiveClass;\n    var beforeEnter = data.beforeEnter;\n    var enter = data.enter;\n    var afterEnter = data.afterEnter;\n    var enterCancelled = data.enterCancelled;\n    var beforeAppear = data.beforeAppear;\n    var appear = data.appear;\n    var afterAppear = data.afterAppear;\n    var appearCancelled = data.appearCancelled;\n    var duration = data.duration;\n\n    // activeInstance will always be the <transition> component managing this\n    // transition. One edge case to check is when the <transition> is placed\n    // as the root node of a child component. In that case we need to check\n    // <transition>'s parent for appear check.\n    var context = activeInstance;\n    var transitionNode = activeInstance.$vnode;\n    while (transitionNode && transitionNode.parent) {\n      transitionNode = transitionNode.parent;\n      context = transitionNode.context;\n    }\n\n    var isAppear = !context._isMounted || !vnode.isRootInsert;\n\n    if (isAppear && !appear && appear !== '') {\n      return\n    }\n\n    var startClass = isAppear && appearClass\n      ? appearClass\n      : enterClass;\n    var activeClass = isAppear && appearActiveClass\n      ? appearActiveClass\n      : enterActiveClass;\n    var toClass = isAppear && appearToClass\n      ? appearToClass\n      : enterToClass;\n\n    var beforeEnterHook = isAppear\n      ? (beforeAppear || beforeEnter)\n      : beforeEnter;\n    var enterHook = isAppear\n      ? (typeof appear === 'function' ? appear : enter)\n      : enter;\n    var afterEnterHook = isAppear\n      ? (afterAppear || afterEnter)\n      : afterEnter;\n    var enterCancelledHook = isAppear\n      ? (appearCancelled || enterCancelled)\n      : enterCancelled;\n\n    var explicitEnterDuration = toNumber(\n      isObject(duration)\n        ? duration.enter\n        : duration\n    );\n\n    if (explicitEnterDuration != null) {\n      checkDuration(explicitEnterDuration, 'enter', vnode);\n    }\n\n    var expectsCSS = css !== false && !isIE9;\n    var userWantsControl = getHookArgumentsLength(enterHook);\n\n    var cb = el._enterCb = once(function () {\n      if (expectsCSS) {\n        removeTransitionClass(el, toClass);\n        removeTransitionClass(el, activeClass);\n      }\n      if (cb.cancelled) {\n        if (expectsCSS) {\n          removeTransitionClass(el, startClass);\n        }\n        enterCancelledHook && enterCancelledHook(el);\n      } else {\n        afterEnterHook && afterEnterHook(el);\n      }\n      el._enterCb = null;\n    });\n\n    if (!vnode.data.show) {\n      // remove pending leave element on enter by injecting an insert hook\n      mergeVNodeHook(vnode, 'insert', function () {\n        var parent = el.parentNode;\n        var pendingNode = parent && parent._pending && parent._pending[vnode.key];\n        if (pendingNode &&\n          pendingNode.tag === vnode.tag &&\n          pendingNode.elm._leaveCb\n        ) {\n          pendingNode.elm._leaveCb();\n        }\n        enterHook && enterHook(el, cb);\n      });\n    }\n\n    // start enter transition\n    beforeEnterHook && beforeEnterHook(el);\n    if (expectsCSS) {\n      addTransitionClass(el, startClass);\n      addTransitionClass(el, activeClass);\n      nextFrame(function () {\n        removeTransitionClass(el, startClass);\n        if (!cb.cancelled) {\n          addTransitionClass(el, toClass);\n          if (!userWantsControl) {\n            if (isValidDuration(explicitEnterDuration)) {\n              setTimeout(cb, explicitEnterDuration);\n            } else {\n              whenTransitionEnds(el, type, cb);\n            }\n          }\n        }\n      });\n    }\n\n    if (vnode.data.show) {\n      toggleDisplay && toggleDisplay();\n      enterHook && enterHook(el, cb);\n    }\n\n    if (!expectsCSS && !userWantsControl) {\n      cb();\n    }\n  }\n\n  function leave (vnode, rm) {\n    var el = vnode.elm;\n\n    // call enter callback now\n    if (isDef(el._enterCb)) {\n      el._enterCb.cancelled = true;\n      el._enterCb();\n    }\n\n    var data = resolveTransition(vnode.data.transition);\n    if (isUndef(data) || el.nodeType !== 1) {\n      return rm()\n    }\n\n    /* istanbul ignore if */\n    if (isDef(el._leaveCb)) {\n      return\n    }\n\n    var css = data.css;\n    var type = data.type;\n    var leaveClass = data.leaveClass;\n    var leaveToClass = data.leaveToClass;\n    var leaveActiveClass = data.leaveActiveClass;\n    var beforeLeave = data.beforeLeave;\n    var leave = data.leave;\n    var afterLeave = data.afterLeave;\n    var leaveCancelled = data.leaveCancelled;\n    var delayLeave = data.delayLeave;\n    var duration = data.duration;\n\n    var expectsCSS = css !== false && !isIE9;\n    var userWantsControl = getHookArgumentsLength(leave);\n\n    var explicitLeaveDuration = toNumber(\n      isObject(duration)\n        ? duration.leave\n        : duration\n    );\n\n    if (isDef(explicitLeaveDuration)) {\n      checkDuration(explicitLeaveDuration, 'leave', vnode);\n    }\n\n    var cb = el._leaveCb = once(function () {\n      if (el.parentNode && el.parentNode._pending) {\n        el.parentNode._pending[vnode.key] = null;\n      }\n      if (expectsCSS) {\n        removeTransitionClass(el, leaveToClass);\n        removeTransitionClass(el, leaveActiveClass);\n      }\n      if (cb.cancelled) {\n        if (expectsCSS) {\n          removeTransitionClass(el, leaveClass);\n        }\n        leaveCancelled && leaveCancelled(el);\n      } else {\n        rm();\n        afterLeave && afterLeave(el);\n      }\n      el._leaveCb = null;\n    });\n\n    if (delayLeave) {\n      delayLeave(performLeave);\n    } else {\n      performLeave();\n    }\n\n    function performLeave () {\n      // the delayed leave may have already been cancelled\n      if (cb.cancelled) {\n        return\n      }\n      // record leaving element\n      if (!vnode.data.show && el.parentNode) {\n        (el.parentNode._pending || (el.parentNode._pending = {}))[(vnode.key)] = vnode;\n      }\n      beforeLeave && beforeLeave(el);\n      if (expectsCSS) {\n        addTransitionClass(el, leaveClass);\n        addTransitionClass(el, leaveActiveClass);\n        nextFrame(function () {\n          removeTransitionClass(el, leaveClass);\n          if (!cb.cancelled) {\n            addTransitionClass(el, leaveToClass);\n            if (!userWantsControl) {\n              if (isValidDuration(explicitLeaveDuration)) {\n                setTimeout(cb, explicitLeaveDuration);\n              } else {\n                whenTransitionEnds(el, type, cb);\n              }\n            }\n          }\n        });\n      }\n      leave && leave(el, cb);\n      if (!expectsCSS && !userWantsControl) {\n        cb();\n      }\n    }\n  }\n\n  // only used in dev mode\n  function checkDuration (val, name, vnode) {\n    if (typeof val !== 'number') {\n      warn(\n        \"<transition> explicit \" + name + \" duration is not a valid number - \" +\n        \"got \" + (JSON.stringify(val)) + \".\",\n        vnode.context\n      );\n    } else if (isNaN(val)) {\n      warn(\n        \"<transition> explicit \" + name + \" duration is NaN - \" +\n        'the duration expression might be incorrect.',\n        vnode.context\n      );\n    }\n  }\n\n  function isValidDuration (val) {\n    return typeof val === 'number' && !isNaN(val)\n  }\n\n  /**\n   * Normalize a transition hook's argument length. The hook may be:\n   * - a merged hook (invoker) with the original in .fns\n   * - a wrapped component method (check ._length)\n   * - a plain function (.length)\n   */\n  function getHookArgumentsLength (fn) {\n    if (isUndef(fn)) {\n      return false\n    }\n    var invokerFns = fn.fns;\n    if (isDef(invokerFns)) {\n      // invoker\n      return getHookArgumentsLength(\n        Array.isArray(invokerFns)\n          ? invokerFns[0]\n          : invokerFns\n      )\n    } else {\n      return (fn._length || fn.length) > 1\n    }\n  }\n\n  function _enter (_, vnode) {\n    if (vnode.data.show !== true) {\n      enter(vnode);\n    }\n  }\n\n  var transition = inBrowser ? {\n    create: _enter,\n    activate: _enter,\n    remove: function remove$$1 (vnode, rm) {\n      /* istanbul ignore else */\n      if (vnode.data.show !== true) {\n        leave(vnode, rm);\n      } else {\n        rm();\n      }\n    }\n  } : {};\n\n  var platformModules = [\n    attrs,\n    klass,\n    events,\n    domProps,\n    style,\n    transition\n  ];\n\n  /*  */\n\n  // the directive module should be applied last, after all\n  // built-in modules have been applied.\n  var modules = platformModules.concat(baseModules);\n\n  var patch = createPatchFunction({ nodeOps: nodeOps, modules: modules });\n\n  /**\n   * Not type checking this file because flow doesn't like attaching\n   * properties to Elements.\n   */\n\n  /* istanbul ignore if */\n  if (isIE9) {\n    // http://www.matts411.com/post/internet-explorer-9-oninput/\n    document.addEventListener('selectionchange', function () {\n      var el = document.activeElement;\n      if (el && el.vmodel) {\n        trigger(el, 'input');\n      }\n    });\n  }\n\n  var directive = {\n    inserted: function inserted (el, binding, vnode, oldVnode) {\n      if (vnode.tag === 'select') {\n        // #6903\n        if (oldVnode.elm && !oldVnode.elm._vOptions) {\n          mergeVNodeHook(vnode, 'postpatch', function () {\n            directive.componentUpdated(el, binding, vnode);\n          });\n        } else {\n          setSelected(el, binding, vnode.context);\n        }\n        el._vOptions = [].map.call(el.options, getValue);\n      } else if (vnode.tag === 'textarea' || isTextInputType(el.type)) {\n        el._vModifiers = binding.modifiers;\n        if (!binding.modifiers.lazy) {\n          el.addEventListener('compositionstart', onCompositionStart);\n          el.addEventListener('compositionend', onCompositionEnd);\n          // Safari < 10.2 & UIWebView doesn't fire compositionend when\n          // switching focus before confirming composition choice\n          // this also fixes the issue where some browsers e.g. iOS Chrome\n          // fires \"change\" instead of \"input\" on autocomplete.\n          el.addEventListener('change', onCompositionEnd);\n          /* istanbul ignore if */\n          if (isIE9) {\n            el.vmodel = true;\n          }\n        }\n      }\n    },\n\n    componentUpdated: function componentUpdated (el, binding, vnode) {\n      if (vnode.tag === 'select') {\n        setSelected(el, binding, vnode.context);\n        // in case the options rendered by v-for have changed,\n        // it's possible that the value is out-of-sync with the rendered options.\n        // detect such cases and filter out values that no longer has a matching\n        // option in the DOM.\n        var prevOptions = el._vOptions;\n        var curOptions = el._vOptions = [].map.call(el.options, getValue);\n        if (curOptions.some(function (o, i) { return !looseEqual(o, prevOptions[i]); })) {\n          // trigger change event if\n          // no matching option found for at least one value\n          var needReset = el.multiple\n            ? binding.value.some(function (v) { return hasNoMatchingOption(v, curOptions); })\n            : binding.value !== binding.oldValue && hasNoMatchingOption(binding.value, curOptions);\n          if (needReset) {\n            trigger(el, 'change');\n          }\n        }\n      }\n    }\n  };\n\n  function setSelected (el, binding, vm) {\n    actuallySetSelected(el, binding, vm);\n    /* istanbul ignore if */\n    if (isIE || isEdge) {\n      setTimeout(function () {\n        actuallySetSelected(el, binding, vm);\n      }, 0);\n    }\n  }\n\n  function actuallySetSelected (el, binding, vm) {\n    var value = binding.value;\n    var isMultiple = el.multiple;\n    if (isMultiple && !Array.isArray(value)) {\n      warn(\n        \"<select multiple v-model=\\\"\" + (binding.expression) + \"\\\"> \" +\n        \"expects an Array value for its binding, but got \" + (Object.prototype.toString.call(value).slice(8, -1)),\n        vm\n      );\n      return\n    }\n    var selected, option;\n    for (var i = 0, l = el.options.length; i < l; i++) {\n      option = el.options[i];\n      if (isMultiple) {\n        selected = looseIndexOf(value, getValue(option)) > -1;\n        if (option.selected !== selected) {\n          option.selected = selected;\n        }\n      } else {\n        if (looseEqual(getValue(option), value)) {\n          if (el.selectedIndex !== i) {\n            el.selectedIndex = i;\n          }\n          return\n        }\n      }\n    }\n    if (!isMultiple) {\n      el.selectedIndex = -1;\n    }\n  }\n\n  function hasNoMatchingOption (value, options) {\n    return options.every(function (o) { return !looseEqual(o, value); })\n  }\n\n  function getValue (option) {\n    return '_value' in option\n      ? option._value\n      : option.value\n  }\n\n  function onCompositionStart (e) {\n    e.target.composing = true;\n  }\n\n  function onCompositionEnd (e) {\n    // prevent triggering an input event for no reason\n    if (!e.target.composing) { return }\n    e.target.composing = false;\n    trigger(e.target, 'input');\n  }\n\n  function trigger (el, type) {\n    var e = document.createEvent('HTMLEvents');\n    e.initEvent(type, true, true);\n    el.dispatchEvent(e);\n  }\n\n  /*  */\n\n  // recursively search for possible transition defined inside the component root\n  function locateNode (vnode) {\n    return vnode.componentInstance && (!vnode.data || !vnode.data.transition)\n      ? locateNode(vnode.componentInstance._vnode)\n      : vnode\n  }\n\n  var show = {\n    bind: function bind (el, ref, vnode) {\n      var value = ref.value;\n\n      vnode = locateNode(vnode);\n      var transition$$1 = vnode.data && vnode.data.transition;\n      var originalDisplay = el.__vOriginalDisplay =\n        el.style.display === 'none' ? '' : el.style.display;\n      if (value && transition$$1) {\n        vnode.data.show = true;\n        enter(vnode, function () {\n          el.style.display = originalDisplay;\n        });\n      } else {\n        el.style.display = value ? originalDisplay : 'none';\n      }\n    },\n\n    update: function update (el, ref, vnode) {\n      var value = ref.value;\n      var oldValue = ref.oldValue;\n\n      /* istanbul ignore if */\n      if (!value === !oldValue) { return }\n      vnode = locateNode(vnode);\n      var transition$$1 = vnode.data && vnode.data.transition;\n      if (transition$$1) {\n        vnode.data.show = true;\n        if (value) {\n          enter(vnode, function () {\n            el.style.display = el.__vOriginalDisplay;\n          });\n        } else {\n          leave(vnode, function () {\n            el.style.display = 'none';\n          });\n        }\n      } else {\n        el.style.display = value ? el.__vOriginalDisplay : 'none';\n      }\n    },\n\n    unbind: function unbind (\n      el,\n      binding,\n      vnode,\n      oldVnode,\n      isDestroy\n    ) {\n      if (!isDestroy) {\n        el.style.display = el.__vOriginalDisplay;\n      }\n    }\n  };\n\n  var platformDirectives = {\n    model: directive,\n    show: show\n  };\n\n  /*  */\n\n  var transitionProps = {\n    name: String,\n    appear: Boolean,\n    css: Boolean,\n    mode: String,\n    type: String,\n    enterClass: String,\n    leaveClass: String,\n    enterToClass: String,\n    leaveToClass: String,\n    enterActiveClass: String,\n    leaveActiveClass: String,\n    appearClass: String,\n    appearActiveClass: String,\n    appearToClass: String,\n    duration: [Number, String, Object]\n  };\n\n  // in case the child is also an abstract component, e.g. <keep-alive>\n  // we want to recursively retrieve the real component to be rendered\n  function getRealChild (vnode) {\n    var compOptions = vnode && vnode.componentOptions;\n    if (compOptions && compOptions.Ctor.options.abstract) {\n      return getRealChild(getFirstComponentChild(compOptions.children))\n    } else {\n      return vnode\n    }\n  }\n\n  function extractTransitionData (comp) {\n    var data = {};\n    var options = comp.$options;\n    // props\n    for (var key in options.propsData) {\n      data[key] = comp[key];\n    }\n    // events.\n    // extract listeners and pass them directly to the transition methods\n    var listeners = options._parentListeners;\n    for (var key$1 in listeners) {\n      data[camelize(key$1)] = listeners[key$1];\n    }\n    return data\n  }\n\n  function placeholder (h, rawChild) {\n    if (/\\d-keep-alive$/.test(rawChild.tag)) {\n      return h('keep-alive', {\n        props: rawChild.componentOptions.propsData\n      })\n    }\n  }\n\n  function hasParentTransition (vnode) {\n    while ((vnode = vnode.parent)) {\n      if (vnode.data.transition) {\n        return true\n      }\n    }\n  }\n\n  function isSameChild (child, oldChild) {\n    return oldChild.key === child.key && oldChild.tag === child.tag\n  }\n\n  var isNotTextNode = function (c) { return c.tag || isAsyncPlaceholder(c); };\n\n  var isVShowDirective = function (d) { return d.name === 'show'; };\n\n  var Transition = {\n    name: 'transition',\n    props: transitionProps,\n    abstract: true,\n\n    render: function render (h) {\n      var this$1 = this;\n\n      var children = this.$slots.default;\n      if (!children) {\n        return\n      }\n\n      // filter out text nodes (possible whitespaces)\n      children = children.filter(isNotTextNode);\n      /* istanbul ignore if */\n      if (!children.length) {\n        return\n      }\n\n      // warn multiple elements\n      if (children.length > 1) {\n        warn(\n          '<transition> can only be used on a single element. Use ' +\n          '<transition-group> for lists.',\n          this.$parent\n        );\n      }\n\n      var mode = this.mode;\n\n      // warn invalid mode\n      if (mode && mode !== 'in-out' && mode !== 'out-in'\n      ) {\n        warn(\n          'invalid <transition> mode: ' + mode,\n          this.$parent\n        );\n      }\n\n      var rawChild = children[0];\n\n      // if this is a component root node and the component's\n      // parent container node also has transition, skip.\n      if (hasParentTransition(this.$vnode)) {\n        return rawChild\n      }\n\n      // apply transition data to child\n      // use getRealChild() to ignore abstract components e.g. keep-alive\n      var child = getRealChild(rawChild);\n      /* istanbul ignore if */\n      if (!child) {\n        return rawChild\n      }\n\n      if (this._leaving) {\n        return placeholder(h, rawChild)\n      }\n\n      // ensure a key that is unique to the vnode type and to this transition\n      // component instance. This key will be used to remove pending leaving nodes\n      // during entering.\n      var id = \"__transition-\" + (this._uid) + \"-\";\n      child.key = child.key == null\n        ? child.isComment\n          ? id + 'comment'\n          : id + child.tag\n        : isPrimitive(child.key)\n          ? (String(child.key).indexOf(id) === 0 ? child.key : id + child.key)\n          : child.key;\n\n      var data = (child.data || (child.data = {})).transition = extractTransitionData(this);\n      var oldRawChild = this._vnode;\n      var oldChild = getRealChild(oldRawChild);\n\n      // mark v-show\n      // so that the transition module can hand over the control to the directive\n      if (child.data.directives && child.data.directives.some(isVShowDirective)) {\n        child.data.show = true;\n      }\n\n      if (\n        oldChild &&\n        oldChild.data &&\n        !isSameChild(child, oldChild) &&\n        !isAsyncPlaceholder(oldChild) &&\n        // #6687 component root is a comment node\n        !(oldChild.componentInstance && oldChild.componentInstance._vnode.isComment)\n      ) {\n        // replace old child transition data with fresh one\n        // important for dynamic transitions!\n        var oldData = oldChild.data.transition = extend({}, data);\n        // handle transition mode\n        if (mode === 'out-in') {\n          // return placeholder node and queue update when leave finishes\n          this._leaving = true;\n          mergeVNodeHook(oldData, 'afterLeave', function () {\n            this$1._leaving = false;\n            this$1.$forceUpdate();\n          });\n          return placeholder(h, rawChild)\n        } else if (mode === 'in-out') {\n          if (isAsyncPlaceholder(child)) {\n            return oldRawChild\n          }\n          var delayedLeave;\n          var performLeave = function () { delayedLeave(); };\n          mergeVNodeHook(data, 'afterEnter', performLeave);\n          mergeVNodeHook(data, 'enterCancelled', performLeave);\n          mergeVNodeHook(oldData, 'delayLeave', function (leave) { delayedLeave = leave; });\n        }\n      }\n\n      return rawChild\n    }\n  };\n\n  /*  */\n\n  var props = extend({\n    tag: String,\n    moveClass: String\n  }, transitionProps);\n\n  delete props.mode;\n\n  var TransitionGroup = {\n    props: props,\n\n    beforeMount: function beforeMount () {\n      var this$1 = this;\n\n      var update = this._update;\n      this._update = function (vnode, hydrating) {\n        var restoreActiveInstance = setActiveInstance(this$1);\n        // force removing pass\n        this$1.__patch__(\n          this$1._vnode,\n          this$1.kept,\n          false, // hydrating\n          true // removeOnly (!important, avoids unnecessary moves)\n        );\n        this$1._vnode = this$1.kept;\n        restoreActiveInstance();\n        update.call(this$1, vnode, hydrating);\n      };\n    },\n\n    render: function render (h) {\n      var tag = this.tag || this.$vnode.data.tag || 'span';\n      var map = Object.create(null);\n      var prevChildren = this.prevChildren = this.children;\n      var rawChildren = this.$slots.default || [];\n      var children = this.children = [];\n      var transitionData = extractTransitionData(this);\n\n      for (var i = 0; i < rawChildren.length; i++) {\n        var c = rawChildren[i];\n        if (c.tag) {\n          if (c.key != null && String(c.key).indexOf('__vlist') !== 0) {\n            children.push(c);\n            map[c.key] = c\n            ;(c.data || (c.data = {})).transition = transitionData;\n          } else {\n            var opts = c.componentOptions;\n            var name = opts ? (opts.Ctor.options.name || opts.tag || '') : c.tag;\n            warn((\"<transition-group> children must be keyed: <\" + name + \">\"));\n          }\n        }\n      }\n\n      if (prevChildren) {\n        var kept = [];\n        var removed = [];\n        for (var i$1 = 0; i$1 < prevChildren.length; i$1++) {\n          var c$1 = prevChildren[i$1];\n          c$1.data.transition = transitionData;\n          c$1.data.pos = c$1.elm.getBoundingClientRect();\n          if (map[c$1.key]) {\n            kept.push(c$1);\n          } else {\n            removed.push(c$1);\n          }\n        }\n        this.kept = h(tag, null, kept);\n        this.removed = removed;\n      }\n\n      return h(tag, null, children)\n    },\n\n    updated: function updated () {\n      var children = this.prevChildren;\n      var moveClass = this.moveClass || ((this.name || 'v') + '-move');\n      if (!children.length || !this.hasMove(children[0].elm, moveClass)) {\n        return\n      }\n\n      // we divide the work into three loops to avoid mixing DOM reads and writes\n      // in each iteration - which helps prevent layout thrashing.\n      children.forEach(callPendingCbs);\n      children.forEach(recordPosition);\n      children.forEach(applyTranslation);\n\n      // force reflow to put everything in position\n      // assign to this to avoid being removed in tree-shaking\n      // $flow-disable-line\n      this._reflow = document.body.offsetHeight;\n\n      children.forEach(function (c) {\n        if (c.data.moved) {\n          var el = c.elm;\n          var s = el.style;\n          addTransitionClass(el, moveClass);\n          s.transform = s.WebkitTransform = s.transitionDuration = '';\n          el.addEventListener(transitionEndEvent, el._moveCb = function cb (e) {\n            if (e && e.target !== el) {\n              return\n            }\n            if (!e || /transform$/.test(e.propertyName)) {\n              el.removeEventListener(transitionEndEvent, cb);\n              el._moveCb = null;\n              removeTransitionClass(el, moveClass);\n            }\n          });\n        }\n      });\n    },\n\n    methods: {\n      hasMove: function hasMove (el, moveClass) {\n        /* istanbul ignore if */\n        if (!hasTransition) {\n          return false\n        }\n        /* istanbul ignore if */\n        if (this._hasMove) {\n          return this._hasMove\n        }\n        // Detect whether an element with the move class applied has\n        // CSS transitions. Since the element may be inside an entering\n        // transition at this very moment, we make a clone of it and remove\n        // all other transition classes applied to ensure only the move class\n        // is applied.\n        var clone = el.cloneNode();\n        if (el._transitionClasses) {\n          el._transitionClasses.forEach(function (cls) { removeClass(clone, cls); });\n        }\n        addClass(clone, moveClass);\n        clone.style.display = 'none';\n        this.$el.appendChild(clone);\n        var info = getTransitionInfo(clone);\n        this.$el.removeChild(clone);\n        return (this._hasMove = info.hasTransform)\n      }\n    }\n  };\n\n  function callPendingCbs (c) {\n    /* istanbul ignore if */\n    if (c.elm._moveCb) {\n      c.elm._moveCb();\n    }\n    /* istanbul ignore if */\n    if (c.elm._enterCb) {\n      c.elm._enterCb();\n    }\n  }\n\n  function recordPosition (c) {\n    c.data.newPos = c.elm.getBoundingClientRect();\n  }\n\n  function applyTranslation (c) {\n    var oldPos = c.data.pos;\n    var newPos = c.data.newPos;\n    var dx = oldPos.left - newPos.left;\n    var dy = oldPos.top - newPos.top;\n    if (dx || dy) {\n      c.data.moved = true;\n      var s = c.elm.style;\n      s.transform = s.WebkitTransform = \"translate(\" + dx + \"px,\" + dy + \"px)\";\n      s.transitionDuration = '0s';\n    }\n  }\n\n  var platformComponents = {\n    Transition: Transition,\n    TransitionGroup: TransitionGroup\n  };\n\n  /*  */\n\n  // install platform specific utils\n  Vue.config.mustUseProp = mustUseProp;\n  Vue.config.isReservedTag = isReservedTag;\n  Vue.config.isReservedAttr = isReservedAttr;\n  Vue.config.getTagNamespace = getTagNamespace;\n  Vue.config.isUnknownElement = isUnknownElement;\n\n  // install platform runtime directives & components\n  extend(Vue.options.directives, platformDirectives);\n  extend(Vue.options.components, platformComponents);\n\n  // install platform patch function\n  Vue.prototype.__patch__ = inBrowser ? patch : noop;\n\n  // public mount method\n  Vue.prototype.$mount = function (\n    el,\n    hydrating\n  ) {\n    el = el && inBrowser ? query(el) : undefined;\n    return mountComponent(this, el, hydrating)\n  };\n\n  // devtools global hook\n  /* istanbul ignore next */\n  if (inBrowser) {\n    setTimeout(function () {\n      if (config.devtools) {\n        if (devtools) {\n          devtools.emit('init', Vue);\n        } else {\n          console[console.info ? 'info' : 'log'](\n            'Download the Vue Devtools extension for a better development experience:\\n' +\n            'https://github.com/vuejs/vue-devtools'\n          );\n        }\n      }\n      if (config.productionTip !== false &&\n        typeof console !== 'undefined'\n      ) {\n        console[console.info ? 'info' : 'log'](\n          \"You are running Vue in development mode.\\n\" +\n          \"Make sure to turn on production mode when deploying for production.\\n\" +\n          \"See more tips at https://vuejs.org/guide/deployment.html\"\n        );\n      }\n    }, 0);\n  }\n\n  /*  */\n\n  var defaultTagRE = /\\{\\{((?:.|\\r?\\n)+?)\\}\\}/g;\n  var regexEscapeRE = /[-.*+?^${}()|[\\]\\/\\\\]/g;\n\n  var buildRegex = cached(function (delimiters) {\n    var open = delimiters[0].replace(regexEscapeRE, '\\\\$&');\n    var close = delimiters[1].replace(regexEscapeRE, '\\\\$&');\n    return new RegExp(open + '((?:.|\\\\n)+?)' + close, 'g')\n  });\n\n\n\n  function parseText (\n    text,\n    delimiters\n  ) {\n    var tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE;\n    if (!tagRE.test(text)) {\n      return\n    }\n    var tokens = [];\n    var rawTokens = [];\n    var lastIndex = tagRE.lastIndex = 0;\n    var match, index, tokenValue;\n    while ((match = tagRE.exec(text))) {\n      index = match.index;\n      // push text token\n      if (index > lastIndex) {\n        rawTokens.push(tokenValue = text.slice(lastIndex, index));\n        tokens.push(JSON.stringify(tokenValue));\n      }\n      // tag token\n      var exp = parseFilters(match[1].trim());\n      tokens.push((\"_s(\" + exp + \")\"));\n      rawTokens.push({ '@binding': exp });\n      lastIndex = index + match[0].length;\n    }\n    if (lastIndex < text.length) {\n      rawTokens.push(tokenValue = text.slice(lastIndex));\n      tokens.push(JSON.stringify(tokenValue));\n    }\n    return {\n      expression: tokens.join('+'),\n      tokens: rawTokens\n    }\n  }\n\n  /*  */\n\n  function transformNode (el, options) {\n    var warn = options.warn || baseWarn;\n    var staticClass = getAndRemoveAttr(el, 'class');\n    if (staticClass) {\n      var res = parseText(staticClass, options.delimiters);\n      if (res) {\n        warn(\n          \"class=\\\"\" + staticClass + \"\\\": \" +\n          'Interpolation inside attributes has been removed. ' +\n          'Use v-bind or the colon shorthand instead. For example, ' +\n          'instead of <div class=\"{{ val }}\">, use <div :class=\"val\">.',\n          el.rawAttrsMap['class']\n        );\n      }\n    }\n    if (staticClass) {\n      el.staticClass = JSON.stringify(staticClass);\n    }\n    var classBinding = getBindingAttr(el, 'class', false /* getStatic */);\n    if (classBinding) {\n      el.classBinding = classBinding;\n    }\n  }\n\n  function genData (el) {\n    var data = '';\n    if (el.staticClass) {\n      data += \"staticClass:\" + (el.staticClass) + \",\";\n    }\n    if (el.classBinding) {\n      data += \"class:\" + (el.classBinding) + \",\";\n    }\n    return data\n  }\n\n  var klass$1 = {\n    staticKeys: ['staticClass'],\n    transformNode: transformNode,\n    genData: genData\n  };\n\n  /*  */\n\n  function transformNode$1 (el, options) {\n    var warn = options.warn || baseWarn;\n    var staticStyle = getAndRemoveAttr(el, 'style');\n    if (staticStyle) {\n      /* istanbul ignore if */\n      {\n        var res = parseText(staticStyle, options.delimiters);\n        if (res) {\n          warn(\n            \"style=\\\"\" + staticStyle + \"\\\": \" +\n            'Interpolation inside attributes has been removed. ' +\n            'Use v-bind or the colon shorthand instead. For example, ' +\n            'instead of <div style=\"{{ val }}\">, use <div :style=\"val\">.',\n            el.rawAttrsMap['style']\n          );\n        }\n      }\n      el.staticStyle = JSON.stringify(parseStyleText(staticStyle));\n    }\n\n    var styleBinding = getBindingAttr(el, 'style', false /* getStatic */);\n    if (styleBinding) {\n      el.styleBinding = styleBinding;\n    }\n  }\n\n  function genData$1 (el) {\n    var data = '';\n    if (el.staticStyle) {\n      data += \"staticStyle:\" + (el.staticStyle) + \",\";\n    }\n    if (el.styleBinding) {\n      data += \"style:(\" + (el.styleBinding) + \"),\";\n    }\n    return data\n  }\n\n  var style$1 = {\n    staticKeys: ['staticStyle'],\n    transformNode: transformNode$1,\n    genData: genData$1\n  };\n\n  /*  */\n\n  var decoder;\n\n  var he = {\n    decode: function decode (html) {\n      decoder = decoder || document.createElement('div');\n      decoder.innerHTML = html;\n      return decoder.textContent\n    }\n  };\n\n  /*  */\n\n  var isUnaryTag = makeMap(\n    'area,base,br,col,embed,frame,hr,img,input,isindex,keygen,' +\n    'link,meta,param,source,track,wbr'\n  );\n\n  // Elements that you can, intentionally, leave open\n  // (and which close themselves)\n  var canBeLeftOpenTag = makeMap(\n    'colgroup,dd,dt,li,options,p,td,tfoot,th,thead,tr,source'\n  );\n\n  // HTML5 tags https://html.spec.whatwg.org/multipage/indices.html#elements-3\n  // Phrasing Content https://html.spec.whatwg.org/multipage/dom.html#phrasing-content\n  var isNonPhrasingTag = makeMap(\n    'address,article,aside,base,blockquote,body,caption,col,colgroup,dd,' +\n    'details,dialog,div,dl,dt,fieldset,figcaption,figure,footer,form,' +\n    'h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,legend,li,menuitem,meta,' +\n    'optgroup,option,param,rp,rt,source,style,summary,tbody,td,tfoot,th,thead,' +\n    'title,tr,track'\n  );\n\n  /**\n   * Not type-checking this file because it's mostly vendor code.\n   */\n\n  // Regular Expressions for parsing tags and attributes\n  var attribute = /^\\s*([^\\s\"'<>\\/=]+)(?:\\s*(=)\\s*(?:\"([^\"]*)\"+|'([^']*)'+|([^\\s\"'=<>`]+)))?/;\n  var dynamicArgAttribute = /^\\s*((?:v-[\\w-]+:|@|:|#)\\[[^=]+\\][^\\s\"'<>\\/=]*)(?:\\s*(=)\\s*(?:\"([^\"]*)\"+|'([^']*)'+|([^\\s\"'=<>`]+)))?/;\n  var ncname = \"[a-zA-Z_][\\\\-\\\\.0-9_a-zA-Z\" + (unicodeRegExp.source) + \"]*\";\n  var qnameCapture = \"((?:\" + ncname + \"\\\\:)?\" + ncname + \")\";\n  var startTagOpen = new RegExp((\"^<\" + qnameCapture));\n  var startTagClose = /^\\s*(\\/?)>/;\n  var endTag = new RegExp((\"^<\\\\/\" + qnameCapture + \"[^>]*>\"));\n  var doctype = /^<!DOCTYPE [^>]+>/i;\n  // #7298: escape - to avoid being pased as HTML comment when inlined in page\n  var comment = /^<!\\--/;\n  var conditionalComment = /^<!\\[/;\n\n  // Special Elements (can contain anything)\n  var isPlainTextElement = makeMap('script,style,textarea', true);\n  var reCache = {};\n\n  var decodingMap = {\n    '&lt;': '<',\n    '&gt;': '>',\n    '&quot;': '\"',\n    '&amp;': '&',\n    '&#10;': '\\n',\n    '&#9;': '\\t',\n    '&#39;': \"'\"\n  };\n  var encodedAttr = /&(?:lt|gt|quot|amp|#39);/g;\n  var encodedAttrWithNewLines = /&(?:lt|gt|quot|amp|#39|#10|#9);/g;\n\n  // #5992\n  var isIgnoreNewlineTag = makeMap('pre,textarea', true);\n  var shouldIgnoreFirstNewline = function (tag, html) { return tag && isIgnoreNewlineTag(tag) && html[0] === '\\n'; };\n\n  function decodeAttr (value, shouldDecodeNewlines) {\n    var re = shouldDecodeNewlines ? encodedAttrWithNewLines : encodedAttr;\n    return value.replace(re, function (match) { return decodingMap[match]; })\n  }\n\n  function parseHTML (html, options) {\n    var stack = [];\n    var expectHTML = options.expectHTML;\n    var isUnaryTag$$1 = options.isUnaryTag || no;\n    var canBeLeftOpenTag$$1 = options.canBeLeftOpenTag || no;\n    var index = 0;\n    var last, lastTag;\n    while (html) {\n      last = html;\n      // Make sure we're not in a plaintext content element like script/style\n      if (!lastTag || !isPlainTextElement(lastTag)) {\n        var textEnd = html.indexOf('<');\n        if (textEnd === 0) {\n          // Comment:\n          if (comment.test(html)) {\n            var commentEnd = html.indexOf('-->');\n\n            if (commentEnd >= 0) {\n              if (options.shouldKeepComment) {\n                options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3);\n              }\n              advance(commentEnd + 3);\n              continue\n            }\n          }\n\n          // http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment\n          if (conditionalComment.test(html)) {\n            var conditionalEnd = html.indexOf(']>');\n\n            if (conditionalEnd >= 0) {\n              advance(conditionalEnd + 2);\n              continue\n            }\n          }\n\n          // Doctype:\n          var doctypeMatch = html.match(doctype);\n          if (doctypeMatch) {\n            advance(doctypeMatch[0].length);\n            continue\n          }\n\n          // End tag:\n          var endTagMatch = html.match(endTag);\n          if (endTagMatch) {\n            var curIndex = index;\n            advance(endTagMatch[0].length);\n            parseEndTag(endTagMatch[1], curIndex, index);\n            continue\n          }\n\n          // Start tag:\n          var startTagMatch = parseStartTag();\n          if (startTagMatch) {\n            handleStartTag(startTagMatch);\n            if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {\n              advance(1);\n            }\n            continue\n          }\n        }\n\n        var text = (void 0), rest = (void 0), next = (void 0);\n        if (textEnd >= 0) {\n          rest = html.slice(textEnd);\n          while (\n            !endTag.test(rest) &&\n            !startTagOpen.test(rest) &&\n            !comment.test(rest) &&\n            !conditionalComment.test(rest)\n          ) {\n            // < in plain text, be forgiving and treat it as text\n            next = rest.indexOf('<', 1);\n            if (next < 0) { break }\n            textEnd += next;\n            rest = html.slice(textEnd);\n          }\n          text = html.substring(0, textEnd);\n        }\n\n        if (textEnd < 0) {\n          text = html;\n        }\n\n        if (text) {\n          advance(text.length);\n        }\n\n        if (options.chars && text) {\n          options.chars(text, index - text.length, index);\n        }\n      } else {\n        var endTagLength = 0;\n        var stackedTag = lastTag.toLowerCase();\n        var reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\\\s\\\\S]*?)(</' + stackedTag + '[^>]*>)', 'i'));\n        var rest$1 = html.replace(reStackedTag, function (all, text, endTag) {\n          endTagLength = endTag.length;\n          if (!isPlainTextElement(stackedTag) && stackedTag !== 'noscript') {\n            text = text\n              .replace(/<!\\--([\\s\\S]*?)-->/g, '$1') // #7298\n              .replace(/<!\\[CDATA\\[([\\s\\S]*?)]]>/g, '$1');\n          }\n          if (shouldIgnoreFirstNewline(stackedTag, text)) {\n            text = text.slice(1);\n          }\n          if (options.chars) {\n            options.chars(text);\n          }\n          return ''\n        });\n        index += html.length - rest$1.length;\n        html = rest$1;\n        parseEndTag(stackedTag, index - endTagLength, index);\n      }\n\n      if (html === last) {\n        options.chars && options.chars(html);\n        if (!stack.length && options.warn) {\n          options.warn((\"Mal-formatted tag at end of template: \\\"\" + html + \"\\\"\"), { start: index + html.length });\n        }\n        break\n      }\n    }\n\n    // Clean up any remaining tags\n    parseEndTag();\n\n    function advance (n) {\n      index += n;\n      html = html.substring(n);\n    }\n\n    function parseStartTag () {\n      var start = html.match(startTagOpen);\n      if (start) {\n        var match = {\n          tagName: start[1],\n          attrs: [],\n          start: index\n        };\n        advance(start[0].length);\n        var end, attr;\n        while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) {\n          attr.start = index;\n          advance(attr[0].length);\n          attr.end = index;\n          match.attrs.push(attr);\n        }\n        if (end) {\n          match.unarySlash = end[1];\n          advance(end[0].length);\n          match.end = index;\n          return match\n        }\n      }\n    }\n\n    function handleStartTag (match) {\n      var tagName = match.tagName;\n      var unarySlash = match.unarySlash;\n\n      if (expectHTML) {\n        if (lastTag === 'p' && isNonPhrasingTag(tagName)) {\n          parseEndTag(lastTag);\n        }\n        if (canBeLeftOpenTag$$1(tagName) && lastTag === tagName) {\n          parseEndTag(tagName);\n        }\n      }\n\n      var unary = isUnaryTag$$1(tagName) || !!unarySlash;\n\n      var l = match.attrs.length;\n      var attrs = new Array(l);\n      for (var i = 0; i < l; i++) {\n        var args = match.attrs[i];\n        var value = args[3] || args[4] || args[5] || '';\n        var shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'\n          ? options.shouldDecodeNewlinesForHref\n          : options.shouldDecodeNewlines;\n        attrs[i] = {\n          name: args[1],\n          value: decodeAttr(value, shouldDecodeNewlines)\n        };\n        if (options.outputSourceRange) {\n          attrs[i].start = args.start + args[0].match(/^\\s*/).length;\n          attrs[i].end = args.end;\n        }\n      }\n\n      if (!unary) {\n        stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end });\n        lastTag = tagName;\n      }\n\n      if (options.start) {\n        options.start(tagName, attrs, unary, match.start, match.end);\n      }\n    }\n\n    function parseEndTag (tagName, start, end) {\n      var pos, lowerCasedTagName;\n      if (start == null) { start = index; }\n      if (end == null) { end = index; }\n\n      // Find the closest opened tag of the same type\n      if (tagName) {\n        lowerCasedTagName = tagName.toLowerCase();\n        for (pos = stack.length - 1; pos >= 0; pos--) {\n          if (stack[pos].lowerCasedTag === lowerCasedTagName) {\n            break\n          }\n        }\n      } else {\n        // If no tag name is provided, clean shop\n        pos = 0;\n      }\n\n      if (pos >= 0) {\n        // Close all the open elements, up the stack\n        for (var i = stack.length - 1; i >= pos; i--) {\n          if (i > pos || !tagName &&\n            options.warn\n          ) {\n            options.warn(\n              (\"tag <\" + (stack[i].tag) + \"> has no matching end tag.\"),\n              { start: stack[i].start, end: stack[i].end }\n            );\n          }\n          if (options.end) {\n            options.end(stack[i].tag, start, end);\n          }\n        }\n\n        // Remove the open elements from the stack\n        stack.length = pos;\n        lastTag = pos && stack[pos - 1].tag;\n      } else if (lowerCasedTagName === 'br') {\n        if (options.start) {\n          options.start(tagName, [], true, start, end);\n        }\n      } else if (lowerCasedTagName === 'p') {\n        if (options.start) {\n          options.start(tagName, [], false, start, end);\n        }\n        if (options.end) {\n          options.end(tagName, start, end);\n        }\n      }\n    }\n  }\n\n  /*  */\n\n  var onRE = /^@|^v-on:/;\n  var dirRE = /^v-|^@|^:/;\n  var forAliasRE = /([\\s\\S]*?)\\s+(?:in|of)\\s+([\\s\\S]*)/;\n  var forIteratorRE = /,([^,\\}\\]]*)(?:,([^,\\}\\]]*))?$/;\n  var stripParensRE = /^\\(|\\)$/g;\n  var dynamicArgRE = /^\\[.*\\]$/;\n\n  var argRE = /:(.*)$/;\n  var bindRE = /^:|^\\.|^v-bind:/;\n  var modifierRE = /\\.[^.\\]]+(?=[^\\]]*$)/g;\n\n  var slotRE = /^v-slot(:|$)|^#/;\n\n  var lineBreakRE = /[\\r\\n]/;\n  var whitespaceRE$1 = /\\s+/g;\n\n  var invalidAttributeRE = /[\\s\"'<>\\/=]/;\n\n  var decodeHTMLCached = cached(he.decode);\n\n  var emptySlotScopeToken = \"_empty_\";\n\n  // configurable state\n  var warn$2;\n  var delimiters;\n  var transforms;\n  var preTransforms;\n  var postTransforms;\n  var platformIsPreTag;\n  var platformMustUseProp;\n  var platformGetTagNamespace;\n  var maybeComponent;\n\n  function createASTElement (\n    tag,\n    attrs,\n    parent\n  ) {\n    return {\n      type: 1,\n      tag: tag,\n      attrsList: attrs,\n      attrsMap: makeAttrsMap(attrs),\n      rawAttrsMap: {},\n      parent: parent,\n      children: []\n    }\n  }\n\n  /**\n   * Convert HTML string to AST.\n   */\n  function parse (\n    template,\n    options\n  ) {\n    warn$2 = options.warn || baseWarn;\n\n    platformIsPreTag = options.isPreTag || no;\n    platformMustUseProp = options.mustUseProp || no;\n    platformGetTagNamespace = options.getTagNamespace || no;\n    var isReservedTag = options.isReservedTag || no;\n    maybeComponent = function (el) { return !!el.component || !isReservedTag(el.tag); };\n\n    transforms = pluckModuleFunction(options.modules, 'transformNode');\n    preTransforms = pluckModuleFunction(options.modules, 'preTransformNode');\n    postTransforms = pluckModuleFunction(options.modules, 'postTransformNode');\n\n    delimiters = options.delimiters;\n\n    var stack = [];\n    var preserveWhitespace = options.preserveWhitespace !== false;\n    var whitespaceOption = options.whitespace;\n    var root;\n    var currentParent;\n    var inVPre = false;\n    var inPre = false;\n    var warned = false;\n\n    function warnOnce (msg, range) {\n      if (!warned) {\n        warned = true;\n        warn$2(msg, range);\n      }\n    }\n\n    function closeElement (element) {\n      trimEndingWhitespace(element);\n      if (!inVPre && !element.processed) {\n        element = processElement(element, options);\n      }\n      // tree management\n      if (!stack.length && element !== root) {\n        // allow root elements with v-if, v-else-if and v-else\n        if (root.if && (element.elseif || element.else)) {\n          {\n            checkRootConstraints(element);\n          }\n          addIfCondition(root, {\n            exp: element.elseif,\n            block: element\n          });\n        } else {\n          warnOnce(\n            \"Component template should contain exactly one root element. \" +\n            \"If you are using v-if on multiple elements, \" +\n            \"use v-else-if to chain them instead.\",\n            { start: element.start }\n          );\n        }\n      }\n      if (currentParent && !element.forbidden) {\n        if (element.elseif || element.else) {\n          processIfConditions(element, currentParent);\n        } else {\n          if (element.slotScope) {\n            // scoped slot\n            // keep it in the children list so that v-else(-if) conditions can\n            // find it as the prev node.\n            var name = element.slotTarget || '\"default\"'\n            ;(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element;\n          }\n          currentParent.children.push(element);\n          element.parent = currentParent;\n        }\n      }\n\n      // final children cleanup\n      // filter out scoped slots\n      element.children = element.children.filter(function (c) { return !(c).slotScope; });\n      // remove trailing whitespace node again\n      trimEndingWhitespace(element);\n\n      // check pre state\n      if (element.pre) {\n        inVPre = false;\n      }\n      if (platformIsPreTag(element.tag)) {\n        inPre = false;\n      }\n      // apply post-transforms\n      for (var i = 0; i < postTransforms.length; i++) {\n        postTransforms[i](element, options);\n      }\n    }\n\n    function trimEndingWhitespace (el) {\n      // remove trailing whitespace node\n      if (!inPre) {\n        var lastNode;\n        while (\n          (lastNode = el.children[el.children.length - 1]) &&\n          lastNode.type === 3 &&\n          lastNode.text === ' '\n        ) {\n          el.children.pop();\n        }\n      }\n    }\n\n    function checkRootConstraints (el) {\n      if (el.tag === 'slot' || el.tag === 'template') {\n        warnOnce(\n          \"Cannot use <\" + (el.tag) + \"> as component root element because it may \" +\n          'contain multiple nodes.',\n          { start: el.start }\n        );\n      }\n      if (el.attrsMap.hasOwnProperty('v-for')) {\n        warnOnce(\n          'Cannot use v-for on stateful component root element because ' +\n          'it renders multiple elements.',\n          el.rawAttrsMap['v-for']\n        );\n      }\n    }\n\n    parseHTML(template, {\n      warn: warn$2,\n      expectHTML: options.expectHTML,\n      isUnaryTag: options.isUnaryTag,\n      canBeLeftOpenTag: options.canBeLeftOpenTag,\n      shouldDecodeNewlines: options.shouldDecodeNewlines,\n      shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,\n      shouldKeepComment: options.comments,\n      outputSourceRange: options.outputSourceRange,\n      start: function start (tag, attrs, unary, start$1, end) {\n        // check namespace.\n        // inherit parent ns if there is one\n        var ns = (currentParent && currentParent.ns) || platformGetTagNamespace(tag);\n\n        // handle IE svg bug\n        /* istanbul ignore if */\n        if (isIE && ns === 'svg') {\n          attrs = guardIESVGBug(attrs);\n        }\n\n        var element = createASTElement(tag, attrs, currentParent);\n        if (ns) {\n          element.ns = ns;\n        }\n\n        {\n          if (options.outputSourceRange) {\n            element.start = start$1;\n            element.end = end;\n            element.rawAttrsMap = element.attrsList.reduce(function (cumulated, attr) {\n              cumulated[attr.name] = attr;\n              return cumulated\n            }, {});\n          }\n          attrs.forEach(function (attr) {\n            if (invalidAttributeRE.test(attr.name)) {\n              warn$2(\n                \"Invalid dynamic argument expression: attribute names cannot contain \" +\n                \"spaces, quotes, <, >, / or =.\",\n                {\n                  start: attr.start + attr.name.indexOf(\"[\"),\n                  end: attr.start + attr.name.length\n                }\n              );\n            }\n          });\n        }\n\n        if (isForbiddenTag(element) && !isServerRendering()) {\n          element.forbidden = true;\n          warn$2(\n            'Templates should only be responsible for mapping the state to the ' +\n            'UI. Avoid placing tags with side-effects in your templates, such as ' +\n            \"<\" + tag + \">\" + ', as they will not be parsed.',\n            { start: element.start }\n          );\n        }\n\n        // apply pre-transforms\n        for (var i = 0; i < preTransforms.length; i++) {\n          element = preTransforms[i](element, options) || element;\n        }\n\n        if (!inVPre) {\n          processPre(element);\n          if (element.pre) {\n            inVPre = true;\n          }\n        }\n        if (platformIsPreTag(element.tag)) {\n          inPre = true;\n        }\n        if (inVPre) {\n          processRawAttrs(element);\n        } else if (!element.processed) {\n          // structural directives\n          processFor(element);\n          processIf(element);\n          processOnce(element);\n        }\n\n        if (!root) {\n          root = element;\n          {\n            checkRootConstraints(root);\n          }\n        }\n\n        if (!unary) {\n          currentParent = element;\n          stack.push(element);\n        } else {\n          closeElement(element);\n        }\n      },\n\n      end: function end (tag, start, end$1) {\n        var element = stack[stack.length - 1];\n        // pop stack\n        stack.length -= 1;\n        currentParent = stack[stack.length - 1];\n        if (options.outputSourceRange) {\n          element.end = end$1;\n        }\n        closeElement(element);\n      },\n\n      chars: function chars (text, start, end) {\n        if (!currentParent) {\n          {\n            if (text === template) {\n              warnOnce(\n                'Component template requires a root element, rather than just text.',\n                { start: start }\n              );\n            } else if ((text = text.trim())) {\n              warnOnce(\n                (\"text \\\"\" + text + \"\\\" outside root element will be ignored.\"),\n                { start: start }\n              );\n            }\n          }\n          return\n        }\n        // IE textarea placeholder bug\n        /* istanbul ignore if */\n        if (isIE &&\n          currentParent.tag === 'textarea' &&\n          currentParent.attrsMap.placeholder === text\n        ) {\n          return\n        }\n        var children = currentParent.children;\n        if (inPre || text.trim()) {\n          text = isTextTag(currentParent) ? text : decodeHTMLCached(text);\n        } else if (!children.length) {\n          // remove the whitespace-only node right after an opening tag\n          text = '';\n        } else if (whitespaceOption) {\n          if (whitespaceOption === 'condense') {\n            // in condense mode, remove the whitespace node if it contains\n            // line break, otherwise condense to a single space\n            text = lineBreakRE.test(text) ? '' : ' ';\n          } else {\n            text = ' ';\n          }\n        } else {\n          text = preserveWhitespace ? ' ' : '';\n        }\n        if (text) {\n          if (whitespaceOption === 'condense') {\n            // condense consecutive whitespaces into single space\n            text = text.replace(whitespaceRE$1, ' ');\n          }\n          var res;\n          var child;\n          if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) {\n            child = {\n              type: 2,\n              expression: res.expression,\n              tokens: res.tokens,\n              text: text\n            };\n          } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {\n            child = {\n              type: 3,\n              text: text\n            };\n          }\n          if (child) {\n            if (options.outputSourceRange) {\n              child.start = start;\n              child.end = end;\n            }\n            children.push(child);\n          }\n        }\n      },\n      comment: function comment (text, start, end) {\n        // adding anyting as a sibling to the root node is forbidden\n        // comments should still be allowed, but ignored\n        if (currentParent) {\n          var child = {\n            type: 3,\n            text: text,\n            isComment: true\n          };\n          if (options.outputSourceRange) {\n            child.start = start;\n            child.end = end;\n          }\n          currentParent.children.push(child);\n        }\n      }\n    });\n    return root\n  }\n\n  function processPre (el) {\n    if (getAndRemoveAttr(el, 'v-pre') != null) {\n      el.pre = true;\n    }\n  }\n\n  function processRawAttrs (el) {\n    var list = el.attrsList;\n    var len = list.length;\n    if (len) {\n      var attrs = el.attrs = new Array(len);\n      for (var i = 0; i < len; i++) {\n        attrs[i] = {\n          name: list[i].name,\n          value: JSON.stringify(list[i].value)\n        };\n        if (list[i].start != null) {\n          attrs[i].start = list[i].start;\n          attrs[i].end = list[i].end;\n        }\n      }\n    } else if (!el.pre) {\n      // non root node in pre blocks with no attributes\n      el.plain = true;\n    }\n  }\n\n  function processElement (\n    element,\n    options\n  ) {\n    processKey(element);\n\n    // determine whether this is a plain element after\n    // removing structural attributes\n    element.plain = (\n      !element.key &&\n      !element.scopedSlots &&\n      !element.attrsList.length\n    );\n\n    processRef(element);\n    processSlotContent(element);\n    processSlotOutlet(element);\n    processComponent(element);\n    for (var i = 0; i < transforms.length; i++) {\n      element = transforms[i](element, options) || element;\n    }\n    processAttrs(element);\n    return element\n  }\n\n  function processKey (el) {\n    var exp = getBindingAttr(el, 'key');\n    if (exp) {\n      {\n        if (el.tag === 'template') {\n          warn$2(\n            \"<template> cannot be keyed. Place the key on real elements instead.\",\n            getRawBindingAttr(el, 'key')\n          );\n        }\n        if (el.for) {\n          var iterator = el.iterator2 || el.iterator1;\n          var parent = el.parent;\n          if (iterator && iterator === exp && parent && parent.tag === 'transition-group') {\n            warn$2(\n              \"Do not use v-for index as key on <transition-group> children, \" +\n              \"this is the same as not using keys.\",\n              getRawBindingAttr(el, 'key'),\n              true /* tip */\n            );\n          }\n        }\n      }\n      el.key = exp;\n    }\n  }\n\n  function processRef (el) {\n    var ref = getBindingAttr(el, 'ref');\n    if (ref) {\n      el.ref = ref;\n      el.refInFor = checkInFor(el);\n    }\n  }\n\n  function processFor (el) {\n    var exp;\n    if ((exp = getAndRemoveAttr(el, 'v-for'))) {\n      var res = parseFor(exp);\n      if (res) {\n        extend(el, res);\n      } else {\n        warn$2(\n          (\"Invalid v-for expression: \" + exp),\n          el.rawAttrsMap['v-for']\n        );\n      }\n    }\n  }\n\n\n\n  function parseFor (exp) {\n    var inMatch = exp.match(forAliasRE);\n    if (!inMatch) { return }\n    var res = {};\n    res.for = inMatch[2].trim();\n    var alias = inMatch[1].trim().replace(stripParensRE, '');\n    var iteratorMatch = alias.match(forIteratorRE);\n    if (iteratorMatch) {\n      res.alias = alias.replace(forIteratorRE, '').trim();\n      res.iterator1 = iteratorMatch[1].trim();\n      if (iteratorMatch[2]) {\n        res.iterator2 = iteratorMatch[2].trim();\n      }\n    } else {\n      res.alias = alias;\n    }\n    return res\n  }\n\n  function processIf (el) {\n    var exp = getAndRemoveAttr(el, 'v-if');\n    if (exp) {\n      el.if = exp;\n      addIfCondition(el, {\n        exp: exp,\n        block: el\n      });\n    } else {\n      if (getAndRemoveAttr(el, 'v-else') != null) {\n        el.else = true;\n      }\n      var elseif = getAndRemoveAttr(el, 'v-else-if');\n      if (elseif) {\n        el.elseif = elseif;\n      }\n    }\n  }\n\n  function processIfConditions (el, parent) {\n    var prev = findPrevElement(parent.children);\n    if (prev && prev.if) {\n      addIfCondition(prev, {\n        exp: el.elseif,\n        block: el\n      });\n    } else {\n      warn$2(\n        \"v-\" + (el.elseif ? ('else-if=\"' + el.elseif + '\"') : 'else') + \" \" +\n        \"used on element <\" + (el.tag) + \"> without corresponding v-if.\",\n        el.rawAttrsMap[el.elseif ? 'v-else-if' : 'v-else']\n      );\n    }\n  }\n\n  function findPrevElement (children) {\n    var i = children.length;\n    while (i--) {\n      if (children[i].type === 1) {\n        return children[i]\n      } else {\n        if (children[i].text !== ' ') {\n          warn$2(\n            \"text \\\"\" + (children[i].text.trim()) + \"\\\" between v-if and v-else(-if) \" +\n            \"will be ignored.\",\n            children[i]\n          );\n        }\n        children.pop();\n      }\n    }\n  }\n\n  function addIfCondition (el, condition) {\n    if (!el.ifConditions) {\n      el.ifConditions = [];\n    }\n    el.ifConditions.push(condition);\n  }\n\n  function processOnce (el) {\n    var once$$1 = getAndRemoveAttr(el, 'v-once');\n    if (once$$1 != null) {\n      el.once = true;\n    }\n  }\n\n  // handle content being passed to a component as slot,\n  // e.g. <template slot=\"xxx\">, <div slot-scope=\"xxx\">\n  function processSlotContent (el) {\n    var slotScope;\n    if (el.tag === 'template') {\n      slotScope = getAndRemoveAttr(el, 'scope');\n      /* istanbul ignore if */\n      if (slotScope) {\n        warn$2(\n          \"the \\\"scope\\\" attribute for scoped slots have been deprecated and \" +\n          \"replaced by \\\"slot-scope\\\" since 2.5. The new \\\"slot-scope\\\" attribute \" +\n          \"can also be used on plain elements in addition to <template> to \" +\n          \"denote scoped slots.\",\n          el.rawAttrsMap['scope'],\n          true\n        );\n      }\n      el.slotScope = slotScope || getAndRemoveAttr(el, 'slot-scope');\n    } else if ((slotScope = getAndRemoveAttr(el, 'slot-scope'))) {\n      /* istanbul ignore if */\n      if (el.attrsMap['v-for']) {\n        warn$2(\n          \"Ambiguous combined usage of slot-scope and v-for on <\" + (el.tag) + \"> \" +\n          \"(v-for takes higher priority). Use a wrapper <template> for the \" +\n          \"scoped slot to make it clearer.\",\n          el.rawAttrsMap['slot-scope'],\n          true\n        );\n      }\n      el.slotScope = slotScope;\n    }\n\n    // slot=\"xxx\"\n    var slotTarget = getBindingAttr(el, 'slot');\n    if (slotTarget) {\n      el.slotTarget = slotTarget === '\"\"' ? '\"default\"' : slotTarget;\n      el.slotTargetDynamic = !!(el.attrsMap[':slot'] || el.attrsMap['v-bind:slot']);\n      // preserve slot as an attribute for native shadow DOM compat\n      // only for non-scoped slots.\n      if (el.tag !== 'template' && !el.slotScope) {\n        addAttr(el, 'slot', slotTarget, getRawBindingAttr(el, 'slot'));\n      }\n    }\n\n    // 2.6 v-slot syntax\n    {\n      if (el.tag === 'template') {\n        // v-slot on <template>\n        var slotBinding = getAndRemoveAttrByRegex(el, slotRE);\n        if (slotBinding) {\n          {\n            if (el.slotTarget || el.slotScope) {\n              warn$2(\n                \"Unexpected mixed usage of different slot syntaxes.\",\n                el\n              );\n            }\n            if (el.parent && !maybeComponent(el.parent)) {\n              warn$2(\n                \"<template v-slot> can only appear at the root level inside \" +\n                \"the receiving the component\",\n                el\n              );\n            }\n          }\n          var ref = getSlotName(slotBinding);\n          var name = ref.name;\n          var dynamic = ref.dynamic;\n          el.slotTarget = name;\n          el.slotTargetDynamic = dynamic;\n          el.slotScope = slotBinding.value || emptySlotScopeToken; // force it into a scoped slot for perf\n        }\n      } else {\n        // v-slot on component, denotes default slot\n        var slotBinding$1 = getAndRemoveAttrByRegex(el, slotRE);\n        if (slotBinding$1) {\n          {\n            if (!maybeComponent(el)) {\n              warn$2(\n                \"v-slot can only be used on components or <template>.\",\n                slotBinding$1\n              );\n            }\n            if (el.slotScope || el.slotTarget) {\n              warn$2(\n                \"Unexpected mixed usage of different slot syntaxes.\",\n                el\n              );\n            }\n            if (el.scopedSlots) {\n              warn$2(\n                \"To avoid scope ambiguity, the default slot should also use \" +\n                \"<template> syntax when there are other named slots.\",\n                slotBinding$1\n              );\n            }\n          }\n          // add the component's children to its default slot\n          var slots = el.scopedSlots || (el.scopedSlots = {});\n          var ref$1 = getSlotName(slotBinding$1);\n          var name$1 = ref$1.name;\n          var dynamic$1 = ref$1.dynamic;\n          var slotContainer = slots[name$1] = createASTElement('template', [], el);\n          slotContainer.slotTarget = name$1;\n          slotContainer.slotTargetDynamic = dynamic$1;\n          slotContainer.children = el.children.filter(function (c) {\n            if (!c.slotScope) {\n              c.parent = slotContainer;\n              return true\n            }\n          });\n          slotContainer.slotScope = slotBinding$1.value || emptySlotScopeToken;\n          // remove children as they are returned from scopedSlots now\n          el.children = [];\n          // mark el non-plain so data gets generated\n          el.plain = false;\n        }\n      }\n    }\n  }\n\n  function getSlotName (binding) {\n    var name = binding.name.replace(slotRE, '');\n    if (!name) {\n      if (binding.name[0] !== '#') {\n        name = 'default';\n      } else {\n        warn$2(\n          \"v-slot shorthand syntax requires a slot name.\",\n          binding\n        );\n      }\n    }\n    return dynamicArgRE.test(name)\n      // dynamic [name]\n      ? { name: name.slice(1, -1), dynamic: true }\n      // static name\n      : { name: (\"\\\"\" + name + \"\\\"\"), dynamic: false }\n  }\n\n  // handle <slot/> outlets\n  function processSlotOutlet (el) {\n    if (el.tag === 'slot') {\n      el.slotName = getBindingAttr(el, 'name');\n      if (el.key) {\n        warn$2(\n          \"`key` does not work on <slot> because slots are abstract outlets \" +\n          \"and can possibly expand into multiple elements. \" +\n          \"Use the key on a wrapping element instead.\",\n          getRawBindingAttr(el, 'key')\n        );\n      }\n    }\n  }\n\n  function processComponent (el) {\n    var binding;\n    if ((binding = getBindingAttr(el, 'is'))) {\n      el.component = binding;\n    }\n    if (getAndRemoveAttr(el, 'inline-template') != null) {\n      el.inlineTemplate = true;\n    }\n  }\n\n  function processAttrs (el) {\n    var list = el.attrsList;\n    var i, l, name, rawName, value, modifiers, syncGen, isDynamic;\n    for (i = 0, l = list.length; i < l; i++) {\n      name = rawName = list[i].name;\n      value = list[i].value;\n      if (dirRE.test(name)) {\n        // mark element as dynamic\n        el.hasBindings = true;\n        // modifiers\n        modifiers = parseModifiers(name.replace(dirRE, ''));\n        // support .foo shorthand syntax for the .prop modifier\n        if (modifiers) {\n          name = name.replace(modifierRE, '');\n        }\n        if (bindRE.test(name)) { // v-bind\n          name = name.replace(bindRE, '');\n          value = parseFilters(value);\n          isDynamic = dynamicArgRE.test(name);\n          if (isDynamic) {\n            name = name.slice(1, -1);\n          }\n          if (\n            value.trim().length === 0\n          ) {\n            warn$2(\n              (\"The value for a v-bind expression cannot be empty. Found in \\\"v-bind:\" + name + \"\\\"\")\n            );\n          }\n          if (modifiers) {\n            if (modifiers.prop && !isDynamic) {\n              name = camelize(name);\n              if (name === 'innerHtml') { name = 'innerHTML'; }\n            }\n            if (modifiers.camel && !isDynamic) {\n              name = camelize(name);\n            }\n            if (modifiers.sync) {\n              syncGen = genAssignmentCode(value, \"$event\");\n              if (!isDynamic) {\n                addHandler(\n                  el,\n                  (\"update:\" + (camelize(name))),\n                  syncGen,\n                  null,\n                  false,\n                  warn$2,\n                  list[i]\n                );\n                if (hyphenate(name) !== camelize(name)) {\n                  addHandler(\n                    el,\n                    (\"update:\" + (hyphenate(name))),\n                    syncGen,\n                    null,\n                    false,\n                    warn$2,\n                    list[i]\n                  );\n                }\n              } else {\n                // handler w/ dynamic event name\n                addHandler(\n                  el,\n                  (\"\\\"update:\\\"+(\" + name + \")\"),\n                  syncGen,\n                  null,\n                  false,\n                  warn$2,\n                  list[i],\n                  true // dynamic\n                );\n              }\n            }\n          }\n          if ((modifiers && modifiers.prop) || (\n            !el.component && platformMustUseProp(el.tag, el.attrsMap.type, name)\n          )) {\n            addProp(el, name, value, list[i], isDynamic);\n          } else {\n            addAttr(el, name, value, list[i], isDynamic);\n          }\n        } else if (onRE.test(name)) { // v-on\n          name = name.replace(onRE, '');\n          isDynamic = dynamicArgRE.test(name);\n          if (isDynamic) {\n            name = name.slice(1, -1);\n          }\n          addHandler(el, name, value, modifiers, false, warn$2, list[i], isDynamic);\n        } else { // normal directives\n          name = name.replace(dirRE, '');\n          // parse arg\n          var argMatch = name.match(argRE);\n          var arg = argMatch && argMatch[1];\n          isDynamic = false;\n          if (arg) {\n            name = name.slice(0, -(arg.length + 1));\n            if (dynamicArgRE.test(arg)) {\n              arg = arg.slice(1, -1);\n              isDynamic = true;\n            }\n          }\n          addDirective(el, name, rawName, value, arg, isDynamic, modifiers, list[i]);\n          if (name === 'model') {\n            checkForAliasModel(el, value);\n          }\n        }\n      } else {\n        // literal attribute\n        {\n          var res = parseText(value, delimiters);\n          if (res) {\n            warn$2(\n              name + \"=\\\"\" + value + \"\\\": \" +\n              'Interpolation inside attributes has been removed. ' +\n              'Use v-bind or the colon shorthand instead. For example, ' +\n              'instead of <div id=\"{{ val }}\">, use <div :id=\"val\">.',\n              list[i]\n            );\n          }\n        }\n        addAttr(el, name, JSON.stringify(value), list[i]);\n        // #6887 firefox doesn't update muted state if set via attribute\n        // even immediately after element creation\n        if (!el.component &&\n            name === 'muted' &&\n            platformMustUseProp(el.tag, el.attrsMap.type, name)) {\n          addProp(el, name, 'true', list[i]);\n        }\n      }\n    }\n  }\n\n  function checkInFor (el) {\n    var parent = el;\n    while (parent) {\n      if (parent.for !== undefined) {\n        return true\n      }\n      parent = parent.parent;\n    }\n    return false\n  }\n\n  function parseModifiers (name) {\n    var match = name.match(modifierRE);\n    if (match) {\n      var ret = {};\n      match.forEach(function (m) { ret[m.slice(1)] = true; });\n      return ret\n    }\n  }\n\n  function makeAttrsMap (attrs) {\n    var map = {};\n    for (var i = 0, l = attrs.length; i < l; i++) {\n      if (\n        map[attrs[i].name] && !isIE && !isEdge\n      ) {\n        warn$2('duplicate attribute: ' + attrs[i].name, attrs[i]);\n      }\n      map[attrs[i].name] = attrs[i].value;\n    }\n    return map\n  }\n\n  // for script (e.g. type=\"x/template\") or style, do not decode content\n  function isTextTag (el) {\n    return el.tag === 'script' || el.tag === 'style'\n  }\n\n  function isForbiddenTag (el) {\n    return (\n      el.tag === 'style' ||\n      (el.tag === 'script' && (\n        !el.attrsMap.type ||\n        el.attrsMap.type === 'text/javascript'\n      ))\n    )\n  }\n\n  var ieNSBug = /^xmlns:NS\\d+/;\n  var ieNSPrefix = /^NS\\d+:/;\n\n  /* istanbul ignore next */\n  function guardIESVGBug (attrs) {\n    var res = [];\n    for (var i = 0; i < attrs.length; i++) {\n      var attr = attrs[i];\n      if (!ieNSBug.test(attr.name)) {\n        attr.name = attr.name.replace(ieNSPrefix, '');\n        res.push(attr);\n      }\n    }\n    return res\n  }\n\n  function checkForAliasModel (el, value) {\n    var _el = el;\n    while (_el) {\n      if (_el.for && _el.alias === value) {\n        warn$2(\n          \"<\" + (el.tag) + \" v-model=\\\"\" + value + \"\\\">: \" +\n          \"You are binding v-model directly to a v-for iteration alias. \" +\n          \"This will not be able to modify the v-for source array because \" +\n          \"writing to the alias is like modifying a function local variable. \" +\n          \"Consider using an array of objects and use v-model on an object property instead.\",\n          el.rawAttrsMap['v-model']\n        );\n      }\n      _el = _el.parent;\n    }\n  }\n\n  /*  */\n\n  function preTransformNode (el, options) {\n    if (el.tag === 'input') {\n      var map = el.attrsMap;\n      if (!map['v-model']) {\n        return\n      }\n\n      var typeBinding;\n      if (map[':type'] || map['v-bind:type']) {\n        typeBinding = getBindingAttr(el, 'type');\n      }\n      if (!map.type && !typeBinding && map['v-bind']) {\n        typeBinding = \"(\" + (map['v-bind']) + \").type\";\n      }\n\n      if (typeBinding) {\n        var ifCondition = getAndRemoveAttr(el, 'v-if', true);\n        var ifConditionExtra = ifCondition ? (\"&&(\" + ifCondition + \")\") : \"\";\n        var hasElse = getAndRemoveAttr(el, 'v-else', true) != null;\n        var elseIfCondition = getAndRemoveAttr(el, 'v-else-if', true);\n        // 1. checkbox\n        var branch0 = cloneASTElement(el);\n        // process for on the main node\n        processFor(branch0);\n        addRawAttr(branch0, 'type', 'checkbox');\n        processElement(branch0, options);\n        branch0.processed = true; // prevent it from double-processed\n        branch0.if = \"(\" + typeBinding + \")==='checkbox'\" + ifConditionExtra;\n        addIfCondition(branch0, {\n          exp: branch0.if,\n          block: branch0\n        });\n        // 2. add radio else-if condition\n        var branch1 = cloneASTElement(el);\n        getAndRemoveAttr(branch1, 'v-for', true);\n        addRawAttr(branch1, 'type', 'radio');\n        processElement(branch1, options);\n        addIfCondition(branch0, {\n          exp: \"(\" + typeBinding + \")==='radio'\" + ifConditionExtra,\n          block: branch1\n        });\n        // 3. other\n        var branch2 = cloneASTElement(el);\n        getAndRemoveAttr(branch2, 'v-for', true);\n        addRawAttr(branch2, ':type', typeBinding);\n        processElement(branch2, options);\n        addIfCondition(branch0, {\n          exp: ifCondition,\n          block: branch2\n        });\n\n        if (hasElse) {\n          branch0.else = true;\n        } else if (elseIfCondition) {\n          branch0.elseif = elseIfCondition;\n        }\n\n        return branch0\n      }\n    }\n  }\n\n  function cloneASTElement (el) {\n    return createASTElement(el.tag, el.attrsList.slice(), el.parent)\n  }\n\n  var model$1 = {\n    preTransformNode: preTransformNode\n  };\n\n  var modules$1 = [\n    klass$1,\n    style$1,\n    model$1\n  ];\n\n  /*  */\n\n  function text (el, dir) {\n    if (dir.value) {\n      addProp(el, 'textContent', (\"_s(\" + (dir.value) + \")\"), dir);\n    }\n  }\n\n  /*  */\n\n  function html (el, dir) {\n    if (dir.value) {\n      addProp(el, 'innerHTML', (\"_s(\" + (dir.value) + \")\"), dir);\n    }\n  }\n\n  var directives$1 = {\n    model: model,\n    text: text,\n    html: html\n  };\n\n  /*  */\n\n  var baseOptions = {\n    expectHTML: true,\n    modules: modules$1,\n    directives: directives$1,\n    isPreTag: isPreTag,\n    isUnaryTag: isUnaryTag,\n    mustUseProp: mustUseProp,\n    canBeLeftOpenTag: canBeLeftOpenTag,\n    isReservedTag: isReservedTag,\n    getTagNamespace: getTagNamespace,\n    staticKeys: genStaticKeys(modules$1)\n  };\n\n  /*  */\n\n  var isStaticKey;\n  var isPlatformReservedTag;\n\n  var genStaticKeysCached = cached(genStaticKeys$1);\n\n  /**\n   * Goal of the optimizer: walk the generated template AST tree\n   * and detect sub-trees that are purely static, i.e. parts of\n   * the DOM that never needs to change.\n   *\n   * Once we detect these sub-trees, we can:\n   *\n   * 1. Hoist them into constants, so that we no longer need to\n   *    create fresh nodes for them on each re-render;\n   * 2. Completely skip them in the patching process.\n   */\n  function optimize (root, options) {\n    if (!root) { return }\n    isStaticKey = genStaticKeysCached(options.staticKeys || '');\n    isPlatformReservedTag = options.isReservedTag || no;\n    // first pass: mark all non-static nodes.\n    markStatic$1(root);\n    // second pass: mark static roots.\n    markStaticRoots(root, false);\n  }\n\n  function genStaticKeys$1 (keys) {\n    return makeMap(\n      'type,tag,attrsList,attrsMap,plain,parent,children,attrs,start,end,rawAttrsMap' +\n      (keys ? ',' + keys : '')\n    )\n  }\n\n  function markStatic$1 (node) {\n    node.static = isStatic(node);\n    if (node.type === 1) {\n      // do not make component slot content static. this avoids\n      // 1. components not able to mutate slot nodes\n      // 2. static slot content fails for hot-reloading\n      if (\n        !isPlatformReservedTag(node.tag) &&\n        node.tag !== 'slot' &&\n        node.attrsMap['inline-template'] == null\n      ) {\n        return\n      }\n      for (var i = 0, l = node.children.length; i < l; i++) {\n        var child = node.children[i];\n        markStatic$1(child);\n        if (!child.static) {\n          node.static = false;\n        }\n      }\n      if (node.ifConditions) {\n        for (var i$1 = 1, l$1 = node.ifConditions.length; i$1 < l$1; i$1++) {\n          var block = node.ifConditions[i$1].block;\n          markStatic$1(block);\n          if (!block.static) {\n            node.static = false;\n          }\n        }\n      }\n    }\n  }\n\n  function markStaticRoots (node, isInFor) {\n    if (node.type === 1) {\n      if (node.static || node.once) {\n        node.staticInFor = isInFor;\n      }\n      // For a node to qualify as a static root, it should have children that\n      // are not just static text. Otherwise the cost of hoisting out will\n      // outweigh the benefits and it's better off to just always render it fresh.\n      if (node.static && node.children.length && !(\n        node.children.length === 1 &&\n        node.children[0].type === 3\n      )) {\n        node.staticRoot = true;\n        return\n      } else {\n        node.staticRoot = false;\n      }\n      if (node.children) {\n        for (var i = 0, l = node.children.length; i < l; i++) {\n          markStaticRoots(node.children[i], isInFor || !!node.for);\n        }\n      }\n      if (node.ifConditions) {\n        for (var i$1 = 1, l$1 = node.ifConditions.length; i$1 < l$1; i$1++) {\n          markStaticRoots(node.ifConditions[i$1].block, isInFor);\n        }\n      }\n    }\n  }\n\n  function isStatic (node) {\n    if (node.type === 2) { // expression\n      return false\n    }\n    if (node.type === 3) { // text\n      return true\n    }\n    return !!(node.pre || (\n      !node.hasBindings && // no dynamic bindings\n      !node.if && !node.for && // not v-if or v-for or v-else\n      !isBuiltInTag(node.tag) && // not a built-in\n      isPlatformReservedTag(node.tag) && // not a component\n      !isDirectChildOfTemplateFor(node) &&\n      Object.keys(node).every(isStaticKey)\n    ))\n  }\n\n  function isDirectChildOfTemplateFor (node) {\n    while (node.parent) {\n      node = node.parent;\n      if (node.tag !== 'template') {\n        return false\n      }\n      if (node.for) {\n        return true\n      }\n    }\n    return false\n  }\n\n  /*  */\n\n  var fnExpRE = /^([\\w$_]+|\\([^)]*?\\))\\s*=>|^function\\s*\\(/;\n  var fnInvokeRE = /\\([^)]*?\\);*$/;\n  var simplePathRE = /^[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*|\\['[^']*?']|\\[\"[^\"]*?\"]|\\[\\d+]|\\[[A-Za-z_$][\\w$]*])*$/;\n\n  // KeyboardEvent.keyCode aliases\n  var keyCodes = {\n    esc: 27,\n    tab: 9,\n    enter: 13,\n    space: 32,\n    up: 38,\n    left: 37,\n    right: 39,\n    down: 40,\n    'delete': [8, 46]\n  };\n\n  // KeyboardEvent.key aliases\n  var keyNames = {\n    // #7880: IE11 and Edge use `Esc` for Escape key name.\n    esc: ['Esc', 'Escape'],\n    tab: 'Tab',\n    enter: 'Enter',\n    // #9112: IE11 uses `Spacebar` for Space key name.\n    space: [' ', 'Spacebar'],\n    // #7806: IE11 uses key names without `Arrow` prefix for arrow keys.\n    up: ['Up', 'ArrowUp'],\n    left: ['Left', 'ArrowLeft'],\n    right: ['Right', 'ArrowRight'],\n    down: ['Down', 'ArrowDown'],\n    // #9112: IE11 uses `Del` for Delete key name.\n    'delete': ['Backspace', 'Delete', 'Del']\n  };\n\n  // #4868: modifiers that prevent the execution of the listener\n  // need to explicitly return null so that we can determine whether to remove\n  // the listener for .once\n  var genGuard = function (condition) { return (\"if(\" + condition + \")return null;\"); };\n\n  var modifierCode = {\n    stop: '$event.stopPropagation();',\n    prevent: '$event.preventDefault();',\n    self: genGuard(\"$event.target !== $event.currentTarget\"),\n    ctrl: genGuard(\"!$event.ctrlKey\"),\n    shift: genGuard(\"!$event.shiftKey\"),\n    alt: genGuard(\"!$event.altKey\"),\n    meta: genGuard(\"!$event.metaKey\"),\n    left: genGuard(\"'button' in $event && $event.button !== 0\"),\n    middle: genGuard(\"'button' in $event && $event.button !== 1\"),\n    right: genGuard(\"'button' in $event && $event.button !== 2\")\n  };\n\n  function genHandlers (\n    events,\n    isNative\n  ) {\n    var prefix = isNative ? 'nativeOn:' : 'on:';\n    var staticHandlers = \"\";\n    var dynamicHandlers = \"\";\n    for (var name in events) {\n      var handlerCode = genHandler(events[name]);\n      if (events[name] && events[name].dynamic) {\n        dynamicHandlers += name + \",\" + handlerCode + \",\";\n      } else {\n        staticHandlers += \"\\\"\" + name + \"\\\":\" + handlerCode + \",\";\n      }\n    }\n    staticHandlers = \"{\" + (staticHandlers.slice(0, -1)) + \"}\";\n    if (dynamicHandlers) {\n      return prefix + \"_d(\" + staticHandlers + \",[\" + (dynamicHandlers.slice(0, -1)) + \"])\"\n    } else {\n      return prefix + staticHandlers\n    }\n  }\n\n  function genHandler (handler) {\n    if (!handler) {\n      return 'function(){}'\n    }\n\n    if (Array.isArray(handler)) {\n      return (\"[\" + (handler.map(function (handler) { return genHandler(handler); }).join(',')) + \"]\")\n    }\n\n    var isMethodPath = simplePathRE.test(handler.value);\n    var isFunctionExpression = fnExpRE.test(handler.value);\n    var isFunctionInvocation = simplePathRE.test(handler.value.replace(fnInvokeRE, ''));\n\n    if (!handler.modifiers) {\n      if (isMethodPath || isFunctionExpression) {\n        return handler.value\n      }\n      return (\"function($event){\" + (isFunctionInvocation ? (\"return \" + (handler.value)) : handler.value) + \"}\") // inline statement\n    } else {\n      var code = '';\n      var genModifierCode = '';\n      var keys = [];\n      for (var key in handler.modifiers) {\n        if (modifierCode[key]) {\n          genModifierCode += modifierCode[key];\n          // left/right\n          if (keyCodes[key]) {\n            keys.push(key);\n          }\n        } else if (key === 'exact') {\n          var modifiers = (handler.modifiers);\n          genModifierCode += genGuard(\n            ['ctrl', 'shift', 'alt', 'meta']\n              .filter(function (keyModifier) { return !modifiers[keyModifier]; })\n              .map(function (keyModifier) { return (\"$event.\" + keyModifier + \"Key\"); })\n              .join('||')\n          );\n        } else {\n          keys.push(key);\n        }\n      }\n      if (keys.length) {\n        code += genKeyFilter(keys);\n      }\n      // Make sure modifiers like prevent and stop get executed after key filtering\n      if (genModifierCode) {\n        code += genModifierCode;\n      }\n      var handlerCode = isMethodPath\n        ? (\"return \" + (handler.value) + \"($event)\")\n        : isFunctionExpression\n          ? (\"return (\" + (handler.value) + \")($event)\")\n          : isFunctionInvocation\n            ? (\"return \" + (handler.value))\n            : handler.value;\n      return (\"function($event){\" + code + handlerCode + \"}\")\n    }\n  }\n\n  function genKeyFilter (keys) {\n    return (\n      // make sure the key filters only apply to KeyboardEvents\n      // #9441: can't use 'keyCode' in $event because Chrome autofill fires fake\n      // key events that do not have keyCode property...\n      \"if(!$event.type.indexOf('key')&&\" +\n      (keys.map(genFilterCode).join('&&')) + \")return null;\"\n    )\n  }\n\n  function genFilterCode (key) {\n    var keyVal = parseInt(key, 10);\n    if (keyVal) {\n      return (\"$event.keyCode!==\" + keyVal)\n    }\n    var keyCode = keyCodes[key];\n    var keyName = keyNames[key];\n    return (\n      \"_k($event.keyCode,\" +\n      (JSON.stringify(key)) + \",\" +\n      (JSON.stringify(keyCode)) + \",\" +\n      \"$event.key,\" +\n      \"\" + (JSON.stringify(keyName)) +\n      \")\"\n    )\n  }\n\n  /*  */\n\n  function on (el, dir) {\n    if (dir.modifiers) {\n      warn(\"v-on without argument does not support modifiers.\");\n    }\n    el.wrapListeners = function (code) { return (\"_g(\" + code + \",\" + (dir.value) + \")\"); };\n  }\n\n  /*  */\n\n  function bind$1 (el, dir) {\n    el.wrapData = function (code) {\n      return (\"_b(\" + code + \",'\" + (el.tag) + \"',\" + (dir.value) + \",\" + (dir.modifiers && dir.modifiers.prop ? 'true' : 'false') + (dir.modifiers && dir.modifiers.sync ? ',true' : '') + \")\")\n    };\n  }\n\n  /*  */\n\n  var baseDirectives = {\n    on: on,\n    bind: bind$1,\n    cloak: noop\n  };\n\n  /*  */\n\n\n\n\n\n  var CodegenState = function CodegenState (options) {\n    this.options = options;\n    this.warn = options.warn || baseWarn;\n    this.transforms = pluckModuleFunction(options.modules, 'transformCode');\n    this.dataGenFns = pluckModuleFunction(options.modules, 'genData');\n    this.directives = extend(extend({}, baseDirectives), options.directives);\n    var isReservedTag = options.isReservedTag || no;\n    this.maybeComponent = function (el) { return !!el.component || !isReservedTag(el.tag); };\n    this.onceId = 0;\n    this.staticRenderFns = [];\n    this.pre = false;\n  };\n\n\n\n  function generate (\n    ast,\n    options\n  ) {\n    var state = new CodegenState(options);\n    var code = ast ? genElement(ast, state) : '_c(\"div\")';\n    return {\n      render: (\"with(this){return \" + code + \"}\"),\n      staticRenderFns: state.staticRenderFns\n    }\n  }\n\n  function genElement (el, state) {\n    if (el.parent) {\n      el.pre = el.pre || el.parent.pre;\n    }\n\n    if (el.staticRoot && !el.staticProcessed) {\n      return genStatic(el, state)\n    } else if (el.once && !el.onceProcessed) {\n      return genOnce(el, state)\n    } else if (el.for && !el.forProcessed) {\n      return genFor(el, state)\n    } else if (el.if && !el.ifProcessed) {\n      return genIf(el, state)\n    } else if (el.tag === 'template' && !el.slotTarget && !state.pre) {\n      return genChildren(el, state) || 'void 0'\n    } else if (el.tag === 'slot') {\n      return genSlot(el, state)\n    } else {\n      // component or element\n      var code;\n      if (el.component) {\n        code = genComponent(el.component, el, state);\n      } else {\n        var data;\n        if (!el.plain || (el.pre && state.maybeComponent(el))) {\n          data = genData$2(el, state);\n        }\n\n        var children = el.inlineTemplate ? null : genChildren(el, state, true);\n        code = \"_c('\" + (el.tag) + \"'\" + (data ? (\",\" + data) : '') + (children ? (\",\" + children) : '') + \")\";\n      }\n      // module transforms\n      for (var i = 0; i < state.transforms.length; i++) {\n        code = state.transforms[i](el, code);\n      }\n      return code\n    }\n  }\n\n  // hoist static sub-trees out\n  function genStatic (el, state) {\n    el.staticProcessed = true;\n    // Some elements (templates) need to behave differently inside of a v-pre\n    // node.  All pre nodes are static roots, so we can use this as a location to\n    // wrap a state change and reset it upon exiting the pre node.\n    var originalPreState = state.pre;\n    if (el.pre) {\n      state.pre = el.pre;\n    }\n    state.staticRenderFns.push((\"with(this){return \" + (genElement(el, state)) + \"}\"));\n    state.pre = originalPreState;\n    return (\"_m(\" + (state.staticRenderFns.length - 1) + (el.staticInFor ? ',true' : '') + \")\")\n  }\n\n  // v-once\n  function genOnce (el, state) {\n    el.onceProcessed = true;\n    if (el.if && !el.ifProcessed) {\n      return genIf(el, state)\n    } else if (el.staticInFor) {\n      var key = '';\n      var parent = el.parent;\n      while (parent) {\n        if (parent.for) {\n          key = parent.key;\n          break\n        }\n        parent = parent.parent;\n      }\n      if (!key) {\n        state.warn(\n          \"v-once can only be used inside v-for that is keyed. \",\n          el.rawAttrsMap['v-once']\n        );\n        return genElement(el, state)\n      }\n      return (\"_o(\" + (genElement(el, state)) + \",\" + (state.onceId++) + \",\" + key + \")\")\n    } else {\n      return genStatic(el, state)\n    }\n  }\n\n  function genIf (\n    el,\n    state,\n    altGen,\n    altEmpty\n  ) {\n    el.ifProcessed = true; // avoid recursion\n    return genIfConditions(el.ifConditions.slice(), state, altGen, altEmpty)\n  }\n\n  function genIfConditions (\n    conditions,\n    state,\n    altGen,\n    altEmpty\n  ) {\n    if (!conditions.length) {\n      return altEmpty || '_e()'\n    }\n\n    var condition = conditions.shift();\n    if (condition.exp) {\n      return (\"(\" + (condition.exp) + \")?\" + (genTernaryExp(condition.block)) + \":\" + (genIfConditions(conditions, state, altGen, altEmpty)))\n    } else {\n      return (\"\" + (genTernaryExp(condition.block)))\n    }\n\n    // v-if with v-once should generate code like (a)?_m(0):_m(1)\n    function genTernaryExp (el) {\n      return altGen\n        ? altGen(el, state)\n        : el.once\n          ? genOnce(el, state)\n          : genElement(el, state)\n    }\n  }\n\n  function genFor (\n    el,\n    state,\n    altGen,\n    altHelper\n  ) {\n    var exp = el.for;\n    var alias = el.alias;\n    var iterator1 = el.iterator1 ? (\",\" + (el.iterator1)) : '';\n    var iterator2 = el.iterator2 ? (\",\" + (el.iterator2)) : '';\n\n    if (state.maybeComponent(el) &&\n      el.tag !== 'slot' &&\n      el.tag !== 'template' &&\n      !el.key\n    ) {\n      state.warn(\n        \"<\" + (el.tag) + \" v-for=\\\"\" + alias + \" in \" + exp + \"\\\">: component lists rendered with \" +\n        \"v-for should have explicit keys. \" +\n        \"See https://vuejs.org/guide/list.html#key for more info.\",\n        el.rawAttrsMap['v-for'],\n        true /* tip */\n      );\n    }\n\n    el.forProcessed = true; // avoid recursion\n    return (altHelper || '_l') + \"((\" + exp + \"),\" +\n      \"function(\" + alias + iterator1 + iterator2 + \"){\" +\n        \"return \" + ((altGen || genElement)(el, state)) +\n      '})'\n  }\n\n  function genData$2 (el, state) {\n    var data = '{';\n\n    // directives first.\n    // directives may mutate the el's other properties before they are generated.\n    var dirs = genDirectives(el, state);\n    if (dirs) { data += dirs + ','; }\n\n    // key\n    if (el.key) {\n      data += \"key:\" + (el.key) + \",\";\n    }\n    // ref\n    if (el.ref) {\n      data += \"ref:\" + (el.ref) + \",\";\n    }\n    if (el.refInFor) {\n      data += \"refInFor:true,\";\n    }\n    // pre\n    if (el.pre) {\n      data += \"pre:true,\";\n    }\n    // record original tag name for components using \"is\" attribute\n    if (el.component) {\n      data += \"tag:\\\"\" + (el.tag) + \"\\\",\";\n    }\n    // module data generation functions\n    for (var i = 0; i < state.dataGenFns.length; i++) {\n      data += state.dataGenFns[i](el);\n    }\n    // attributes\n    if (el.attrs) {\n      data += \"attrs:\" + (genProps(el.attrs)) + \",\";\n    }\n    // DOM props\n    if (el.props) {\n      data += \"domProps:\" + (genProps(el.props)) + \",\";\n    }\n    // event handlers\n    if (el.events) {\n      data += (genHandlers(el.events, false)) + \",\";\n    }\n    if (el.nativeEvents) {\n      data += (genHandlers(el.nativeEvents, true)) + \",\";\n    }\n    // slot target\n    // only for non-scoped slots\n    if (el.slotTarget && !el.slotScope) {\n      data += \"slot:\" + (el.slotTarget) + \",\";\n    }\n    // scoped slots\n    if (el.scopedSlots) {\n      data += (genScopedSlots(el, el.scopedSlots, state)) + \",\";\n    }\n    // component v-model\n    if (el.model) {\n      data += \"model:{value:\" + (el.model.value) + \",callback:\" + (el.model.callback) + \",expression:\" + (el.model.expression) + \"},\";\n    }\n    // inline-template\n    if (el.inlineTemplate) {\n      var inlineTemplate = genInlineTemplate(el, state);\n      if (inlineTemplate) {\n        data += inlineTemplate + \",\";\n      }\n    }\n    data = data.replace(/,$/, '') + '}';\n    // v-bind dynamic argument wrap\n    // v-bind with dynamic arguments must be applied using the same v-bind object\n    // merge helper so that class/style/mustUseProp attrs are handled correctly.\n    if (el.dynamicAttrs) {\n      data = \"_b(\" + data + \",\\\"\" + (el.tag) + \"\\\",\" + (genProps(el.dynamicAttrs)) + \")\";\n    }\n    // v-bind data wrap\n    if (el.wrapData) {\n      data = el.wrapData(data);\n    }\n    // v-on data wrap\n    if (el.wrapListeners) {\n      data = el.wrapListeners(data);\n    }\n    return data\n  }\n\n  function genDirectives (el, state) {\n    var dirs = el.directives;\n    if (!dirs) { return }\n    var res = 'directives:[';\n    var hasRuntime = false;\n    var i, l, dir, needRuntime;\n    for (i = 0, l = dirs.length; i < l; i++) {\n      dir = dirs[i];\n      needRuntime = true;\n      var gen = state.directives[dir.name];\n      if (gen) {\n        // compile-time directive that manipulates AST.\n        // returns true if it also needs a runtime counterpart.\n        needRuntime = !!gen(el, dir, state.warn);\n      }\n      if (needRuntime) {\n        hasRuntime = true;\n        res += \"{name:\\\"\" + (dir.name) + \"\\\",rawName:\\\"\" + (dir.rawName) + \"\\\"\" + (dir.value ? (\",value:(\" + (dir.value) + \"),expression:\" + (JSON.stringify(dir.value))) : '') + (dir.arg ? (\",arg:\" + (dir.isDynamicArg ? dir.arg : (\"\\\"\" + (dir.arg) + \"\\\"\"))) : '') + (dir.modifiers ? (\",modifiers:\" + (JSON.stringify(dir.modifiers))) : '') + \"},\";\n      }\n    }\n    if (hasRuntime) {\n      return res.slice(0, -1) + ']'\n    }\n  }\n\n  function genInlineTemplate (el, state) {\n    var ast = el.children[0];\n    if (el.children.length !== 1 || ast.type !== 1) {\n      state.warn(\n        'Inline-template components must have exactly one child element.',\n        { start: el.start }\n      );\n    }\n    if (ast && ast.type === 1) {\n      var inlineRenderFns = generate(ast, state.options);\n      return (\"inlineTemplate:{render:function(){\" + (inlineRenderFns.render) + \"},staticRenderFns:[\" + (inlineRenderFns.staticRenderFns.map(function (code) { return (\"function(){\" + code + \"}\"); }).join(',')) + \"]}\")\n    }\n  }\n\n  function genScopedSlots (\n    el,\n    slots,\n    state\n  ) {\n    // by default scoped slots are considered \"stable\", this allows child\n    // components with only scoped slots to skip forced updates from parent.\n    // but in some cases we have to bail-out of this optimization\n    // for example if the slot contains dynamic names, has v-if or v-for on them...\n    var needsForceUpdate = el.for || Object.keys(slots).some(function (key) {\n      var slot = slots[key];\n      return (\n        slot.slotTargetDynamic ||\n        slot.if ||\n        slot.for ||\n        containsSlotChild(slot) // is passing down slot from parent which may be dynamic\n      )\n    });\n\n    // #9534: if a component with scoped slots is inside a conditional branch,\n    // it's possible for the same component to be reused but with different\n    // compiled slot content. To avoid that, we generate a unique key based on\n    // the generated code of all the slot contents.\n    var needsKey = !!el.if;\n\n    // OR when it is inside another scoped slot or v-for (the reactivity may be\n    // disconnected due to the intermediate scope variable)\n    // #9438, #9506\n    // TODO: this can be further optimized by properly analyzing in-scope bindings\n    // and skip force updating ones that do not actually use scope variables.\n    if (!needsForceUpdate) {\n      var parent = el.parent;\n      while (parent) {\n        if (\n          (parent.slotScope && parent.slotScope !== emptySlotScopeToken) ||\n          parent.for\n        ) {\n          needsForceUpdate = true;\n          break\n        }\n        if (parent.if) {\n          needsKey = true;\n        }\n        parent = parent.parent;\n      }\n    }\n\n    var generatedSlots = Object.keys(slots)\n      .map(function (key) { return genScopedSlot(slots[key], state); })\n      .join(',');\n\n    return (\"scopedSlots:_u([\" + generatedSlots + \"]\" + (needsForceUpdate ? \",null,true\" : \"\") + (!needsForceUpdate && needsKey ? (\",null,false,\" + (hash(generatedSlots))) : \"\") + \")\")\n  }\n\n  function hash(str) {\n    var hash = 5381;\n    var i = str.length;\n    while(i) {\n      hash = (hash * 33) ^ str.charCodeAt(--i);\n    }\n    return hash >>> 0\n  }\n\n  function containsSlotChild (el) {\n    if (el.type === 1) {\n      if (el.tag === 'slot') {\n        return true\n      }\n      return el.children.some(containsSlotChild)\n    }\n    return false\n  }\n\n  function genScopedSlot (\n    el,\n    state\n  ) {\n    var isLegacySyntax = el.attrsMap['slot-scope'];\n    if (el.if && !el.ifProcessed && !isLegacySyntax) {\n      return genIf(el, state, genScopedSlot, \"null\")\n    }\n    if (el.for && !el.forProcessed) {\n      return genFor(el, state, genScopedSlot)\n    }\n    var slotScope = el.slotScope === emptySlotScopeToken\n      ? \"\"\n      : String(el.slotScope);\n    var fn = \"function(\" + slotScope + \"){\" +\n      \"return \" + (el.tag === 'template'\n        ? el.if && isLegacySyntax\n          ? (\"(\" + (el.if) + \")?\" + (genChildren(el, state) || 'undefined') + \":undefined\")\n          : genChildren(el, state) || 'undefined'\n        : genElement(el, state)) + \"}\";\n    // reverse proxy v-slot without scope on this.$slots\n    var reverseProxy = slotScope ? \"\" : \",proxy:true\";\n    return (\"{key:\" + (el.slotTarget || \"\\\"default\\\"\") + \",fn:\" + fn + reverseProxy + \"}\")\n  }\n\n  function genChildren (\n    el,\n    state,\n    checkSkip,\n    altGenElement,\n    altGenNode\n  ) {\n    var children = el.children;\n    if (children.length) {\n      var el$1 = children[0];\n      // optimize single v-for\n      if (children.length === 1 &&\n        el$1.for &&\n        el$1.tag !== 'template' &&\n        el$1.tag !== 'slot'\n      ) {\n        var normalizationType = checkSkip\n          ? state.maybeComponent(el$1) ? \",1\" : \",0\"\n          : \"\";\n        return (\"\" + ((altGenElement || genElement)(el$1, state)) + normalizationType)\n      }\n      var normalizationType$1 = checkSkip\n        ? getNormalizationType(children, state.maybeComponent)\n        : 0;\n      var gen = altGenNode || genNode;\n      return (\"[\" + (children.map(function (c) { return gen(c, state); }).join(',')) + \"]\" + (normalizationType$1 ? (\",\" + normalizationType$1) : ''))\n    }\n  }\n\n  // determine the normalization needed for the children array.\n  // 0: no normalization needed\n  // 1: simple normalization needed (possible 1-level deep nested array)\n  // 2: full normalization needed\n  function getNormalizationType (\n    children,\n    maybeComponent\n  ) {\n    var res = 0;\n    for (var i = 0; i < children.length; i++) {\n      var el = children[i];\n      if (el.type !== 1) {\n        continue\n      }\n      if (needsNormalization(el) ||\n          (el.ifConditions && el.ifConditions.some(function (c) { return needsNormalization(c.block); }))) {\n        res = 2;\n        break\n      }\n      if (maybeComponent(el) ||\n          (el.ifConditions && el.ifConditions.some(function (c) { return maybeComponent(c.block); }))) {\n        res = 1;\n      }\n    }\n    return res\n  }\n\n  function needsNormalization (el) {\n    return el.for !== undefined || el.tag === 'template' || el.tag === 'slot'\n  }\n\n  function genNode (node, state) {\n    if (node.type === 1) {\n      return genElement(node, state)\n    } else if (node.type === 3 && node.isComment) {\n      return genComment(node)\n    } else {\n      return genText(node)\n    }\n  }\n\n  function genText (text) {\n    return (\"_v(\" + (text.type === 2\n      ? text.expression // no need for () because already wrapped in _s()\n      : transformSpecialNewlines(JSON.stringify(text.text))) + \")\")\n  }\n\n  function genComment (comment) {\n    return (\"_e(\" + (JSON.stringify(comment.text)) + \")\")\n  }\n\n  function genSlot (el, state) {\n    var slotName = el.slotName || '\"default\"';\n    var children = genChildren(el, state);\n    var res = \"_t(\" + slotName + (children ? (\",\" + children) : '');\n    var attrs = el.attrs || el.dynamicAttrs\n      ? genProps((el.attrs || []).concat(el.dynamicAttrs || []).map(function (attr) { return ({\n          // slot props are camelized\n          name: camelize(attr.name),\n          value: attr.value,\n          dynamic: attr.dynamic\n        }); }))\n      : null;\n    var bind$$1 = el.attrsMap['v-bind'];\n    if ((attrs || bind$$1) && !children) {\n      res += \",null\";\n    }\n    if (attrs) {\n      res += \",\" + attrs;\n    }\n    if (bind$$1) {\n      res += (attrs ? '' : ',null') + \",\" + bind$$1;\n    }\n    return res + ')'\n  }\n\n  // componentName is el.component, take it as argument to shun flow's pessimistic refinement\n  function genComponent (\n    componentName,\n    el,\n    state\n  ) {\n    var children = el.inlineTemplate ? null : genChildren(el, state, true);\n    return (\"_c(\" + componentName + \",\" + (genData$2(el, state)) + (children ? (\",\" + children) : '') + \")\")\n  }\n\n  function genProps (props) {\n    var staticProps = \"\";\n    var dynamicProps = \"\";\n    for (var i = 0; i < props.length; i++) {\n      var prop = props[i];\n      var value = transformSpecialNewlines(prop.value);\n      if (prop.dynamic) {\n        dynamicProps += (prop.name) + \",\" + value + \",\";\n      } else {\n        staticProps += \"\\\"\" + (prop.name) + \"\\\":\" + value + \",\";\n      }\n    }\n    staticProps = \"{\" + (staticProps.slice(0, -1)) + \"}\";\n    if (dynamicProps) {\n      return (\"_d(\" + staticProps + \",[\" + (dynamicProps.slice(0, -1)) + \"])\")\n    } else {\n      return staticProps\n    }\n  }\n\n  // #3895, #4268\n  function transformSpecialNewlines (text) {\n    return text\n      .replace(/\\u2028/g, '\\\\u2028')\n      .replace(/\\u2029/g, '\\\\u2029')\n  }\n\n  /*  */\n\n\n\n  // these keywords should not appear inside expressions, but operators like\n  // typeof, instanceof and in are allowed\n  var prohibitedKeywordRE = new RegExp('\\\\b' + (\n    'do,if,for,let,new,try,var,case,else,with,await,break,catch,class,const,' +\n    'super,throw,while,yield,delete,export,import,return,switch,default,' +\n    'extends,finally,continue,debugger,function,arguments'\n  ).split(',').join('\\\\b|\\\\b') + '\\\\b');\n\n  // these unary operators should not be used as property/method names\n  var unaryOperatorsRE = new RegExp('\\\\b' + (\n    'delete,typeof,void'\n  ).split(',').join('\\\\s*\\\\([^\\\\)]*\\\\)|\\\\b') + '\\\\s*\\\\([^\\\\)]*\\\\)');\n\n  // strip strings in expressions\n  var stripStringRE = /'(?:[^'\\\\]|\\\\.)*'|\"(?:[^\"\\\\]|\\\\.)*\"|`(?:[^`\\\\]|\\\\.)*\\$\\{|\\}(?:[^`\\\\]|\\\\.)*`|`(?:[^`\\\\]|\\\\.)*`/g;\n\n  // detect problematic expressions in a template\n  function detectErrors (ast, warn) {\n    if (ast) {\n      checkNode(ast, warn);\n    }\n  }\n\n  function checkNode (node, warn) {\n    if (node.type === 1) {\n      for (var name in node.attrsMap) {\n        if (dirRE.test(name)) {\n          var value = node.attrsMap[name];\n          if (value) {\n            var range = node.rawAttrsMap[name];\n            if (name === 'v-for') {\n              checkFor(node, (\"v-for=\\\"\" + value + \"\\\"\"), warn, range);\n            } else if (onRE.test(name)) {\n              checkEvent(value, (name + \"=\\\"\" + value + \"\\\"\"), warn, range);\n            } else {\n              checkExpression(value, (name + \"=\\\"\" + value + \"\\\"\"), warn, range);\n            }\n          }\n        }\n      }\n      if (node.children) {\n        for (var i = 0; i < node.children.length; i++) {\n          checkNode(node.children[i], warn);\n        }\n      }\n    } else if (node.type === 2) {\n      checkExpression(node.expression, node.text, warn, node);\n    }\n  }\n\n  function checkEvent (exp, text, warn, range) {\n    var stipped = exp.replace(stripStringRE, '');\n    var keywordMatch = stipped.match(unaryOperatorsRE);\n    if (keywordMatch && stipped.charAt(keywordMatch.index - 1) !== '$') {\n      warn(\n        \"avoid using JavaScript unary operator as property name: \" +\n        \"\\\"\" + (keywordMatch[0]) + \"\\\" in expression \" + (text.trim()),\n        range\n      );\n    }\n    checkExpression(exp, text, warn, range);\n  }\n\n  function checkFor (node, text, warn, range) {\n    checkExpression(node.for || '', text, warn, range);\n    checkIdentifier(node.alias, 'v-for alias', text, warn, range);\n    checkIdentifier(node.iterator1, 'v-for iterator', text, warn, range);\n    checkIdentifier(node.iterator2, 'v-for iterator', text, warn, range);\n  }\n\n  function checkIdentifier (\n    ident,\n    type,\n    text,\n    warn,\n    range\n  ) {\n    if (typeof ident === 'string') {\n      try {\n        new Function((\"var \" + ident + \"=_\"));\n      } catch (e) {\n        warn((\"invalid \" + type + \" \\\"\" + ident + \"\\\" in expression: \" + (text.trim())), range);\n      }\n    }\n  }\n\n  function checkExpression (exp, text, warn, range) {\n    try {\n      new Function((\"return \" + exp));\n    } catch (e) {\n      var keywordMatch = exp.replace(stripStringRE, '').match(prohibitedKeywordRE);\n      if (keywordMatch) {\n        warn(\n          \"avoid using JavaScript keyword as property name: \" +\n          \"\\\"\" + (keywordMatch[0]) + \"\\\"\\n  Raw expression: \" + (text.trim()),\n          range\n        );\n      } else {\n        warn(\n          \"invalid expression: \" + (e.message) + \" in\\n\\n\" +\n          \"    \" + exp + \"\\n\\n\" +\n          \"  Raw expression: \" + (text.trim()) + \"\\n\",\n          range\n        );\n      }\n    }\n  }\n\n  /*  */\n\n  var range = 2;\n\n  function generateCodeFrame (\n    source,\n    start,\n    end\n  ) {\n    if ( start === void 0 ) start = 0;\n    if ( end === void 0 ) end = source.length;\n\n    var lines = source.split(/\\r?\\n/);\n    var count = 0;\n    var res = [];\n    for (var i = 0; i < lines.length; i++) {\n      count += lines[i].length + 1;\n      if (count >= start) {\n        for (var j = i - range; j <= i + range || end > count; j++) {\n          if (j < 0 || j >= lines.length) { continue }\n          res.push((\"\" + (j + 1) + (repeat$1(\" \", 3 - String(j + 1).length)) + \"|  \" + (lines[j])));\n          var lineLength = lines[j].length;\n          if (j === i) {\n            // push underline\n            var pad = start - (count - lineLength) + 1;\n            var length = end > count ? lineLength - pad : end - start;\n            res.push(\"   |  \" + repeat$1(\" \", pad) + repeat$1(\"^\", length));\n          } else if (j > i) {\n            if (end > count) {\n              var length$1 = Math.min(end - count, lineLength);\n              res.push(\"   |  \" + repeat$1(\"^\", length$1));\n            }\n            count += lineLength + 1;\n          }\n        }\n        break\n      }\n    }\n    return res.join('\\n')\n  }\n\n  function repeat$1 (str, n) {\n    var result = '';\n    if (n > 0) {\n      while (true) { // eslint-disable-line\n        if (n & 1) { result += str; }\n        n >>>= 1;\n        if (n <= 0) { break }\n        str += str;\n      }\n    }\n    return result\n  }\n\n  /*  */\n\n\n\n  function createFunction (code, errors) {\n    try {\n      return new Function(code)\n    } catch (err) {\n      errors.push({ err: err, code: code });\n      return noop\n    }\n  }\n\n  function createCompileToFunctionFn (compile) {\n    var cache = Object.create(null);\n\n    return function compileToFunctions (\n      template,\n      options,\n      vm\n    ) {\n      options = extend({}, options);\n      var warn$$1 = options.warn || warn;\n      delete options.warn;\n\n      /* istanbul ignore if */\n      {\n        // detect possible CSP restriction\n        try {\n          new Function('return 1');\n        } catch (e) {\n          if (e.toString().match(/unsafe-eval|CSP/)) {\n            warn$$1(\n              'It seems you are using the standalone build of Vue.js in an ' +\n              'environment with Content Security Policy that prohibits unsafe-eval. ' +\n              'The template compiler cannot work in this environment. Consider ' +\n              'relaxing the policy to allow unsafe-eval or pre-compiling your ' +\n              'templates into render functions.'\n            );\n          }\n        }\n      }\n\n      // check cache\n      var key = options.delimiters\n        ? String(options.delimiters) + template\n        : template;\n      if (cache[key]) {\n        return cache[key]\n      }\n\n      // compile\n      var compiled = compile(template, options);\n\n      // check compilation errors/tips\n      {\n        if (compiled.errors && compiled.errors.length) {\n          if (options.outputSourceRange) {\n            compiled.errors.forEach(function (e) {\n              warn$$1(\n                \"Error compiling template:\\n\\n\" + (e.msg) + \"\\n\\n\" +\n                generateCodeFrame(template, e.start, e.end),\n                vm\n              );\n            });\n          } else {\n            warn$$1(\n              \"Error compiling template:\\n\\n\" + template + \"\\n\\n\" +\n              compiled.errors.map(function (e) { return (\"- \" + e); }).join('\\n') + '\\n',\n              vm\n            );\n          }\n        }\n        if (compiled.tips && compiled.tips.length) {\n          if (options.outputSourceRange) {\n            compiled.tips.forEach(function (e) { return tip(e.msg, vm); });\n          } else {\n            compiled.tips.forEach(function (msg) { return tip(msg, vm); });\n          }\n        }\n      }\n\n      // turn code into functions\n      var res = {};\n      var fnGenErrors = [];\n      res.render = createFunction(compiled.render, fnGenErrors);\n      res.staticRenderFns = compiled.staticRenderFns.map(function (code) {\n        return createFunction(code, fnGenErrors)\n      });\n\n      // check function generation errors.\n      // this should only happen if there is a bug in the compiler itself.\n      // mostly for codegen development use\n      /* istanbul ignore if */\n      {\n        if ((!compiled.errors || !compiled.errors.length) && fnGenErrors.length) {\n          warn$$1(\n            \"Failed to generate render function:\\n\\n\" +\n            fnGenErrors.map(function (ref) {\n              var err = ref.err;\n              var code = ref.code;\n\n              return ((err.toString()) + \" in\\n\\n\" + code + \"\\n\");\n          }).join('\\n'),\n            vm\n          );\n        }\n      }\n\n      return (cache[key] = res)\n    }\n  }\n\n  /*  */\n\n  function createCompilerCreator (baseCompile) {\n    return function createCompiler (baseOptions) {\n      function compile (\n        template,\n        options\n      ) {\n        var finalOptions = Object.create(baseOptions);\n        var errors = [];\n        var tips = [];\n\n        var warn = function (msg, range, tip) {\n          (tip ? tips : errors).push(msg);\n        };\n\n        if (options) {\n          if (options.outputSourceRange) {\n            // $flow-disable-line\n            var leadingSpaceLength = template.match(/^\\s*/)[0].length;\n\n            warn = function (msg, range, tip) {\n              var data = { msg: msg };\n              if (range) {\n                if (range.start != null) {\n                  data.start = range.start + leadingSpaceLength;\n                }\n                if (range.end != null) {\n                  data.end = range.end + leadingSpaceLength;\n                }\n              }\n              (tip ? tips : errors).push(data);\n            };\n          }\n          // merge custom modules\n          if (options.modules) {\n            finalOptions.modules =\n              (baseOptions.modules || []).concat(options.modules);\n          }\n          // merge custom directives\n          if (options.directives) {\n            finalOptions.directives = extend(\n              Object.create(baseOptions.directives || null),\n              options.directives\n            );\n          }\n          // copy other options\n          for (var key in options) {\n            if (key !== 'modules' && key !== 'directives') {\n              finalOptions[key] = options[key];\n            }\n          }\n        }\n\n        finalOptions.warn = warn;\n\n        var compiled = baseCompile(template.trim(), finalOptions);\n        {\n          detectErrors(compiled.ast, warn);\n        }\n        compiled.errors = errors;\n        compiled.tips = tips;\n        return compiled\n      }\n\n      return {\n        compile: compile,\n        compileToFunctions: createCompileToFunctionFn(compile)\n      }\n    }\n  }\n\n  /*  */\n\n  // `createCompilerCreator` allows creating compilers that use alternative\n  // parser/optimizer/codegen, e.g the SSR optimizing compiler.\n  // Here we just export a default compiler using the default parts.\n  var createCompiler = createCompilerCreator(function baseCompile (\n    template,\n    options\n  ) {\n    var ast = parse(template.trim(), options);\n    if (options.optimize !== false) {\n      optimize(ast, options);\n    }\n    var code = generate(ast, options);\n    return {\n      ast: ast,\n      render: code.render,\n      staticRenderFns: code.staticRenderFns\n    }\n  });\n\n  /*  */\n\n  var ref$1 = createCompiler(baseOptions);\n  var compile = ref$1.compile;\n  var compileToFunctions = ref$1.compileToFunctions;\n\n  /*  */\n\n  // check whether current browser encodes a char inside attribute values\n  var div;\n  function getShouldDecode (href) {\n    div = div || document.createElement('div');\n    div.innerHTML = href ? \"<a href=\\\"\\n\\\"/>\" : \"<div a=\\\"\\n\\\"/>\";\n    return div.innerHTML.indexOf('&#10;') > 0\n  }\n\n  // #3663: IE encodes newlines inside attribute values while other browsers don't\n  var shouldDecodeNewlines = inBrowser ? getShouldDecode(false) : false;\n  // #6828: chrome encodes content in a[href]\n  var shouldDecodeNewlinesForHref = inBrowser ? getShouldDecode(true) : false;\n\n  /*  */\n\n  var idToTemplate = cached(function (id) {\n    var el = query(id);\n    return el && el.innerHTML\n  });\n\n  var mount = Vue.prototype.$mount;\n  Vue.prototype.$mount = function (\n    el,\n    hydrating\n  ) {\n    el = el && query(el);\n\n    /* istanbul ignore if */\n    if (el === document.body || el === document.documentElement) {\n      warn(\n        \"Do not mount Vue to <html> or <body> - mount to normal elements instead.\"\n      );\n      return this\n    }\n\n    var options = this.$options;\n    // resolve template/el and convert to render function\n    if (!options.render) {\n      var template = options.template;\n      if (template) {\n        if (typeof template === 'string') {\n          if (template.charAt(0) === '#') {\n            template = idToTemplate(template);\n            /* istanbul ignore if */\n            if (!template) {\n              warn(\n                (\"Template element not found or is empty: \" + (options.template)),\n                this\n              );\n            }\n          }\n        } else if (template.nodeType) {\n          template = template.innerHTML;\n        } else {\n          {\n            warn('invalid template option:' + template, this);\n          }\n          return this\n        }\n      } else if (el) {\n        template = getOuterHTML(el);\n      }\n      if (template) {\n        /* istanbul ignore if */\n        if (config.performance && mark) {\n          mark('compile');\n        }\n\n        var ref = compileToFunctions(template, {\n          outputSourceRange: \"development\" !== 'production',\n          shouldDecodeNewlines: shouldDecodeNewlines,\n          shouldDecodeNewlinesForHref: shouldDecodeNewlinesForHref,\n          delimiters: options.delimiters,\n          comments: options.comments\n        }, this);\n        var render = ref.render;\n        var staticRenderFns = ref.staticRenderFns;\n        options.render = render;\n        options.staticRenderFns = staticRenderFns;\n\n        /* istanbul ignore if */\n        if (config.performance && mark) {\n          mark('compile end');\n          measure((\"vue \" + (this._name) + \" compile\"), 'compile', 'compile end');\n        }\n      }\n    }\n    return mount.call(this, el, hydrating)\n  };\n\n  /**\n   * Get outerHTML of elements, taking care\n   * of SVG elements in IE as well.\n   */\n  function getOuterHTML (el) {\n    if (el.outerHTML) {\n      return el.outerHTML\n    } else {\n      var container = document.createElement('div');\n      container.appendChild(el.cloneNode(true));\n      return container.innerHTML\n    }\n  }\n\n  Vue.compile = compileToFunctions;\n\n  return Vue;\n\n}));\n"
  },
  {
    "path": "internal/view/assets/less/archive.less",
    "content": "@import \"./variables.less\";\n\n:root {\n\t--main: #f44336;\n\t--border: #e5e5e5;\n\t--colorLink: #999;\n\t--archiveHeaderBg: rgba(255, 255, 255, 0.95);\n\n\t@media (prefers-color-scheme: dark) {\n\t\t--border: #191919;\n\t\t--archiveHeaderBg: rgba(41, 41, 41, 0.95);\n\t}\n}\n\nbody {\n\tpadding: 0;\n\tmargin: 0;\n}\n\n* {\n\tbox-sizing: border-box;\n}\n\nbody.archive {\n\tdisplay: grid;\n\tgrid-template-rows: minmax(1px, auto) 1fr;\n\theight: 100dvh;\n\twidth: 100%;\n\n\t.header {\n\t\tdisplay: flex;\n\t\tflex-flow: row wrap;\n\t\theight: @header-height-desktop;\n\t\tbox-sizing: border-box;\n\t\tpadding: 0 @padding-standard;\n\t\talign-items: center;\n\t\tfont-size: 16px;\n\t\tborder-bottom: 1px solid var(--border);\n\t\tbackground-color: var(--archiveHeaderBg);\n\t\tgrid-row: 1;\n\n\t\t* {\n\t\t\tborder-width: 0;\n\t\t\tbox-sizing: border-box;\n\t\t\tfont-family: \"Source Sans Pro\", sans-serif;\n\t\t\tmargin: 0;\n\t\t\tpadding: 0;\n\t\t}\n\n\t\t> *:not(:last-child) {\n\t\t\tmargin-right: @padding-small;\n\t\t}\n\n\t\t> .spacer {\n\t\t\tflex: 1;\n\t\t}\n\n\t\t#shiori-logo {\n\t\t\tfont-size: 2em;\n\t\t\tfont-weight: 100;\n\t\t\tcolor: var(--main);\n\n\t\t\tspan {\n\t\t\t\tmargin-right: @padding-small;\n\t\t\t}\n\t\t}\n\n\t\ta {\n\t\t\tdisplay: block;\n\t\t\tcolor: var(--colorLink);\n\t\t\ttext-decoration: underline;\n\n\t\t\t&:hover,\n\t\t\t&:focus {\n\t\t\t\tcolor: var(--main);\n\t\t\t}\n\t\t}\n\n\t\t@media (max-width: @screen-sm-max) {\n\t\t\tfont-size: 14px;\n\t\t\theight: @header-height-mobile;\n\n\t\t\t#shiori-logo {\n\t\t\t\tfont-size: 1.5em;\n\t\t\t}\n\t\t}\n\t}\n\n\tiframe {\n\t\twidth: 100%;\n\t\theight: 100%;\n\t\tborder: none;\n\t\tgrid-row: 2;\n\t}\n}\n"
  },
  {
    "path": "internal/view/assets/less/bookmark-item.less",
    "content": ".bookmark {\n\tdisplay: flex;\n\tflex-flow: column nowrap;\n\tmin-width: 0;\n\tborder: 1px solid var(--border);\n\tbackground-color: var(--contentBg);\n\theight: 100%;\n\tposition: relative;\n\n\t&:hover,\n\t&:focus {\n\t\t.bookmark-menu > a {\n\t\t\tdisplay: block;\n\t\t}\n\t}\n\n\t&.selected {\n\t\tbackground-color: var(--selectedBg);\n\t}\n\n\t.bookmark-selector {\n\t\tposition: absolute;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\twidth: 100%;\n\t\theight: 100%;\n\t\tz-index: 9;\n\t}\n\n\t.bookmark-link {\n\t\tdisplay: block;\n\t\tcursor: default;\n\n\t\t&[href] {\n\t\t\tcursor: pointer;\n\n\t\t\t&:hover,\n\t\t\t&:focus {\n\t\t\t\t.title {\n\t\t\t\t\tcolor: var(--main);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tspan.thumbnail {\n\t\t\twidth: 100%;\n\t\t\theight: 200px;\n\t\t\tdisplay: block;\n\t\t\tbackground-size: cover;\n\t\t\tbackground-repeat: no-repeat;\n\t\t\tbackground-position: center center;\n\t\t\tmargin-bottom: 8px;\n\t\t\tborder-bottom: 1px solid var(--border);\n\t\t}\n\n\t\t.id {\n\t\t\tcolor: var(--color);\n\t\t\tborder: 1px solid var(--border);\n\t\t\tbackground-color: var(--contentBg);\n\t\t\tfont-size: 0.7em;\n\t\t\tfont-weight: bold;\n\t\t\tleft: -1px;\n\t\t\ttop: -1px;\n\t\t\tposition: absolute;\n\t\t\tpadding: 0px 0.3em;\n\t\t\topacity: 0.7;\n\t\t}\n\n\t\t.title {\n\t\t\ttext-overflow: ellipsis;\n\t\t\tword-wrap: break-word;\n\t\t\toverflow: hidden;\n\t\t\tfont-size: 1.2em;\n\t\t\tline-height: 1.3em;\n\t\t\tmax-height: 5.2em;\n\t\t\tfont-weight: 600;\n\t\t\tpadding: 0 16px;\n\t\t\tcolor: var(--color);\n\n\t\t\t&:first-child {\n\t\t\t\tmargin-top: 16px;\n\t\t\t}\n\n\t\t\ti {\n\t\t\t\tcolor: var(--colorLink);\n\t\t\t\tmargin-left: 4px;\n\t\t\t\tfont-size: 14px;\n\t\t\t}\n\t\t}\n\n\t\t.excerpt {\n\t\t\tcolor: var(--color);\n\t\t\tmargin-top: 8px;\n\t\t\tpadding: 0 16px;\n\t\t\ttext-overflow: ellipsis;\n\t\t\tword-wrap: break-word;\n\t\t\toverflow: hidden;\n\t\t\tfont-size: 0.9em;\n\t\t\tline-height: 1.5em;\n\t\t\tmax-height: 10.5em;\n\t\t}\n\t}\n\n\t.bookmark-tags {\n\t\tdisplay: flex;\n\t\tflex-flow: row wrap;\n\t\tmargin: 8px 0 -4px;\n\t\tpadding: 0 8px;\n\n\t\ta {\n\t\t\tmargin: 4px;\n\t\t\tpadding: 4px 8px;\n\t\t\tfont-size: 0.8em;\n\t\t\tfont-weight: 600;\n\t\t\tborder: 1px solid var(--border);\n\t\t\tborder-radius: 4px;\n\t\t\tcolor: var(--colorLink);\n\t\t\tbackground-color: var(--contentBg);\n\n\t\t\t&:hover,\n\t\t\t&:focus {\n\t\t\t\tcolor: var(--main);\n\t\t\t}\n\t\t}\n\t}\n\n\t.bookmark-menu {\n\t\tpadding: 8px 16px 16px;\n\t\tdisplay: flex;\n\t\tflex-flow: row nowrap;\n\t\tmin-width: 0;\n\t\tmin-height: 0;\n\t\talign-items: center;\n\n\t\ta {\n\t\t\tcolor: var(--colorLink);\n\t\t\tflex-shrink: 0;\n\t\t\topacity: 0.8;\n\t\t\tdisplay: none;\n\t\t\tfont-size: 0.9em;\n\n\t\t\t&:not(:last-child) {\n\t\t\t\tmargin-right: 12px;\n\t\t\t}\n\n\t\t\t&:hover,\n\t\t\t&:focus {\n\t\t\t\tcolor: var(--main);\n\t\t\t\topacity: 1;\n\t\t\t}\n\t\t}\n\n\t\t.url {\n\t\t\tflex: 1 0;\n\t\t\topacity: 1;\n\t\t\tdisplay: block;\n\t\t\twhite-space: nowrap;\n\t\t\toverflow: hidden;\n\t\t\ttext-overflow: ellipsis;\n\t\t\tline-height: 21px;\n\n\t\t\t&:not([href]) {\n\t\t\t\tcursor: default;\n\t\t\t\tcolor: var(--colorLink);\n\t\t\t}\n\t\t}\n\n\t\t// Display bookmark menu items on small/medium screens\n\t\t@media (max-width: 1024px) {\n\t\t\ta {\n\t\t\t\tdisplay: block;\n\t\t\t}\n\t\t}\n\t}\n}\n\n.bookmark.list {\n\tborder-top-width: 0;\n\tborder-bottom-width: 1px;\n\tpadding: 16px 24px 16px 100px;\n\n\t&:first-child {\n\t\tborder-top-width: 1px;\n\t}\n\n\t.bookmark-link {\n\t\tspan.thumbnail {\n\t\t\tposition: absolute;\n\t\t\ttop: 0;\n\t\t\tleft: 0;\n\t\t\twidth: 100px;\n\t\t\theight: 100%;\n\t\t\tmargin-bottom: 0;\n\t\t\tborder-bottom: 0px;\n\t\t\tborder-right: 1px solid var(--border);\n\t\t}\n\n\t\t.title {\n\t\t\tmargin: 0;\n\t\t\tpadding-left: 24px;\n\t\t}\n\t}\n\n\t.excerpt,\n\t> .spacer {\n\t\tdisplay: none;\n\t}\n\n\t.bookmark-tags {\n\t\tpadding-left: 16px;\n\t\tpadding-right: 0;\n\t}\n\n\t.bookmark-menu {\n\t\tpadding: 8px 0 0 24px;\n\t\talign-items: flex-end;\n\t}\n\n\t&.no-thumbnail {\n\t\tpadding-left: 16px;\n\t\tpadding-right: 16px;\n\n\t\t.bookmark-link .title {\n\t\t\tpadding: 0;\n\t\t\tmargin-bottom: 4px;\n\t\t}\n\n\t\t.excerpt {\n\t\t\tmargin-top: 0;\n\t\t\tmargin-bottom: 4px;\n\t\t\tpadding: 0;\n\t\t\tdisplay: block;\n\t\t}\n\n\t\t.bookmark-tags {\n\t\t\tpadding-left: 0;\n\t\t\tmargin: 0 -4px 0;\n\t\t}\n\n\t\t.bookmark-menu {\n\t\t\tpadding-top: 0;\n\t\t\tpadding-left: 0;\n\t\t}\n\t}\n\n\t@media (max-width: 600px) {\n\t\tpadding: 8px 16px 8px 70px;\n\t\tborder-width: 0 !important;\n\t\tborder-bottom-width: 1px !important;\n\n\t\t.bookmark-link {\n\t\t\tspan.thumbnail {\n\t\t\t\twidth: 70px;\n\t\t\t}\n\n\t\t\t.title {\n\t\t\t\tfont-size: 1.1em;\n\t\t\t\tfont-weight: 500;\n\t\t\t\tpadding-left: 16px;\n\t\t\t}\n\t\t}\n\n\t\t.bookmark-tags {\n\t\t\tpadding-left: 8px;\n\t\t}\n\n\t\t.bookmark-menu {\n\t\t\tpadding-left: 16px;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/view/assets/less/common.less",
    "content": "select {\n\tbackground: var(--contentBg);\n\tborder: 1px solid var(--border);\n\tborder-radius: 4px;\n\tcolor: var(--color);\n}\n\n.login-footer {\n\tcolor: var(--color);\n}\n\n.content-footer {\n\twidth: 100%;\n\tpadding: @padding-large;\n\tmax-width: 840px;\n\tmargin-bottom: 16px;\n\tbackground-color: var(--contentBg);\n\tborder: 1px solid var(--border);\n\tdisplay: flex;\n\tflex-flow: column;\n\talign-items: center;\n\n\t@media @display-mode-pwa {\n\t\tpadding-bottom: calc(@padding-large + env(safe-area-inset-bottom));\n\t}\n}\n\n.metadata {\n\tdisplay: flex;\n\tflex-flow: row wrap;\n\ttext-align: center;\n\tfont-size: 16px;\n\tcolor: var(--colorLink);\n\t&:nth-child(1) {\n\t\tjustify-content: flex-start;\n\t}\n\n\t&:nth-child(2) {\n\t\tjustify-content: flex-end;\n\t}\n\n\t&[v-cloak] {\n\t\tvisibility: hidden;\n\t}\n}\n\n.links {\n\tdisplay: flex;\n\tflex-flow: row wrap;\n\n\ta {\n\t\tpadding: 0 4px;\n\t\tcolor: var(--color);\n\t\ttext-decoration: underline;\n\n\t\t&:hover,\n\t\t&:focus {\n\t\t\tcolor: var(--main);\n\t\t}\n\t}\n}\n\n* {\n\tborder-width: 0;\n\tbox-sizing: border-box;\n\tfont-family: \"Source Sans Pro\", sans-serif;\n\tmargin: 0;\n\tpadding: 0;\n\ttext-decoration: none;\n}\n\nbody {\n\tbackground-color: var(--bg);\n}\n\na {\n\tcursor: pointer;\n}\n\n.spacer {\n\tflex: 1;\n}\n\n#login-scene {\n\theight: 100%;\n\theight: 100dvh;\n\tpadding: 16px;\n\toverflow: auto;\n\tdisplay: flex;\n\talign-items: center;\n\tflex-flow: column nowrap;\n\tbackground-color: var(--bg);\n\n\t> .error-message {\n\t\twidth: 100%;\n\t\tmax-width: 400px;\n\t\tfont-size: 1em;\n\t\tbackground-color: var(--contentBg);\n\t\tborder: 1px solid var(--border);\n\t\tpadding: 16px;\n\t\tmargin-top: auto;\n\t\tmargin-bottom: 16px;\n\t\ttext-align: center;\n\t\tcolor: var(--errorColor);\n\t}\n\n\t#login-box {\n\t\twidth: 100%;\n\t\tmax-width: 400px;\n\t\tmargin-bottom: auto;\n\t\tbackground-color: var(--contentBg);\n\t\tdisplay: flex;\n\t\tflex-flow: column nowrap;\n\t\tborder: 1px solid var(--border);\n\t\tflex-shrink: 0;\n\n\t\t&:first-child {\n\t\t\tmargin-top: auto;\n\t\t}\n\n\t\t#logo-area {\n\t\t\tdisplay: flex;\n\t\t\talign-items: center;\n\t\t\tflex-flow: column nowrap;\n\t\t\tpadding: 16px;\n\t\t\tbackground-color: var(--main);\n\t\t\tborder-bottom: 1px solid var(--border);\n\t\t\tflex-shrink: 0;\n\n\t\t\t#logo {\n\t\t\t\tfont-size: 3em;\n\t\t\t\tfont-weight: 100;\n\t\t\t\tcolor: var(--contentBg);\n\n\t\t\t\tspan {\n\t\t\t\t\tmargin-right: 8px;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t#tagline {\n\t\t\t\tfont-weight: 500;\n\t\t\t\tmargin-top: 4px;\n\t\t\t\tcolor: var(--contentBg);\n\t\t\t\ttext-align: center;\n\t\t\t}\n\t\t}\n\n\t\t#input-area {\n\t\t\tpadding: 16px;\n\t\t\tdisplay: grid;\n\t\t\tgrid-gap: 16px;\n\t\t\tgrid-template-columns: auto 1fr;\n\t\t\tjustify-content: baseline;\n\t\t\talign-items: center;\n\t\t\tborder-bottom: 1px solid var(--border);\n\n\t\t\t> label {\n\t\t\t\tcolor: var(--color);\n\t\t\t}\n\n\t\t\t> input {\n\t\t\t\tcolor: var(--color);\n\t\t\t\tpadding: 8px;\n\t\t\t\tbackground-color: var(--contentBg);\n\t\t\t\tborder: 1px solid var(--border);\n\t\t\t\tmin-width: 0;\n\t\t\t\tfont-size: 1em;\n\t\t\t}\n\n\t\t\t.checkbox-field {\n\t\t\t\tgrid-column: 1 / span 2;\n\t\t\t\tdisplay: flex;\n\t\t\t\tflex-flow: row nowrap;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\tcursor: pointer;\n\n\t\t\t\t&:hover,\n\t\t\t\t&:focus {\n\t\t\t\t\ttext-decoration: underline;\n\t\t\t\t\ttext-decoration-color: var(--main);\n\t\t\t\t}\n\n\t\t\t\t> input[type=\"checkbox\"] {\n\t\t\t\t\tmargin-right: 8px;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t#button-area {\n\t\t\tdisplay: flex;\n\t\t\tflex-flow: row nowrap;\n\t\t\tpadding: 16px;\n\t\t\tjustify-content: center;\n\n\t\t\ta {\n\t\t\t\tcolor: var(--color);\n\t\t\t\ttext-transform: uppercase;\n\t\t\t\ttext-align: center;\n\t\t\t\tfont-weight: 600;\n\t\t\t\tcursor: default;\n\n\t\t\t\t&.button {\n\t\t\t\t\tcursor: pointer;\n\n\t\t\t\t\t&:hover,\n\t\t\t\t\t&:focus {\n\t\t\t\t\t\tcolor: var(--main);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n#main-scene {\n\tmin-height: 100%;\n\tmin-height: 100dvh;\n\tpadding-top: @header-height-desktop;\n\tpadding-left: @sidebar-width;\n\tbackground-color: var(--bg);\n\n\t@media @display-mode-pwa {\n\t\tpadding-bottom: env(safe-area-inset-bottom);\n\t}\n\n\t#main-sidebar {\n\t\ttop: 0;\n\t\tleft: 0;\n\t\twidth: @sidebar-width;\n\t\theight: 100%;\n\t\theight: 100dvh;\n\t\tposition: fixed;\n\t\tdisplay: flex;\n\t\tflex-flow: column nowrap;\n\t\tbackground-color: var(--sidebarBg);\n\t\tz-index: 1;\n\n\t\ta {\n\t\t\tflex-shrink: 0;\n\t\t\tdisplay: block;\n\t\t\twidth: @sidebar-width;\n\t\t\tline-height: @sidebar-height-desktop;\n\t\t\ttext-align: center;\n\t\t\tfont-size: 1em;\n\t\t\tcolor: var(--colorSidebar);\n\n\t\t\t&.active {\n\t\t\t\tcursor: default;\n\t\t\t\tcolor: var(--colorSidebar);\n\t\t\t\tbackground-color: var(--main);\n\t\t\t}\n\n\t\t\t&:hover,\n\t\t\t&:focus {\n\t\t\t\tcolor: var(--main);\n\t\t\t\tbackground-color: var(--sidebarHoverBg);\n\t\t\t}\n\t\t}\n\t}\n\n\t.page-header {\n\t\ttop: 0px;\n\t\tleft: @sidebar-width;\n\t\tright: 0px;\n\t\theight: @header-height-desktop;\n\t\tposition: fixed;\n\t\tcolor: var(--color);\n\t\tbackground-color: var(--headerBg);\n\t\tborder-bottom: 1px solid var(--border);\n\t\tpadding: 0 @padding-standard;\n\t\tz-index: 10;\n\t}\n\n\th1.page-header {\n\t\tline-height: @header-height-desktop;\n\t\tfont-size: 1.3em;\n\t\tfont-weight: 600;\n\t}\n\n\tdiv.page-header {\n\t\tdisplay: flex;\n\t\tflex-flow: row nowrap;\n\t\talign-items: center;\n\n\t\tp {\n\t\t\tflex: 1 0;\n\t\t\tfont-size: 1.3em;\n\t\t\tfont-weight: 600;\n\t\t\tline-height: @header-height-desktop;\n\t\t\tcolor: var(--color);\n\t\t}\n\n\t\tinput[type=\"text\"] {\n\t\t\tflex: 1 0;\n\t\t\tmin-width: 0;\n\t\t\tmargin-right: @padding-small;\n\t\t\tfont-size: 1.1em;\n\t\t\tfont-weight: 500;\n\t\t\tline-height: calc(@header-height-desktop - 1px);\n\t\t\tcolor: var(--color);\n\t\t\tbackground-color: var(--contentBg);\n\n\t\t\t&::placeholder {\n\t\t\t\tcolor: var(--colorLink);\n\t\t\t}\n\t\t}\n\n\t\ta {\n\t\t\tdisplay: block;\n\t\t\twidth: 24px;\n\t\t\tline-height: 24px;\n\t\t\tcolor: var(--colorLink);\n\t\t\ttext-align: center;\n\n\t\t\t&:not(:last-child) {\n\t\t\t\tmargin-right: @padding-small;\n\t\t\t}\n\n\t\t\t&:hover {\n\t\t\t\tcolor: var(--main);\n\t\t\t}\n\t\t}\n\t}\n\n\t.loading-overlay {\n\t\tdisplay: flex;\n\t\tflex-flow: column nowrap;\n\t\talign-items: center;\n\t\tjustify-content: center;\n\t\toverflow: hidden;\n\t\tposition: fixed;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\twidth: 100%;\n\t\twidth: 100dvw;\n\t\theight: 100%;\n\t\theight: 100dvh;\n\t\tz-index: 10001;\n\t\tbackground-color: rgba(0, 0, 0, 0.6);\n\n\t\ti {\n\t\t\tcolor: var(--colorSidebar);\n\t\t\tfont-size: 4em;\n\t\t\ttext-align: center;\n\t\t\twidth: 80px;\n\t\t\tline-height: 80px;\n\t\t\tposition: absolute;\n\t\t}\n\t}\n\n\t@media (max-width: @screen-sm-max) {\n\t\tpadding-top: @header-height-mobile;\n\t\tpadding-left: 0;\n\t\tpadding-bottom: @sidebar-height-mobile;\n\n\t\t#main-sidebar {\n\t\t\ttop: auto;\n\t\t\tright: 0;\n\t\t\tbottom: 0;\n\t\t\twidth: 100%;\n\t\t\twidth: 100dvw;\n\t\t\theight: @sidebar-height-mobile;\n\t\t\tflex-flow: row nowrap;\n\t\t\tborder-top: 1px solid var(--border);\n\n\t\t\t@media @display-mode-pwa {\n\t\t\t\theight: calc(@sidebar-height-mobile + @inlet-bottom);\n\t\t\t}\n\n\t\t\t.spacer {\n\t\t\t\tdisplay: none;\n\t\t\t}\n\n\t\t\ta {\n\t\t\t\twidth: auto;\n\t\t\t\tflex: 1 0;\n\t\t\t\tline-height: @sidebar-height-mobile;\n\n\t\t\t\t&:hover,\n\t\t\t\t&:focus {\n\t\t\t\t\tcolor: var(--colorSidebar);\n\t\t\t\t\tbackground-color: var(--main);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t.page-header {\n\t\t\tleft: 0;\n\t\t\theight: @header-height-mobile;\n\t\t}\n\n\t\th1.page-header {\n\t\t\ttext-align: center;\n\t\t\tfont-size: 1em;\n\t\t\tline-height: @header-height-mobile;\n\t\t\ttext-transform: uppercase;\n\t\t}\n\n\t\tdiv.page-header {\n\t\t\tflex-flow: row wrap;\n\n\t\t\tp {\n\t\t\t\tflex: 1 0;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tfont-weight: 500;\n\t\t\t\tline-height: 3em;\n\t\t\t\tpadding: 0;\n\t\t\t}\n\n\t\t\tinput[type=\"text\"] {\n\t\t\t\tflex: 1 0;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tfont-weight: 500;\n\t\t\t\tline-height: 3em;\n\t\t\t}\n\n\t\t\ta {\n\t\t\t\tdisplay: block;\n\t\t\t\twidth: 24px;\n\t\t\t\tline-height: 100%;\n\t\t\t}\n\t\t}\n\t}\n}\n\n#content-scene {\n\tpadding: 20px;\n\tdisplay: flex;\n\tcolor: var(--color);\n\tbackground-color: var(--bg);\n\tflex-flow: column nowrap;\n\talign-items: center;\n\n\t#header {\n\t\twidth: 100%;\n\t\tpadding: 20px;\n\t\tmax-width: 840px;\n\t\tmargin-bottom: 16px;\n\t\tbackground-color: var(--contentBg);\n\t\tborder: 1px solid var(--border);\n\t\tdisplay: flex;\n\t\tflex-flow: column;\n\t\talign-items: center;\n\n\t\t#title {\n\t\t\tpadding: 8px 0;\n\t\t\tgrid-column-start: 1;\n\t\t\tgrid-column-end: -1;\n\t\t\tfont-size: 36px;\n\t\t\tfont-weight: 700;\n\t\t\tword-break: break-word;\n\t\t\thyphens: none;\n\t\t\ttext-align: center;\n\t\t}\n\t}\n\n\t#content {\n\t\twidth: 100%;\n\t\tpadding: 20px;\n\t\tmax-width: 840px;\n\t\tbackground-color: var(--contentBg);\n\t\tborder: 1px solid var(--border);\n\n\t\t* {\n\t\t\tfont-size: 18px;\n\t\t\tline-height: 180%;\n\n\t\t\t&:not(:last-child) {\n\t\t\t\tmargin-bottom: 20px;\n\t\t\t}\n\t\t}\n\n\t\ta {\n\t\t\tcolor: var(--color);\n\t\t\ttext-decoration: underline;\n\n\t\t\t&:hover,\n\t\t\t&:focus {\n\t\t\t\tcolor: var(--main);\n\t\t\t}\n\t\t}\n\n\t\tpre,\n\t\tcode {\n\t\t\toverflow: auto;\n\t\t\tborder: 1px solid var(--border);\n\t\t\tfont-family: \"Ubuntu Mono\", \"Courier New\", Courier, monospace;\n\t\t\tfont-size: 16px;\n\t\t}\n\n\t\tpre {\n\t\t\tpadding: 8px;\n\n\t\t\t> code {\n\t\t\t\tborder: 0;\n\t\t\t}\n\t\t}\n\n\t\tol,\n\t\tul {\n\t\t\tpadding-left: 16px;\n\t\t}\n\n\t\timg {\n\t\t\theight: auto;\n\t\t\tmax-width: 100%;\n\t\t}\n\n\t\ttable {\n\t\t\tborder: 1px solid var(--border);\n\t\t\tborder-collapse: collapse;\n\n\t\t\ttr,\n\t\t\tth,\n\t\t\ttd {\n\t\t\t\tborder: 1px solid var(--border);\n\t\t\t}\n\t\t}\n\t\tblockquote {\n\t\t\tmargin: 15px;\n\t\t\tpadding: 15px;\n\t\t\tfont-style: italic;\n\t\t\tbackground: var(--bgqoute);\n\t\t}\n\t}\n}\n\n#page-home {\n\t> .empty-message {\n\t\tmax-width: 400px;\n\t\tfont-size: 1em;\n\t\tbackground-color: var(--contentBg);\n\t\tborder: 1px solid var(--border);\n\t\tpadding: 16px;\n\t\tmargin: 16px;\n\t\tcolor: var(--errorColor);\n\t}\n\n\t#edit-box {\n\t\tbackground-color: var(--selectedBg);\n\t\tborder-bottom: 1px solid var(--main);\n\t}\n\n\t#bookmarks-grid {\n\t\tdisplay: grid;\n\t\tgrid-template-rows: min-content;\n\t\tgrid-template-columns: repeat(auto-fill, minmax(300px, 1fr));\n\t\tgrid-gap: 16px;\n\t\tpadding: 16px;\n\t\toverflow: auto;\n\n\t\t.bookmark {\n\t\t\talign-self: start;\n\t\t}\n\n\t\t.pagination-box {\n\t\t\tgrid-column-end: -1;\n\t\t\tgrid-column-start: 1;\n\t\t\tdisplay: flex;\n\t\t\tflex-flow: row nowrap;\n\t\t\talign-self: start;\n\n\t\t\ta {\n\t\t\t\tpadding: 8px;\n\t\t\t\tcolor: var(--colorLink);\n\n\t\t\t\t&:hover,\n\t\t\t\t&:focus {\n\t\t\t\t\tcolor: var(--main);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tinput {\n\t\t\t\twidth: 40px;\n\t\t\t\tpadding: 8px;\n\t\t\t\ttext-align: center;\n\t\t\t\tfont-size: 0.9em;\n\t\t\t\tcolor: var(--color);\n\t\t\t\tborder: 1px solid var(--border);\n\t\t\t\tbackground-color: var(--contentBg);\n\t\t\t\tmargin: 0 8px;\n\t\t\t}\n\n\t\t\tp {\n\t\t\t\tfont-size: 0.9em;\n\t\t\t\tcolor: var(--colorLink);\n\t\t\t\tline-height: 37px;\n\t\t\t\tfont-weight: 600;\n\n\t\t\t\t&:last-of-type::before {\n\t\t\t\t\tcontent: \"/\";\n\t\t\t\t\tmargin-right: 8px;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t&.list {\n\t\t\tgrid-gap: 0;\n\t\t\tpadding-bottom: 0;\n\t\t\tgrid-template-columns: auto;\n\n\t\t\t.pagination-box {\n\t\t\t\tpadding: 16px 0;\n\n\t\t\t\t&:first-child {\n\t\t\t\t\tpadding-top: 0;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t@media (max-width: 600px) {\n\t\t\t\tpadding: 16px 0 0;\n\n\t\t\t\t.pagination-box {\n\t\t\t\t\tpadding: 16px;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t#dialog-tags {\n\t\t.custom-dialog-body {\n\t\t\tgrid-template-columns: repeat(2, minmax(0, 1fr));\n\n\t\t\t@media (max-width: 600px) {\n\t\t\t\tgrid-template-columns: minmax(0, 1fr);\n\t\t\t}\n\n\t\t\ta {\n\t\t\t\tfont-size: 1em;\n\t\t\t\tcolor: var(--color);\n\n\t\t\t\tspan {\n\t\t\t\t\t&:last-child {\n\t\t\t\t\t\tfont-size: 1em;\n\t\t\t\t\t\tcolor: var(--colorLink);\n\t\t\t\t\t\tmargin-left: 4px;\n\n\t\t\t\t\t\t&::before {\n\t\t\t\t\t\t\tcontent: \"(\";\n\t\t\t\t\t\t\tmargin-right: 2px;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t&::after {\n\t\t\t\t\t\t\tcontent: \")\";\n\t\t\t\t\t\t\tmargin-left: 2px;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t&:hover,\n\t\t\t\t&:focus {\n\t\t\t\t\tcolor: var(--main);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n#page-setting {\n\tmin-height: 0;\n\tmax-height: 100%;\n\tdisplay: flex;\n\tflex-flow: column nowrap;\n\n\t.setting-container {\n\t\tpadding: 8px;\n\t\tdisplay: flex;\n\t\toverflow: auto;\n\t\tflex-flow: column nowrap;\n\t\tflex: 1 0;\n\n\t\t&::after {\n\t\t\tcontent: \"\";\n\t\t\tdisplay: block;\n\t\t\tmin-height: 1px;\n\t\t}\n\n\t\tdetails.setting-group {\n\t\t\tmargin: 8px;\n\t\t\tdisplay: block;\n\t\t\tmax-width: 350px;\n\t\t\tcolor: var(--color);\n\t\t\tbackground-color: var(--contentBg);\n\t\t\tborder: 1px solid var(--border);\n\n\t\t\t@media (max-width: 600px) {\n\t\t\t\tmax-width: 100%;\n\t\t\t}\n\n\t\t\tsummary {\n\t\t\t\tlist-style: none;\n\t\t\t\tfont-weight: 600;\n\t\t\t\twidth: 100%;\n\t\t\t\tpadding: 12px 8px;\n\t\t\t\tfont-size: 1.1em;\n\n\t\t\t\tcursor: pointer;\n\n\t\t\t\t&:hover {\n\t\t\t\t\tcolor: var(--main);\n\t\t\t\t}\n\n\t\t\t\t&::-webkit-details-marker {\n\t\t\t\t\tdisplay: none;\n\t\t\t\t}\n\n\t\t\t\t&::after {\n\t\t\t\t\tcontent: \"+\";\n\t\t\t\t\tmargin-left: 8px;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t&[open] summary {\n\t\t\t\tborder-bottom: 1px solid var(--border);\n\n\t\t\t\t&::after {\n\t\t\t\t\tcontent: \"-\" !important;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tul {\n\t\t\t\tlist-style: none;\n\n\t\t\t\tli {\n\t\t\t\t\tpadding: 4px 8px;\n\t\t\t\t\tcolor: var(--color);\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tflex-flow: row nowrap;\n\t\t\t\t\talign-items: center;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tdiv.setting-group-footer {\n\t\t\t\tpadding: 4px 8px;\n\t\t\t\tdisplay: flex;\n\t\t\t\tflex-flow: column nowrap;\n\t\t\t\talign-items: flex-end;\n\t\t\t\tborder-top: 1px solid var(--border);\n\n\t\t\t\t> a {\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tpadding: 8px 4px;\n\t\t\t\t\tfont-size: 0.9em;\n\t\t\t\t\tfont-weight: 600;\n\n\t\t\t\t\t&:hover {\n\t\t\t\t\t\tcolor: var(--main);\n\t\t\t\t\t}\n\n\t\t\t\t\t&:focus {\n\t\t\t\t\t\toutline: none;\n\t\t\t\t\t\tcolor: var(--main);\n\t\t\t\t\t\tborder-bottom: 1px dashed var(--main);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t#setting-display,\n\t#setting-bookmarks {\n\t\tdisplay: flex;\n\t\tflex-flow: column nowrap;\n\n\t\t&[open] {\n\t\t\tpadding-bottom: 8px;\n\n\t\t\tsummary {\n\t\t\t\tmargin-bottom: 8px;\n\t\t\t}\n\t\t}\n\n\t\tlabel {\n\t\t\tpadding: 4px 8px;\n\t\t\tcolor: var(--color);\n\t\t\tdisplay: flex;\n\t\t\tflex-flow: row nowrap;\n\t\t\talign-items: center;\n\t\t\tcursor: pointer;\n\n\t\t\t&:hover,\n\t\t\t&:focus {\n\t\t\t\ttext-decoration: underline;\n\t\t\t\ttext-decoration-color: var(--main);\n\t\t\t}\n\n\t\t\t> input[type=\"checkbox\"] {\n\t\t\t\tmargin-right: 8px;\n\t\t\t}\n\t\t}\n\t}\n\n\t.setting-accounts {\n\t\tsummary {\n\t\t\tmargin-bottom: 0;\n\t\t}\n\n\t\tul {\n\t\t\tlist-style: none;\n\n\t\t\tli {\n\t\t\t\tpadding: 8px;\n\t\t\t\tdisplay: flex;\n\t\t\t\tflex-flow: row nowrap;\n\t\t\t\talign-items: center;\n\n\t\t\t\t&:not(:last-child) {\n\t\t\t\t\tborder-bottom: 1px solid var(--border);\n\t\t\t\t}\n\n\t\t\t\tp {\n\t\t\t\t\tfont-size: 1em;\n\t\t\t\t\tcolor: var(--color);\n\t\t\t\t\tflex: 1 0;\n\n\t\t\t\t\tspan {\n\t\t\t\t\t\tcolor: var(--colorLink);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\ta {\n\t\t\t\t\tmargin-left: 8px;\n\t\t\t\t\tcolor: var(--colorLink);\n\n\t\t\t\t\t&:hover {\n\t\t\t\t\t\tcolor: var(--main);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t#setting-system-info {\n\t\tul {\n\t\t\tpadding-top: 4px;\n\t\t\tpadding-bottom: 4px;\n\n\t\t\tli {\n\t\t\t\tspan {\n\t\t\t\t\tmargin-left: 8px;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/view/assets/less/custom-dialog.less",
    "content": ":root {\n\t--dialogHeaderBg: #292929;\n\t--colorDialogHeader: #fff;\n}\n\n.custom-dialog-overlay {\n\tdisplay: flex;\n\tflex-flow: column nowrap;\n\talign-items: center;\n\tjustify-content: center;\n\tmin-width: 0;\n\tmin-height: 0;\n\toverflow: hidden;\n\tposition: fixed;\n\ttop: 0;\n\tleft: 0;\n\twidth: 100%;\n\twidth: 100dvw;\n\theight: 100%;\n\theight: 100dvh;\n\tz-index: 10001;\n\tbackground-color: rgba(0, 0, 0, 0.6);\n\tpadding: @padding-large;\n\n\t.custom-dialog {\n\t\tdisplay: flex;\n\t\tflex-flow: column nowrap;\n\t\tmin-height: 0;\n\t\tmax-height: 100%;\n\t\tmax-width: 100%;\n\t\twidth: 400px;\n\t\toverflow: auto;\n\t\tbackground-color: var(--contentBg);\n\t\tfont-size: 16px;\n\t\tresize: both;\n\n\t\t.custom-dialog-header {\n\t\t\tpadding: @padding-standard;\n\t\t\tcolor: var(--colorDialogHeader);\n\t\t\tbackground-color: var(--dialogHeaderBg);\n\t\t\tfont-weight: 600;\n\t\t\tfont-size: 1em;\n\t\t\ttext-transform: uppercase;\n\t\t\tborder-bottom: 1px solid var(--border);\n\t\t}\n\n\t\t.custom-dialog-body {\n\t\t\tpadding: @padding-standard @padding-standard 0;\n\t\t\tdisplay: grid;\n\t\t\tmax-height: 100%;\n\t\t\tmin-height: 80px;\n\t\t\tmin-width: 0;\n\t\t\toverflow: auto;\n\t\t\tfont-size: 1em;\n\t\t\tgrid-template-columns: max-content 1fr;\n\t\t\talign-content: start;\n\t\t\talign-items: baseline;\n\t\t\tgrid-gap: @padding-standard;\n\t\t\tflex-grow: 1;\n\n\t\t\t&::after {\n\t\t\t\tcontent: \"\";\n\t\t\t\tdisplay: block;\n\t\t\t\tmin-height: 1px;\n\t\t\t\tgrid-column-end: -1;\n\t\t\t\tgrid-column-start: 1;\n\t\t\t}\n\n\t\t\t.custom-dialog-content {\n\t\t\t\tgrid-column-end: -1;\n\t\t\t\tgrid-column-start: 1;\n\t\t\t\tcolor: var(--color);\n\t\t\t\talign-self: baseline;\n\t\t\t}\n\n\t\t\t> label {\n\t\t\t\tcolor: var(--color);\n\t\t\t\tpadding: @padding-small 0;\n\t\t\t\tfont-size: 1em;\n\t\t\t}\n\n\t\t\t> input[type=\"text\"],\n\t\t\t> input[type=\"password\"],\n\t\t\t> textarea {\n\t\t\t\tcolor: var(--color);\n\t\t\t\tpadding: @padding-small;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tborder: 1px solid var(--border);\n\t\t\t\tbackground-color: var(--contentBg);\n\t\t\t\tmin-width: 0;\n\t\t\t}\n\n\t\t\t.checkbox-field {\n\t\t\t\tcolor: var(--color);\n\t\t\t\tfont-size: 1em;\n\t\t\t\tdisplay: flex;\n\t\t\t\tflex-flow: row nowrap;\n\t\t\t\tpadding: 0;\n\t\t\t\tgrid-column-start: 1;\n\t\t\t\tgrid-column-end: -1;\n\t\t\t\tcursor: pointer;\n\n\t\t\t\t&:hover,\n\t\t\t\t&:focus {\n\t\t\t\t\ttext-decoration: underline;\n\t\t\t\t\ttext-decoration-color: var(--main);\n\t\t\t\t}\n\n\t\t\t\t> input[type=\"checkbox\"] {\n\t\t\t\t\tmargin-right: @padding-small;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t> textarea {\n\t\t\t\theight: 6em;\n\t\t\t\tmin-height: 37px;\n\t\t\t\tresize: vertical;\n\t\t\t}\n\n\t\t\t> .suggestion {\n\t\t\t\tposition: absolute;\n\t\t\t\tdisplay: block;\n\t\t\t\tpadding: @padding-small;\n\t\t\t\tbackground-color: var(--contentBg);\n\t\t\t\tborder: 1px solid var(--border);\n\t\t\t\tcolor: var(--color);\n\t\t\t\tfont-size: 0.9em;\n\t\t\t}\n\t\t}\n\n\t\t.custom-dialog-footer {\n\t\t\tpadding: @padding-standard;\n\t\t\tdisplay: flex;\n\t\t\tflex-flow: row wrap;\n\t\t\tjustify-content: flex-end;\n\t\t\tborder-top: 1px solid var(--border);\n\n\t\t\t@media @display-mode-pwa {\n\t\t\t\tpadding-bottom: calc(@padding-standard + env(safe-area-inset-bottom));\n\t\t\t}\n\n\t\t\t> a {\n\t\t\t\tpadding: 0 @padding-small;\n\t\t\t\tfont-size: 0.9em;\n\t\t\t\tfont-weight: 600;\n\t\t\t\tcolor: var(--color);\n\t\t\t\ttext-transform: uppercase;\n\n\t\t\t\t&:hover,\n\t\t\t\t&:focus {\n\t\t\t\t\toutline: none;\n\t\t\t\t\tcolor: var(--main);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t> i.fa-spinner.fa-spin {\n\t\t\t\twidth: 19px;\n\t\t\t\tline-height: 19px;\n\t\t\t\ttext-align: center;\n\t\t\t\tcolor: var(--color);\n\t\t\t}\n\t\t}\n\t}\n}\n\n@media only screen and (max-width: @screen-md-max) {\n\t.custom-dialog-overlay {\n\t\tpadding: 0px;\n\t}\n\n\t.custom-dialog {\n\t\twidth: 100% !important;\n\t\theight: 100% !important;\n\t\tresize: none !important;\n\t}\n}\n"
  },
  {
    "path": "internal/view/assets/less/style.less",
    "content": "// CSS external libraries live under ../css/libs\n@import \"./libs/source-sans-pro.min.css\";\n@import \"./libs/fontawesome.min.css\";\n\n// Less files\n@import \"./variables.less\";\n@import \"./theme.less\";\n@import \"./common.less\";\n@import \"./custom-dialog.less\";\n@import \"./bookmark-item.less\";\n"
  },
  {
    "path": "internal/view/assets/less/theme.less",
    "content": ":root {\n\t--colorLink: #999;\n\t--colorSidebar: #fff;\n\t--errorColor: #f44336;\n\t--main: #f44336;\n\t--sidebarBg: #292929;\n\t--sidebarHoverBg: #232323;\n\t.light-colors();\n}\n\n.night-colors {\n\t--bg: #1f1f1f;\n\t--border: #191919;\n\t--color: #fff;\n\t--contentBg: #292929;\n\t--headerBg: #292929;\n\t--selectedBg: #261918;\n\t--bgqoute: #1f1f1f5e;\n}\n\n.light-colors {\n\t--bg: #eee;\n\t--border: #e5e5e5;\n\t--color: #232323;\n\t--contentBg: #fff;\n\t--headerBg: #fff;\n\t--selectedBg: #ffe7e5;\n\t--bgqoute: #eee;\n}\n\nbody.dark {\n\t.night-colors();\n}\n\nbody.light {\n\t.light-colors();\n}\n\n@media (prefers-color-scheme: dark) {\n\t:root {\n\t\t.night-colors();\n\t}\n}\n\n@media (prefers-color-scheme: light) {\n\t:root {\n\t\t.light-colors();\n\t}\n}\n"
  },
  {
    "path": "internal/view/assets/less/variables.less",
    "content": "// Layout dimensions\n@sidebar-width: 60px;\n@sidebar-height-desktop: 60px;\n@sidebar-height-mobile: 50px;\n\n// Header dimensions\n@header-height-desktop: 60px;\n@header-height-mobile: 50px;\n\n// Media query breakpoints\n@screen-sm-max: 600px;\n@screen-md-max: 768px;\n\n// Padding and spacing\n@padding-standard: 16px;\n@padding-large: 20px;\n@padding-small: 8px;\n\n// Border radius\n@border-radius-standard: 4px;\n\n// Inlets\n@inlet-bottom: 20px;\n\n// Display mode detection\n@display-mode-browser: ~\"(display-mode: browser)\";\n@display-mode-standalone: ~\"(display-mode: standalone)\";\n@display-mode-minimal-ui: ~\"(display-mode: minimal-ui)\";\n@display-mode-fullscreen: ~\"(display-mode: fullscreen)\";\n@display-mode-pwa: ~\"(display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui)\";\n"
  },
  {
    "path": "internal/view/assets/manifest.webmanifest",
    "content": "{\n\t\"name\": \"Shiori\",\n\t\"short_name\": \"Shiori\",\n\t\"display\": \"standalone\",\n\t\"description\": \"Shiori is a simple bookmarks manager written in the Go language. Intended as a simple clone of Pocket. You can use it as a command line application or as a web application. This application is distributed as a single binary, which means it can be installed and used easily.\",\n\t\"icons\": [\n\t\t{\n\t\t\t\"src\": \"res/apple-touch-icon-144x144.png\",\n\t\t\t\"type\": \"image/png\",\n\t\t\t\"sizes\": \"144x144\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"res/apple-touch-icon-152x152.png\",\n\t\t\t\"type\": \"image/png\",\n\t\t\t\"sizes\": \"152x152\",\n\t\t\t\"purpose\": \"maskable\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"res/pwa-192x192.png\",\n\t\t\t\"type\": \"image/png\",\n\t\t\t\"sizes\": \"192x192\",\n\t\t\t\"purpose\": \"maskable\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"res/pwa-512x512.png\",\n\t\t\t\"type\": \"image/png\",\n\t\t\t\"sizes\": \"512x512\",\n\t\t\t\"purpose\": \"maskable\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"res/pwa-1024x1024.png\",\n\t\t\t\"type\": \"image/png\",\n\t\t\t\"sizes\": \"1024x1024\",\n\t\t\t\"purpose\": \"maskable\"\n\t\t}\n\t],\n\t\"start_url\": \"../\",\n\t\"share_target\": {\n\t\t\"action\": \"../\",\n\t\t\"enctype\": \"application/x-www-form-urlencoded\",\n\t\t\"method\": \"GET\",\n\t\t\"params\": {\n\t\t\t\"title\": \"title\",\n\t\t\t\"text\": \"excerpt\",\n\t\t\t\"url\": \"url\"\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/view/content.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n\t<base href=\"$$.RootPath$$\">\n\t<title>$$.Book.Title$$ - Shiori - Bookmarks Manager</title>\n\n\t<meta charset=\"UTF-8\">\n\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n\n\t<link rel=\"apple-touch-icon-precomposed\" sizes=\"152x152\" href=\"assets/res/apple-touch-icon-152x152.png\">\n\t<link rel=\"apple-touch-icon-precomposed\" sizes=\"144x144\" href=\"assets/res/apple-touch-icon-144x144.png\">\n\t<link rel=\"icon\" type=\"image/png\" href=\"assets/res/favicon-32x32.png\" sizes=\"32x32\">\n\t<link rel=\"icon\" type=\"image/png\" href=\"assets/res/favicon-16x16.png\" sizes=\"16x16\">\n\t<link rel=\"icon\" type=\"image/x-icon\" href=\"assets/res/favicon.ico\">\n\t<link rel=\"manifest\" href=\"assets/manifest.webmanifest\" />\n\n\t<link href=\"assets/css/style.css\" rel=\"stylesheet\">\n\n\t<script src=\"assets/js/vue.min.js\"></script>\n</head>\n\n<body>\n\t<div id=\"content-scene\">\n\t\t<div id=\"header\">\n\t\t\t<p id=\"title\" dir=\"auto\">$$.Book.Title$$</p>\n\t\t\t<div class=\"links\">\n\t\t\t\t<a href=\"$$.Book.URL$$\" target=\"_blank\" rel=\"noopener noreferrer\">View Original</a>\n\t\t\t\t$$if .Book.HasArchive$$\n\t\t\t\t<a href=\"bookmark/$$.Book.ID$$/archive\">View Archive</a>\n\t\t\t\t$$end$$\n\t\t\t\t$$if .Book.HasEbook$$\n\t\t\t\t<a href=\"bookmark/$$.Book.ID$$/ebook\" download=\"$$.Book.Title$$.epub\">Download Ebook</a>\n\t\t\t\t$$end$$\n\t\t\t</div>\n\t\t</div>\n\t\t<div id=\"content\" dir=\"auto\" v-pre>\n\t\t\t$$.HTML$$\n\t\t</div>\n        <footer class=\"content-footer\">\n            <p class=\"metadata\">{{ createdModifiedTime() }} </p>\n        </footer>\n\t</div>\n\n\t<script type=\"module\">\n\t\t// Create initial variable\n\t\timport basePage from \"./assets/js/page/base.js\";\n\n\t\tnew Vue({\n\t\t\tel: '#content-scene',\n\t\t\tmixins: [basePage],\n\t\t\tdata: {\n\t\t\t\tcreated: \"$$.Book.CreatedAt$$\"\n\t\t\t},\n\t\t\tmethods: {\n\t\t\t\tcreatedModifiedTime() {\n          const strCreatedTime = \"$$.Book.CreatedAt$$\".replace(\" \", \"T\") + (\"$$.Book.CreatedAt$$\".endsWith(\"Z\") ? \"\" : \"Z\");\n          const strModifiedTime = \"$$.Book.ModifiedAt$$\".replace(\" \", \"T\") + (\"$$.Book.ModifiedAt$$\".endsWith(\"Z\") ? \"\" : \"Z\");\n\n          const createdDate = new Date(strCreatedTime);\n          const modifiedDate = new Date(strModifiedTime);\n\n          if (createdDate.toDateString() === modifiedDate.toDateString()) {\n            return `Added ${createdDate.getDate()} ${createdDate.toLocaleString('default', { month: 'long' })} ${createdDate.getFullYear()}`;\n          } else {\n            return `Added ${createdDate.getDate()} ${createdDate.toLocaleString('default', { month: 'long' })}  ${createdDate.getFullYear()}  | Last Modified ${modifiedDate.getDate()} ${modifiedDate.toLocaleString('default', {month: 'long'})} ${modifiedDate.getFullYear()}`;\n        }\n\t\t\t\t},\n\t\t\t\tloadSetting() {\n\t\t\t\t\tvar opts = JSON.parse(localStorage.getItem(\"shiori-account\")) || {},\n\t\t\t\t\t\tShowId = (typeof opts.config.ShowId === \"boolean\") ? opts.config.ShowId : false,\n\t\t\t\t\t\tListMode = (typeof opts.config.ListMode === \"boolean\") ? opts.config.ListMode : false,\n\t\t\t\t\t\tTheme = (typeof opts.config.Theme === \"string\" && opts.config.Theme !== \"\") ? opts.config.Theme : \"follow\",\n\t\t\t\t\t\tUseArchive = (typeof opts.config.UseArchive === \"boolean\") ? opts.config.UseArchive : false,\n\t\t\t\t\t\tCreateEbook = (typeof opts.config.CreateEbook === \"boolean\") ? opts.config.CreateEbook : false;\n\n\t\t\t\t\tthis.appOptions = {\n\t\t\t\t\t\tShowId: ShowId,\n\t\t\t\t\t\tListMode: ListMode,\n\t\t\t\t\t\tTheme: Theme,\n\t\t\t\t\t\tUseArchive: UseArchive,\n\t\t\t\t\t\tCreateEbook: CreateEbook,\n\t\t\t\t\t};\n                    this.themeSwitch(Theme)\n\t\t\t\t}\n\t\t\t},\n\t\t\tmounted() {\n\t\t\t\tthis.loadSetting();\n\n\t\t\t\tdocument.querySelectorAll(\"#content a\").forEach(elem => {\n\t\t\t\t\telem.setAttribute(\"target\", \"_blank\");\n\t\t\t\t\telem.setAttribute(\"rel\", \"noopener noreferrer\");\n\t\t\t\t});\n\t\t\t}\n\t\t});\n\t</script>\n</body>\n\n</html>\n"
  },
  {
    "path": "internal/view/embed.go",
    "content": "package views\n\nimport \"embed\"\n\n//go:embed assets/js/* assets/css/* assets/res/* assets/manifest.webmanifest\nvar Assets embed.FS\n\n//go:embed *.html\nvar Templates embed.FS\n"
  },
  {
    "path": "internal/view/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n\t<base href=\"$$.RootPath$$\">\n\t<title>Shiori - Bookmarks Manager</title>\n\n\t<meta charset=\"UTF-8\">\n\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover\">\n\n\t<link rel=\"apple-touch-icon-precomposed\" sizes=\"152x152\" href=\"assets/res/apple-touch-icon-152x152.png\">\n\t<link rel=\"apple-touch-icon-precomposed\" sizes=\"144x144\" href=\"assets/res/apple-touch-icon-144x144.png\">\n\t<link rel=\"icon\" type=\"image/png\" href=\"assets/res/favicon-32x32.png\" sizes=\"32x32\">\n\t<link rel=\"icon\" type=\"image/png\" href=\"assets/res/favicon-16x16.png\" sizes=\"16x16\">\n\t<link rel=\"icon\" type=\"image/x-icon\" href=\"assets/res/favicon.ico\">\n\t<link rel=\"manifest\" href=\"assets/manifest.webmanifest\" />\n\n\t<link href=\"assets/css/style.css\" rel=\"stylesheet\">\n\n\t<script src=\"assets/js/vue.min.js\" type=\"text/javascript\"></script>\n\t<script src=\"assets/js/url.min.js\" type=\"text/javascript\"></script>\n</head>\n\n<body>\n\t<div id=\"app\">\n\t\t<login-view v-if=\"isLoggedIn === false && loginRequired\" @login-success=\"onLoginSuccess\"></login-view>\n\t\t<div id=\"main-scene\" v-else-if=\"isLoggedIn === true\">\n    \t\t<div id=\"main-sidebar\">\n    \t\t\t<a v-for=\"item in sidebarItems\" :title=\"item.title\" :class=\"{active: activePage === item.page}\" @click=\"switchPage(item.page)\">\n    \t\t\t\t<i class=\"fas fa-fw\" :class=\"item.icon\"></i>\n    \t\t\t</a>\n    \t\t\t<div class=\"spacer\"></div>\n    \t\t\t<a title=\"Logout\" @click=\"logout\">\n    \t\t\t\t<i class=\"fas fa-fw fa-sign-out-alt\"></i>\n    \t\t\t</a>\n    \t\t</div>\n    \t\t<keep-alive>\n    \t\t\t<component :is=\"activePage\" :active-account=\"activeAccount\" :app-options=\"appOptions\" @setting-changed=\"saveSetting\"></component>\n    \t\t</keep-alive>\n    \t\t<custom-dialog v-bind=\"dialog\"></custom-dialog>\n\t\t</div>\n\t</div>\n\n\t<script type=\"module\">\n\t\timport basePage from \"./assets/js/page/base.js\";\n\t\timport LoginComponent from \"./assets/js/component/login.js\";\n\t\timport pageHome from \"./assets/js/page/home.js\";\n\t\timport pageSetting from \"./assets/js/page/setting.js\";\n\t\timport customDialog from \"./assets/js/component/dialog.js\";\n        import EventBus from \"./assets/js/component/eventBus.js\";\n        Vue.prototype.$bus = EventBus;\n\n\t\tvar app = new Vue({\n\t\t\tel: '#app',\n\t\t\tmixins: [basePage],\n\t\t\tcomponents: {\n\t\t\t\tpageHome,\n\t\t\t\tpageSetting,\n\t\t\t\tcustomDialog,\n\t\t\t\t'login-view': LoginComponent\n\t\t\t},\n\t\t\tdata: {\n\t\t\t\tisLoggedIn: null,\n\t\t\t\tloginRequired: false,\n\t\t\t\tactivePage: \"page-home\",\n\t\t\t\tsidebarItems: [{\n\t\t\t\t\ttitle: \"Home\",\n\t\t\t\t\ticon: \"fa-home\",\n\t\t\t\t\tpage: \"page-home\",\n\t\t\t\t}, {\n\t\t\t\t\ttitle: \"Settings\",\n\t\t\t\t\ticon: \"fa-cog\",\n\t\t\t\t\tpage: \"page-setting\",\n\t\t\t\t}],\n\t\t\t},\n\t\t\tmethods: {\n\t\t\t\tswitchPage(page) {\n\t\t\t\t\tvar pageName = page.replace(\"page-\", \"\"),\n\t\t\t\t\t\tstate = { activePage: page },\n\t\t\t\t\t\turl = new Url;\n\n                    if (page === 'page-home'  && this.activePage === 'page-home') {\n                        Vue.prototype.$bus.$emit('clearHomePage', {});\n                    }\n\t\t\t\t\turl.hash = pageName;\n\t\t\t\t\tthis.activePage = page;\n\t\t\t\t\thistory.pushState(state, page, url);\n\t\t\t\t},\n\t\t\t\tlogout() {\n\t\t\t\t\tthis.showDialog({\n\t\t\t\t\t\ttitle: \"Log Out\",\n\t\t\t\t\t\tcontent: \"Are you sure you want to log out ?\",\n\t\t\t\t\t\tmainText: \"Yes\",\n\t\t\t\t\t\tsecondText: \"No\",\n\t\t\t\t\t\tmainClick: () => {\n\t\t\t\t\t\t\tthis.dialog.loading = true;\n\t\t\t\t\t\t\tfetch(new URL(\"api/v1/auth/logout\", document.baseURI), {\n\t\t\t\t\t\t\t\tmethod: \"post\"\n\t\t\t\t\t\t\t}).then(response => {\n\t\t\t\t\t\t\t\tif (!response.ok) throw response;\n\t\t\t\t\t\t\t\treturn response;\n\t\t\t\t\t\t\t}).then(() => {\n\t\t\t\t\t\t\t\tlocalStorage.removeItem(\"shiori-account\");\n\t\t\t\t\t\t\t\tlocalStorage.removeItem(\"shiori-token\");\n\t\t\t\t\t\t\t\tdocument.cookie = `session-id=; Path=${new URL(document.baseURI).pathname}; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`;\n\t\t\t\t\t\t\t\tdocument.cookie = `token=; Path=${new URL(document.baseURI).pathname}; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`;\n\t\t\t\t\t\t\t\tthis.isLoggedIn = false;\n\t\t\t\t\t\t\t\tthis.loginRequired = true;\n\t\t\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\t\t\tthis.dialog.visible = false;\n\t\t\t\t\t\t\t}).catch(err => {\n\t\t\t\t\t\t\t\tthis.dialog.loading = false;\n\t\t\t\t\t\t\t\tthis.getErrorMessage(err).then(msg => {\n\t\t\t\t\t\t\t\t\tthis.showErrorDialog(msg);\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t},\n\t\t\t\tsaveSetting(opts) {\n\t\t\t\t\tthis.appOptions = opts;\n                    this.themeSwitch(opts.Theme)\n\t\t\t\t},\n\t\t\t\tloadSetting() {\n\t\t\t\t\tvar opts = JSON.parse(localStorage.getItem(\"shiori-account\")) || {},\n\t\t\t\t\t\tShowId = (typeof opts.config.ShowId === \"boolean\") ? opts.config.ShowId : false,\n\t\t\t\t\t\tListMode = (typeof opts.config.ListMode === \"boolean\") ? opts.config.ListMode : false,\n\t\t\t\t\t\tHideThumbnail = (typeof opts.config.HideThumbnail === \"boolean\") ? opts.config.HideThumbnail : false,\n\t\t\t\t\t\tHideExcerpt = (typeof opts.config.HideExcerpt === \"boolean\") ? opts.config.HideExcerpt : false,\n                        Theme = (typeof opts.config.Theme === \"string\" && opts.config.Theme !== \"\") ? opts.config.Theme : \"follow\",\n\t\t\t\t\t\tKeepMetadata = (typeof opts.config.KeepMetadata === \"boolean\") ? opts.config.KeepMetadata : false,\n\t\t\t\t\t\tUseArchive = (typeof opts.config.UseArchive === \"boolean\") ? opts.config.UseArchive : false,\n\t\t\t\t\t\tCreateEbook = (typeof opts.config.CreateEbook === \"boolean\") ? opts.config.CreateEbook : false,\n\t\t\t\t\t\tMakePublic = (typeof opts.config.MakePublic === \"boolean\") ? opts.config.MakePublic : false;\n\n\t\t\t\t\tthis.appOptions = {\n\t\t\t\t\t\tShowId: ShowId,\n\t\t\t\t\t\tListMode: ListMode,\n\t\t\t\t\t\tHideThumbnail: HideThumbnail,\n\t\t\t\t\t\tHideExcerpt: HideExcerpt,\n\t\t\t\t\t\tTheme: Theme,\n\t\t\t\t\t\tKeepMetadata: KeepMetadata,\n\t\t\t\t\t\tUseArchive: UseArchive,\n\t\t\t\t\t\tCreateEbook: CreateEbook,\n\t\t\t\t\t\tMakePublic: MakePublic,\n\t\t\t\t\t};\n                    this.themeSwitch(Theme)\n\t\t\t\t},\n\t\t\t\tloadAccount() {\n\t\t\t\t\tvar account = JSON.parse(localStorage.getItem(\"shiori-account\")) || {},\n\t\t\t\t\t\tid = (typeof account.id === \"number\") ? account.id : 0,\n\t\t\t\t\t\tusername = (typeof account.username === \"string\") ? account.username : \"\",\n\t\t\t\t\t\towner = (typeof account.owner === \"boolean\") ? account.owner : false;\n\n\t\t\t\t\tthis.activeAccount = {\n\t\t\t\t\t\tid: id,\n\t\t\t\t\t\tusername: username,\n\t\t\t\t\t\towner: owner,\n\t\t\t\t\t};\n\t\t\t\t},\n\n\t\t\t\tonLoginSuccess() {\n\t\t\t\t\tthis.loadAccount();\n\t\t\t\t\tthis.loadSetting();\n\t\t\t\t\tthis.isLoggedIn = true;\n\t\t\t\t},\n\n\t\t\t\tasync validateSession() {\n\t\t\t\t\tconst token = localStorage.getItem(\"shiori-token\");\n\t\t\t\t\tconst account = localStorage.getItem(\"shiori-account\");\n\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst response = await fetch(new URL(\"api/v1/auth/me\", document.baseURI), {\n\t\t\t\t\t\t\theaders: {\n\t\t\t\t\t\t\t\t\"Authorization\": `Bearer ${token}`\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tif (!response.ok) {\n\t\t\t\t\t\t\tthrow new Error('Invalid session');\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tconst responseJSON = await response.json();\n\t\t\t\t\t\tlocalStorage.setItem(\n\t\t\t\t\t\t\t\"shiori-account\",\n\t\t\t\t\t\t\tJSON.stringify(responseJSON.message),\n\t\t\t\t\t\t);\n\t\t\t\t\t\treturn true;\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t// Clear invalid session data\n\t\t\t\t\t\tlocalStorage.removeItem(\"shiori-account\");\n\t\t\t\t\t\tlocalStorage.removeItem(\"shiori-token\");\n\t\t\t\t\t\tdocument.cookie = `session-id=; Path=${new URL(document.baseURI).pathname}; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`;\n\t\t\t\t\t\tdocument.cookie = `token=; Path=${new URL(document.baseURI).pathname}; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`;\n\t\t\t\t\t\treturn false;\n\t\t\t\t\t}\n\t\t\t\t},\n\n\t\t\t\tasync checkLoginStatus() {\n\t\t\t\t\tconst isValid = await this.validateSession();\n\t\t\t\t\tthis.isLoggedIn = isValid;\n\n\t\t\t\t\tif (isValid) {\n\t\t\t\t\t\tthis.loadSetting();\n\t\t\t\t\t\tthis.loadAccount();\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.loginRequired = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t},\n\t\t\tasync mounted() {\n\t\t\t\tawait this.checkLoginStatus();\n\t\t\t\tif (this.isLoggedIn) {\n\t\t\t\t\tthis.loadSetting();\n\t\t\t\t\tthis.loadAccount();\n\t\t\t\t}\n\n\t\t\t\t// Prepare history state watcher\n\t\t\t\tvar stateWatcher = (e) => {\n\t\t\t\t\tvar state = e.state || {};\n\t\t\t\t\tthis.activePage = state.activePage || \"page-home\";\n\t\t\t\t}\n\n\t\t\t\twindow.addEventListener('popstate', stateWatcher);\n\t\t\t\tthis.$once('hook:beforeDestroy', function () {\n\t\t\t\t\twindow.removeEventListener('popstate', stateWatcher);\n\t\t\t\t})\n\n\t\t\t\t// Set initial active page\n\t\t\t\tvar initialPage = (new Url).hash || \"home\";\n\t\t\t\tif (initialPage === \"home\" || initialPage === \"setting\") {\n\t\t\t\t\tthis.activePage = `page-${initialPage}`;\n\t\t\t\t} else {\n\t\t\t\t\thistory.replaceState(null, \"page-home\", \"/#home\");\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t</script>\n</body>\n\n</html>\n"
  },
  {
    "path": "internal/webserver/handler-api-ext.go",
    "content": "package webserver\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\tfp \"path/filepath\"\n\t\"strconv\"\n\n\t\"github.com/go-shiori/shiori/internal/core\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/julienschmidt/httprouter\"\n)\n\n// ApiInsertViaExtension is handler for POST /api/bookmarks/ext\nfunc (h *Handler) ApiInsertViaExtension(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {\n\tctx := r.Context()\n\n\t// Make sure session still valid\n\terr := h.validateSession(r)\n\tcheckError(err)\n\n\t// Decode request\n\trequest := model.BookmarkDTO{}\n\terr = json.NewDecoder(r.Body).Decode(&request)\n\tcheckError(err)\n\n\t// Clean up bookmark URL\n\trequest.URL, err = core.RemoveUTMParams(request.URL)\n\tif err != nil {\n\t\tpanic(fmt.Errorf(\"failed to clean URL: %v\", err))\n\t}\n\n\t// Check if bookmark already exists.\n\tbook, exist, err := h.DB.GetBookmark(ctx, 0, request.URL)\n\tif err != nil {\n\t\tpanic(fmt.Errorf(\"failed to get bookmark, URL: %v\", err))\n\t}\n\n\t// If it already exists, we need to set ID and tags.\n\tif exist {\n\t\tbook.HTML = request.HTML\n\n\t\tmapOldTags := map[string]model.TagDTO{}\n\t\tfor _, oldTag := range book.Tags {\n\t\t\tmapOldTags[oldTag.Name] = oldTag\n\t\t}\n\n\t\tfor _, newTag := range request.Tags {\n\t\t\tif _, tagExist := mapOldTags[newTag.Name]; !tagExist {\n\t\t\t\tbook.Tags = append(book.Tags, newTag)\n\t\t\t}\n\t\t}\n\t} else if request.Title == \"\" {\n\t\trequest.Title = request.URL\n\t}\n\n\t// Since we are using extension, the extension might send the HTML content\n\t// so no need to download it again here. However, if it's empty, it might be not HTML file\n\t// so we download it here.\n\tvar contentType string\n\tvar contentBuffer io.Reader\n\n\tif request.HTML == \"\" {\n\t\tcontentBuffer, contentType, _ = core.DownloadBookmark(request.URL)\n\t} else {\n\t\tcontentType = \"text/html; charset=UTF-8\"\n\t\tcontentBuffer = bytes.NewBufferString(request.HTML)\n\t}\n\n\t// Save the bookmark with whatever we already have downloaded\n\t// since we need the ID in order to download the archive\n\t// Only when old bookmark is not exists.\n\tif !exist {\n\t\tbooks, err := h.DB.SaveBookmarks(ctx, true, request)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error saving bookmark before downloading content: %s\", err)\n\t\t\treturn\n\t\t}\n\t\tbook = books[0]\n\t}\n\n\t// At this point the web page already downloaded.\n\t// Time to process it.\n\tif contentBuffer != nil {\n\t\tbook.CreateArchive = true\n\t\trequest := core.ProcessRequest{\n\t\t\tDataDir:     h.DataDir,\n\t\t\tBookmark:    book,\n\t\t\tContent:     contentBuffer,\n\t\t\tContentType: contentType,\n\t\t}\n\n\t\tvar isFatalErr bool\n\t\tbook, isFatalErr, err = core.ProcessBookmark(h.dependencies, request)\n\n\t\tif tmp, ok := contentBuffer.(io.ReadCloser); ok {\n\t\t\ttmp.Close()\n\t\t}\n\n\t\t// If we can't process or update the saved bookmark, just log it and continue on with the\n\t\t// request.\n\t\tif err != nil && isFatalErr {\n\t\t\tlog.Printf(\"failed to process bookmark: %v\", err)\n\t\t} else if _, err := h.DB.SaveBookmarks(ctx, false, book); err != nil {\n\t\t\tlog.Printf(\"error saving bookmark after downloading content: %s\", err)\n\t\t}\n\t}\n\n\t// Return the new bookmark\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\terr = json.NewEncoder(w).Encode(&book)\n\tcheckError(err)\n}\n\n// ApiDeleteViaExtension is handler for DELETE /api/bookmark/ext\nfunc (h *Handler) ApiDeleteViaExtension(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {\n\tctx := r.Context()\n\n\t// Make sure session still valid\n\terr := h.validateSession(r)\n\tcheckError(err)\n\n\t// Decode request\n\trequest := model.BookmarkDTO{}\n\terr = json.NewDecoder(r.Body).Decode(&request)\n\tcheckError(err)\n\n\t// Check if bookmark already exists.\n\tbook, exist, err := h.DB.GetBookmark(ctx, 0, request.URL)\n\tcheckError(err)\n\n\tif exist {\n\t\t// Delete bookmarks\n\t\terr = h.DB.DeleteBookmarks(ctx, book.ID)\n\t\tcheckError(err)\n\n\t\t// Delete thumbnail image and archives from local disk\n\t\tstrID := strconv.Itoa(book.ID)\n\t\timgPath := fp.Join(h.DataDir, \"thumb\", strID)\n\t\tarchivePath := fp.Join(h.DataDir, \"archive\", strID)\n\n\t\tos.Remove(imgPath)\n\t\tos.Remove(archivePath)\n\t}\n\n\tfmt.Fprint(w, 1)\n}\n"
  },
  {
    "path": "internal/webserver/handler-api.go",
    "content": "package webserver\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"math\"\n\t\"net/http\"\n\t\"os\"\n\t\"path\"\n\tfp \"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/go-shiori/shiori/internal/core\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/julienschmidt/httprouter\"\n)\n\nfunc downloadBookmarkContent(deps model.Dependencies, book *model.BookmarkDTO, dataDir string, _ *http.Request, keepTitle, keepExcerpt bool) (*model.BookmarkDTO, error) {\n\tcontent, contentType, err := core.DownloadBookmark(book.URL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error downloading url: %s\", err)\n\t}\n\n\tprocessRequest := core.ProcessRequest{\n\t\tDataDir:     dataDir,\n\t\tBookmark:    *book,\n\t\tContent:     content,\n\t\tContentType: contentType,\n\t\tKeepTitle:   keepTitle,\n\t\tKeepExcerpt: keepExcerpt,\n\t}\n\n\tresult, isFatalErr, err := core.ProcessBookmark(deps, processRequest)\n\tcontent.Close()\n\n\tif err != nil && isFatalErr {\n\t\treturn nil, fmt.Errorf(\"failed to process: %v\", err)\n\t}\n\n\treturn &result, err\n}\n\n// ApiGetBookmarks is handler for GET /api/bookmarks\nfunc (h *Handler) ApiGetBookmarks(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {\n\tctx := r.Context()\n\n\t// Make sure session still valid\n\terr := h.validateSession(r)\n\tcheckError(err)\n\n\t// Get URL queries\n\tkeyword := r.URL.Query().Get(\"keyword\")\n\tstrPage := r.URL.Query().Get(\"page\")\n\tstrTags := r.URL.Query().Get(\"tags\")\n\tstrExcludedTags := r.URL.Query().Get(\"exclude\")\n\n\ttags := strings.Split(strTags, \",\")\n\tif len(tags) == 1 && tags[0] == \"\" {\n\t\ttags = []string{}\n\t}\n\n\texcludedTags := strings.Split(strExcludedTags, \",\")\n\tif len(excludedTags) == 1 && excludedTags[0] == \"\" {\n\t\texcludedTags = []string{}\n\t}\n\n\tpage, _ := strconv.Atoi(strPage)\n\tif page < 1 {\n\t\tpage = 1\n\t}\n\n\t// Prepare filter for database\n\tsearchOptions := model.DBGetBookmarksOptions{\n\t\tTags:         tags,\n\t\tExcludedTags: excludedTags,\n\t\tKeyword:      keyword,\n\t\tLimit:        30,\n\t\tOffset:       (page - 1) * 30,\n\t\tOrderMethod:  model.ByLastAdded,\n\t}\n\n\t// Calculate max page\n\tnBookmarks, err := h.DB.GetBookmarksCount(ctx, searchOptions)\n\tcheckError(err)\n\tmaxPage := int(math.Ceil(float64(nBookmarks) / 30))\n\n\t// Fetch all matching bookmarks\n\tbookmarks, err := h.DB.GetBookmarks(ctx, searchOptions)\n\tcheckError(err)\n\n\t// Get image URL for each bookmark, and check if it has archive\n\tfor i := range bookmarks {\n\t\tstrID := strconv.Itoa(bookmarks[i].ID)\n\t\timgPath := fp.Join(h.DataDir, \"thumb\", strID)\n\t\tarchivePath := fp.Join(h.DataDir, \"archive\", strID)\n\t\tebookPath := fp.Join(h.DataDir, \"ebook\", strID+\".epub\")\n\n\t\tif FileExists(imgPath) {\n\t\t\tbookmarks[i].ImageURL = path.Join(h.RootPath, \"bookmark\", strID, \"thumb\")\n\t\t}\n\n\t\tif FileExists(archivePath) {\n\t\t\tbookmarks[i].HasArchive = true\n\t\t}\n\t\tif FileExists(ebookPath) {\n\t\t\tbookmarks[i].HasEbook = true\n\t\t}\n\t}\n\n\t// Return JSON response\n\tresp := map[string]interface{}{\n\t\t\"page\":      page,\n\t\t\"maxPage\":   maxPage,\n\t\t\"bookmarks\": bookmarks,\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\terr = json.NewEncoder(w).Encode(&resp)\n\tcheckError(err)\n}\n\n// ApiGetTags is handler for GET /api/tags\nfunc (h *Handler) ApiGetTags(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {\n\tctx := r.Context()\n\n\t// Make sure session still valid\n\terr := h.validateSession(r)\n\tcheckError(err)\n\n\t// Fetch all tags\n\ttags, err := h.DB.GetTags(ctx, model.DBListTagsOptions{\n\t\tWithBookmarkCount: true,\n\t})\n\tcheckError(err)\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\terr = json.NewEncoder(w).Encode(&tags)\n\tcheckError(err)\n}\n\n// ApiRenameTag is handler for PUT /api/tag\nfunc (h *Handler) ApiRenameTag(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {\n\tctx := r.Context()\n\n\t// Make sure session still valid\n\terr := h.validateSession(r)\n\tcheckError(err)\n\n\t// Decode request\n\ttag := model.Tag{}\n\terr = json.NewDecoder(r.Body).Decode(&tag)\n\tcheckError(err)\n\n\t// Update name\n\terr = h.DB.RenameTag(ctx, tag.ID, tag.Name)\n\tcheckError(err)\n\n\tfmt.Fprint(w, 1)\n}\n\n// Bookmark is the record for an URL.\ntype apiInsertBookmarkPayload struct {\n\tURL           string      `json:\"url\"`\n\tTitle         string      `json:\"title\"`\n\tExcerpt       string      `json:\"excerpt\"`\n\tTags          []model.Tag `json:\"tags\"`\n\tCreateArchive bool        `json:\"create_archive\"`\n\tCreateEbook   bool        `json:\"create_ebook\"`\n\tMakePublic    int         `json:\"public\"`\n\tAsync         bool        `json:\"async\"`\n}\n\n// newApiInsertBookmarkPayload\n// Returns the payload struct with its defaults\nfunc newAPIInsertBookmarkPayload() *apiInsertBookmarkPayload {\n\treturn &apiInsertBookmarkPayload{\n\t\tCreateArchive: false,\n\t\tCreateEbook:   false,\n\t\tAsync:         true,\n\t}\n}\n\n// ApiInsertBookmark is handler for POST /api/bookmark\nfunc (h *Handler) ApiInsertBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {\n\tctx := r.Context()\n\n\t// Make sure session still valid\n\terr := h.validateSession(r)\n\tcheckError(err)\n\n\t// Decode request\n\tpayload := newAPIInsertBookmarkPayload()\n\terr = json.NewDecoder(r.Body).Decode(&payload)\n\tcheckError(err)\n\n\tbook := &model.BookmarkDTO{\n\t\tURL:           payload.URL,\n\t\tTitle:         payload.Title,\n\t\tExcerpt:       payload.Excerpt,\n\t\tTags:          make([]model.TagDTO, len(payload.Tags)),\n\t\tPublic:        payload.MakePublic,\n\t\tCreateArchive: payload.CreateArchive,\n\t\tCreateEbook:   payload.CreateEbook,\n\t}\n\n\tfor i, tag := range payload.Tags {\n\t\tbook.Tags[i] = tag.ToDTO()\n\t}\n\n\t// Clean up bookmark URL\n\tbook.URL, err = core.RemoveUTMParams(book.URL)\n\tif err != nil {\n\t\tpanic(fmt.Errorf(\"failed to clean URL: %v\", err))\n\t}\n\n\tuserHasDefinedTitle := book.Title != \"\"\n\t// Make sure bookmark's title not empty\n\tif book.Title == \"\" {\n\t\tbook.Title = book.URL\n\t}\n\n\t// Save bookmark to database\n\tresults, err := h.DB.SaveBookmarks(ctx, true, *book)\n\tif err != nil || len(results) == 0 {\n\t\tpanic(fmt.Errorf(\"failed to save bookmark: %v\", err))\n\t}\n\n\tbook = &results[0]\n\n\tif payload.Async {\n\t\tgo func() {\n\t\t\tbookmark, err := downloadBookmarkContent(h.dependencies, book, h.DataDir, r, userHasDefinedTitle, book.Excerpt != \"\")\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"error downloading boorkmark: %s\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif _, err := h.DB.SaveBookmarks(context.Background(), false, *bookmark); err != nil {\n\t\t\t\tlog.Printf(\"failed to save bookmark: %s\", err)\n\t\t\t}\n\t\t}()\n\t} else {\n\t\t// Workaround. Download content after saving the bookmark so we have the proper database\n\t\t// id already set in the object regardless of the database engine.\n\t\tbook, err = downloadBookmarkContent(h.dependencies, book, h.DataDir, r, userHasDefinedTitle, book.Excerpt != \"\")\n\t\tif err != nil {\n\t\t\tlog.Printf(\"error downloading boorkmark: %s\", err)\n\t\t} else if _, err := h.DB.SaveBookmarks(ctx, false, *book); err != nil {\n\t\t\tlog.Printf(\"failed to save bookmark: %s\", err)\n\t\t}\n\t}\n\n\t// Return the new bookmark\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\terr = json.NewEncoder(w).Encode(results[0])\n\tcheckError(err)\n}\n\n// ApiDeleteBookmarks is handler for DELETE /api/bookmark\nfunc (h *Handler) ApiDeleteBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {\n\tctx := r.Context()\n\n\t// Make sure session still valid\n\terr := h.validateSession(r)\n\tcheckError(err)\n\n\t// Decode request\n\tids := []int{}\n\terr = json.NewDecoder(r.Body).Decode(&ids)\n\tcheckError(err)\n\n\t// Delete bookmarks\n\terr = h.DB.DeleteBookmarks(ctx, ids...)\n\tcheckError(err)\n\n\t// Delete thumbnail image and archives from local disk\n\tfor _, id := range ids {\n\t\tstrID := strconv.Itoa(id)\n\t\timgPath := fp.Join(h.DataDir, \"thumb\", strID)\n\t\tarchivePath := fp.Join(h.DataDir, \"archive\", strID)\n\t\tebookPath := fp.Join(h.DataDir, \"ebook\", strID+\".epub\")\n\n\t\tos.Remove(imgPath)\n\t\tos.Remove(archivePath)\n\t\tos.Remove(ebookPath)\n\t}\n\n\tfmt.Fprint(w, 1)\n}\n\n// ApiUpdateBookmark is handler for PUT /api/bookmarks\nfunc (h *Handler) ApiUpdateBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {\n\tctx := r.Context()\n\n\t// Make sure session still valid\n\terr := h.validateSession(r)\n\tcheckError(err)\n\n\t// Decode request\n\trequest := model.BookmarkDTO{}\n\terr = json.NewDecoder(r.Body).Decode(&request)\n\tcheckError(err)\n\n\t// Validate input\n\tif request.Title == \"\" {\n\t\tpanic(fmt.Errorf(\"title must not empty\"))\n\t}\n\n\t// Get existing bookmark from database\n\tfilter := model.DBGetBookmarksOptions{\n\t\tIDs:         []int{request.ID},\n\t\tWithContent: true,\n\t}\n\n\tbookmarks, err := h.DB.GetBookmarks(ctx, filter)\n\tcheckError(err)\n\tif len(bookmarks) == 0 {\n\t\tpanic(fmt.Errorf(\"no bookmark with matching ids\"))\n\t}\n\n\t// Set new bookmark data\n\tbook := bookmarks[0]\n\tbook.URL = request.URL\n\tbook.Title = request.Title\n\tbook.Excerpt = request.Excerpt\n\tbook.Public = request.Public\n\n\t// Clean up bookmark URL\n\tbook.URL, err = core.RemoveUTMParams(book.URL)\n\tif err != nil {\n\t\tpanic(fmt.Errorf(\"failed to clean URL: %v\", err))\n\t}\n\n\t// Set new tags\n\tfor i := range book.Tags {\n\t\tbook.Tags[i].Deleted = true\n\t}\n\n\tfor _, newTag := range request.Tags {\n\t\tfor i, oldTag := range book.Tags {\n\t\t\tif newTag.Name == oldTag.Name {\n\t\t\t\tnewTag.ID = oldTag.ID\n\t\t\t\tbook.Tags[i].Deleted = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif newTag.ID == 0 {\n\t\t\tbook.Tags = append(book.Tags, newTag)\n\t\t}\n\t}\n\n\t// Set bookmark modified\n\tbook.ModifiedAt = \"\"\n\n\t// Update database\n\tres, err := h.DB.SaveBookmarks(ctx, false, book)\n\tcheckError(err)\n\n\t// Add thumbnail image to the saved bookmarks again\n\tnewBook := res[0]\n\tnewBook.ImageURL = request.ImageURL\n\tnewBook.HasArchive = request.HasArchive\n\n\t// Return new saved result\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\terr = json.NewEncoder(w).Encode(&newBook)\n\tcheckError(err)\n}\n\n// ApiUpdateBookmarkTags is handler for PUT /api/bookmarks/tags\nfunc (h *Handler) ApiUpdateBookmarkTags(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {\n\tctx := r.Context()\n\n\t// Make sure session still valid\n\terr := h.validateSession(r)\n\tcheckError(err)\n\n\t// Decode request\n\trequest := struct {\n\t\tIDs  []int       `json:\"ids\"`\n\t\tTags []model.Tag `json:\"tags\"`\n\t}{}\n\n\terr = json.NewDecoder(r.Body).Decode(&request)\n\tcheckError(err)\n\n\t// Validate input\n\tif len(request.IDs) == 0 || len(request.Tags) == 0 {\n\t\tpanic(fmt.Errorf(\"IDs and tags must not empty\"))\n\t}\n\n\t// Get existing bookmark from database\n\tfilter := model.DBGetBookmarksOptions{\n\t\tIDs:         request.IDs,\n\t\tWithContent: true,\n\t}\n\n\tbookmarks, err := h.DB.GetBookmarks(ctx, filter)\n\tcheckError(err)\n\tif len(bookmarks) == 0 {\n\t\tpanic(fmt.Errorf(\"no bookmark with matching ids\"))\n\t}\n\n\t// Set new tags\n\tfor i, book := range bookmarks {\n\t\tfor _, newTag := range request.Tags {\n\t\t\tfor _, oldTag := range book.Tags {\n\t\t\t\tif newTag.Name == oldTag.Name {\n\t\t\t\t\tnewTag.ID = oldTag.ID\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif newTag.ID == 0 {\n\t\t\t\tbook.Tags = append(book.Tags, newTag.ToDTO())\n\t\t\t}\n\t\t}\n\n\t\tbookmarks[i] = book\n\t}\n\n\t// Update database\n\tbookmarks, err = h.DB.SaveBookmarks(ctx, false, bookmarks...)\n\tcheckError(err)\n\n\t// Get image URL for each bookmark\n\tfor i := range bookmarks {\n\t\tstrID := strconv.Itoa(bookmarks[i].ID)\n\t\timgPath := fp.Join(h.DataDir, \"thumb\", strID)\n\t\timgURL := path.Join(h.RootPath, \"bookmark\", strID, \"thumb\")\n\n\t\tif FileExists(imgPath) {\n\t\t\tbookmarks[i].ImageURL = imgURL\n\t\t}\n\t}\n\n\t// Return new saved result\n\terr = json.NewEncoder(w).Encode(&bookmarks)\n\tcheckError(err)\n}\n"
  },
  {
    "path": "internal/webserver/handler.go",
    "content": "package webserver\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\tcch \"github.com/patrickmn/go-cache\"\n\t\"github.com/sirupsen/logrus\"\n)\n\n// Handler is Handler for serving the web interface.\ntype Handler struct {\n\tDB        model.DB\n\tDataDir   string\n\tRootPath  string\n\tUserCache *cch.Cache\n\t// SessionCache *cch.Cache\n\tArchiveCache *cch.Cache\n\tLog          bool\n\n\tdependencies model.Dependencies\n\ttrustedIPs   []*net.IPNet\n}\n\nfunc (h *Handler) PrepareSessionCache() {\n\t// h.SessionCache.OnEvicted(func(key string, val interface{}) {\n\t// \taccount := val.(*model.AccountDTO)\n\t// \tarr, found := h.UserCache.Get(account.Username)\n\t// \tif !found {\n\t// \t\treturn\n\t// \t}\n\n\t// \tsessionIDs := arr.([]string)\n\t// \tfor i := 0; i < len(sessionIDs); i++ {\n\t// \t\tif sessionIDs[i] == key {\n\t// \t\t\tsessionIDs = append(sessionIDs[:i], sessionIDs[i+1:]...)\n\t// \t\t\tbreak\n\t// \t\t}\n\t// \t}\n\n\t// \th.UserCache.Set(account.Username, sessionIDs, -1)\n\t// })\n}\n\n// validateSession checks whether user session is still valid or not\nfunc (h *Handler) validateSession(r *http.Request) error {\n\tvar account *model.AccountDTO\n\tvar err error\n\n\tif h.dependencies.Config().Http.SSOProxyAuth {\n\t\taccount, err = h.ssoAccount(r)\n\t\tif err != nil {\n\t\t\th.dependencies.Logger().WithError(err).Error(\"getting sso account\")\n\t\t}\n\t}\n\n\tif account == nil {\n\t\taccount, err = h.tokenAccount(r)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif r.Method != \"\" && r.Method != \"GET\" && account.Owner != nil && !*account.Owner {\n\t\treturn fmt.Errorf(\"account level is not sufficient\")\n\t}\n\n\th.dependencies.Logger().WithFields(logrus.Fields{\n\t\t\"username\": account.Username,\n\t\t\"method\":   r.Method,\n\t\t\"path\":     r.URL.Path,\n\t}).Info(\"allowing legacy api access using JWT token\")\n\n\treturn nil\n\n}\n\nfunc (h *Handler) tokenAccount(r *http.Request) (*model.AccountDTO, error) {\n\tauthorization := r.Header.Get(model.AuthorizationHeader)\n\tif authorization == \"\" {\n\t\t// Get token from cookie\n\t\ttokenCookie, err := r.Cookie(\"token\")\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"session is not exist\")\n\t\t}\n\n\t\tauthorization = tokenCookie.Value\n\t}\n\n\tif authorization != \"\" {\n\t\tauthParts := strings.SplitN(authorization, \" \", 2)\n\t\tif len(authParts) != 2 || authParts[0] != model.AuthorizationTokenType {\n\t\t\treturn nil, fmt.Errorf(\"session has been expired\")\n\t\t}\n\n\t\taccount, err := h.dependencies.Domains().Auth().CheckToken(r.Context(), authParts[1])\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"session has been expired\")\n\t\t}\n\n\t\treturn account, nil\n\t}\n\n\treturn nil, errors.New(\"session has been expired\")\n}\n\nfunc (h *Handler) ssoAccount(r *http.Request) (*model.AccountDTO, error) {\n\tremoteAddr := r.RemoteAddr\n\tip, _, err := net.SplitHostPort(remoteAddr)\n\tif err != nil {\n\t\tvar addrErr *net.AddrError\n\t\tif errors.As(err, &addrErr) && addrErr.Err == \"missing port in address\" {\n\t\t\tip = remoteAddr\n\t\t} else {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\trequestIP := net.ParseIP(ip)\n\tif !h.isTrustedIP(requestIP) {\n\t\treturn nil, fmt.Errorf(\"'%s' is not a trusted ip\", r.RemoteAddr)\n\t}\n\n\theaderName := h.dependencies.Config().Http.SSOProxyAuthHeaderName\n\tuserName := r.Header.Get(headerName)\n\tif userName == \"\" {\n\t\treturn nil, nil\n\t}\n\n\taccount, err := h.dependencies.Domains().Accounts().GetAccountByUsername(r.Context(), userName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn account, nil\n}\nfunc (h *Handler) isTrustedIP(ip net.IP) bool {\n\tfor _, net := range h.trustedIPs {\n\t\tif ok := net.Contains(ip); ok {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "internal/webserver/server.go",
    "content": "package webserver\n\nimport (\n\t\"net\"\n\t\"time\"\n\n\t\"github.com/go-shiori/shiori/internal/model\"\n\tcch \"github.com/patrickmn/go-cache\"\n)\n\n// Config is parameter that used for starting web server\ntype Config struct {\n\tDB            model.DB\n\tDataDir       string\n\tServerAddress string\n\tServerPort    int\n\tRootPath      string\n\tLog           bool\n}\n\n// GetLegacyHandler returns a legacy handler to use with the new webserver\nfunc GetLegacyHandler(cfg Config, dependencies model.Dependencies) *Handler {\n\tplainIPs := dependencies.Config().Http.SSOProxyAuthTrusted\n\ttrustedIPs := make([]*net.IPNet, len(plainIPs))\n\tfor i, ip := range plainIPs {\n\t\t_, ipNet, err := net.ParseCIDR(ip)\n\t\tif err != nil {\n\t\t\tdependencies.Logger().WithError(err).WithField(\"ip\", ip).Error(\"Failed to parse trusted ip cidr\")\n\t\t\tcontinue\n\t\t}\n\n\t\ttrustedIPs[i] = ipNet\n\t}\n\n\treturn &Handler{\n\t\tDB:        cfg.DB,\n\t\tDataDir:   cfg.DataDir,\n\t\tUserCache: cch.New(time.Hour, 10*time.Minute),\n\t\t// SessionCache: cch.New(time.Hour, 10*time.Minute),\n\t\tArchiveCache: cch.New(time.Minute, 5*time.Minute),\n\t\tRootPath:     cfg.RootPath,\n\t\tLog:          cfg.Log,\n\t\tdependencies: dependencies,\n\t\ttrustedIPs:   trustedIPs,\n\t}\n}\n"
  },
  {
    "path": "internal/webserver/utils.go",
    "content": "package webserver\n\nimport (\n\t\"net\"\n\t\"os\"\n\t\"syscall\"\n)\n\nfunc FileExists(filePath string) bool {\n\tinfo, err := os.Stat(filePath)\n\treturn err == nil && !info.IsDir()\n}\n\nfunc checkError(err error) {\n\tif err == nil {\n\t\treturn\n\t}\n\n\t// Check for a broken connection, as it is not really a\n\t// condition that warrants a panic stack trace.\n\tif ne, ok := err.(*net.OpError); ok {\n\t\tif se, ok := ne.Err.(*os.SyscallError); ok {\n\t\t\tif se.Err == syscall.EPIPE || se.Err == syscall.ECONNRESET {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\tpanic(err)\n}\n"
  },
  {
    "path": "internal/webserver/utils_ip.go",
    "content": "package webserver\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n)\n\nvar (\n\tuserRealIpHeaderCandidates = [...]string{\"X-Real-Ip\", \"X-Forwarded-For\"}\n\t// From: https://github.com/letsencrypt/boulder/blob/main/bdns/dns.go#L30-L146\n\t// Private CIDRs to ignore\n\tprivateNetworks = []net.IPNet{\n\t\t// RFC1918\n\t\t// 10.0.0.0/8\n\t\t{\n\t\t\tIP:   []byte{10, 0, 0, 0},\n\t\t\tMask: []byte{255, 0, 0, 0},\n\t\t},\n\t\t// 172.16.0.0/12\n\t\t{\n\t\t\tIP:   []byte{172, 16, 0, 0},\n\t\t\tMask: []byte{255, 240, 0, 0},\n\t\t},\n\t\t// 192.168.0.0/16\n\t\t{\n\t\t\tIP:   []byte{192, 168, 0, 0},\n\t\t\tMask: []byte{255, 255, 0, 0},\n\t\t},\n\t\t// RFC5735\n\t\t// 127.0.0.0/8\n\t\t{\n\t\t\tIP:   []byte{127, 0, 0, 0},\n\t\t\tMask: []byte{255, 0, 0, 0},\n\t\t},\n\t\t// RFC1122 Section 3.2.1.3\n\t\t// 0.0.0.0/8\n\t\t{\n\t\t\tIP:   []byte{0, 0, 0, 0},\n\t\t\tMask: []byte{255, 0, 0, 0},\n\t\t},\n\t\t// RFC3927\n\t\t// 169.254.0.0/16\n\t\t{\n\t\t\tIP:   []byte{169, 254, 0, 0},\n\t\t\tMask: []byte{255, 255, 0, 0},\n\t\t},\n\t\t// RFC 5736\n\t\t// 192.0.0.0/24\n\t\t{\n\t\t\tIP:   []byte{192, 0, 0, 0},\n\t\t\tMask: []byte{255, 255, 255, 0},\n\t\t},\n\t\t// RFC 5737\n\t\t// 192.0.2.0/24\n\t\t{\n\t\t\tIP:   []byte{192, 0, 2, 0},\n\t\t\tMask: []byte{255, 255, 255, 0},\n\t\t},\n\t\t// 198.51.100.0/24\n\t\t{\n\t\t\tIP:   []byte{198, 51, 100, 0},\n\t\t\tMask: []byte{255, 255, 255, 0},\n\t\t},\n\t\t// 203.0.113.0/24\n\t\t{\n\t\t\tIP:   []byte{203, 0, 113, 0},\n\t\t\tMask: []byte{255, 255, 255, 0},\n\t\t},\n\t\t// RFC 3068\n\t\t// 192.88.99.0/24\n\t\t{\n\t\t\tIP:   []byte{192, 88, 99, 0},\n\t\t\tMask: []byte{255, 255, 255, 0},\n\t\t},\n\t\t// RFC 2544, Errata 423\n\t\t// 198.18.0.0/15\n\t\t{\n\t\t\tIP:   []byte{198, 18, 0, 0},\n\t\t\tMask: []byte{255, 254, 0, 0},\n\t\t},\n\t\t// RFC 3171\n\t\t// 224.0.0.0/4\n\t\t{\n\t\t\tIP:   []byte{224, 0, 0, 0},\n\t\t\tMask: []byte{240, 0, 0, 0},\n\t\t},\n\t\t// RFC 1112\n\t\t// 240.0.0.0/4\n\t\t{\n\t\t\tIP:   []byte{240, 0, 0, 0},\n\t\t\tMask: []byte{240, 0, 0, 0},\n\t\t},\n\t\t// RFC 919 Section 7\n\t\t// 255.255.255.255/32\n\t\t{\n\t\t\tIP:   []byte{255, 255, 255, 255},\n\t\t\tMask: []byte{255, 255, 255, 255},\n\t\t},\n\t\t// RFC 6598\n\t\t// 100.64.0.0/10\n\t\t{\n\t\t\tIP:   []byte{100, 64, 0, 0},\n\t\t\tMask: []byte{255, 192, 0, 0},\n\t\t},\n\t}\n\t// Sourced from https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml\n\t// where Global, Source, or Destination is False\n\tprivateV6Networks = []net.IPNet{\n\t\tparseCIDR(\"::/128\", \"RFC 4291: Unspecified Address\"),\n\t\tparseCIDR(\"::1/128\", \"RFC 4291: Loopback Address\"),\n\t\tparseCIDR(\"::ffff:0:0/96\", \"RFC 4291: IPv4-mapped Address\"),\n\t\tparseCIDR(\"100::/64\", \"RFC 6666: Discard Address Block\"),\n\t\tparseCIDR(\"2001::/23\", \"RFC 2928: IETF Protocol Assignments\"),\n\t\tparseCIDR(\"2001:2::/48\", \"RFC 5180: Benchmarking\"),\n\t\tparseCIDR(\"2001:db8::/32\", \"RFC 3849: Documentation\"),\n\t\tparseCIDR(\"2001::/32\", \"RFC 4380: TEREDO\"),\n\t\tparseCIDR(\"fc00::/7\", \"RFC 4193: Unique-Local\"),\n\t\tparseCIDR(\"fe80::/10\", \"RFC 4291: Section 2.5.6 Link-Scoped Unicast\"),\n\t\tparseCIDR(\"ff00::/8\", \"RFC 4291: Section 2.7\"),\n\t\t// We disable validations to IPs under the 6to4 anycase prefix because\n\t\t// there's too much risk of a malicious actor advertising the prefix and\n\t\t// answering validations for a 6to4 host they do not control.\n\t\t// https://community.letsencrypt.org/t/problems-validating-ipv6-against-host-running-6to4/18312/9\n\t\tparseCIDR(\"2002::/16\", \"RFC 7526: 6to4 anycast prefix deprecated\"),\n\t}\n)\n\n// parseCIDR parses the predefined CIDR to `net.IPNet` that consisting of IP and IPMask.\nfunc parseCIDR(network string, comment string) net.IPNet {\n\t_, subNet, err := net.ParseCIDR(network)\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"error parsing %s (%s): %s\", network, comment, err))\n\t}\n\treturn *subNet\n}\n\n// isPrivateV4 checks whether an `ip` is private based on whether the IP is in the private CIDR range.\nfunc isPrivateV4(ip net.IP) bool {\n\tfor _, subNet := range privateNetworks {\n\t\tif subNet.Contains(ip) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// isPrivateV6 checks whether an `ip` is private based on whether the IP is in the private CIDR range.\nfunc isPrivateV6(ip net.IP) bool {\n\tfor _, subNet := range privateV6Networks {\n\t\tif subNet.Contains(ip) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// IsPrivateIP check IPv4 or IPv6 address according to the length of byte array\nfunc IsPrivateIP(ip net.IP) bool {\n\tif ip4 := ip.To4(); ip4 != nil {\n\t\treturn isPrivateV4(ip4)\n\t}\n\treturn ip.To16() != nil && isPrivateV6(ip)\n}\n\n// IsIPValidAndPublic is a helper function check if an IP address is valid and public.\nfunc IsIPValidAndPublic(ipAddr string) bool {\n\tif ipAddr == \"\" {\n\t\treturn false\n\t}\n\tipAddr = strings.TrimSpace(ipAddr)\n\tip := net.ParseIP(ipAddr)\n\t// remote address within public address range\n\tif ip != nil && !IsPrivateIP(ip) {\n\t\treturn true\n\t}\n\treturn false\n}\n\n// GetUserRealIP Get User Real IP from headers of request `r`\n//  1. First, determine whether the remote addr of request is a private address.\n//     If it is a public network address, return it directly;\n//  2. Otherwise, get and check the real IP from X-REAL-IP and X-Forwarded-For headers in turn.\n//     if the header value contains multiple IP addresses separated by commas, that is,\n//     the request may pass through multiple reverse proxies, we just keep the first one,\n//     which imply it is the user connecting IP.\n//     then we check the value is a valid public IP address using the `IsIPValidAndPublic` function.\n//     If it is, the function returns the value as the client's real IP address.\n//  3. Finally, If the above headers do not exist or are invalid, the remote addr is returned as is.\nfunc GetUserRealIP(r *http.Request) string {\n\tfallbackAddr := r.RemoteAddr\n\tconnectAddr, _, err := net.SplitHostPort(r.RemoteAddr)\n\tif err != nil {\n\t\treturn fallbackAddr\n\t}\n\tif IsIPValidAndPublic(connectAddr) {\n\t\treturn connectAddr\n\t}\n\t// in case that remote address is private(container or internal)\n\tfor _, hd := range userRealIpHeaderCandidates {\n\t\tval := r.Header.Get(hd)\n\t\tif val == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t// remove leading or tailing comma, tab, space\n\t\tipAddr := strings.Trim(val, \",\\t \")\n\t\tif idxFirstIP := strings.Index(ipAddr, \",\"); idxFirstIP >= 0 {\n\t\t\tipAddr = ipAddr[:idxFirstIP]\n\t\t}\n\t\tif IsIPValidAndPublic(ipAddr) {\n\t\t\treturn ipAddr\n\t\t}\n\t}\n\treturn fallbackAddr\n}\n"
  },
  {
    "path": "internal/webserver/utils_ip_test.go",
    "content": "package webserver\n\nimport (\n\t\"fmt\"\n\t\"math/rand\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestParseCidr(t *testing.T) {\n\tres := parseCIDR(\"192.168.0.0/16\", \"internal 192.168.x.x\")\n\tassert.Equal(t, res.IP, net.IP([]byte{192, 168, 0, 0}))\n\tassert.Equal(t, res.Mask, net.IPMask([]byte{255, 255, 0, 0}))\n}\n\nfunc TestParseCidrInvalidAddr(t *testing.T) {\n\tassert.Panics(t, func() { parseCIDR(\"192.168.0.0/34\", \"internal 192.168.x.x\") })\n}\n\nfunc TestIsPrivateIP(t *testing.T) {\n\tassert.True(t, IsPrivateIP(net.ParseIP(\"127.0.0.1\")), \"should be private\")\n\tassert.True(t, IsPrivateIP(net.ParseIP(\"192.168.254.254\")), \"should be private\")\n\tassert.True(t, IsPrivateIP(net.ParseIP(\"10.255.0.3\")), \"should be private\")\n\tassert.True(t, IsPrivateIP(net.ParseIP(\"172.16.255.255\")), \"should be private\")\n\tassert.True(t, IsPrivateIP(net.ParseIP(\"172.31.255.255\")), \"should be private\")\n\tassert.True(t, !IsPrivateIP(net.ParseIP(\"128.0.0.1\")), \"should be private\")\n\tassert.True(t, !IsPrivateIP(net.ParseIP(\"192.169.255.255\")), \"should not be private\")\n\tassert.True(t, !IsPrivateIP(net.ParseIP(\"9.255.0.255\")), \"should not be private\")\n\tassert.True(t, !IsPrivateIP(net.ParseIP(\"172.32.255.255\")), \"should not be private\")\n\n\tassert.True(t, IsPrivateIP(net.ParseIP(\"::0\")), \"should be private\")\n\tassert.True(t, IsPrivateIP(net.ParseIP(\"::1\")), \"should be private\")\n\tassert.True(t, !IsPrivateIP(net.ParseIP(\"::2\")), \"should not be private\")\n\n\tassert.True(t, IsPrivateIP(net.ParseIP(\"fe80::1\")), \"should be private\")\n\tassert.True(t, IsPrivateIP(net.ParseIP(\"febf::1\")), \"should be private\")\n\tassert.True(t, !IsPrivateIP(net.ParseIP(\"fec0::1\")), \"should not be private\")\n\tassert.True(t, !IsPrivateIP(net.ParseIP(\"feff::1\")), \"should not be private\")\n\n\tassert.True(t, IsPrivateIP(net.ParseIP(\"ff00::1\")), \"should be private\")\n\tassert.True(t, IsPrivateIP(net.ParseIP(\"ff10::1\")), \"should be private\")\n\tassert.True(t, IsPrivateIP(net.ParseIP(\"ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff\")), \"should be private\")\n\n\tassert.True(t, IsPrivateIP(net.ParseIP(\"2002::\")), \"should be private\")\n\tassert.True(t, IsPrivateIP(net.ParseIP(\"2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff\")), \"should be private\")\n\tassert.True(t, IsPrivateIP(net.ParseIP(\"0100::\")), \"should be private\")\n\tassert.True(t, IsPrivateIP(net.ParseIP(\"0100::0000:ffff:ffff:ffff:ffff\")), \"should be private\")\n\tassert.True(t, !IsPrivateIP(net.ParseIP(\"0100::0001:0000:0000:0000:0000\")), \"should be private\")\n}\n\nfunc TestIsIpValidAndPublic(t *testing.T) {\n\t// test empty address\n\tassert.False(t, IsIPValidAndPublic(\"\"))\n\t// test public address\n\tassert.True(t, IsIPValidAndPublic(\"31.41.244.124\"))\n\tassert.True(t, IsIPValidAndPublic(\"62.233.50.248\"))\n\t// trim head or tail space\n\tassert.True(t, IsIPValidAndPublic(\" 62.233.50.249\"))\n\tassert.True(t, IsIPValidAndPublic(\" 62.233.50.250 \"))\n\tassert.True(t, IsIPValidAndPublic(\"62.233.50.251 \"))\n\t// test private address\n\tassert.False(t, IsIPValidAndPublic(\"10.1.123.52\"))\n\tassert.False(t, IsIPValidAndPublic(\"192.168.123.24\"))\n\tassert.False(t, IsIPValidAndPublic(\"172.17.0.1\"))\n}\n\nfunc BenchmarkIsPrivateIPv4(b *testing.B) {\n\t// range: 2-254\n\tn1 := 2 + rand.Intn(252)\n\tn2 := 2 + rand.Intn(252)\n\tfor i := 0; i < b.N; i++ {\n\t\tIsPrivateIP(net.ParseIP(fmt.Sprintf(\"192.168.%d.%d\", n1, n2)))\n\t}\n}\n\nfunc BenchmarkIsPrivateIPv6(b *testing.B) {\n\tn1 := 2 + rand.Intn(252)\n\tfor i := 0; i < b.N; i++ {\n\t\tIsPrivateIP(net.ParseIP(fmt.Sprintf(\"2002::%d\", n1)))\n\t}\n}\n\nfunc testIsPublicHttpRequestAddressHelper(\n\tt *testing.T, wantIP string, headers map[string]string, isPublic bool,\n) {\n\ttestIsPublicHttpRequestAddressHelperWrapped(t, nil, wantIP, headers, isPublic)\n}\n\nfunc testIsPublicHttpRequestAddressHelperWrapped(\n\tt *testing.T, r *http.Request, wantIP string, headers map[string]string, isPublic bool,\n) {\n\tvar (\n\t\terr    error\n\t\tuserIP string\n\t)\n\tif r == nil {\n\t\tr = httptest.NewRequest(\"GET\", \"/\", nil)\n\t}\n\tfor k, v := range headers {\n\t\tr.Header.Set(k, v)\n\t}\n\n\torigVal := GetUserRealIP(r)\n\tif strings.Contains(origVal, \":\") {\n\t\tuserIP, _, err = net.SplitHostPort(origVal)\n\t\tif err != nil {\n\t\t\tt.Error(err)\n\t\t}\n\t} else {\n\t\tuserIP = origVal\n\t}\n\n\tif isPublic {\n\t\t// should equal first ip in list\n\t\tassert.Equal(t, wantIP, userIP)\n\t\tassert.True(t, IsIPValidAndPublic(userIP))\n\t} else {\n\t\tassert.Equal(t, origVal, r.RemoteAddr)\n\t\tassert.False(t, IsIPValidAndPublic(userIP))\n\t}\n}\n\nfunc TestGetUserRealIPWithSetRemoteAddr(t *testing.T) {\n\t// Test Public RemoteAddr\n\ttestIsPublicHttpRequestAddressHelper(t, \"\", nil, false)\n\n\tr := httptest.NewRequest(\"GET\", \"/\", nil)\n\twantIP := \"34.23.123.122\"\n\tr.RemoteAddr = fmt.Sprintf(\"%s:1234\", wantIP)\n\ttestIsPublicHttpRequestAddressHelperWrapped(t, r, wantIP, nil, true)\n}\n\nfunc TestGetUserRealIPWithInvalidRemoteAddr(t *testing.T) {\n\t// Test Public RemoteAddr\n\ttestIsPublicHttpRequestAddressHelper(t, \"\", nil, false)\n\n\tr := httptest.NewRequest(\"GET\", \"/\", nil)\n\twantIP := \"34.23.123.122\"\n\t// without port\n\tr.RemoteAddr = wantIP\n\ttestIsPublicHttpRequestAddressHelperWrapped(t, r, wantIP, nil, true)\n}\n\nfunc TestGetUserRealIPWithEmptyHeader(t *testing.T) {\n\t// Test Empty X-Real-IP\n\ttestIsPublicHttpRequestAddressHelper(t, \"\", nil, false)\n}\n\nfunc TestGetUserRealIPWithInvalidHeaderValue(t *testing.T) {\n\tfor _, name := range userRealIpHeaderCandidates {\n\t\t// invalid ip\n\t\tm := map[string]string{\n\t\t\tname: \"31.41.24a.12\",\n\t\t}\n\t\ttestIsPublicHttpRequestAddressHelper(t, \"\", m, false)\n\t}\n}\n\nfunc TestGetUserRealIPWithXRealIpHeader(t *testing.T) {\n\t// Test public Real IP\n\tfor _, name := range userRealIpHeaderCandidates {\n\t\twantIP := \"31.41.242.12\"\n\t\tm := map[string]string{\n\t\t\tname: wantIP,\n\t\t}\n\t\ttestIsPublicHttpRequestAddressHelper(t, wantIP, m, true)\n\t}\n}\n\nfunc TestGetUserRealIPWithPrivateXRealIpHeader(t *testing.T) {\n\tfor _, name := range userRealIpHeaderCandidates {\n\t\twantIP := \"192.168.123.123\"\n\t\t// test private ip in header\n\t\tm := map[string]string{\n\t\t\tname: wantIP,\n\t\t}\n\t\ttestIsPublicHttpRequestAddressHelper(t, wantIP, m, false)\n\t}\n}\n\nfunc TestGetUserRealIPWithXRealIpListHeader(t *testing.T) {\n\t// Test Real IP List\n\tfor _, name := range userRealIpHeaderCandidates {\n\t\tipList := []string{\"34.23.123.122\", \"34.23.123.123\"}\n\t\t// should equal first ip in list\n\t\twantIP := ipList[0]\n\t\t// test private ip in header\n\t\tm := map[string]string{\n\t\t\tname: strings.Join(ipList, \", \"),\n\t\t}\n\t\ttestIsPublicHttpRequestAddressHelper(t, wantIP, m, true)\n\t}\n}\n\nfunc TestGetUserRealIPWithXRealIpHeaderIgnoreComma(t *testing.T) {\n\t// Test Real IP List with leading or tailing comma\n\twantIP := \"34.23.123.124\"\n\tipVariants := []string{\n\t\t\",34.23.123.124\", \" ,34.23.123.124\", \"\\t,34.23.123.124\",\n\t\t\",34.23.123.124,\", \" ,34.23.123.124, \", \"\\t,34.23.123.124,\\t\",\n\t\t\"34.23.123.124,\", \"34.23.123.124, \", \"34.23.123.124,\\t\"}\n\tfor _, variant := range ipVariants {\n\t\tfor _, name := range userRealIpHeaderCandidates {\n\t\t\tm := map[string]string{name: variant}\n\t\t\ttestIsPublicHttpRequestAddressHelper(t, wantIP, m, true)\n\t\t}\n\t}\n}\n\nfunc TestGetUserRealIPWithDifferentHeaderOrder(t *testing.T) {\n\tvar m map[string]string\n\twantIP := \"34.23.123.124\"\n\tm = map[string]string{\n\t\t\"X-Real-Ip\":       \"192.168.123.122\",\n\t\t\"X-Forwarded-For\": wantIP,\n\t}\n\n\ttestIsPublicHttpRequestAddressHelper(t, wantIP, m, true)\n\tm = map[string]string{\n\t\t\"X-Real-Ip\":       wantIP,\n\t\t\"X-Forwarded-For\": \"192.168.123.122\",\n\t}\n\ttestIsPublicHttpRequestAddressHelper(t, wantIP, m, true)\n}\n"
  },
  {
    "path": "main.go",
    "content": "package main\n\nimport (\n\t\"github.com/go-shiori/shiori/internal/cmd\"\n\t\"github.com/go-shiori/shiori/internal/model\"\n\t\"github.com/sirupsen/logrus\"\n\n\t// Add this to prevent it removed by go mod tidy\n\t_ \"github.com/shurcooL/vfsgen\"\n)\n\nvar (\n\tversion = \"dev\"\n\tcommit  = \"none\"\n\tdate    = \"unknown\"\n)\n\nfunc init() {\n\t// Set globally\n\tmodel.BuildVersion = version\n\tmodel.BuildCommit = commit\n\tmodel.BuildDate = date\n}\n\nfunc main() {\n\terr := cmd.ShioriCmd().Execute()\n\tif err != nil {\n\t\tlogrus.Fatalln(err)\n\t}\n}\n"
  },
  {
    "path": "mkdocs.yml",
    "content": "site_name: Shiori Documentation\nsite_description: Documentation for the Shiori bookmark manager\nrepo_url: https://github.com/go-shiori/shiori\ntheme:\n  name: material\n  palette:\n  # Light mode\n  - scheme: shiori\n    media: \"(prefers-color-scheme: light)\"\n    toggle:\n      icon: material/brightness-7\n      name: Switch to dark mode\n  # Dark mode\n  - scheme: slate\n    media: \"(prefers-color-scheme: dark)\"\n    toggle:\n      icon: material/brightness-4\n      name: Switch to light mode\n  features:\n    - navigation.instant\n    - navigation.tracking\n    - navigation.sections\n    - navigation.expand\n    - navigation.indexes\n    - toc.follow\n    - search.suggest\n    - search.highlight\n    - content.tabs.link\nextra_css:\n  - assets/css/style.css\nnav:\n  - Home: index.md\n  - Getting Started:\n    - Installation: installation.md\n    - Usage: usage.md\n    - Configuration: configuration.md\n    - Storage: storage.md\n  - API Reference:\n    - API v1: apiv1.md\n    - Legacy API: api.md\n  - Contributing:\n    - Contributing Guide: contribute.md\n    - FAQ: faq.md\n  - Screenshots: screenshots.md\n\nmarkdown_extensions:\n  - admonition\n  - pymdownx.details\n  - pymdownx.superfences\n  - pymdownx.tabbed:\n      alternate_style: true\n  - tables\n  - toc:\n      permalink: true\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"dependencies\": {\n    \"clean-css-cli\": \"^5.6.3\",\n    \"less\": \"^4.4.1\",\n    \"prettier\": \"^3.6.2\"\n  }\n}\n"
  },
  {
    "path": "scripts/buildx.sh",
    "content": "#!/usr/bin/env bash\nset -ex\n\n# Check if the shiori_builder builder exists\nif [ \"$CONTAINER_RUNTIME\" == \"docker\" ]; then\n    if [ -z \"$($CONTAINER_RUNTIME buildx ls | grep shiori_builder)\" ]; then\n        echo \"Creating shiori_builder builder\"\n        $CONTAINER_RUNTIME buildx create --use --name shiori_builder\n    fi\nfi\n\nif [[ -d \"dist/shiori_linux_arm_7\" ]]; then\n    cp -r dist/shiori_linux_arm_7 dist/shiori_linux_armv7\nfi\n\nif [[ -d \"dist/shiori_linux_amd64_v1\" ]]; then\n    cp -r dist/shiori_linux_amd64_v1 dist/shiori_linux_amd64\nfi\n\nif [[ -d \"dist/shiori_linux_arm64_v8.0\" ]]; then\n    cp -r dist/shiori_linux_arm64_v8.0 dist/shiori_linux_arm64\nfi\n\n$CONTAINER_RUNTIME buildx build \\\n    -f ${CONTAINERFILE_NAME} \\\n    --platform=${BUILDX_PLATFORMS} \\\n    --build-arg \"ALPINE_VERSION=${CONTAINER_ALPINE_VERSION}\" \\\n    --build-arg \"GOLANG_VERSION=${GOLANG_VERSION}\" \\\n    ${CONTAINER_BUILDX_OPTIONS} \\\n    .\n\nif [ \"$CONTAINER_RUNTIME\" == \"docker\" ]; then\n    $CONTAINER_RUNTIME buildx rm shiori_builder\nfi\n"
  },
  {
    "path": "scripts/e2e.sh",
    "content": "#!/bin/bash\n\nset -e\n\nTIMEOUT=30m\n\n# Run the e2e tests\necho \"Running e2e tests...\"\n\n# Disable Ryuk for CI environments to avoid errors due to each test\n# using its own container, which can cause race conditions with Ryuk\n# due to us being the ones creating/removing the containers.\nexport TESTCONTAINERS_RYUK_DISABLED=true\n\nexport CONTEXT_PATH=$(pwd)\n\n# if gotestfmt is installed, run with it\nif [ -x \"$(command -v gotestfmt)\" ]; then\n    go test ./e2e/... -count=1 -v -timeout=${TIMEOUT} -json | gotestfmt ${GOTESTFMT_FLAGS}\nelse\n    go test ./e2e/... -count=1 -v -timeout=${TIMEOUT}\nfi\n"
  },
  {
    "path": "scripts/styles.sh",
    "content": "#!/bin/bash\n\nINPUT_STYLECSS=internal/view/assets/less/style.less\nOUTPUT_STYLECSS=internal/view/assets/css/style.css\n\nINPUT_ARCHIVECSS=internal/view/assets/less/archive.less\nOUTPUT_ARCHIVECSS=internal/view/assets/css/archive.css\n\n# Detect support of avx2\nBUN=\"bun\"\ncase `uname -o` in\n    GNU/Linux)\n    # Detect support of avx2 in linux hosts\n    if ! grep -q avx2 /proc/cpuinfo; then\n        echo \"It seems that your CPU does not support AVX2, if you experience long build times (>1m) ensure that you use bun's baseline builds. More information at https://github.com/oven-sh/bun/issues/67\"\n    fi\n    ;;\nesac\n\n# Use bun is installled\nif [ -x \"$(command -v bun)\" ]; then\n    $BUN install\n    $BUN x prettier internal/view/ --write\n    $BUN x lessc $INPUT_STYLECSS $OUTPUT_STYLECSS\n    $BUN x lessc $INPUT_ARCHIVECSS $OUTPUT_ARCHIVECSS\n    $BUN x clean-css-cli $CLEANCSS_OPTS -o $OUTPUT_STYLECSS $OUTPUT_STYLECSS\n    $BUN x clean-css-cli $CLEANCSS_OPTS -o $OUTPUT_ARCHIVECSS $OUTPUT_ARCHIVECSS\n    exit 0\nfi\n\n# Default to lessc and cleancss\nlessc $INPUT_STYLECSS $OUTPUT_STYLECSS\nlessc $INPUT_ARCHIVECSS $OUTPUT_ARCHIVECSS\ncleancss $CLEANCSS_OPTS -o $OUTPUT_STYLECSS $OUTPUT_STYLECSS\ncleancss $CLEANCSS_OPTS -o $OUTPUT_ARCHIVECSS $OUTPUT_ARCHIVECSS\n"
  },
  {
    "path": "scripts/styles_check.sh",
    "content": "#!/bin/bash\n\n# This script is used to check if the style.css file is up to date.\n\n# Check if the git tree for CWD is clean\nif [ -n \"$(git status internal/view/assets/css --porcelain)\" ]; then\n    echo \"❌ git tree is not clean. Please commit all changes before running this script.\"\n    exit 1\nfi\n\n# Check if prettier is ok\nif ! bun x prettier internal/view/ --check; then\n    echo \"❌ code style issues found. Please run 'make styles' and commit the changes.\"\n    exit 1\nfi\n\n# Check style.css file\nCLEANCSS_OPTS=${CLEANCSS_OPTS} make styles\nif [ -n \"$(git status internal/view/assets/css --porcelain)\" ]; then\n    echo \"❌ style.css wasn't built from less changes. Please run 'make styles' and commit the changes.\"\n    git checkout -- internal/view/assets/css/\n    exit 1\nfi\n"
  },
  {
    "path": "scripts/swagger.sh",
    "content": "#!/bin/bash\n\n# This script is used to generate the swagger files for the API.\nswag init --output=$SWAGGER_DOCS_PATH\n"
  },
  {
    "path": "scripts/swagger_check.sh",
    "content": "#!/bin/bash\n\n# This script is used to check if the swagger files are up to date.\n\n# Check if swag version is correct\nCURRENT_SWAG_VERSION=$(swag --version | cut -d \" \" -f 3)\nif [ \"$CURRENT_SWAG_VERSION\" != \"$REQUIRED_SWAG_VERSION\" ]; then\n    echo \"swag version is incorrect. Required version: $REQUIRED_SWAG_VERSION, current version: $CURRENT_SWAG_VERSION\"\n    exit 1\nfi\n\n# Check if the git tree for CWD is clean\nif [ -n \"$(git status docs/swagger --porcelain)\" ]; then\n    echo \"❌ git tree is not clean. Please commit all changes before running this script.\"\n    exit 1\nfi\n\n# Check swag comments\nmake swag-fmt\nif [ -n \"$(git status docs/swagger --porcelain)\" ]; then\n    echo \"❌ swag comments are not formatted. Please run 'make swag-fmt' and commit the changes.\"\n    git checkout -- docs/swagger\n    exit 1\nfi\n\n# Check swagger documentation\nmake swagger\nif [ -n \"$(git status docs/swagger --porcelain)\" ]; then\n    echo \"❌ swagger documentation not updated, please run 'make swagger' and commit the changes.\"\n    git checkout -- docs/swagger\n    exit 1\nfi\n"
  },
  {
    "path": "scripts/test.sh",
    "content": "#!/bin/bash\n\n# Check if gotestfmt is installed\nif ! [ -x \"$(command -v gotestfmt)\" ]; then\n    echo \"gotestfmt not found. Using test standard output.\"\nfi\n\n# if gotestfmt is installed, run with it\nif [ -x \"$(command -v gotestfmt)\" ]; then\n    set -o pipefail\n    go test ${SOURCE_FILES} -json ${GO_TEST_FLAGS} | gotestfmt ${GOTESTFMT_FLAGS}\nelse\n    go test ${SOURCE_FILES} ${GO_TEST_FLAGS}\nfi\n"
  },
  {
    "path": "testdata/nginx.conf",
    "content": "events {\n    worker_connections 1024;\n}\n\nhttp {\n    include       /etc/nginx/mime.types;\n    default_type  application/octet-stream;\n\n    server {\n        listen 8081;\n        server_name localhost;\n\n        location /shiori/ {\n            proxy_pass http://shiori:8080/;\n            proxy_set_header Host $host;\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n            proxy_set_header X-Forwarded-Proto $scheme;\n            proxy_read_timeout 300;\n            proxy_connect_timeout 300;\n            proxy_send_timeout 300;\n        }\n    }\n}\n"
  },
  {
    "path": "testdata/pocket-new.csv",
    "content": "title,url,time_added,tags,status\nShiori,https://github.com/go-shiori/shiori,1541343937,shiori,unread"
  },
  {
    "path": "testdata/pocket-old.csv",
    "content": "title,url,time_added,cursor,tags,status\nShiori,https://github.com/go-shiori/shiori,1541343937,,shiori,unread"
  },
  {
    "path": "webapp/.editorconfig",
    "content": "[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]\ncharset = utf-8\nindent_size = 2\nindent_style = space\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\nend_of_line = lf\nmax_line_length = 100\n"
  },
  {
    "path": "webapp/.gitattributes",
    "content": "* text=auto eol=lf\n"
  },
  {
    "path": "webapp/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\n.DS_Store\n#dist\ndist-ssr\ncoverage\n*.local\n\n/cypress/videos/\n/cypress/screenshots/\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n*.tsbuildinfo\n"
  },
  {
    "path": "webapp/.prettierrc.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/prettierrc\",\n  \"semi\": false,\n  \"singleQuote\": true,\n  \"printWidth\": 100\n}\n"
  },
  {
    "path": "webapp/README.md",
    "content": "# .\n\nThis template should help get you started developing with Vue 3 in Vite.\n\n## Recommended IDE Setup\n\n[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).\n\n## Type Support for `.vue` Imports in TS\n\nTypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.\n\n## Customize configuration\n\nSee [Vite Configuration Reference](https://vite.dev/config/).\n\n## Project Setup\n\n```sh\nbun install\n```\n\n### Compile and Hot-Reload for Development\n\n```sh\nbun dev\n```\n\n### Type-Check, Compile and Minify for Production\n\n```sh\nbun run build\n```\n\n### Run Unit Tests with [Vitest](https://vitest.dev/)\n\n```sh\nbun test:unit\n```\n\n### Lint with [ESLint](https://eslint.org/)\n\n```sh\nbun lint\n```\n"
  },
  {
    "path": "webapp/dist/assets/ArchiveView-DZOySksr.js",
    "content": "import{b as n,e as o,w as t,_ as a,f as i,i as s}from\"./index-C8c580-n.js\";const f=n({__name:\"ArchiveView\",setup(r){return(l,e)=>(i(),o(a,null,{header:t(()=>e[0]||(e[0]=[s(\"div\",{class:\"flex justify-between items-center\"},[s(\"h1\",{class:\"text-xl font-bold\"},\"Archive\")],-1)])),default:t(()=>[e[1]||(e[1]=s(\"div\",{class:\"bg-white p-6 rounded-md shadow-sm\"},[s(\"p\",null,\"Archive view - Coming soon\")],-1))]),_:1}))}});export{f as default};\n"
  },
  {
    "path": "webapp/dist/assets/FoldersView-B-TWh6ac.js",
    "content": "import{b as d,r as i,e as c,w as l,_ as m,f as s,i as e,g as o,F as u,m as g,t as n,n as h}from\"./index-C8c580-n.js\";const x={class:\"mt-6\"},v={key:0,class:\"text-center py-8 text-gray-500\"},w={key:1,class:\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4\"},p={class:\"flex items-center\"},f={class:\"flex-1\"},k={class:\"font-medium text-lg\"},_={class:\"text-sm text-gray-500\"},b=d({__name:\"FoldersView\",setup(y){const r=i([{id:1,name:\"Work\",bookmarkCount:12},{id:2,name:\"Personal\",bookmarkCount:8},{id:3,name:\"Research\",bookmarkCount:15},{id:4,name:\"Reading List\",bookmarkCount:23}]);return(C,t)=>(s(),c(m,null,{header:l(()=>t[0]||(t[0]=[e(\"div\",{class:\"flex justify-between items-center\"},[e(\"h1\",{class:\"text-xl font-bold\"},\"Folders\"),e(\"div\",{class:\"flex space-x-2\"},[e(\"button\",{class:\"bg-red-500 text-white px-3 py-1 rounded-md hover:bg-red-600\"},\" New Folder \")])],-1)])),default:l(()=>[e(\"div\",x,[r.value.length===0?(s(),o(\"div\",v,\" No folders yet. Create your first folder to organize your bookmarks. \")):(s(),o(\"ul\",w,[(s(!0),o(u,null,g(r.value,a=>(s(),o(\"li\",{key:a.id,class:\"bg-white p-4 rounded-md shadow-sm hover:shadow-md transition-shadow border border-gray-200\"},[e(\"div\",p,[t[1]||(t[1]=e(\"div\",{class:\"mr-3 text-gray-400\"},[e(\"svg\",{xmlns:\"http://www.w3.org/2000/svg\",class:\"h-8 w-8\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\"},[e(\"path\",{\"stroke-linecap\":\"round\",\"stroke-linejoin\":\"round\",\"stroke-width\":\"2\",d:\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z\"})])],-1)),e(\"div\",f,[e(\"h3\",k,n(a.name),1),e(\"p\",_,n(a.bookmarkCount)+\" bookmarks\",1)]),t[2]||(t[2]=e(\"div\",{class:\"flex space-x-1\"},[e(\"button\",{class:\"text-gray-400 hover:text-gray-600 p-1\",title:\"Edit\"},[e(\"svg\",{xmlns:\"http://www.w3.org/2000/svg\",class:\"h-5 w-5\",viewBox:\"0 0 20 20\",fill:\"currentColor\"},[e(\"path\",{d:\"M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z\"})])]),e(\"button\",{class:\"text-gray-400 hover:text-red-500 p-1\",title:\"Delete\"},[e(\"svg\",{xmlns:\"http://www.w3.org/2000/svg\",class:\"h-5 w-5\",viewBox:\"0 0 20 20\",fill:\"currentColor\"},[e(\"path\",{\"fill-rule\":\"evenodd\",d:\"M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z\",\"clip-rule\":\"evenodd\"})])])],-1))])]))),128))]))])]),_:1}))}}),z=h(b,[[\"__scopeId\",\"data-v-f30fbcbb\"]]);export{z as default};\n"
  },
  {
    "path": "webapp/dist/assets/FoldersView-tn0RQdqM.css",
    "content": ""
  },
  {
    "path": "webapp/dist/assets/SettingsView-BWJgD3kk.js",
    "content": "import{b as h,p as u,r as v,e as f,w as c,_ as p,f as a,i as e,t as d,l,g as i,m as b,q as w,h as x,F as k,s as y}from\"./index-C8c580-n.js\";const C={class:\"flex justify-between items-center\"},B={class:\"text-xl font-bold\"},L={class:\"bg-white dark:bg-gray-800 p-6 rounded-md shadow-sm\"},z={class:\"mb-6\"},E={class:\"text-lg font-semibold mb-4\"},F={class:\"grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4\"},V=[\"onClick\"],j={class:\"flex items-center\"},D={class:\"flex-1\"},N={class:\"font-medium\"},S={key:0,class:\"text-red-500\"},M=h({__name:\"SettingsView\",setup($){const{t:r,locale:m}=u(),_=[{code:\"en\",name:\"English\"},{code:\"es\",name:\"Español\"},{code:\"fr\",name:\"Français\"},{code:\"de\",name:\"Deutsch\"},{code:\"ja\",name:\"日本語\"}],t=v(m.value),g=o=>{t.value=o,y(o)};return(o,n)=>(a(),f(p,null,{header:c(()=>[e(\"div\",C,[e(\"h1\",B,d(l(r)(\"settings.title\")),1)])]),default:c(()=>[e(\"div\",L,[e(\"div\",z,[e(\"h2\",E,d(l(r)(\"settings.language\")),1),e(\"div\",F,[(a(),i(k,null,b(_,s=>e(\"div\",{key:s.code,onClick:q=>g(s.code),class:w([\"border rounded-md p-4 cursor-pointer transition-colors\",t.value===s.code?\"border-red-500 bg-red-50 dark:bg-red-900/20\":\"border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700\"])},[e(\"div\",j,[e(\"div\",D,[e(\"div\",N,d(s.name),1)]),t.value===s.code?(a(),i(\"div\",S,n[0]||(n[0]=[e(\"svg\",{xmlns:\"http://www.w3.org/2000/svg\",class:\"h-5 w-5\",viewBox:\"0 0 20 20\",fill:\"currentColor\"},[e(\"path\",{\"fill-rule\":\"evenodd\",d:\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z\",\"clip-rule\":\"evenodd\"})],-1)]))):x(\"\",!0)])],10,V)),64))])])])]),_:1}))}});export{M as default};\n"
  },
  {
    "path": "webapp/dist/assets/TagsView-CmDnarVi.js",
    "content": "import{B as U,J as P,M as E,R as k,V as O,a as A,d as J,r as f,u as F,C as H,b as X,o as K,c as Q,e as W,w as B,_ as Y,f as g,g as v,h as C,i as t,j as M,v as N,t as V,k as Z,l as b,F as ee,m as te}from\"./index-C8c580-n.js\";class ae extends U{async apiV1TagsGetRaw(e,s){const o={};e.withBookmarkCount!=null&&(o.with_bookmark_count=e.withBookmarkCount),e.bookmarkId!=null&&(o.bookmark_id=e.bookmarkId),e.search!=null&&(o.search=e.search);const r={},u=await this.request({path:\"/api/v1/tags\",method:\"GET\",headers:r,query:o},s);return new P(u,w=>w.map(E))}async apiV1TagsGet(e={},s){return await(await this.apiV1TagsGetRaw(e,s)).value()}async apiV1TagsIdDeleteRaw(e,s){if(e.id==null)throw new k(\"id\",'Required parameter \"id\" was null or undefined when calling apiV1TagsIdDelete().');const o={},r={},u=await this.request({path:\"/api/v1/tags/{id}\".replace(\"{id}\",encodeURIComponent(String(e.id))),method:\"DELETE\",headers:r,query:o},s);return new O(u)}async apiV1TagsIdDelete(e,s){await this.apiV1TagsIdDeleteRaw(e,s)}async apiV1TagsIdGetRaw(e,s){if(e.id==null)throw new k(\"id\",'Required parameter \"id\" was null or undefined when calling apiV1TagsIdGet().');const o={},r={},u=await this.request({path:\"/api/v1/tags/{id}\".replace(\"{id}\",encodeURIComponent(String(e.id))),method:\"GET\",headers:r,query:o},s);return new P(u,w=>E(w))}async apiV1TagsIdGet(e,s){return await(await this.apiV1TagsIdGetRaw(e,s)).value()}async apiV1TagsIdPutRaw(e,s){if(e.id==null)throw new k(\"id\",'Required parameter \"id\" was null or undefined when calling apiV1TagsIdPut().');if(e.tag==null)throw new k(\"tag\",'Required parameter \"tag\" was null or undefined when calling apiV1TagsIdPut().');const o={},r={};r[\"Content-Type\"]=\"application/json\";const u=await this.request({path:\"/api/v1/tags/{id}\".replace(\"{id}\",encodeURIComponent(String(e.id))),method:\"PUT\",headers:r,query:o,body:A(e.tag)},s);return new P(u,w=>E(w))}async apiV1TagsIdPut(e,s){return await(await this.apiV1TagsIdPutRaw(e,s)).value()}async apiV1TagsPostRaw(e,s){if(e.tag==null)throw new k(\"tag\",'Required parameter \"tag\" was null or undefined when calling apiV1TagsPost().');const o={},r={};r[\"Content-Type\"]=\"application/json\";const u=await this.request({path:\"/api/v1/tags\",method:\"POST\",headers:r,query:o,body:A(e.tag)},s);return new P(u,w=>E(w))}async apiV1TagsPost(e,s){return await(await this.apiV1TagsPostRaw(e,s)).value()}}const se=J(\"tags\",()=>{const h=f([]),e=f(!1),s=f(null),o=()=>{const n=F().token,l=new H({basePath:\"http://localhost:8080\",accessToken:n||void 0,headers:n?{Authorization:`Bearer ${n}`,\"X-Shiori-Response-Format\":\"new\"}:void 0});return new ae(l)};return{tags:h,isLoading:e,error:s,fetchTags:async(c=!0)=>{e.value=!0,s.value=null;try{const l=await o().apiV1TagsGet({withBookmarkCount:c});return Array.isArray(l)?h.value=l:(console.error(\"Expected array response but got:\",typeof l),h.value=[]),h.value}catch(n){throw console.error(\"Failed to fetch tags:\",n),n instanceof Error&&n.message.includes(\"401\")?s.value=\"Authentication error. Please log in again.\":s.value=\"Failed to load tags. Please try again.\",n}finally{e.value=!1}},createTag:async c=>{e.value=!0,s.value=null;try{const l=await o().apiV1TagsPost({tag:{name:c}});return h.value.push(l),l}catch(n){throw console.error(\"Failed to create tag:\",n),n instanceof Error&&n.message.includes(\"401\")?s.value=\"Authentication error. Please log in again.\":s.value=\"Failed to create tag. Please try again.\",n}finally{e.value=!1}},updateTag:async(c,n)=>{e.value=!0,s.value=null;try{const m=await o().apiV1TagsIdPut({id:c,tag:{id:c,name:n}}),y=h.value.findIndex(p=>p.id===c);return y!==-1&&(h.value[y]=m),m}catch(l){throw console.error(\"Failed to update tag:\",l),l instanceof Error&&l.message.includes(\"401\")?s.value=\"Authentication error. Please log in again.\":s.value=\"Failed to update tag. Please try again.\",l}finally{e.value=!1}},deleteTag:async c=>{e.value=!0,s.value=null;try{await o().apiV1TagsIdDelete({id:c}),h.value=h.value.filter(l=>l.id!==c)}catch(n){throw console.error(\"Failed to delete tag:\",n),n instanceof Error&&n.message.includes(\"401\")?s.value=\"Authentication error. Please log in again.\":s.value=\"Failed to delete tag. Please try again.\",n}finally{e.value=!1}}}}),oe={class:\"flex justify-between items-center\"},ne={class:\"flex space-x-2\"},le={key:0,class:\"bg-white p-4 rounded-md shadow-sm mb-6\"},re=[\"disabled\"],ie={key:0,class:\"mt-1 text-sm text-red-600\"},de={class:\"flex justify-end space-x-2\"},ue=[\"disabled\"],ce=[\"disabled\"],ge={key:1,class:\"bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-6\"},pe={key:2,class:\"bg-white p-6 rounded-md shadow-sm flex justify-center\"},ve={key:3,class:\"bg-white p-6 rounded-md shadow-sm text-center\"},he={key:4,class:\"mt-6\"},we={class:\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4\"},me={key:0,class:\"flex items-center\"},fe=[\"disabled\"],ye={class:\"flex ml-2 space-x-1\"},be=[\"onClick\",\"disabled\"],xe=[\"disabled\"],Te={key:1,class:\"flex items-center\"},ke={class:\"flex-1\"},Ce={class:\"font-medium text-lg\"},Ve={class:\"text-sm text-gray-500\"},_e={class:\"flex space-x-1\"},Re=[\"onClick\"],Ie=[\"onClick\"],Pe={key:5,class:\"fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50\"},Ee={class:\"bg-white rounded-lg p-6 max-w-md w-full\"},De={class:\"flex justify-end space-x-3\"},Ae=X({__name:\"TagsView\",setup(h){const e=se(),s=F(),o=Q(),{tags:r,isLoading:u,error:w,fetchTags:D,createTag:c,updateTag:n,deleteTag:l}=e,m=f(!1),y=f(\"\"),p=f(!1),_=f(null),R=f(null),x=f(\"\");K(async()=>{try{s.isAuthenticated?await D():await s.validateToken()?(await D(),console.log(\"tags\",r)):(s.setRedirectDestination(\"/tags\"),o.push(\"/login\"))}catch(i){i instanceof Error&&i.message.includes(\"401\")&&(s.setRedirectDestination(\"/tags\"),o.push(\"/login\"))}});const S=i=>{i instanceof Error&&i.message.includes(\"401\")&&(s.setRedirectDestination(\"/tags\"),o.push(\"/login\"))},L=async()=>{if(!y.value.trim()){_.value=\"Tag name cannot be empty\";return}_.value=null,p.value=!0;try{await c(y.value.trim()),y.value=\"\",m.value=!1}catch(i){S(i)}finally{p.value=!1}},$=(i,a)=>{R.value=i,x.value=a},z=()=>{R.value=null,x.value=\"\"},q=async i=>{if(x.value.trim()){p.value=!0;try{await n(i,x.value.trim()),R.value=null}catch(a){S(a)}finally{p.value=!1}}},T=f(null),G=i=>{T.value=i},j=async()=>{if(T.value!==null)try{await l(T.value),T.value=null}catch(i){S(i)}};return(i,a)=>(g(),W(Y,null,{header:B(()=>[t(\"div\",oe,[a[6]||(a[6]=t(\"h1\",{class:\"text-xl font-bold\"},\"Tags\",-1)),t(\"div\",ne,[t(\"button\",{onClick:a[0]||(a[0]=d=>m.value=!m.value),class:\"bg-blue-500 text-white px-3 py-1 rounded-md hover:bg-blue-600 transition\"},V(m.value?\"Cancel\":\"New Tag\"),1)])])]),default:B(()=>[m.value?(g(),v(\"div\",le,[a[8]||(a[8]=t(\"h2\",{class:\"text-lg font-medium mb-3\"},\"Create New Tag\",-1)),t(\"form\",{onSubmit:Z(L,[\"prevent\"]),class:\"flex flex-col space-y-3\"},[t(\"div\",null,[a[7]||(a[7]=t(\"label\",{for:\"tagName\",class:\"block text-sm font-medium text-gray-700 mb-1\"},\"Tag Name\",-1)),M(t(\"input\",{id:\"tagName\",\"onUpdate:modelValue\":a[1]||(a[1]=d=>y.value=d),type:\"text\",class:\"w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500\",placeholder:\"Enter tag name\",disabled:p.value},null,8,re),[[N,y.value]]),_.value?(g(),v(\"p\",ie,V(_.value),1)):C(\"\",!0)]),t(\"div\",de,[t(\"button\",{type:\"button\",onClick:a[2]||(a[2]=d=>m.value=!1),class:\"px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50\",disabled:p.value},\" Cancel \",8,ue),t(\"button\",{type:\"submit\",class:\"px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50\",disabled:p.value},\" Create \",8,ce)])],32)])):C(\"\",!0),b(w)?(g(),v(\"div\",ge,V(b(w)),1)):C(\"\",!0),b(u)&&!b(r).length?(g(),v(\"div\",pe,a[9]||(a[9]=[t(\"div\",{class:\"animate-pulse text-gray-500\"},\"Loading tags...\",-1)]))):!b(u)&&!b(r).length?(g(),v(\"div\",ve,[a[10]||(a[10]=t(\"p\",{class:\"text-gray-500 mb-4\"},\"No tags found. Create your first tag to organize your bookmarks.\",-1)),m.value?C(\"\",!0):(g(),v(\"button\",{key:0,onClick:a[3]||(a[3]=d=>m.value=!0),class:\"px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600\"},\" Create Tag \"))])):(g(),v(\"div\",he,[t(\"ul\",we,[(g(!0),v(ee,null,te(b(r),d=>(g(),v(\"li\",{key:d.id,class:\"bg-white p-4 rounded-md shadow-sm hover:shadow-md transition-shadow border border-gray-200\"},[R.value===d.id?(g(),v(\"div\",me,[M(t(\"input\",{\"onUpdate:modelValue\":a[4]||(a[4]=I=>x.value=I),type:\"text\",class:\"flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500\",disabled:p.value},null,8,fe),[[N,x.value]]),t(\"div\",ye,[t(\"button\",{onClick:I=>q(d.id),class:\"text-blue-500 hover:text-blue-700 p-1\",disabled:p.value,title:\"Save\"},a[11]||(a[11]=[t(\"svg\",{xmlns:\"http://www.w3.org/2000/svg\",class:\"h-5 w-5\",viewBox:\"0 0 20 20\",fill:\"currentColor\"},[t(\"path\",{\"fill-rule\":\"evenodd\",d:\"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z\",\"clip-rule\":\"evenodd\"})],-1)]),8,be),t(\"button\",{onClick:z,class:\"text-gray-500 hover:text-gray-700 p-1\",disabled:p.value,title:\"Cancel\"},a[12]||(a[12]=[t(\"svg\",{xmlns:\"http://www.w3.org/2000/svg\",class:\"h-5 w-5\",viewBox:\"0 0 20 20\",fill:\"currentColor\"},[t(\"path\",{\"fill-rule\":\"evenodd\",d:\"M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z\",\"clip-rule\":\"evenodd\"})],-1)]),8,xe)])])):(g(),v(\"div\",Te,[a[15]||(a[15]=t(\"div\",{class:\"mr-3 text-blue-400\"},[t(\"svg\",{xmlns:\"http://www.w3.org/2000/svg\",class:\"h-6 w-6\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\"},[t(\"path\",{\"stroke-linecap\":\"round\",\"stroke-linejoin\":\"round\",\"stroke-width\":\"2\",d:\"M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z\"})])],-1)),t(\"div\",ke,[t(\"h3\",Ce,V(d.name),1),t(\"p\",Ve,V(d.bookmarkCount||0)+\" bookmarks\",1)]),t(\"div\",_e,[t(\"button\",{onClick:I=>$(d.id,d.name),class:\"text-gray-400 hover:text-gray-600 p-1\",title:\"Edit\"},a[13]||(a[13]=[t(\"svg\",{xmlns:\"http://www.w3.org/2000/svg\",class:\"h-5 w-5\",viewBox:\"0 0 20 20\",fill:\"currentColor\"},[t(\"path\",{d:\"M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z\"})],-1)]),8,Re),t(\"button\",{onClick:I=>G(d.id),class:\"text-gray-400 hover:text-red-500 p-1\",title:\"Delete\"},a[14]||(a[14]=[t(\"svg\",{xmlns:\"http://www.w3.org/2000/svg\",class:\"h-5 w-5\",viewBox:\"0 0 20 20\",fill:\"currentColor\"},[t(\"path\",{\"fill-rule\":\"evenodd\",d:\"M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z\",\"clip-rule\":\"evenodd\"})],-1)]),8,Ie)])]))]))),128))])])),T.value!==null?(g(),v(\"div\",Pe,[t(\"div\",Ee,[a[16]||(a[16]=t(\"h3\",{class:\"text-lg font-medium mb-4\"},\"Confirm Delete\",-1)),a[17]||(a[17]=t(\"p\",{class:\"mb-6\"},\"Are you sure you want to delete this tag? This action cannot be undone.\",-1)),t(\"div\",De,[t(\"button\",{onClick:a[5]||(a[5]=d=>T.value=null),class:\"px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50\"},\" Cancel \"),t(\"button\",{onClick:j,class:\"px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600\"},\" Delete \")])])])):C(\"\",!0)]),_:1}))}});export{Ae as default};\n"
  },
  {
    "path": "webapp/dist/assets/index-C8c580-n.js",
    "content": "const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=[\"assets/FoldersView-B-TWh6ac.js\",\"assets/FoldersView-tn0RQdqM.css\"])))=>i.map(i=>d[i]);\nvar mc=Object.defineProperty;var gc=(e,t,n)=>t in e?mc(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var Qt=(e,t,n)=>gc(e,typeof t!=\"symbol\"?t+\"\":t,n);(function(){const t=document.createElement(\"link\").relList;if(t&&t.supports&&t.supports(\"modulepreload\"))return;for(const s of document.querySelectorAll('link[rel=\"modulepreload\"]'))r(s);new MutationObserver(s=>{for(const o of s)if(o.type===\"childList\")for(const a of o.addedNodes)a.tagName===\"LINK\"&&a.rel===\"modulepreload\"&&r(a)}).observe(document,{childList:!0,subtree:!0});function n(s){const o={};return s.integrity&&(o.integrity=s.integrity),s.referrerPolicy&&(o.referrerPolicy=s.referrerPolicy),s.crossOrigin===\"use-credentials\"?o.credentials=\"include\":s.crossOrigin===\"anonymous\"?o.credentials=\"omit\":o.credentials=\"same-origin\",o}function r(s){if(s.ep)return;s.ep=!0;const o=n(s);fetch(s.href,o)}})();/**\n* @vue/shared v3.5.13\n* (c) 2018-present Yuxi (Evan) You and Vue contributors\n* @license MIT\n**//*! #__NO_SIDE_EFFECTS__ */function Ys(e){const t=Object.create(null);for(const n of e.split(\",\"))t[n]=1;return n=>n in t}const _e={},bn=[],vt=()=>{},pc=()=>!1,xr=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),Js=e=>e.startsWith(\"onUpdate:\"),We=Object.assign,Xs=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},_c=Object.prototype.hasOwnProperty,he=(e,t)=>_c.call(e,t),J=Array.isArray,yn=e=>or(e)===\"[object Map]\",Mr=e=>or(e)===\"[object Set]\",Lo=e=>or(e)===\"[object Date]\",te=e=>typeof e==\"function\",Re=e=>typeof e==\"string\",dt=e=>typeof e==\"symbol\",ve=e=>e!==null&&typeof e==\"object\",ci=e=>(ve(e)||te(e))&&te(e.then)&&te(e.catch),ui=Object.prototype.toString,or=e=>ui.call(e),bc=e=>or(e).slice(8,-1),fi=e=>or(e)===\"[object Object]\",zs=e=>Re(e)&&e!==\"NaN\"&&e[0]!==\"-\"&&\"\"+parseInt(e,10)===e,$n=Ys(\",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted\"),Dr=e=>{const t=Object.create(null);return n=>t[n]||(t[n]=e(n))},yc=/-(\\w)/g,Wt=Dr(e=>e.replace(yc,(t,n)=>n?n.toUpperCase():\"\")),vc=/\\B([A-Z])/g,un=Dr(e=>e.replace(vc,\"-$1\").toLowerCase()),di=Dr(e=>e.charAt(0).toUpperCase()+e.slice(1)),rs=Dr(e=>e?`on${di(e)}`:\"\"),$t=(e,t)=>!Object.is(e,t),_r=(e,...t)=>{for(let n=0;n<e.length;n++)e[n](...t)},hi=(e,t,n,r=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:r,value:n})},ks=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let Oo;const Fr=()=>Oo||(Oo=typeof globalThis<\"u\"?globalThis:typeof self<\"u\"?self:typeof window<\"u\"?window:typeof global<\"u\"?global:{});function Qs(e){if(J(e)){const t={};for(let n=0;n<e.length;n++){const r=e[n],s=Re(r)?wc(r):Qs(r);if(s)for(const o in s)t[o]=s[o]}return t}else if(Re(e)||ve(e))return e}const Ec=/;(?![^(]*\\))/g,kc=/:([^]+)/,Tc=/\\/\\*[^]*?\\*\\//g;function wc(e){const t={};return e.replace(Tc,\"\").split(Ec).forEach(n=>{if(n){const r=n.split(kc);r.length>1&&(t[r[0].trim()]=r[1].trim())}}),t}function $r(e){let t=\"\";if(Re(e))t=e;else if(J(e))for(let n=0;n<e.length;n++){const r=$r(e[n]);r&&(t+=r+\" \")}else if(ve(e))for(const n in e)e[n]&&(t+=n+\" \");return t.trim()}const Sc=\"itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly\",Lc=Ys(Sc);function mi(e){return!!e||e===\"\"}function Oc(e,t){if(e.length!==t.length)return!1;let n=!0;for(let r=0;n&&r<e.length;r++)n=Ur(e[r],t[r]);return n}function Ur(e,t){if(e===t)return!0;let n=Lo(e),r=Lo(t);if(n||r)return n&&r?e.getTime()===t.getTime():!1;if(n=dt(e),r=dt(t),n||r)return e===t;if(n=J(e),r=J(t),n||r)return n&&r?Oc(e,t):!1;if(n=ve(e),r=ve(t),n||r){if(!n||!r)return!1;const s=Object.keys(e).length,o=Object.keys(t).length;if(s!==o)return!1;for(const a in e){const i=e.hasOwnProperty(a),l=t.hasOwnProperty(a);if(i&&!l||!i&&l||!Ur(e[a],t[a]))return!1}}return String(e)===String(t)}function gi(e,t){return e.findIndex(n=>Ur(n,t))}const pi=e=>!!(e&&e.__v_isRef===!0),Ie=e=>Re(e)?e:e==null?\"\":J(e)||ve(e)&&(e.toString===ui||!te(e.toString))?pi(e)?Ie(e.value):JSON.stringify(e,_i,2):String(e),_i=(e,t)=>pi(t)?_i(e,t.value):yn(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[r,s],o)=>(n[ss(r,o)+\" =>\"]=s,n),{})}:Mr(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>ss(n))}:dt(t)?ss(t):ve(t)&&!J(t)&&!fi(t)?String(t):t,ss=(e,t=\"\")=>{var n;return dt(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};/**\n* @vue/reactivity v3.5.13\n* (c) 2018-present Yuxi (Evan) You and Vue contributors\n* @license MIT\n**/let qe;class bi{constructor(t=!1){this.detached=t,this._active=!0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.parent=qe,!t&&qe&&(this.index=(qe.scopes||(qe.scopes=[])).push(this)-1)}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes)for(t=0,n=this.scopes.length;t<n;t++)this.scopes[t].pause();for(t=0,n=this.effects.length;t<n;t++)this.effects[t].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let t,n;if(this.scopes)for(t=0,n=this.scopes.length;t<n;t++)this.scopes[t].resume();for(t=0,n=this.effects.length;t<n;t++)this.effects[t].resume()}}run(t){if(this._active){const n=qe;try{return qe=this,t()}finally{qe=n}}}on(){qe=this}off(){qe=this.parent}stop(t){if(this._active){this._active=!1;let n,r;for(n=0,r=this.effects.length;n<r;n++)this.effects[n].stop();for(this.effects.length=0,n=0,r=this.cleanups.length;n<r;n++)this.cleanups[n]();if(this.cleanups.length=0,this.scopes){for(n=0,r=this.scopes.length;n<r;n++)this.scopes[n].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!t){const s=this.parent.scopes.pop();s&&s!==this&&(this.parent.scopes[this.index]=s,s.index=this.index)}this.parent=void 0}}}function Zs(e){return new bi(e)}function yi(){return qe}function Ic(e,t=!1){qe&&qe.cleanups.push(e)}let be;const os=new WeakSet;class vi{constructor(t){this.fn=t,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,qe&&qe.active&&qe.effects.push(this)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,os.has(this)&&(os.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||ki(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,Io(this),Ti(this);const t=be,n=ft;be=this,ft=!0;try{return this.fn()}finally{wi(this),be=t,ft=n,this.flags&=-3}}stop(){if(this.flags&1){for(let t=this.deps;t;t=t.nextDep)no(t);this.deps=this.depsTail=void 0,Io(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?os.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){Ts(this)&&this.run()}get dirty(){return Ts(this)}}let Ei=0,Un,Vn;function ki(e,t=!1){if(e.flags|=8,t){e.next=Vn,Vn=e;return}e.next=Un,Un=e}function eo(){Ei++}function to(){if(--Ei>0)return;if(Vn){let t=Vn;for(Vn=void 0;t;){const n=t.next;t.next=void 0,t.flags&=-9,t=n}}let e;for(;Un;){let t=Un;for(Un=void 0;t;){const n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(r){e||(e=r)}t=n}}if(e)throw e}function Ti(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function wi(e){let t,n=e.depsTail,r=n;for(;r;){const s=r.prevDep;r.version===-1?(r===n&&(n=s),no(r),Ac(r)):t=r,r.dep.activeLink=r.prevActiveLink,r.prevActiveLink=void 0,r=s}e.deps=t,e.depsTail=n}function Ts(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Si(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Si(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===jn))return;e.globalVersion=jn;const t=e.dep;if(e.flags|=2,t.version>0&&!e.isSSR&&e.deps&&!Ts(e)){e.flags&=-3;return}const n=be,r=ft;be=e,ft=!0;try{Ti(e);const s=e.fn(e._value);(t.version===0||$t(s,e._value))&&(e._value=s,t.version++)}catch(s){throw t.version++,s}finally{be=n,ft=r,wi(e),e.flags&=-3}}function no(e,t=!1){const{dep:n,prevSub:r,nextSub:s}=e;if(r&&(r.nextSub=s,e.prevSub=void 0),s&&(s.prevSub=r,e.nextSub=void 0),n.subs===e&&(n.subs=r,!r&&n.computed)){n.computed.flags&=-5;for(let o=n.computed.deps;o;o=o.nextDep)no(o,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function Ac(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let ft=!0;const Li=[];function Gt(){Li.push(ft),ft=!1}function jt(){const e=Li.pop();ft=e===void 0?!0:e}function Io(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const n=be;be=void 0;try{t()}finally{be=n}}}let jn=0;class Pc{constructor(t,n){this.sub=t,this.dep=n,this.version=n.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class ro{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0}track(t){if(!be||!ft||be===this.computed)return;let n=this.activeLink;if(n===void 0||n.sub!==be)n=this.activeLink=new Pc(be,this),be.deps?(n.prevDep=be.depsTail,be.depsTail.nextDep=n,be.depsTail=n):be.deps=be.depsTail=n,Oi(n);else if(n.version===-1&&(n.version=this.version,n.nextDep)){const r=n.nextDep;r.prevDep=n.prevDep,n.prevDep&&(n.prevDep.nextDep=r),n.prevDep=be.depsTail,n.nextDep=void 0,be.depsTail.nextDep=n,be.depsTail=n,be.deps===n&&(be.deps=r)}return n}trigger(t){this.version++,jn++,this.notify(t)}notify(t){eo();try{for(let n=this.subs;n;n=n.prevSub)n.sub.notify()&&n.sub.dep.notify()}finally{to()}}}function Oi(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let r=t.deps;r;r=r.nextDep)Oi(r)}const n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}const Tr=new WeakMap,an=Symbol(\"\"),ws=Symbol(\"\"),qn=Symbol(\"\");function Ve(e,t,n){if(ft&&be){let r=Tr.get(e);r||Tr.set(e,r=new Map);let s=r.get(n);s||(r.set(n,s=new ro),s.map=r,s.key=n),s.track()}}function Ot(e,t,n,r,s,o){const a=Tr.get(e);if(!a){jn++;return}const i=l=>{l&&l.trigger()};if(eo(),t===\"clear\")a.forEach(i);else{const l=J(e),c=l&&zs(n);if(l&&n===\"length\"){const u=Number(r);a.forEach((f,m)=>{(m===\"length\"||m===qn||!dt(m)&&m>=u)&&i(f)})}else switch((n!==void 0||a.has(void 0))&&i(a.get(n)),c&&i(a.get(qn)),t){case\"add\":l?c&&i(a.get(\"length\")):(i(a.get(an)),yn(e)&&i(a.get(ws)));break;case\"delete\":l||(i(a.get(an)),yn(e)&&i(a.get(ws)));break;case\"set\":yn(e)&&i(a.get(an));break}}to()}function Rc(e,t){const n=Tr.get(e);return n&&n.get(t)}function dn(e){const t=ie(e);return t===e?t:(Ve(t,\"iterate\",qn),at(e)?t:t.map(He))}function Vr(e){return Ve(e=ie(e),\"iterate\",qn),e}const Cc={__proto__:null,[Symbol.iterator](){return as(this,Symbol.iterator,He)},concat(...e){return dn(this).concat(...e.map(t=>J(t)?dn(t):t))},entries(){return as(this,\"entries\",e=>(e[1]=He(e[1]),e))},every(e,t){return kt(this,\"every\",e,t,void 0,arguments)},filter(e,t){return kt(this,\"filter\",e,t,n=>n.map(He),arguments)},find(e,t){return kt(this,\"find\",e,t,He,arguments)},findIndex(e,t){return kt(this,\"findIndex\",e,t,void 0,arguments)},findLast(e,t){return kt(this,\"findLast\",e,t,He,arguments)},findLastIndex(e,t){return kt(this,\"findLastIndex\",e,t,void 0,arguments)},forEach(e,t){return kt(this,\"forEach\",e,t,void 0,arguments)},includes(...e){return is(this,\"includes\",e)},indexOf(...e){return is(this,\"indexOf\",e)},join(e){return dn(this).join(e)},lastIndexOf(...e){return is(this,\"lastIndexOf\",e)},map(e,t){return kt(this,\"map\",e,t,void 0,arguments)},pop(){return Nn(this,\"pop\")},push(...e){return Nn(this,\"push\",e)},reduce(e,...t){return Ao(this,\"reduce\",e,t)},reduceRight(e,...t){return Ao(this,\"reduceRight\",e,t)},shift(){return Nn(this,\"shift\")},some(e,t){return kt(this,\"some\",e,t,void 0,arguments)},splice(...e){return Nn(this,\"splice\",e)},toReversed(){return dn(this).toReversed()},toSorted(e){return dn(this).toSorted(e)},toSpliced(...e){return dn(this).toSpliced(...e)},unshift(...e){return Nn(this,\"unshift\",e)},values(){return as(this,\"values\",He)}};function as(e,t,n){const r=Vr(e),s=r[t]();return r!==e&&!at(e)&&(s._next=s.next,s.next=()=>{const o=s._next();return o.value&&(o.value=n(o.value)),o}),s}const Nc=Array.prototype;function kt(e,t,n,r,s,o){const a=Vr(e),i=a!==e&&!at(e),l=a[t];if(l!==Nc[t]){const f=l.apply(e,o);return i?He(f):f}let c=n;a!==e&&(i?c=function(f,m){return n.call(this,He(f),m,e)}:n.length>2&&(c=function(f,m){return n.call(this,f,m,e)}));const u=l.call(a,c,r);return i&&s?s(u):u}function Ao(e,t,n,r){const s=Vr(e);let o=n;return s!==e&&(at(e)?n.length>3&&(o=function(a,i,l){return n.call(this,a,i,l,e)}):o=function(a,i,l){return n.call(this,a,He(i),l,e)}),s[t](o,...r)}function is(e,t,n){const r=ie(e);Ve(r,\"iterate\",qn);const s=r[t](...n);return(s===-1||s===!1)&&ao(n[0])?(n[0]=ie(n[0]),r[t](...n)):s}function Nn(e,t,n=[]){Gt(),eo();const r=ie(e)[t].apply(e,n);return to(),jt(),r}const xc=Ys(\"__proto__,__v_isRef,__isVue\"),Ii=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!==\"arguments\"&&e!==\"caller\").map(e=>Symbol[e]).filter(dt));function Mc(e){dt(e)||(e=String(e));const t=ie(this);return Ve(t,\"has\",e),t.hasOwnProperty(e)}class Ai{constructor(t=!1,n=!1){this._isReadonly=t,this._isShallow=n}get(t,n,r){if(n===\"__v_skip\")return t.__v_skip;const s=this._isReadonly,o=this._isShallow;if(n===\"__v_isReactive\")return!s;if(n===\"__v_isReadonly\")return s;if(n===\"__v_isShallow\")return o;if(n===\"__v_raw\")return r===(s?o?Gc:Ni:o?Ci:Ri).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(r)?t:void 0;const a=J(t);if(!s){let l;if(a&&(l=Cc[n]))return l;if(n===\"hasOwnProperty\")return Mc}const i=Reflect.get(t,n,Oe(t)?t:r);return(dt(n)?Ii.has(n):xc(n))||(s||Ve(t,\"get\",n),o)?i:Oe(i)?a&&zs(n)?i:i.value:ve(i)?s?Mi(i):ar(i):i}}class Pi extends Ai{constructor(t=!1){super(!1,t)}set(t,n,r,s){let o=t[n];if(!this._isShallow){const l=cn(o);if(!at(r)&&!cn(r)&&(o=ie(o),r=ie(r)),!J(t)&&Oe(o)&&!Oe(r))return l?!1:(o.value=r,!0)}const a=J(t)&&zs(n)?Number(n)<t.length:he(t,n),i=Reflect.set(t,n,r,Oe(t)?t:s);return t===ie(s)&&(a?$t(r,o)&&Ot(t,\"set\",n,r):Ot(t,\"add\",n,r)),i}deleteProperty(t,n){const r=he(t,n);t[n];const s=Reflect.deleteProperty(t,n);return s&&r&&Ot(t,\"delete\",n,void 0),s}has(t,n){const r=Reflect.has(t,n);return(!dt(n)||!Ii.has(n))&&Ve(t,\"has\",n),r}ownKeys(t){return Ve(t,\"iterate\",J(t)?\"length\":an),Reflect.ownKeys(t)}}class Dc extends Ai{constructor(t=!1){super(!0,t)}set(t,n){return!0}deleteProperty(t,n){return!0}}const Fc=new Pi,$c=new Dc,Uc=new Pi(!0);const Ss=e=>e,fr=e=>Reflect.getPrototypeOf(e);function Vc(e,t,n){return function(...r){const s=this.__v_raw,o=ie(s),a=yn(o),i=e===\"entries\"||e===Symbol.iterator&&a,l=e===\"keys\"&&a,c=s[e](...r),u=n?Ss:t?Ls:He;return!t&&Ve(o,\"iterate\",l?ws:an),{next(){const{value:f,done:m}=c.next();return m?{value:f,done:m}:{value:i?[u(f[0]),u(f[1])]:u(f),done:m}},[Symbol.iterator](){return this}}}}function dr(e){return function(...t){return e===\"delete\"?!1:e===\"clear\"?void 0:this}}function Hc(e,t){const n={get(s){const o=this.__v_raw,a=ie(o),i=ie(s);e||($t(s,i)&&Ve(a,\"get\",s),Ve(a,\"get\",i));const{has:l}=fr(a),c=t?Ss:e?Ls:He;if(l.call(a,s))return c(o.get(s));if(l.call(a,i))return c(o.get(i));o!==a&&o.get(s)},get size(){const s=this.__v_raw;return!e&&Ve(ie(s),\"iterate\",an),Reflect.get(s,\"size\",s)},has(s){const o=this.__v_raw,a=ie(o),i=ie(s);return e||($t(s,i)&&Ve(a,\"has\",s),Ve(a,\"has\",i)),s===i?o.has(s):o.has(s)||o.has(i)},forEach(s,o){const a=this,i=a.__v_raw,l=ie(i),c=t?Ss:e?Ls:He;return!e&&Ve(l,\"iterate\",an),i.forEach((u,f)=>s.call(o,c(u),c(f),a))}};return We(n,e?{add:dr(\"add\"),set:dr(\"set\"),delete:dr(\"delete\"),clear:dr(\"clear\")}:{add(s){!t&&!at(s)&&!cn(s)&&(s=ie(s));const o=ie(this);return fr(o).has.call(o,s)||(o.add(s),Ot(o,\"add\",s,s)),this},set(s,o){!t&&!at(o)&&!cn(o)&&(o=ie(o));const a=ie(this),{has:i,get:l}=fr(a);let c=i.call(a,s);c||(s=ie(s),c=i.call(a,s));const u=l.call(a,s);return a.set(s,o),c?$t(o,u)&&Ot(a,\"set\",s,o):Ot(a,\"add\",s,o),this},delete(s){const o=ie(this),{has:a,get:i}=fr(o);let l=a.call(o,s);l||(s=ie(s),l=a.call(o,s)),i&&i.call(o,s);const c=o.delete(s);return l&&Ot(o,\"delete\",s,void 0),c},clear(){const s=ie(this),o=s.size!==0,a=s.clear();return o&&Ot(s,\"clear\",void 0,void 0),a}}),[\"keys\",\"values\",\"entries\",Symbol.iterator].forEach(s=>{n[s]=Vc(s,e,t)}),n}function so(e,t){const n=Hc(e,t);return(r,s,o)=>s===\"__v_isReactive\"?!e:s===\"__v_isReadonly\"?e:s===\"__v_raw\"?r:Reflect.get(he(n,s)&&s in r?n:r,s,o)}const Wc={get:so(!1,!1)},Bc={get:so(!1,!0)},Kc={get:so(!0,!1)};const Ri=new WeakMap,Ci=new WeakMap,Ni=new WeakMap,Gc=new WeakMap;function jc(e){switch(e){case\"Object\":case\"Array\":return 1;case\"Map\":case\"Set\":case\"WeakMap\":case\"WeakSet\":return 2;default:return 0}}function qc(e){return e.__v_skip||!Object.isExtensible(e)?0:jc(bc(e))}function ar(e){return cn(e)?e:oo(e,!1,Fc,Wc,Ri)}function xi(e){return oo(e,!1,Uc,Bc,Ci)}function Mi(e){return oo(e,!0,$c,Kc,Ni)}function oo(e,t,n,r,s){if(!ve(e)||e.__v_raw&&!(t&&e.__v_isReactive))return e;const o=s.get(e);if(o)return o;const a=qc(e);if(a===0)return e;const i=new Proxy(e,a===2?r:n);return s.set(e,i),i}function Ut(e){return cn(e)?Ut(e.__v_raw):!!(e&&e.__v_isReactive)}function cn(e){return!!(e&&e.__v_isReadonly)}function at(e){return!!(e&&e.__v_isShallow)}function ao(e){return e?!!e.__v_raw:!1}function ie(e){const t=e&&e.__v_raw;return t?ie(t):e}function io(e){return!he(e,\"__v_skip\")&&Object.isExtensible(e)&&hi(e,\"__v_skip\",!0),e}const He=e=>ve(e)?ar(e):e,Ls=e=>ve(e)?Mi(e):e;function Oe(e){return e?e.__v_isRef===!0:!1}function ge(e){return Di(e,!1)}function lo(e){return Di(e,!0)}function Di(e,t){return Oe(e)?e:new Yc(e,t)}class Yc{constructor(t,n){this.dep=new ro,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=n?t:ie(t),this._value=n?t:He(t),this.__v_isShallow=n}get value(){return this.dep.track(),this._value}set value(t){const n=this._rawValue,r=this.__v_isShallow||at(t)||cn(t);t=r?t:ie(t),$t(t,n)&&(this._rawValue=t,this._value=r?t:He(t),this.dep.trigger())}}function le(e){return Oe(e)?e.value:e}const Jc={get:(e,t,n)=>t===\"__v_raw\"?e:le(Reflect.get(e,t,n)),set:(e,t,n,r)=>{const s=e[t];return Oe(s)&&!Oe(n)?(s.value=n,!0):Reflect.set(e,t,n,r)}};function Fi(e){return Ut(e)?e:new Proxy(e,Jc)}function Xc(e){const t=J(e)?new Array(e.length):{};for(const n in e)t[n]=Qc(e,n);return t}class zc{constructor(t,n,r){this._object=t,this._key=n,this._defaultValue=r,this.__v_isRef=!0,this._value=void 0}get value(){const t=this._object[this._key];return this._value=t===void 0?this._defaultValue:t}set value(t){this._object[this._key]=t}get dep(){return Rc(ie(this._object),this._key)}}function Qc(e,t,n){const r=e[t];return Oe(r)?r:new zc(e,t,n)}class Zc{constructor(t,n,r){this.fn=t,this.setter=n,this._value=void 0,this.dep=new ro(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=jn-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!n,this.isSSR=r}notify(){if(this.flags|=16,!(this.flags&8)&&be!==this)return ki(this,!0),!0}get value(){const t=this.dep.track();return Si(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function eu(e,t,n=!1){let r,s;return te(e)?r=e:(r=e.get,s=e.set),new Zc(r,s,n)}const hr={},wr=new WeakMap;let sn;function tu(e,t=!1,n=sn){if(n){let r=wr.get(n);r||wr.set(n,r=[]),r.push(e)}}function nu(e,t,n=_e){const{immediate:r,deep:s,once:o,scheduler:a,augmentJob:i,call:l}=n,c=v=>s?v:at(v)||s===!1||s===0?It(v,1):It(v);let u,f,m,_,S=!1,T=!1;if(Oe(e)?(f=()=>e.value,S=at(e)):Ut(e)?(f=()=>c(e),S=!0):J(e)?(T=!0,S=e.some(v=>Ut(v)||at(v)),f=()=>e.map(v=>{if(Oe(v))return v.value;if(Ut(v))return c(v);if(te(v))return l?l(v,2):v()})):te(e)?t?f=l?()=>l(e,2):e:f=()=>{if(m){Gt();try{m()}finally{jt()}}const v=sn;sn=u;try{return l?l(e,3,[_]):e(_)}finally{sn=v}}:f=vt,t&&s){const v=f,L=s===!0?1/0:s;f=()=>It(v(),L)}const I=yi(),P=()=>{u.stop(),I&&I.active&&Xs(I.effects,u)};if(o&&t){const v=t;t=(...L)=>{v(...L),P()}}let F=T?new Array(e.length).fill(hr):hr;const y=v=>{if(!(!(u.flags&1)||!u.dirty&&!v))if(t){const L=u.run();if(s||S||(T?L.some((R,D)=>$t(R,F[D])):$t(L,F))){m&&m();const R=sn;sn=u;try{const D=[L,F===hr?void 0:T&&F[0]===hr?[]:F,_];l?l(t,3,D):t(...D),F=L}finally{sn=R}}}else u.run()};return i&&i(y),u=new vi(f),u.scheduler=a?()=>a(y,!1):y,_=v=>tu(v,!1,u),m=u.onStop=()=>{const v=wr.get(u);if(v){if(l)l(v,4);else for(const L of v)L();wr.delete(u)}},t?r?y(!0):F=u.run():a?a(y.bind(null,!0),!0):u.run(),P.pause=u.pause.bind(u),P.resume=u.resume.bind(u),P.stop=P,P}function It(e,t=1/0,n){if(t<=0||!ve(e)||e.__v_skip||(n=n||new Set,n.has(e)))return e;if(n.add(e),t--,Oe(e))It(e.value,t,n);else if(J(e))for(let r=0;r<e.length;r++)It(e[r],t,n);else if(Mr(e)||yn(e))e.forEach(r=>{It(r,t,n)});else if(fi(e)){for(const r in e)It(e[r],t,n);for(const r of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,r)&&It(e[r],t,n)}return e}/**\n* @vue/runtime-core v3.5.13\n* (c) 2018-present Yuxi (Evan) You and Vue contributors\n* @license MIT\n**/function ir(e,t,n,r){try{return r?e(...r):e()}catch(s){Hr(s,t,n)}}function Et(e,t,n,r){if(te(e)){const s=ir(e,t,n,r);return s&&ci(s)&&s.catch(o=>{Hr(o,t,n)}),s}if(J(e)){const s=[];for(let o=0;o<e.length;o++)s.push(Et(e[o],t,n,r));return s}}function Hr(e,t,n,r=!0){const s=t?t.vnode:null,{errorHandler:o,throwUnhandledErrorInProduction:a}=t&&t.appContext.config||_e;if(t){let i=t.parent;const l=t.proxy,c=`https://vuejs.org/error-reference/#runtime-${n}`;for(;i;){const u=i.ec;if(u){for(let f=0;f<u.length;f++)if(u[f](e,l,c)===!1)return}i=i.parent}if(o){Gt(),ir(o,null,10,[e,l,c]),jt();return}}ru(e,n,s,r,a)}function ru(e,t,n,r=!0,s=!1){if(s)throw e;console.error(e)}const Ye=[];let _t=-1;const vn=[];let Mt=null,mn=0;const $i=Promise.resolve();let Sr=null;function co(e){const t=Sr||$i;return e?t.then(this?e.bind(this):e):t}function su(e){let t=_t+1,n=Ye.length;for(;t<n;){const r=t+n>>>1,s=Ye[r],o=Yn(s);o<e||o===e&&s.flags&2?t=r+1:n=r}return t}function uo(e){if(!(e.flags&1)){const t=Yn(e),n=Ye[Ye.length-1];!n||!(e.flags&2)&&t>=Yn(n)?Ye.push(e):Ye.splice(su(t),0,e),e.flags|=1,Ui()}}function Ui(){Sr||(Sr=$i.then(Hi))}function ou(e){J(e)?vn.push(...e):Mt&&e.id===-1?Mt.splice(mn+1,0,e):e.flags&1||(vn.push(e),e.flags|=1),Ui()}function Po(e,t,n=_t+1){for(;n<Ye.length;n++){const r=Ye[n];if(r&&r.flags&2){if(e&&r.id!==e.uid)continue;Ye.splice(n,1),n--,r.flags&4&&(r.flags&=-2),r(),r.flags&4||(r.flags&=-2)}}}function Vi(e){if(vn.length){const t=[...new Set(vn)].sort((n,r)=>Yn(n)-Yn(r));if(vn.length=0,Mt){Mt.push(...t);return}for(Mt=t,mn=0;mn<Mt.length;mn++){const n=Mt[mn];n.flags&4&&(n.flags&=-2),n.flags&8||n(),n.flags&=-2}Mt=null,mn=0}}const Yn=e=>e.id==null?e.flags&2?-1:1/0:e.id;function Hi(e){try{for(_t=0;_t<Ye.length;_t++){const t=Ye[_t];t&&!(t.flags&8)&&(t.flags&4&&(t.flags&=-2),ir(t,t.i,t.i?15:14),t.flags&4||(t.flags&=-2))}}finally{for(;_t<Ye.length;_t++){const t=Ye[_t];t&&(t.flags&=-2)}_t=-1,Ye.length=0,Vi(),Sr=null,(Ye.length||vn.length)&&Hi()}}let Me=null,Wi=null;function Lr(e){const t=Me;return Me=e,Wi=e&&e.type.__scopeId||null,t}function En(e,t=Me,n){if(!t||e._n)return e;const r=(...s)=>{r._d&&Vo(-1);const o=Lr(t);let a;try{a=e(...s)}finally{Lr(o),r._d&&Vo(1)}return a};return r._n=!0,r._c=!0,r._d=!0,r}function ls(e,t){if(Me===null)return e;const n=Kr(Me),r=e.dirs||(e.dirs=[]);for(let s=0;s<t.length;s++){let[o,a,i,l=_e]=t[s];o&&(te(o)&&(o={mounted:o,updated:o}),o.deep&&It(a),r.push({dir:o,instance:n,value:a,oldValue:void 0,arg:i,modifiers:l}))}return e}function Zt(e,t,n,r){const s=e.dirs,o=t&&t.dirs;for(let a=0;a<s.length;a++){const i=s[a];o&&(i.oldValue=o[a].value);let l=i.dir[r];l&&(Gt(),Et(l,n,8,[e.el,i,e,t]),jt())}}const au=Symbol(\"_vte\"),iu=e=>e.__isTeleport;function fo(e,t){e.shapeFlag&6&&e.component?(e.transition=t,fo(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}/*! #__NO_SIDE_EFFECTS__ */function it(e,t){return te(e)?We({name:e.name},t,{setup:e}):e}function Bi(e){e.ids=[e.ids[0]+e.ids[2]+++\"-\",0,0]}function Or(e,t,n,r,s=!1){if(J(e)){e.forEach((S,T)=>Or(S,t&&(J(t)?t[T]:t),n,r,s));return}if(kn(r)&&!s){r.shapeFlag&512&&r.type.__asyncResolved&&r.component.subTree.component&&Or(e,t,n,r.component.subTree);return}const o=r.shapeFlag&4?Kr(r.component):r.el,a=s?null:o,{i,r:l}=e,c=t&&t.r,u=i.refs===_e?i.refs={}:i.refs,f=i.setupState,m=ie(f),_=f===_e?()=>!1:S=>he(m,S);if(c!=null&&c!==l&&(Re(c)?(u[c]=null,_(c)&&(f[c]=null)):Oe(c)&&(c.value=null)),te(l))ir(l,i,12,[a,u]);else{const S=Re(l),T=Oe(l);if(S||T){const I=()=>{if(e.f){const P=S?_(l)?f[l]:u[l]:l.value;s?J(P)&&Xs(P,o):J(P)?P.includes(o)||P.push(o):S?(u[l]=[o],_(l)&&(f[l]=u[l])):(l.value=[o],e.k&&(u[e.k]=l.value))}else S?(u[l]=a,_(l)&&(f[l]=a)):T&&(l.value=a,e.k&&(u[e.k]=a))};a?(I.id=-1,tt(I,n)):I()}}}Fr().requestIdleCallback;Fr().cancelIdleCallback;const kn=e=>!!e.type.__asyncLoader,Ki=e=>e.type.__isKeepAlive;function lu(e,t){Gi(e,\"a\",t)}function cu(e,t){Gi(e,\"da\",t)}function Gi(e,t,n=$e){const r=e.__wdc||(e.__wdc=()=>{let s=n;for(;s;){if(s.isDeactivated)return;s=s.parent}return e()});if(Wr(t,r,n),n){let s=n.parent;for(;s&&s.parent;)Ki(s.parent.vnode)&&uu(r,t,n,s),s=s.parent}}function uu(e,t,n,r){const s=Wr(t,e,r,!0);Rn(()=>{Xs(r[t],s)},n)}function Wr(e,t,n=$e,r=!1){if(n){const s=n[e]||(n[e]=[]),o=t.__weh||(t.__weh=(...a)=>{Gt();const i=cr(n),l=Et(t,n,e,a);return i(),jt(),l});return r?s.unshift(o):s.push(o),o}}const Pt=e=>(t,n=$e)=>{(!Zn||e===\"sp\")&&Wr(e,(...r)=>t(...r),n)},ji=Pt(\"bm\"),qt=Pt(\"m\"),fu=Pt(\"bu\"),du=Pt(\"u\"),hu=Pt(\"bum\"),Rn=Pt(\"um\"),mu=Pt(\"sp\"),gu=Pt(\"rtg\"),pu=Pt(\"rtc\");function _u(e,t=$e){Wr(\"ec\",e,t)}const bu=Symbol.for(\"v-ndc\");function Jn(e,t,n,r){let s;const o=n,a=J(e);if(a||Re(e)){const i=a&&Ut(e);let l=!1;i&&(l=!at(e),e=Vr(e)),s=new Array(e.length);for(let c=0,u=e.length;c<u;c++)s[c]=t(l?He(e[c]):e[c],c,void 0,o)}else if(typeof e==\"number\"){s=new Array(e);for(let i=0;i<e;i++)s[i]=t(i+1,i,void 0,o)}else if(ve(e))if(e[Symbol.iterator])s=Array.from(e,(i,l)=>t(i,l,void 0,o));else{const i=Object.keys(e);s=new Array(i.length);for(let l=0,c=i.length;l<c;l++){const u=i[l];s[l]=t(e[u],u,l,o)}}else s=[];return s}function Ro(e,t,n={},r,s){if(Me.ce||Me.parent&&kn(Me.parent)&&Me.parent.ce)return t!==\"default\"&&(n.name=t),fe(),wn(Ce,null,[Pe(\"slot\",n,r)],64);let o=e[t];o&&o._c&&(o._d=!1),fe();const a=o&&qi(o(n)),i=n.key||a&&a.key,l=wn(Ce,{key:(i&&!dt(i)?i:`_${t}`)+\"\"},a||[],a&&e._===1?64:-2);return l.scopeId&&(l.slotScopeIds=[l.scopeId+\"-s\"]),o&&o._c&&(o._d=!0),l}function qi(e){return e.some(t=>zn(t)?!(t.type===Bt||t.type===Ce&&!qi(t.children)):!0)?e:null}const Os=e=>e?gl(e)?Kr(e):Os(e.parent):null,Hn=We(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Os(e.parent),$root:e=>Os(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Ji(e),$forceUpdate:e=>e.f||(e.f=()=>{uo(e.update)}),$nextTick:e=>e.n||(e.n=co.bind(e.proxy)),$watch:e=>Vu.bind(e)}),cs=(e,t)=>e!==_e&&!e.__isScriptSetup&&he(e,t),yu={get({_:e},t){if(t===\"__v_skip\")return!0;const{ctx:n,setupState:r,data:s,props:o,accessCache:a,type:i,appContext:l}=e;let c;if(t[0]!==\"$\"){const _=a[t];if(_!==void 0)switch(_){case 1:return r[t];case 2:return s[t];case 4:return n[t];case 3:return o[t]}else{if(cs(r,t))return a[t]=1,r[t];if(s!==_e&&he(s,t))return a[t]=2,s[t];if((c=e.propsOptions[0])&&he(c,t))return a[t]=3,o[t];if(n!==_e&&he(n,t))return a[t]=4,n[t];Is&&(a[t]=0)}}const u=Hn[t];let f,m;if(u)return t===\"$attrs\"&&Ve(e.attrs,\"get\",\"\"),u(e);if((f=i.__cssModules)&&(f=f[t]))return f;if(n!==_e&&he(n,t))return a[t]=4,n[t];if(m=l.config.globalProperties,he(m,t))return m[t]},set({_:e},t,n){const{data:r,setupState:s,ctx:o}=e;return cs(s,t)?(s[t]=n,!0):r!==_e&&he(r,t)?(r[t]=n,!0):he(e.props,t)||t[0]===\"$\"&&t.slice(1)in e?!1:(o[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:r,appContext:s,propsOptions:o}},a){let i;return!!n[a]||e!==_e&&he(e,a)||cs(t,a)||(i=o[0])&&he(i,a)||he(r,a)||he(Hn,a)||he(s.config.globalProperties,a)},defineProperty(e,t,n){return n.get!=null?e._.accessCache[t]=0:he(n,\"value\")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function Co(e){return J(e)?e.reduce((t,n)=>(t[n]=null,t),{}):e}let Is=!0;function vu(e){const t=Ji(e),n=e.proxy,r=e.ctx;Is=!1,t.beforeCreate&&No(t.beforeCreate,e,\"bc\");const{data:s,computed:o,methods:a,watch:i,provide:l,inject:c,created:u,beforeMount:f,mounted:m,beforeUpdate:_,updated:S,activated:T,deactivated:I,beforeDestroy:P,beforeUnmount:F,destroyed:y,unmounted:v,render:L,renderTracked:R,renderTriggered:D,errorCaptured:$,serverPrefetch:M,expose:ee,inheritAttrs:me,components:z,directives:ue,filters:Qe}=t;if(c&&Eu(c,r,null),a)for(const Q in a){const re=a[Q];te(re)&&(r[Q]=re.bind(n))}if(s){const Q=s.call(n,n);ve(Q)&&(e.data=ar(Q))}if(Is=!0,o)for(const Q in o){const re=o[Q],Be=te(re)?re.bind(n,n):te(re.get)?re.get.bind(n,n):vt,Xe=!te(re)&&te(re.set)?re.set.bind(n):vt,Se=we({get:Be,set:Xe});Object.defineProperty(r,Q,{enumerable:!0,configurable:!0,get:()=>Se.value,set:Le=>Se.value=Le})}if(i)for(const Q in i)Yi(i[Q],r,n,Q);if(l){const Q=te(l)?l.call(n):l;Reflect.ownKeys(Q).forEach(re=>{br(re,Q[re])})}u&&No(u,e,\"c\");function se(Q,re){J(re)?re.forEach(Be=>Q(Be.bind(n))):re&&Q(re.bind(n))}if(se(ji,f),se(qt,m),se(fu,_),se(du,S),se(lu,T),se(cu,I),se(_u,$),se(pu,R),se(gu,D),se(hu,F),se(Rn,v),se(mu,M),J(ee))if(ee.length){const Q=e.exposed||(e.exposed={});ee.forEach(re=>{Object.defineProperty(Q,re,{get:()=>n[re],set:Be=>n[re]=Be})})}else e.exposed||(e.exposed={});L&&e.render===vt&&(e.render=L),me!=null&&(e.inheritAttrs=me),z&&(e.components=z),ue&&(e.directives=ue),M&&Bi(e)}function Eu(e,t,n=vt){J(e)&&(e=As(e));for(const r in e){const s=e[r];let o;ve(s)?\"default\"in s?o=rt(s.from||r,s.default,!0):o=rt(s.from||r):o=rt(s),Oe(o)?Object.defineProperty(t,r,{enumerable:!0,configurable:!0,get:()=>o.value,set:a=>o.value=a}):t[r]=o}}function No(e,t,n){Et(J(e)?e.map(r=>r.bind(t.proxy)):e.bind(t.proxy),t,n)}function Yi(e,t,n,r){let s=r.includes(\".\")?cl(n,r):()=>n[r];if(Re(e)){const o=t[e];te(o)&&Vt(s,o)}else if(te(e))Vt(s,e.bind(n));else if(ve(e))if(J(e))e.forEach(o=>Yi(o,t,n,r));else{const o=te(e.handler)?e.handler.bind(n):t[e.handler];te(o)&&Vt(s,o,e)}}function Ji(e){const t=e.type,{mixins:n,extends:r}=t,{mixins:s,optionsCache:o,config:{optionMergeStrategies:a}}=e.appContext,i=o.get(t);let l;return i?l=i:!s.length&&!n&&!r?l=t:(l={},s.length&&s.forEach(c=>Ir(l,c,a,!0)),Ir(l,t,a)),ve(t)&&o.set(t,l),l}function Ir(e,t,n,r=!1){const{mixins:s,extends:o}=t;o&&Ir(e,o,n,!0),s&&s.forEach(a=>Ir(e,a,n,!0));for(const a in t)if(!(r&&a===\"expose\")){const i=ku[a]||n&&n[a];e[a]=i?i(e[a],t[a]):t[a]}return e}const ku={data:xo,props:Mo,emits:Mo,methods:Fn,computed:Fn,beforeCreate:Ge,created:Ge,beforeMount:Ge,mounted:Ge,beforeUpdate:Ge,updated:Ge,beforeDestroy:Ge,beforeUnmount:Ge,destroyed:Ge,unmounted:Ge,activated:Ge,deactivated:Ge,errorCaptured:Ge,serverPrefetch:Ge,components:Fn,directives:Fn,watch:wu,provide:xo,inject:Tu};function xo(e,t){return t?e?function(){return We(te(e)?e.call(this,this):e,te(t)?t.call(this,this):t)}:t:e}function Tu(e,t){return Fn(As(e),As(t))}function As(e){if(J(e)){const t={};for(let n=0;n<e.length;n++)t[e[n]]=e[n];return t}return e}function Ge(e,t){return e?[...new Set([].concat(e,t))]:t}function Fn(e,t){return e?We(Object.create(null),e,t):t}function Mo(e,t){return e?J(e)&&J(t)?[...new Set([...e,...t])]:We(Object.create(null),Co(e),Co(t??{})):t}function wu(e,t){if(!e)return t;if(!t)return e;const n=We(Object.create(null),e);for(const r in t)n[r]=Ge(e[r],t[r]);return n}function Xi(){return{app:null,config:{isNativeTag:pc,performance:!1,globalProperties:{},optionMergeStrategies:{},errorHandler:void 0,warnHandler:void 0,compilerOptions:{}},mixins:[],components:{},directives:{},provides:Object.create(null),optionsCache:new WeakMap,propsCache:new WeakMap,emitsCache:new WeakMap}}let Su=0;function Lu(e,t){return function(r,s=null){te(r)||(r=We({},r)),s!=null&&!ve(s)&&(s=null);const o=Xi(),a=new WeakSet,i=[];let l=!1;const c=o.app={_uid:Su++,_component:r,_props:s,_container:null,_context:o,_instance:null,version:af,get config(){return o.config},set config(u){},use(u,...f){return a.has(u)||(u&&te(u.install)?(a.add(u),u.install(c,...f)):te(u)&&(a.add(u),u(c,...f))),c},mixin(u){return o.mixins.includes(u)||o.mixins.push(u),c},component(u,f){return f?(o.components[u]=f,c):o.components[u]},directive(u,f){return f?(o.directives[u]=f,c):o.directives[u]},mount(u,f,m){if(!l){const _=c._ceVNode||Pe(r,s);return _.appContext=o,m===!0?m=\"svg\":m===!1&&(m=void 0),e(_,u,m),l=!0,c._container=u,u.__vue_app__=c,Kr(_.component)}},onUnmount(u){i.push(u)},unmount(){l&&(Et(i,c._instance,16),e(null,c._container),delete c._container.__vue_app__)},provide(u,f){return o.provides[u]=f,c},runWithContext(u){const f=ln;ln=c;try{return u()}finally{ln=f}}};return c}}let ln=null;function br(e,t){if($e){let n=$e.provides;const r=$e.parent&&$e.parent.provides;r===n&&(n=$e.provides=Object.create(r)),n[e]=t}}function rt(e,t,n=!1){const r=$e||Me;if(r||ln){const s=ln?ln._context.provides:r?r.parent==null?r.vnode.appContext&&r.vnode.appContext.provides:r.parent.provides:void 0;if(s&&e in s)return s[e];if(arguments.length>1)return n&&te(t)?t.call(r&&r.proxy):t}}function Ou(){return!!($e||Me||ln)}const zi={},Qi=()=>Object.create(zi),Zi=e=>Object.getPrototypeOf(e)===zi;function Iu(e,t,n,r=!1){const s={},o=Qi();e.propsDefaults=Object.create(null),el(e,t,s,o);for(const a in e.propsOptions[0])a in s||(s[a]=void 0);n?e.props=r?s:xi(s):e.type.props?e.props=s:e.props=o,e.attrs=o}function Au(e,t,n,r){const{props:s,attrs:o,vnode:{patchFlag:a}}=e,i=ie(s),[l]=e.propsOptions;let c=!1;if((r||a>0)&&!(a&16)){if(a&8){const u=e.vnode.dynamicProps;for(let f=0;f<u.length;f++){let m=u[f];if(Br(e.emitsOptions,m))continue;const _=t[m];if(l)if(he(o,m))_!==o[m]&&(o[m]=_,c=!0);else{const S=Wt(m);s[S]=Ps(l,i,S,_,e,!1)}else _!==o[m]&&(o[m]=_,c=!0)}}}else{el(e,t,s,o)&&(c=!0);let u;for(const f in i)(!t||!he(t,f)&&((u=un(f))===f||!he(t,u)))&&(l?n&&(n[f]!==void 0||n[u]!==void 0)&&(s[f]=Ps(l,i,f,void 0,e,!0)):delete s[f]);if(o!==i)for(const f in o)(!t||!he(t,f))&&(delete o[f],c=!0)}c&&Ot(e.attrs,\"set\",\"\")}function el(e,t,n,r){const[s,o]=e.propsOptions;let a=!1,i;if(t)for(let l in t){if($n(l))continue;const c=t[l];let u;s&&he(s,u=Wt(l))?!o||!o.includes(u)?n[u]=c:(i||(i={}))[u]=c:Br(e.emitsOptions,l)||(!(l in r)||c!==r[l])&&(r[l]=c,a=!0)}if(o){const l=ie(n),c=i||_e;for(let u=0;u<o.length;u++){const f=o[u];n[f]=Ps(s,l,f,c[f],e,!he(c,f))}}return a}function Ps(e,t,n,r,s,o){const a=e[n];if(a!=null){const i=he(a,\"default\");if(i&&r===void 0){const l=a.default;if(a.type!==Function&&!a.skipFactory&&te(l)){const{propsDefaults:c}=s;if(n in c)r=c[n];else{const u=cr(s);r=c[n]=l.call(null,t),u()}}else r=l;s.ce&&s.ce._setProp(n,r)}a[0]&&(o&&!i?r=!1:a[1]&&(r===\"\"||r===un(n))&&(r=!0))}return r}const Pu=new WeakMap;function tl(e,t,n=!1){const r=n?Pu:t.propsCache,s=r.get(e);if(s)return s;const o=e.props,a={},i=[];let l=!1;if(!te(e)){const u=f=>{l=!0;const[m,_]=tl(f,t,!0);We(a,m),_&&i.push(..._)};!n&&t.mixins.length&&t.mixins.forEach(u),e.extends&&u(e.extends),e.mixins&&e.mixins.forEach(u)}if(!o&&!l)return ve(e)&&r.set(e,bn),bn;if(J(o))for(let u=0;u<o.length;u++){const f=Wt(o[u]);Do(f)&&(a[f]=_e)}else if(o)for(const u in o){const f=Wt(u);if(Do(f)){const m=o[u],_=a[f]=J(m)||te(m)?{type:m}:We({},m),S=_.type;let T=!1,I=!0;if(J(S))for(let P=0;P<S.length;++P){const F=S[P],y=te(F)&&F.name;if(y===\"Boolean\"){T=!0;break}else y===\"String\"&&(I=!1)}else T=te(S)&&S.name===\"Boolean\";_[0]=T,_[1]=I,(T||he(_,\"default\"))&&i.push(f)}}const c=[a,i];return ve(e)&&r.set(e,c),c}function Do(e){return e[0]!==\"$\"&&!$n(e)}const nl=e=>e[0]===\"_\"||e===\"$stable\",ho=e=>J(e)?e.map(bt):[bt(e)],Ru=(e,t,n)=>{if(t._n)return t;const r=En((...s)=>ho(t(...s)),n);return r._c=!1,r},rl=(e,t,n)=>{const r=e._ctx;for(const s in e){if(nl(s))continue;const o=e[s];if(te(o))t[s]=Ru(s,o,r);else if(o!=null){const a=ho(o);t[s]=()=>a}}},sl=(e,t)=>{const n=ho(t);e.slots.default=()=>n},ol=(e,t,n)=>{for(const r in t)(n||r!==\"_\")&&(e[r]=t[r])},Cu=(e,t,n)=>{const r=e.slots=Qi();if(e.vnode.shapeFlag&32){const s=t._;s?(ol(r,t,n),n&&hi(r,\"_\",s,!0)):rl(t,r)}else t&&sl(e,t)},Nu=(e,t,n)=>{const{vnode:r,slots:s}=e;let o=!0,a=_e;if(r.shapeFlag&32){const i=t._;i?n&&i===1?o=!1:ol(s,t,n):(o=!t.$stable,rl(t,s)),a=t}else t&&(sl(e,t),a={default:1});if(o)for(const i in s)!nl(i)&&a[i]==null&&delete s[i]},tt=qu;function xu(e){return Mu(e)}function Mu(e,t){const n=Fr();n.__VUE__=!0;const{insert:r,remove:s,patchProp:o,createElement:a,createText:i,createComment:l,setText:c,setElementText:u,parentNode:f,nextSibling:m,setScopeId:_=vt,insertStaticContent:S}=e,T=(p,b,g,w=null,x=null,N=null,W=void 0,V=null,d=!!b.dynamicChildren)=>{if(p===b)return;p&&!xn(p,b)&&(w=C(p),Le(p,x,N,!0),p=null),b.patchFlag===-2&&(d=!1,b.dynamicChildren=null);const{type:h,ref:k,shapeFlag:O}=b;switch(h){case lr:I(p,b,g,w);break;case Bt:P(p,b,g,w);break;case fs:p==null&&F(b,g,w,W);break;case Ce:z(p,b,g,w,x,N,W,V,d);break;default:O&1?L(p,b,g,w,x,N,W,V,d):O&6?ue(p,b,g,w,x,N,W,V,d):(O&64||O&128)&&h.process(p,b,g,w,x,N,W,V,d,q)}k!=null&&x&&Or(k,p&&p.ref,N,b||p,!b)},I=(p,b,g,w)=>{if(p==null)r(b.el=i(b.children),g,w);else{const x=b.el=p.el;b.children!==p.children&&c(x,b.children)}},P=(p,b,g,w)=>{p==null?r(b.el=l(b.children||\"\"),g,w):b.el=p.el},F=(p,b,g,w)=>{[p.el,p.anchor]=S(p.children,b,g,w,p.el,p.anchor)},y=({el:p,anchor:b},g,w)=>{let x;for(;p&&p!==b;)x=m(p),r(p,g,w),p=x;r(b,g,w)},v=({el:p,anchor:b})=>{let g;for(;p&&p!==b;)g=m(p),s(p),p=g;s(b)},L=(p,b,g,w,x,N,W,V,d)=>{b.type===\"svg\"?W=\"svg\":b.type===\"math\"&&(W=\"mathml\"),p==null?R(b,g,w,x,N,W,V,d):M(p,b,x,N,W,V,d)},R=(p,b,g,w,x,N,W,V)=>{let d,h;const{props:k,shapeFlag:O,transition:K,dirs:U}=p;if(d=p.el=a(p.type,N,k&&k.is,k),O&8?u(d,p.children):O&16&&$(p.children,d,null,w,x,us(p,N),W,V),U&&Zt(p,null,w,\"created\"),D(d,p,p.scopeId,W,w),k){for(const A in k)A!==\"value\"&&!$n(A)&&o(d,A,null,k[A],N,w);\"value\"in k&&o(d,\"value\",null,k.value,N),(h=k.onVnodeBeforeMount)&&pt(h,w,p)}U&&Zt(p,null,w,\"beforeMount\");const E=Du(x,K);E&&K.beforeEnter(d),r(d,b,g),((h=k&&k.onVnodeMounted)||E||U)&&tt(()=>{h&&pt(h,w,p),E&&K.enter(d),U&&Zt(p,null,w,\"mounted\")},x)},D=(p,b,g,w,x)=>{if(g&&_(p,g),w)for(let N=0;N<w.length;N++)_(p,w[N]);if(x){let N=x.subTree;if(b===N||fl(N.type)&&(N.ssContent===b||N.ssFallback===b)){const W=x.vnode;D(p,W,W.scopeId,W.slotScopeIds,x.parent)}}},$=(p,b,g,w,x,N,W,V,d=0)=>{for(let h=d;h<p.length;h++){const k=p[h]=V?Dt(p[h]):bt(p[h]);T(null,k,b,g,w,x,N,W,V)}},M=(p,b,g,w,x,N,W)=>{const V=b.el=p.el;let{patchFlag:d,dynamicChildren:h,dirs:k}=b;d|=p.patchFlag&16;const O=p.props||_e,K=b.props||_e;let U;if(g&&en(g,!1),(U=K.onVnodeBeforeUpdate)&&pt(U,g,b,p),k&&Zt(b,p,g,\"beforeUpdate\"),g&&en(g,!0),(O.innerHTML&&K.innerHTML==null||O.textContent&&K.textContent==null)&&u(V,\"\"),h?ee(p.dynamicChildren,h,V,g,w,us(b,x),N):W||re(p,b,V,null,g,w,us(b,x),N,!1),d>0){if(d&16)me(V,O,K,g,x);else if(d&2&&O.class!==K.class&&o(V,\"class\",null,K.class,x),d&4&&o(V,\"style\",O.style,K.style,x),d&8){const E=b.dynamicProps;for(let A=0;A<E.length;A++){const Y=E[A],oe=O[Y],Te=K[Y];(Te!==oe||Y===\"value\")&&o(V,Y,oe,Te,x,g)}}d&1&&p.children!==b.children&&u(V,b.children)}else!W&&h==null&&me(V,O,K,g,x);((U=K.onVnodeUpdated)||k)&&tt(()=>{U&&pt(U,g,b,p),k&&Zt(b,p,g,\"updated\")},w)},ee=(p,b,g,w,x,N,W)=>{for(let V=0;V<b.length;V++){const d=p[V],h=b[V],k=d.el&&(d.type===Ce||!xn(d,h)||d.shapeFlag&70)?f(d.el):g;T(d,h,k,null,w,x,N,W,!0)}},me=(p,b,g,w,x)=>{if(b!==g){if(b!==_e)for(const N in b)!$n(N)&&!(N in g)&&o(p,N,b[N],null,x,w);for(const N in g){if($n(N))continue;const W=g[N],V=b[N];W!==V&&N!==\"value\"&&o(p,N,V,W,x,w)}\"value\"in g&&o(p,\"value\",b.value,g.value,x)}},z=(p,b,g,w,x,N,W,V,d)=>{const h=b.el=p?p.el:i(\"\"),k=b.anchor=p?p.anchor:i(\"\");let{patchFlag:O,dynamicChildren:K,slotScopeIds:U}=b;U&&(V=V?V.concat(U):U),p==null?(r(h,g,w),r(k,g,w),$(b.children||[],g,k,x,N,W,V,d)):O>0&&O&64&&K&&p.dynamicChildren?(ee(p.dynamicChildren,K,g,x,N,W,V),(b.key!=null||x&&b===x.subTree)&&al(p,b,!0)):re(p,b,g,k,x,N,W,V,d)},ue=(p,b,g,w,x,N,W,V,d)=>{b.slotScopeIds=V,p==null?b.shapeFlag&512?x.ctx.activate(b,g,w,W,d):Qe(b,g,w,x,N,W,d):Je(p,b,d)},Qe=(p,b,g,w,x,N,W)=>{const V=p.component=ef(p,w,x);if(Ki(p)&&(V.ctx.renderer=q),tf(V,!1,W),V.asyncDep){if(x&&x.registerDep(V,se,W),!p.el){const d=V.subTree=Pe(Bt);P(null,d,b,g)}}else se(V,p,b,g,x,N,W)},Je=(p,b,g)=>{const w=b.component=p.component;if(Gu(p,b,g))if(w.asyncDep&&!w.asyncResolved){Q(w,b,g);return}else w.next=b,w.update();else b.el=p.el,w.vnode=b},se=(p,b,g,w,x,N,W)=>{const V=()=>{if(p.isMounted){let{next:O,bu:K,u:U,parent:E,vnode:A}=p;{const Fe=il(p);if(Fe){O&&(O.el=A.el,Q(p,O,W)),Fe.asyncDep.then(()=>{p.isUnmounted||V()});return}}let Y=O,oe;en(p,!1),O?(O.el=A.el,Q(p,O,W)):O=A,K&&_r(K),(oe=O.props&&O.props.onVnodeBeforeUpdate)&&pt(oe,E,O,A),en(p,!0);const Te=$o(p),Ke=p.subTree;p.subTree=Te,T(Ke,Te,f(Ke.el),C(Ke),p,x,N),O.el=Te.el,Y===null&&ju(p,Te.el),U&&tt(U,x),(oe=O.props&&O.props.onVnodeUpdated)&&tt(()=>pt(oe,E,O,A),x)}else{let O;const{el:K,props:U}=b,{bm:E,m:A,parent:Y,root:oe,type:Te}=p,Ke=kn(b);en(p,!1),E&&_r(E),!Ke&&(O=U&&U.onVnodeBeforeMount)&&pt(O,Y,b),en(p,!0);{oe.ce&&oe.ce._injectChildStyle(Te);const Fe=p.subTree=$o(p);T(null,Fe,g,w,p,x,N),b.el=Fe.el}if(A&&tt(A,x),!Ke&&(O=U&&U.onVnodeMounted)){const Fe=b;tt(()=>pt(O,Y,Fe),x)}(b.shapeFlag&256||Y&&kn(Y.vnode)&&Y.vnode.shapeFlag&256)&&p.a&&tt(p.a,x),p.isMounted=!0,b=g=w=null}};p.scope.on();const d=p.effect=new vi(V);p.scope.off();const h=p.update=d.run.bind(d),k=p.job=d.runIfDirty.bind(d);k.i=p,k.id=p.uid,d.scheduler=()=>uo(k),en(p,!0),h()},Q=(p,b,g)=>{b.component=p;const w=p.vnode.props;p.vnode=b,p.next=null,Au(p,b.props,w,g),Nu(p,b.children,g),Gt(),Po(p),jt()},re=(p,b,g,w,x,N,W,V,d=!1)=>{const h=p&&p.children,k=p?p.shapeFlag:0,O=b.children,{patchFlag:K,shapeFlag:U}=b;if(K>0){if(K&128){Xe(h,O,g,w,x,N,W,V,d);return}else if(K&256){Be(h,O,g,w,x,N,W,V,d);return}}U&8?(k&16&&xe(h,x,N),O!==h&&u(g,O)):k&16?U&16?Xe(h,O,g,w,x,N,W,V,d):xe(h,x,N,!0):(k&8&&u(g,\"\"),U&16&&$(O,g,w,x,N,W,V,d))},Be=(p,b,g,w,x,N,W,V,d)=>{p=p||bn,b=b||bn;const h=p.length,k=b.length,O=Math.min(h,k);let K;for(K=0;K<O;K++){const U=b[K]=d?Dt(b[K]):bt(b[K]);T(p[K],U,g,null,x,N,W,V,d)}h>k?xe(p,x,N,!0,!1,O):$(b,g,w,x,N,W,V,d,O)},Xe=(p,b,g,w,x,N,W,V,d)=>{let h=0;const k=b.length;let O=p.length-1,K=k-1;for(;h<=O&&h<=K;){const U=p[h],E=b[h]=d?Dt(b[h]):bt(b[h]);if(xn(U,E))T(U,E,g,null,x,N,W,V,d);else break;h++}for(;h<=O&&h<=K;){const U=p[O],E=b[K]=d?Dt(b[K]):bt(b[K]);if(xn(U,E))T(U,E,g,null,x,N,W,V,d);else break;O--,K--}if(h>O){if(h<=K){const U=K+1,E=U<k?b[U].el:w;for(;h<=K;)T(null,b[h]=d?Dt(b[h]):bt(b[h]),g,E,x,N,W,V,d),h++}}else if(h>K)for(;h<=O;)Le(p[h],x,N,!0),h++;else{const U=h,E=h,A=new Map;for(h=E;h<=K;h++){const et=b[h]=d?Dt(b[h]):bt(b[h]);et.key!=null&&A.set(et.key,h)}let Y,oe=0;const Te=K-E+1;let Ke=!1,Fe=0;const zt=new Array(Te);for(h=0;h<Te;h++)zt[h]=0;for(h=U;h<=O;h++){const et=p[h];if(oe>=Te){Le(et,x,N,!0);continue}let gt;if(et.key!=null)gt=A.get(et.key);else for(Y=E;Y<=K;Y++)if(zt[Y-E]===0&&xn(et,b[Y])){gt=Y;break}gt===void 0?Le(et,x,N,!0):(zt[gt-E]=h+1,gt>=Fe?Fe=gt:Ke=!0,T(et,b[gt],g,null,x,N,W,V,d),oe++)}const ns=Ke?Fu(zt):bn;for(Y=ns.length-1,h=Te-1;h>=0;h--){const et=E+h,gt=b[et],So=et+1<k?b[et+1].el:w;zt[h]===0?T(null,gt,g,So,x,N,W,V,d):Ke&&(Y<0||h!==ns[Y]?Se(gt,g,So,2):Y--)}}},Se=(p,b,g,w,x=null)=>{const{el:N,type:W,transition:V,children:d,shapeFlag:h}=p;if(h&6){Se(p.component.subTree,b,g,w);return}if(h&128){p.suspense.move(b,g,w);return}if(h&64){W.move(p,b,g,q);return}if(W===Ce){r(N,b,g);for(let O=0;O<d.length;O++)Se(d[O],b,g,w);r(p.anchor,b,g);return}if(W===fs){y(p,b,g);return}if(w!==2&&h&1&&V)if(w===0)V.beforeEnter(N),r(N,b,g),tt(()=>V.enter(N),x);else{const{leave:O,delayLeave:K,afterLeave:U}=V,E=()=>r(N,b,g),A=()=>{O(N,()=>{E(),U&&U()})};K?K(N,E,A):A()}else r(N,b,g)},Le=(p,b,g,w=!1,x=!1)=>{const{type:N,props:W,ref:V,children:d,dynamicChildren:h,shapeFlag:k,patchFlag:O,dirs:K,cacheIndex:U}=p;if(O===-2&&(x=!1),V!=null&&Or(V,null,g,p,!0),U!=null&&(b.renderCache[U]=void 0),k&256){b.ctx.deactivate(p);return}const E=k&1&&K,A=!kn(p);let Y;if(A&&(Y=W&&W.onVnodeBeforeUnmount)&&pt(Y,b,p),k&6)mt(p.component,g,w);else{if(k&128){p.suspense.unmount(g,w);return}E&&Zt(p,null,b,\"beforeUnmount\"),k&64?p.type.remove(p,b,g,q,w):h&&!h.hasOnce&&(N!==Ce||O>0&&O&64)?xe(h,b,g,!1,!0):(N===Ce&&O&384||!x&&k&16)&&xe(d,b,g),w&&st(p)}(A&&(Y=W&&W.onVnodeUnmounted)||E)&&tt(()=>{Y&&pt(Y,b,p),E&&Zt(p,null,b,\"unmounted\")},g)},st=p=>{const{type:b,el:g,anchor:w,transition:x}=p;if(b===Ce){Ze(g,w);return}if(b===fs){v(p);return}const N=()=>{s(g),x&&!x.persisted&&x.afterLeave&&x.afterLeave()};if(p.shapeFlag&1&&x&&!x.persisted){const{leave:W,delayLeave:V}=x,d=()=>W(g,N);V?V(p.el,N,d):d()}else N()},Ze=(p,b)=>{let g;for(;p!==b;)g=m(p),s(p),p=g;s(b)},mt=(p,b,g)=>{const{bum:w,scope:x,job:N,subTree:W,um:V,m:d,a:h}=p;Fo(d),Fo(h),w&&_r(w),x.stop(),N&&(N.flags|=8,Le(W,p,b,g)),V&&tt(V,b),tt(()=>{p.isUnmounted=!0},b),b&&b.pendingBranch&&!b.isUnmounted&&p.asyncDep&&!p.asyncResolved&&p.suspenseId===b.pendingId&&(b.deps--,b.deps===0&&b.resolve())},xe=(p,b,g,w=!1,x=!1,N=0)=>{for(let W=N;W<p.length;W++)Le(p[W],b,g,w,x)},C=p=>{if(p.shapeFlag&6)return C(p.component.subTree);if(p.shapeFlag&128)return p.suspense.next();const b=m(p.anchor||p.el),g=b&&b[au];return g?m(g):b};let j=!1;const B=(p,b,g)=>{p==null?b._vnode&&Le(b._vnode,null,null,!0):T(b._vnode||null,p,b,null,null,null,g),b._vnode=p,j||(j=!0,Po(),Vi(),j=!1)},q={p:T,um:Le,m:Se,r:st,mt:Qe,mc:$,pc:re,pbc:ee,n:C,o:e};return{render:B,hydrate:void 0,createApp:Lu(B)}}function us({type:e,props:t},n){return n===\"svg\"&&e===\"foreignObject\"||n===\"mathml\"&&e===\"annotation-xml\"&&t&&t.encoding&&t.encoding.includes(\"html\")?void 0:n}function en({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function Du(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function al(e,t,n=!1){const r=e.children,s=t.children;if(J(r)&&J(s))for(let o=0;o<r.length;o++){const a=r[o];let i=s[o];i.shapeFlag&1&&!i.dynamicChildren&&((i.patchFlag<=0||i.patchFlag===32)&&(i=s[o]=Dt(s[o]),i.el=a.el),!n&&i.patchFlag!==-2&&al(a,i)),i.type===lr&&(i.el=a.el)}}function Fu(e){const t=e.slice(),n=[0];let r,s,o,a,i;const l=e.length;for(r=0;r<l;r++){const c=e[r];if(c!==0){if(s=n[n.length-1],e[s]<c){t[r]=s,n.push(r);continue}for(o=0,a=n.length-1;o<a;)i=o+a>>1,e[n[i]]<c?o=i+1:a=i;c<e[n[o]]&&(o>0&&(t[r]=n[o-1]),n[o]=r)}}for(o=n.length,a=n[o-1];o-- >0;)n[o]=a,a=t[a];return n}function il(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:il(t)}function Fo(e){if(e)for(let t=0;t<e.length;t++)e[t].flags|=8}const $u=Symbol.for(\"v-scx\"),Uu=()=>rt($u);function Vt(e,t,n){return ll(e,t,n)}function ll(e,t,n=_e){const{immediate:r,deep:s,flush:o,once:a}=n,i=We({},n),l=t&&r||!t&&o!==\"post\";let c;if(Zn){if(o===\"sync\"){const _=Uu();c=_.__watcherHandles||(_.__watcherHandles=[])}else if(!l){const _=()=>{};return _.stop=vt,_.resume=vt,_.pause=vt,_}}const u=$e;i.call=(_,S,T)=>Et(_,u,S,T);let f=!1;o===\"post\"?i.scheduler=_=>{tt(_,u&&u.suspense)}:o!==\"sync\"&&(f=!0,i.scheduler=(_,S)=>{S?_():uo(_)}),i.augmentJob=_=>{t&&(_.flags|=4),f&&(_.flags|=2,u&&(_.id=u.uid,_.i=u))};const m=nu(e,t,i);return Zn&&(c?c.push(m):l&&m()),m}function Vu(e,t,n){const r=this.proxy,s=Re(e)?e.includes(\".\")?cl(r,e):()=>r[e]:e.bind(r,r);let o;te(t)?o=t:(o=t.handler,n=t);const a=cr(this),i=ll(s,o.bind(r),n);return a(),i}function cl(e,t){const n=t.split(\".\");return()=>{let r=e;for(let s=0;s<n.length&&r;s++)r=r[n[s]];return r}}const Hu=(e,t)=>t===\"modelValue\"||t===\"model-value\"?e.modelModifiers:e[`${t}Modifiers`]||e[`${Wt(t)}Modifiers`]||e[`${un(t)}Modifiers`];function Wu(e,t,...n){if(e.isUnmounted)return;const r=e.vnode.props||_e;let s=n;const o=t.startsWith(\"update:\"),a=o&&Hu(r,t.slice(7));a&&(a.trim&&(s=n.map(u=>Re(u)?u.trim():u)),a.number&&(s=n.map(ks)));let i,l=r[i=rs(t)]||r[i=rs(Wt(t))];!l&&o&&(l=r[i=rs(un(t))]),l&&Et(l,e,6,s);const c=r[i+\"Once\"];if(c){if(!e.emitted)e.emitted={};else if(e.emitted[i])return;e.emitted[i]=!0,Et(c,e,6,s)}}function ul(e,t,n=!1){const r=t.emitsCache,s=r.get(e);if(s!==void 0)return s;const o=e.emits;let a={},i=!1;if(!te(e)){const l=c=>{const u=ul(c,t,!0);u&&(i=!0,We(a,u))};!n&&t.mixins.length&&t.mixins.forEach(l),e.extends&&l(e.extends),e.mixins&&e.mixins.forEach(l)}return!o&&!i?(ve(e)&&r.set(e,null),null):(J(o)?o.forEach(l=>a[l]=null):We(a,o),ve(e)&&r.set(e,a),a)}function Br(e,t){return!e||!xr(t)?!1:(t=t.slice(2).replace(/Once$/,\"\"),he(e,t[0].toLowerCase()+t.slice(1))||he(e,un(t))||he(e,t))}function $o(e){const{type:t,vnode:n,proxy:r,withProxy:s,propsOptions:[o],slots:a,attrs:i,emit:l,render:c,renderCache:u,props:f,data:m,setupState:_,ctx:S,inheritAttrs:T}=e,I=Lr(e);let P,F;try{if(n.shapeFlag&4){const v=s||r,L=v;P=bt(c.call(L,v,u,f,_,m,S)),F=i}else{const v=t;P=bt(v.length>1?v(f,{attrs:i,slots:a,emit:l}):v(f,null)),F=t.props?i:Bu(i)}}catch(v){Wn.length=0,Hr(v,e,1),P=Pe(Bt)}let y=P;if(F&&T!==!1){const v=Object.keys(F),{shapeFlag:L}=y;v.length&&L&7&&(o&&v.some(Js)&&(F=Ku(F,o)),y=Sn(y,F,!1,!0))}return n.dirs&&(y=Sn(y,null,!1,!0),y.dirs=y.dirs?y.dirs.concat(n.dirs):n.dirs),n.transition&&fo(y,n.transition),P=y,Lr(I),P}const Bu=e=>{let t;for(const n in e)(n===\"class\"||n===\"style\"||xr(n))&&((t||(t={}))[n]=e[n]);return t},Ku=(e,t)=>{const n={};for(const r in e)(!Js(r)||!(r.slice(9)in t))&&(n[r]=e[r]);return n};function Gu(e,t,n){const{props:r,children:s,component:o}=e,{props:a,children:i,patchFlag:l}=t,c=o.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&l>=0){if(l&1024)return!0;if(l&16)return r?Uo(r,a,c):!!a;if(l&8){const u=t.dynamicProps;for(let f=0;f<u.length;f++){const m=u[f];if(a[m]!==r[m]&&!Br(c,m))return!0}}}else return(s||i)&&(!i||!i.$stable)?!0:r===a?!1:r?a?Uo(r,a,c):!0:!!a;return!1}function Uo(e,t,n){const r=Object.keys(t);if(r.length!==Object.keys(e).length)return!0;for(let s=0;s<r.length;s++){const o=r[s];if(t[o]!==e[o]&&!Br(n,o))return!0}return!1}function ju({vnode:e,parent:t},n){for(;t;){const r=t.subTree;if(r.suspense&&r.suspense.activeBranch===e&&(r.el=e.el),r===e)(e=t.vnode).el=n,t=t.parent;else break}}const fl=e=>e.__isSuspense;function qu(e,t){t&&t.pendingBranch?J(e)?t.effects.push(...e):t.effects.push(e):ou(e)}const Ce=Symbol.for(\"v-fgt\"),lr=Symbol.for(\"v-txt\"),Bt=Symbol.for(\"v-cmt\"),fs=Symbol.for(\"v-stc\"),Wn=[];let nt=null;function fe(e=!1){Wn.push(nt=e?null:[])}function Yu(){Wn.pop(),nt=Wn[Wn.length-1]||null}let Xn=1;function Vo(e,t=!1){Xn+=e,e<0&&nt&&t&&(nt.hasOnce=!0)}function dl(e){return e.dynamicChildren=Xn>0?nt||bn:null,Yu(),Xn>0&&nt&&nt.push(e),e}function Ee(e,t,n,r,s,o){return dl(H(e,t,n,r,s,o,!0))}function wn(e,t,n,r,s){return dl(Pe(e,t,n,r,s,!0))}function zn(e){return e?e.__v_isVNode===!0:!1}function xn(e,t){return e.type===t.type&&e.key===t.key}const hl=({key:e})=>e??null,yr=({ref:e,ref_key:t,ref_for:n})=>(typeof e==\"number\"&&(e=\"\"+e),e!=null?Re(e)||Oe(e)||te(e)?{i:Me,r:e,k:t,f:!!n}:e:null);function H(e,t=null,n=null,r=0,s=null,o=e===Ce?0:1,a=!1,i=!1){const l={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&hl(t),ref:t&&yr(t),scopeId:Wi,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:o,patchFlag:r,dynamicProps:s,dynamicChildren:null,appContext:null,ctx:Me};return i?(mo(l,n),o&128&&e.normalize(l)):n&&(l.shapeFlag|=Re(n)?8:16),Xn>0&&!a&&nt&&(l.patchFlag>0||o&6)&&l.patchFlag!==32&&nt.push(l),l}const Pe=Ju;function Ju(e,t=null,n=null,r=0,s=null,o=!1){if((!e||e===bu)&&(e=Bt),zn(e)){const i=Sn(e,t,!0);return n&&mo(i,n),Xn>0&&!o&&nt&&(i.shapeFlag&6?nt[nt.indexOf(e)]=i:nt.push(i)),i.patchFlag=-2,i}if(of(e)&&(e=e.__vccOpts),t){t=Xu(t);let{class:i,style:l}=t;i&&!Re(i)&&(t.class=$r(i)),ve(l)&&(ao(l)&&!J(l)&&(l=We({},l)),t.style=Qs(l))}const a=Re(e)?1:fl(e)?128:iu(e)?64:ve(e)?4:te(e)?2:0;return H(e,t,n,r,s,a,o,!0)}function Xu(e){return e?ao(e)||Zi(e)?We({},e):e:null}function Sn(e,t,n=!1,r=!1){const{props:s,ref:o,patchFlag:a,children:i,transition:l}=e,c=t?zu(s||{},t):s,u={__v_isVNode:!0,__v_skip:!0,type:e.type,props:c,key:c&&hl(c),ref:t&&t.ref?n&&o?J(o)?o.concat(yr(t)):[o,yr(t)]:yr(t):o,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:i,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==Ce?a===-1?16:a|16:a,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:l,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&Sn(e.ssContent),ssFallback:e.ssFallback&&Sn(e.ssFallback),el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return l&&r&&fo(u,l.clone(u)),u}function ml(e=\" \",t=0){return Pe(lr,null,e,t)}function Ht(e=\"\",t=!1){return t?(fe(),wn(Bt,null,e)):Pe(Bt,null,e)}function bt(e){return e==null||typeof e==\"boolean\"?Pe(Bt):J(e)?Pe(Ce,null,e.slice()):zn(e)?Dt(e):Pe(lr,null,String(e))}function Dt(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:Sn(e)}function mo(e,t){let n=0;const{shapeFlag:r}=e;if(t==null)t=null;else if(J(t))n=16;else if(typeof t==\"object\")if(r&65){const s=t.default;s&&(s._c&&(s._d=!1),mo(e,s()),s._c&&(s._d=!0));return}else{n=32;const s=t._;!s&&!Zi(t)?t._ctx=Me:s===3&&Me&&(Me.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else te(t)?(t={default:t,_ctx:Me},n=32):(t=String(t),r&64?(n=16,t=[ml(t)]):n=8);e.children=t,e.shapeFlag|=n}function zu(...e){const t={};for(let n=0;n<e.length;n++){const r=e[n];for(const s in r)if(s===\"class\")t.class!==r.class&&(t.class=$r([t.class,r.class]));else if(s===\"style\")t.style=Qs([t.style,r.style]);else if(xr(s)){const o=t[s],a=r[s];a&&o!==a&&!(J(o)&&o.includes(a))&&(t[s]=o?[].concat(o,a):a)}else s!==\"\"&&(t[s]=r[s])}return t}function pt(e,t,n,r=null){Et(e,t,7,[n,r])}const Qu=Xi();let Zu=0;function ef(e,t,n){const r=e.type,s=(t?t.appContext:e.appContext)||Qu,o={uid:Zu++,vnode:e,type:r,parent:t,appContext:s,root:null,next:null,subTree:null,effect:null,update:null,job:null,scope:new bi(!0),render:null,proxy:null,exposed:null,exposeProxy:null,withProxy:null,provides:t?t.provides:Object.create(s.provides),ids:t?t.ids:[\"\",0,0],accessCache:null,renderCache:[],components:null,directives:null,propsOptions:tl(r,s),emitsOptions:ul(r,s),emit:null,emitted:null,propsDefaults:_e,inheritAttrs:r.inheritAttrs,ctx:_e,data:_e,props:_e,attrs:_e,slots:_e,refs:_e,setupState:_e,setupContext:null,suspense:n,suspenseId:n?n.pendingId:0,asyncDep:null,asyncResolved:!1,isMounted:!1,isUnmounted:!1,isDeactivated:!1,bc:null,c:null,bm:null,m:null,bu:null,u:null,um:null,bum:null,da:null,a:null,rtg:null,rtc:null,ec:null,sp:null};return o.ctx={_:o},o.root=t?t.root:o,o.emit=Wu.bind(null,o),e.ce&&e.ce(o),o}let $e=null;const Qn=()=>$e||Me;let Ar,Rs;{const e=Fr(),t=(n,r)=>{let s;return(s=e[n])||(s=e[n]=[]),s.push(r),o=>{s.length>1?s.forEach(a=>a(o)):s[0](o)}};Ar=t(\"__VUE_INSTANCE_SETTERS__\",n=>$e=n),Rs=t(\"__VUE_SSR_SETTERS__\",n=>Zn=n)}const cr=e=>{const t=$e;return Ar(e),e.scope.on(),()=>{e.scope.off(),Ar(t)}},Ho=()=>{$e&&$e.scope.off(),Ar(null)};function gl(e){return e.vnode.shapeFlag&4}let Zn=!1;function tf(e,t=!1,n=!1){t&&Rs(t);const{props:r,children:s}=e.vnode,o=gl(e);Iu(e,r,o,t),Cu(e,s,n);const a=o?nf(e,t):void 0;return t&&Rs(!1),a}function nf(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,yu);const{setup:r}=n;if(r){Gt();const s=e.setupContext=r.length>1?sf(e):null,o=cr(e),a=ir(r,e,0,[e.props,s]),i=ci(a);if(jt(),o(),(i||e.sp)&&!kn(e)&&Bi(e),i){if(a.then(Ho,Ho),t)return a.then(l=>{Wo(e,l)}).catch(l=>{Hr(l,e,0)});e.asyncDep=a}else Wo(e,a)}else pl(e)}function Wo(e,t,n){te(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:ve(t)&&(e.setupState=Fi(t)),pl(e)}function pl(e,t,n){const r=e.type;e.render||(e.render=r.render||vt);{const s=cr(e);Gt();try{vu(e)}finally{jt(),s()}}}const rf={get(e,t){return Ve(e,\"get\",\"\"),e[t]}};function sf(e){const t=n=>{e.exposed=n||{}};return{attrs:new Proxy(e.attrs,rf),slots:e.slots,emit:e.emit,expose:t}}function Kr(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(Fi(io(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in Hn)return Hn[n](e)},has(t,n){return n in t||n in Hn}})):e.proxy}function of(e){return te(e)&&\"__vccOpts\"in e}const we=(e,t)=>eu(e,t,Zn);function Gr(e,t,n){const r=arguments.length;return r===2?ve(t)&&!J(t)?zn(t)?Pe(e,null,[t]):Pe(e,t):Pe(e,null,t):(r>3?n=Array.prototype.slice.call(arguments,2):r===3&&zn(n)&&(n=[n]),Pe(e,t,n))}const af=\"3.5.13\";/**\n* @vue/runtime-dom v3.5.13\n* (c) 2018-present Yuxi (Evan) You and Vue contributors\n* @license MIT\n**/let Cs;const Bo=typeof window<\"u\"&&window.trustedTypes;if(Bo)try{Cs=Bo.createPolicy(\"vue\",{createHTML:e=>e})}catch{}const _l=Cs?e=>Cs.createHTML(e):e=>e,lf=\"http://www.w3.org/2000/svg\",cf=\"http://www.w3.org/1998/Math/MathML\",Lt=typeof document<\"u\"?document:null,Ko=Lt&&Lt.createElement(\"template\"),uf={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,r)=>{const s=t===\"svg\"?Lt.createElementNS(lf,e):t===\"mathml\"?Lt.createElementNS(cf,e):n?Lt.createElement(e,{is:n}):Lt.createElement(e);return e===\"select\"&&r&&r.multiple!=null&&s.setAttribute(\"multiple\",r.multiple),s},createText:e=>Lt.createTextNode(e),createComment:e=>Lt.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Lt.querySelector(e),setScopeId(e,t){e.setAttribute(t,\"\")},insertStaticContent(e,t,n,r,s,o){const a=n?n.previousSibling:t.lastChild;if(s&&(s===o||s.nextSibling))for(;t.insertBefore(s.cloneNode(!0),n),!(s===o||!(s=s.nextSibling)););else{Ko.innerHTML=_l(r===\"svg\"?`<svg>${e}</svg>`:r===\"mathml\"?`<math>${e}</math>`:e);const i=Ko.content;if(r===\"svg\"||r===\"mathml\"){const l=i.firstChild;for(;l.firstChild;)i.appendChild(l.firstChild);i.removeChild(l)}t.insertBefore(i,n)}return[a?a.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},ff=Symbol(\"_vtc\");function df(e,t,n){const r=e[ff];r&&(t=(t?[t,...r]:[...r]).join(\" \")),t==null?e.removeAttribute(\"class\"):n?e.setAttribute(\"class\",t):e.className=t}const Go=Symbol(\"_vod\"),hf=Symbol(\"_vsh\"),mf=Symbol(\"\"),gf=/(^|;)\\s*display\\s*:/;function pf(e,t,n){const r=e.style,s=Re(n);let o=!1;if(n&&!s){if(t)if(Re(t))for(const a of t.split(\";\")){const i=a.slice(0,a.indexOf(\":\")).trim();n[i]==null&&vr(r,i,\"\")}else for(const a in t)n[a]==null&&vr(r,a,\"\");for(const a in n)a===\"display\"&&(o=!0),vr(r,a,n[a])}else if(s){if(t!==n){const a=r[mf];a&&(n+=\";\"+a),r.cssText=n,o=gf.test(n)}}else t&&e.removeAttribute(\"style\");Go in e&&(e[Go]=o?r.display:\"\",e[hf]&&(r.display=\"none\"))}const jo=/\\s*!important$/;function vr(e,t,n){if(J(n))n.forEach(r=>vr(e,t,r));else if(n==null&&(n=\"\"),t.startsWith(\"--\"))e.setProperty(t,n);else{const r=_f(e,t);jo.test(n)?e.setProperty(un(r),n.replace(jo,\"\"),\"important\"):e[r]=n}}const qo=[\"Webkit\",\"Moz\",\"ms\"],ds={};function _f(e,t){const n=ds[t];if(n)return n;let r=Wt(t);if(r!==\"filter\"&&r in e)return ds[t]=r;r=di(r);for(let s=0;s<qo.length;s++){const o=qo[s]+r;if(o in e)return ds[t]=o}return t}const Yo=\"http://www.w3.org/1999/xlink\";function Jo(e,t,n,r,s,o=Lc(t)){r&&t.startsWith(\"xlink:\")?n==null?e.removeAttributeNS(Yo,t.slice(6,t.length)):e.setAttributeNS(Yo,t,n):n==null||o&&!mi(n)?e.removeAttribute(t):e.setAttribute(t,o?\"\":dt(n)?String(n):n)}function Xo(e,t,n,r,s){if(t===\"innerHTML\"||t===\"textContent\"){n!=null&&(e[t]=t===\"innerHTML\"?_l(n):n);return}const o=e.tagName;if(t===\"value\"&&o!==\"PROGRESS\"&&!o.includes(\"-\")){const i=o===\"OPTION\"?e.getAttribute(\"value\")||\"\":e.value,l=n==null?e.type===\"checkbox\"?\"on\":\"\":String(n);(i!==l||!(\"_value\"in e))&&(e.value=l),n==null&&e.removeAttribute(t),e._value=n;return}let a=!1;if(n===\"\"||n==null){const i=typeof e[t];i===\"boolean\"?n=mi(n):n==null&&i===\"string\"?(n=\"\",a=!0):i===\"number\"&&(n=0,a=!0)}try{e[t]=n}catch{}a&&e.removeAttribute(s||t)}function on(e,t,n,r){e.addEventListener(t,n,r)}function bf(e,t,n,r){e.removeEventListener(t,n,r)}const zo=Symbol(\"_vei\");function yf(e,t,n,r,s=null){const o=e[zo]||(e[zo]={}),a=o[t];if(r&&a)a.value=r;else{const[i,l]=vf(t);if(r){const c=o[t]=Tf(r,s);on(e,i,c,l)}else a&&(bf(e,i,a,l),o[t]=void 0)}}const Qo=/(?:Once|Passive|Capture)$/;function vf(e){let t;if(Qo.test(e)){t={};let r;for(;r=e.match(Qo);)e=e.slice(0,e.length-r[0].length),t[r[0].toLowerCase()]=!0}return[e[2]===\":\"?e.slice(3):un(e.slice(2)),t]}let hs=0;const Ef=Promise.resolve(),kf=()=>hs||(Ef.then(()=>hs=0),hs=Date.now());function Tf(e,t){const n=r=>{if(!r._vts)r._vts=Date.now();else if(r._vts<=n.attached)return;Et(wf(r,n.value),t,5,[r])};return n.value=e,n.attached=kf(),n}function wf(e,t){if(J(t)){const n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map(r=>s=>!s._stopped&&r&&r(s))}else return t}const Zo=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Sf=(e,t,n,r,s,o)=>{const a=s===\"svg\";t===\"class\"?df(e,r,a):t===\"style\"?pf(e,n,r):xr(t)?Js(t)||yf(e,t,n,r,o):(t[0]===\".\"?(t=t.slice(1),!0):t[0]===\"^\"?(t=t.slice(1),!1):Lf(e,t,r,a))?(Xo(e,t,r),!e.tagName.includes(\"-\")&&(t===\"value\"||t===\"checked\"||t===\"selected\")&&Jo(e,t,r,a,o,t!==\"value\")):e._isVueCE&&(/[A-Z]/.test(t)||!Re(r))?Xo(e,Wt(t),r,o,t):(t===\"true-value\"?e._trueValue=r:t===\"false-value\"&&(e._falseValue=r),Jo(e,t,r,a))};function Lf(e,t,n,r){if(r)return!!(t===\"innerHTML\"||t===\"textContent\"||t in e&&Zo(t)&&te(n));if(t===\"spellcheck\"||t===\"draggable\"||t===\"translate\"||t===\"form\"||t===\"list\"&&e.tagName===\"INPUT\"||t===\"type\"&&e.tagName===\"TEXTAREA\")return!1;if(t===\"width\"||t===\"height\"){const s=e.tagName;if(s===\"IMG\"||s===\"VIDEO\"||s===\"CANVAS\"||s===\"SOURCE\")return!1}return Zo(t)&&Re(n)?!1:t in e}const Pr=e=>{const t=e.props[\"onUpdate:modelValue\"]||!1;return J(t)?n=>_r(t,n):t};function Of(e){e.target.composing=!0}function ea(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event(\"input\")))}const Tn=Symbol(\"_assign\"),ta={created(e,{modifiers:{lazy:t,trim:n,number:r}},s){e[Tn]=Pr(s);const o=r||s.props&&s.props.type===\"number\";on(e,t?\"change\":\"input\",a=>{if(a.target.composing)return;let i=e.value;n&&(i=i.trim()),o&&(i=ks(i)),e[Tn](i)}),n&&on(e,\"change\",()=>{e.value=e.value.trim()}),t||(on(e,\"compositionstart\",Of),on(e,\"compositionend\",ea),on(e,\"change\",ea))},mounted(e,{value:t}){e.value=t??\"\"},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:r,trim:s,number:o}},a){if(e[Tn]=Pr(a),e.composing)return;const i=(o||e.type===\"number\")&&!/^0\\d/.test(e.value)?ks(e.value):e.value,l=t??\"\";i!==l&&(document.activeElement===e&&e.type!==\"range\"&&(r&&t===n||s&&e.value.trim()===l)||(e.value=l))}},If={deep:!0,created(e,t,n){e[Tn]=Pr(n),on(e,\"change\",()=>{const r=e._modelValue,s=Af(e),o=e.checked,a=e[Tn];if(J(r)){const i=gi(r,s),l=i!==-1;if(o&&!l)a(r.concat(s));else if(!o&&l){const c=[...r];c.splice(i,1),a(c)}}else if(Mr(r)){const i=new Set(r);o?i.add(s):i.delete(s),a(i)}else a(bl(e,o))})},mounted:na,beforeUpdate(e,t,n){e[Tn]=Pr(n),na(e,t,n)}};function na(e,{value:t,oldValue:n},r){e._modelValue=t;let s;if(J(t))s=gi(t,r.props.value)>-1;else if(Mr(t))s=t.has(r.props.value);else{if(t===n)return;s=Ur(t,bl(e,!0))}e.checked!==s&&(e.checked=s)}function Af(e){return\"_value\"in e?e._value:e.value}function bl(e,t){const n=t?\"_trueValue\":\"_falseValue\";return n in e?e[n]:t}const Pf=[\"ctrl\",\"shift\",\"alt\",\"meta\"],Rf={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>\"button\"in e&&e.button!==0,middle:e=>\"button\"in e&&e.button!==1,right:e=>\"button\"in e&&e.button!==2,exact:(e,t)=>Pf.some(n=>e[`${n}Key`]&&!t.includes(n))},jr=(e,t)=>{const n=e._withMods||(e._withMods={}),r=t.join(\".\");return n[r]||(n[r]=(s,...o)=>{for(let a=0;a<t.length;a++){const i=Rf[t[a]];if(i&&i(s,t))return}return e(s,...o)})},Cf=We({patchProp:Sf},uf);let ra;function Nf(){return ra||(ra=xu(Cf))}const xf=(...e)=>{const t=Nf().createApp(...e),{mount:n}=t;return t.mount=r=>{const s=Df(r);if(!s)return;const o=t._component;!te(o)&&!o.render&&!o.template&&(o.template=s.innerHTML),s.nodeType===1&&(s.textContent=\"\");const a=n(s,!1,Mf(s));return s instanceof Element&&(s.removeAttribute(\"v-cloak\"),s.setAttribute(\"data-v-app\",\"\")),a},t};function Mf(e){if(e instanceof SVGElement)return\"svg\";if(typeof MathMLElement==\"function\"&&e instanceof MathMLElement)return\"mathml\"}function Df(e){return Re(e)?document.querySelector(e):e}/*!\n * pinia v3.0.1\n * (c) 2025 Eduardo San Martin Morote\n * @license MIT\n */let yl;const qr=e=>yl=e,vl=Symbol();function Ns(e){return e&&typeof e==\"object\"&&Object.prototype.toString.call(e)===\"[object Object]\"&&typeof e.toJSON!=\"function\"}var Bn;(function(e){e.direct=\"direct\",e.patchObject=\"patch object\",e.patchFunction=\"patch function\"})(Bn||(Bn={}));function Ff(){const e=Zs(!0),t=e.run(()=>ge({}));let n=[],r=[];const s=io({install(o){qr(s),s._a=o,o.provide(vl,s),o.config.globalProperties.$pinia=s,r.forEach(a=>n.push(a)),r=[]},use(o){return this._a?n.push(o):r.push(o),this},_p:n,_a:null,_e:e,_s:new Map,state:t});return s}const El=()=>{};function sa(e,t,n,r=El){e.push(t);const s=()=>{const o=e.indexOf(t);o>-1&&(e.splice(o,1),r())};return!n&&yi()&&Ic(s),s}function hn(e,...t){e.slice().forEach(n=>{n(...t)})}const $f=e=>e(),oa=Symbol(),ms=Symbol();function xs(e,t){e instanceof Map&&t instanceof Map?t.forEach((n,r)=>e.set(r,n)):e instanceof Set&&t instanceof Set&&t.forEach(e.add,e);for(const n in t){if(!t.hasOwnProperty(n))continue;const r=t[n],s=e[n];Ns(s)&&Ns(r)&&e.hasOwnProperty(n)&&!Oe(r)&&!Ut(r)?e[n]=xs(s,r):e[n]=r}return e}const Uf=Symbol();function Vf(e){return!Ns(e)||!e.hasOwnProperty(Uf)}const{assign:xt}=Object;function Hf(e){return!!(Oe(e)&&e.effect)}function Wf(e,t,n,r){const{state:s,actions:o,getters:a}=t,i=n.state.value[e];let l;function c(){i||(n.state.value[e]=s?s():{});const u=Xc(n.state.value[e]);return xt(u,o,Object.keys(a||{}).reduce((f,m)=>(f[m]=io(we(()=>{qr(n);const _=n._s.get(e);return a[m].call(_,_)})),f),{}))}return l=kl(e,c,t,n,r,!0),l}function kl(e,t,n={},r,s,o){let a;const i=xt({actions:{}},n),l={deep:!0};let c,u,f=[],m=[],_;const S=r.state.value[e];!o&&!S&&(r.state.value[e]={}),ge({});let T;function I($){let M;c=u=!1,typeof $==\"function\"?($(r.state.value[e]),M={type:Bn.patchFunction,storeId:e,events:_}):(xs(r.state.value[e],$),M={type:Bn.patchObject,payload:$,storeId:e,events:_});const ee=T=Symbol();co().then(()=>{T===ee&&(c=!0)}),u=!0,hn(f,M,r.state.value[e])}const P=o?function(){const{state:M}=n,ee=M?M():{};this.$patch(me=>{xt(me,ee)})}:El;function F(){a.stop(),f=[],m=[],r._s.delete(e)}const y=($,M=\"\")=>{if(oa in $)return $[ms]=M,$;const ee=function(){qr(r);const me=Array.from(arguments),z=[],ue=[];function Qe(Q){z.push(Q)}function Je(Q){ue.push(Q)}hn(m,{args:me,name:ee[ms],store:L,after:Qe,onError:Je});let se;try{se=$.apply(this&&this.$id===e?this:L,me)}catch(Q){throw hn(ue,Q),Q}return se instanceof Promise?se.then(Q=>(hn(z,Q),Q)).catch(Q=>(hn(ue,Q),Promise.reject(Q))):(hn(z,se),se)};return ee[oa]=!0,ee[ms]=M,ee},v={_p:r,$id:e,$onAction:sa.bind(null,m),$patch:I,$reset:P,$subscribe($,M={}){const ee=sa(f,$,M.detached,()=>me()),me=a.run(()=>Vt(()=>r.state.value[e],z=>{(M.flush===\"sync\"?u:c)&&$({storeId:e,type:Bn.direct,events:_},z)},xt({},l,M)));return ee},$dispose:F},L=ar(v);r._s.set(e,L);const D=(r._a&&r._a.runWithContext||$f)(()=>r._e.run(()=>(a=Zs()).run(()=>t({action:y}))));for(const $ in D){const M=D[$];if(Oe(M)&&!Hf(M)||Ut(M))o||(S&&Vf(M)&&(Oe(M)?M.value=S[$]:xs(M,S[$])),r.state.value[e][$]=M);else if(typeof M==\"function\"){const ee=y(M,$);D[$]=ee,i.actions[$]=M}}return xt(L,D),xt(ie(L),D),Object.defineProperty(L,\"$state\",{get:()=>r.state.value[e],set:$=>{I(M=>{xt(M,$)})}}),r._p.forEach($=>{xt(L,a.run(()=>$({store:L,app:r._a,pinia:r,options:i})))}),S&&o&&n.hydrate&&n.hydrate(L.$state,S),c=!0,u=!0,L}/*! #__NO_SIDE_EFFECTS__ */function Bf(e,t,n){let r;const s=typeof t==\"function\";r=s?n:t;function o(a,i){const l=Ou();return a=a||(l?rt(vl,null):null),a&&qr(a),a=yl,a._s.has(e)||(s?kl(e,t,r,a):Wf(e,r,a)),a._s.get(e)}return o.$id=e,o}/*!\n  * vue-router v4.5.0\n  * (c) 2024 Eduardo San Martin Morote\n  * @license MIT\n  */const gn=typeof document<\"u\";function Tl(e){return typeof e==\"object\"||\"displayName\"in e||\"props\"in e||\"__vccOpts\"in e}function Kf(e){return e.__esModule||e[Symbol.toStringTag]===\"Module\"||e.default&&Tl(e.default)}const de=Object.assign;function gs(e,t){const n={};for(const r in t){const s=t[r];n[r]=ht(s)?s.map(e):e(s)}return n}const Kn=()=>{},ht=Array.isArray,wl=/#/g,Gf=/&/g,jf=/\\//g,qf=/=/g,Yf=/\\?/g,Sl=/\\+/g,Jf=/%5B/g,Xf=/%5D/g,Ll=/%5E/g,zf=/%60/g,Ol=/%7B/g,Qf=/%7C/g,Il=/%7D/g,Zf=/%20/g;function go(e){return encodeURI(\"\"+e).replace(Qf,\"|\").replace(Jf,\"[\").replace(Xf,\"]\")}function ed(e){return go(e).replace(Ol,\"{\").replace(Il,\"}\").replace(Ll,\"^\")}function Ms(e){return go(e).replace(Sl,\"%2B\").replace(Zf,\"+\").replace(wl,\"%23\").replace(Gf,\"%26\").replace(zf,\"`\").replace(Ol,\"{\").replace(Il,\"}\").replace(Ll,\"^\")}function td(e){return Ms(e).replace(qf,\"%3D\")}function nd(e){return go(e).replace(wl,\"%23\").replace(Yf,\"%3F\")}function rd(e){return e==null?\"\":nd(e).replace(jf,\"%2F\")}function er(e){try{return decodeURIComponent(\"\"+e)}catch{}return\"\"+e}const sd=/\\/$/,od=e=>e.replace(sd,\"\");function ps(e,t,n=\"/\"){let r,s={},o=\"\",a=\"\";const i=t.indexOf(\"#\");let l=t.indexOf(\"?\");return i<l&&i>=0&&(l=-1),l>-1&&(r=t.slice(0,l),o=t.slice(l+1,i>-1?i:t.length),s=e(o)),i>-1&&(r=r||t.slice(0,i),a=t.slice(i,t.length)),r=cd(r??t,n),{fullPath:r+(o&&\"?\")+o+a,path:r,query:s,hash:er(a)}}function ad(e,t){const n=t.query?e(t.query):\"\";return t.path+(n&&\"?\")+n+(t.hash||\"\")}function aa(e,t){return!t||!e.toLowerCase().startsWith(t.toLowerCase())?e:e.slice(t.length)||\"/\"}function id(e,t,n){const r=t.matched.length-1,s=n.matched.length-1;return r>-1&&r===s&&Ln(t.matched[r],n.matched[s])&&Al(t.params,n.params)&&e(t.query)===e(n.query)&&t.hash===n.hash}function Ln(e,t){return(e.aliasOf||e)===(t.aliasOf||t)}function Al(e,t){if(Object.keys(e).length!==Object.keys(t).length)return!1;for(const n in e)if(!ld(e[n],t[n]))return!1;return!0}function ld(e,t){return ht(e)?ia(e,t):ht(t)?ia(t,e):e===t}function ia(e,t){return ht(t)?e.length===t.length&&e.every((n,r)=>n===t[r]):e.length===1&&e[0]===t}function cd(e,t){if(e.startsWith(\"/\"))return e;if(!e)return t;const n=t.split(\"/\"),r=e.split(\"/\"),s=r[r.length-1];(s===\"..\"||s===\".\")&&r.push(\"\");let o=n.length-1,a,i;for(a=0;a<r.length;a++)if(i=r[a],i!==\".\")if(i===\"..\")o>1&&o--;else break;return n.slice(0,o).join(\"/\")+\"/\"+r.slice(a).join(\"/\")}const Rt={path:\"/\",name:void 0,params:{},query:{},hash:\"\",fullPath:\"/\",matched:[],meta:{},redirectedFrom:void 0};var tr;(function(e){e.pop=\"pop\",e.push=\"push\"})(tr||(tr={}));var Gn;(function(e){e.back=\"back\",e.forward=\"forward\",e.unknown=\"\"})(Gn||(Gn={}));function ud(e){if(!e)if(gn){const t=document.querySelector(\"base\");e=t&&t.getAttribute(\"href\")||\"/\",e=e.replace(/^\\w+:\\/\\/[^\\/]+/,\"\")}else e=\"/\";return e[0]!==\"/\"&&e[0]!==\"#\"&&(e=\"/\"+e),od(e)}const fd=/^[^#]+#/;function dd(e,t){return e.replace(fd,\"#\")+t}function hd(e,t){const n=document.documentElement.getBoundingClientRect(),r=e.getBoundingClientRect();return{behavior:t.behavior,left:r.left-n.left-(t.left||0),top:r.top-n.top-(t.top||0)}}const Yr=()=>({left:window.scrollX,top:window.scrollY});function md(e){let t;if(\"el\"in e){const n=e.el,r=typeof n==\"string\"&&n.startsWith(\"#\"),s=typeof n==\"string\"?r?document.getElementById(n.slice(1)):document.querySelector(n):n;if(!s)return;t=hd(s,e)}else t=e;\"scrollBehavior\"in document.documentElement.style?window.scrollTo(t):window.scrollTo(t.left!=null?t.left:window.scrollX,t.top!=null?t.top:window.scrollY)}function la(e,t){return(history.state?history.state.position-t:-1)+e}const Ds=new Map;function gd(e,t){Ds.set(e,t)}function pd(e){const t=Ds.get(e);return Ds.delete(e),t}let _d=()=>location.protocol+\"//\"+location.host;function Pl(e,t){const{pathname:n,search:r,hash:s}=t,o=e.indexOf(\"#\");if(o>-1){let i=s.includes(e.slice(o))?e.slice(o).length:1,l=s.slice(i);return l[0]!==\"/\"&&(l=\"/\"+l),aa(l,\"\")}return aa(n,e)+r+s}function bd(e,t,n,r){let s=[],o=[],a=null;const i=({state:m})=>{const _=Pl(e,location),S=n.value,T=t.value;let I=0;if(m){if(n.value=_,t.value=m,a&&a===S){a=null;return}I=T?m.position-T.position:0}else r(_);s.forEach(P=>{P(n.value,S,{delta:I,type:tr.pop,direction:I?I>0?Gn.forward:Gn.back:Gn.unknown})})};function l(){a=n.value}function c(m){s.push(m);const _=()=>{const S=s.indexOf(m);S>-1&&s.splice(S,1)};return o.push(_),_}function u(){const{history:m}=window;m.state&&m.replaceState(de({},m.state,{scroll:Yr()}),\"\")}function f(){for(const m of o)m();o=[],window.removeEventListener(\"popstate\",i),window.removeEventListener(\"beforeunload\",u)}return window.addEventListener(\"popstate\",i),window.addEventListener(\"beforeunload\",u,{passive:!0}),{pauseListeners:l,listen:c,destroy:f}}function ca(e,t,n,r=!1,s=!1){return{back:e,current:t,forward:n,replaced:r,position:window.history.length,scroll:s?Yr():null}}function yd(e){const{history:t,location:n}=window,r={value:Pl(e,n)},s={value:t.state};s.value||o(r.value,{back:null,current:r.value,forward:null,position:t.length-1,replaced:!0,scroll:null},!0);function o(l,c,u){const f=e.indexOf(\"#\"),m=f>-1?(n.host&&document.querySelector(\"base\")?e:e.slice(f))+l:_d()+e+l;try{t[u?\"replaceState\":\"pushState\"](c,\"\",m),s.value=c}catch(_){console.error(_),n[u?\"replace\":\"assign\"](m)}}function a(l,c){const u=de({},t.state,ca(s.value.back,l,s.value.forward,!0),c,{position:s.value.position});o(l,u,!0),r.value=l}function i(l,c){const u=de({},s.value,t.state,{forward:l,scroll:Yr()});o(u.current,u,!0);const f=de({},ca(r.value,l,null),{position:u.position+1},c);o(l,f,!1),r.value=l}return{location:r,state:s,push:i,replace:a}}function vd(e){e=ud(e);const t=yd(e),n=bd(e,t.state,t.location,t.replace);function r(o,a=!0){a||n.pauseListeners(),history.go(o)}const s=de({location:\"\",base:e,go:r,createHref:dd.bind(null,e)},t,n);return Object.defineProperty(s,\"location\",{enumerable:!0,get:()=>t.location.value}),Object.defineProperty(s,\"state\",{enumerable:!0,get:()=>t.state.value}),s}function Ed(e){return typeof e==\"string\"||e&&typeof e==\"object\"}function Rl(e){return typeof e==\"string\"||typeof e==\"symbol\"}const Cl=Symbol(\"\");var ua;(function(e){e[e.aborted=4]=\"aborted\",e[e.cancelled=8]=\"cancelled\",e[e.duplicated=16]=\"duplicated\"})(ua||(ua={}));function On(e,t){return de(new Error,{type:e,[Cl]:!0},t)}function Tt(e,t){return e instanceof Error&&Cl in e&&(t==null||!!(e.type&t))}const fa=\"[^/]+?\",kd={sensitive:!1,strict:!1,start:!0,end:!0},Td=/[.+*?^${}()[\\]/\\\\]/g;function wd(e,t){const n=de({},kd,t),r=[];let s=n.start?\"^\":\"\";const o=[];for(const c of e){const u=c.length?[]:[90];n.strict&&!c.length&&(s+=\"/\");for(let f=0;f<c.length;f++){const m=c[f];let _=40+(n.sensitive?.25:0);if(m.type===0)f||(s+=\"/\"),s+=m.value.replace(Td,\"\\\\$&\"),_+=40;else if(m.type===1){const{value:S,repeatable:T,optional:I,regexp:P}=m;o.push({name:S,repeatable:T,optional:I});const F=P||fa;if(F!==fa){_+=10;try{new RegExp(`(${F})`)}catch(v){throw new Error(`Invalid custom RegExp for param \"${S}\" (${F}): `+v.message)}}let y=T?`((?:${F})(?:/(?:${F}))*)`:`(${F})`;f||(y=I&&c.length<2?`(?:/${y})`:\"/\"+y),I&&(y+=\"?\"),s+=y,_+=20,I&&(_+=-8),T&&(_+=-20),F===\".*\"&&(_+=-50)}u.push(_)}r.push(u)}if(n.strict&&n.end){const c=r.length-1;r[c][r[c].length-1]+=.7000000000000001}n.strict||(s+=\"/?\"),n.end?s+=\"$\":n.strict&&!s.endsWith(\"/\")&&(s+=\"(?:/|$)\");const a=new RegExp(s,n.sensitive?\"\":\"i\");function i(c){const u=c.match(a),f={};if(!u)return null;for(let m=1;m<u.length;m++){const _=u[m]||\"\",S=o[m-1];f[S.name]=_&&S.repeatable?_.split(\"/\"):_}return f}function l(c){let u=\"\",f=!1;for(const m of e){(!f||!u.endsWith(\"/\"))&&(u+=\"/\"),f=!1;for(const _ of m)if(_.type===0)u+=_.value;else if(_.type===1){const{value:S,repeatable:T,optional:I}=_,P=S in c?c[S]:\"\";if(ht(P)&&!T)throw new Error(`Provided param \"${S}\" is an array but it is not repeatable (* or + modifiers)`);const F=ht(P)?P.join(\"/\"):P;if(!F)if(I)m.length<2&&(u.endsWith(\"/\")?u=u.slice(0,-1):f=!0);else throw new Error(`Missing required param \"${S}\"`);u+=F}}return u||\"/\"}return{re:a,score:r,keys:o,parse:i,stringify:l}}function Sd(e,t){let n=0;for(;n<e.length&&n<t.length;){const r=t[n]-e[n];if(r)return r;n++}return e.length<t.length?e.length===1&&e[0]===80?-1:1:e.length>t.length?t.length===1&&t[0]===80?1:-1:0}function Nl(e,t){let n=0;const r=e.score,s=t.score;for(;n<r.length&&n<s.length;){const o=Sd(r[n],s[n]);if(o)return o;n++}if(Math.abs(s.length-r.length)===1){if(da(r))return 1;if(da(s))return-1}return s.length-r.length}function da(e){const t=e[e.length-1];return e.length>0&&t[t.length-1]<0}const Ld={type:0,value:\"\"},Od=/[a-zA-Z0-9_]/;function Id(e){if(!e)return[[]];if(e===\"/\")return[[Ld]];if(!e.startsWith(\"/\"))throw new Error(`Invalid path \"${e}\"`);function t(_){throw new Error(`ERR (${n})/\"${c}\": ${_}`)}let n=0,r=n;const s=[];let o;function a(){o&&s.push(o),o=[]}let i=0,l,c=\"\",u=\"\";function f(){c&&(n===0?o.push({type:0,value:c}):n===1||n===2||n===3?(o.length>1&&(l===\"*\"||l===\"+\")&&t(`A repeatable param (${c}) must be alone in its segment. eg: '/:ids+.`),o.push({type:1,value:c,regexp:u,repeatable:l===\"*\"||l===\"+\",optional:l===\"*\"||l===\"?\"})):t(\"Invalid state to consume buffer\"),c=\"\")}function m(){c+=l}for(;i<e.length;){if(l=e[i++],l===\"\\\\\"&&n!==2){r=n,n=4;continue}switch(n){case 0:l===\"/\"?(c&&f(),a()):l===\":\"?(f(),n=1):m();break;case 4:m(),n=r;break;case 1:l===\"(\"?n=2:Od.test(l)?m():(f(),n=0,l!==\"*\"&&l!==\"?\"&&l!==\"+\"&&i--);break;case 2:l===\")\"?u[u.length-1]==\"\\\\\"?u=u.slice(0,-1)+l:n=3:u+=l;break;case 3:f(),n=0,l!==\"*\"&&l!==\"?\"&&l!==\"+\"&&i--,u=\"\";break;default:t(\"Unknown state\");break}}return n===2&&t(`Unfinished custom RegExp for param \"${c}\"`),f(),a(),s}function Ad(e,t,n){const r=wd(Id(e.path),n),s=de(r,{record:e,parent:t,children:[],alias:[]});return t&&!s.record.aliasOf==!t.record.aliasOf&&t.children.push(s),s}function Pd(e,t){const n=[],r=new Map;t=pa({strict:!1,end:!0,sensitive:!1},t);function s(f){return r.get(f)}function o(f,m,_){const S=!_,T=ma(f);T.aliasOf=_&&_.record;const I=pa(t,f),P=[T];if(\"alias\"in f){const v=typeof f.alias==\"string\"?[f.alias]:f.alias;for(const L of v)P.push(ma(de({},T,{components:_?_.record.components:T.components,path:L,aliasOf:_?_.record:T})))}let F,y;for(const v of P){const{path:L}=v;if(m&&L[0]!==\"/\"){const R=m.record.path,D=R[R.length-1]===\"/\"?\"\":\"/\";v.path=m.record.path+(L&&D+L)}if(F=Ad(v,m,I),_?_.alias.push(F):(y=y||F,y!==F&&y.alias.push(F),S&&f.name&&!ga(F)&&a(f.name)),xl(F)&&l(F),T.children){const R=T.children;for(let D=0;D<R.length;D++)o(R[D],F,_&&_.children[D])}_=_||F}return y?()=>{a(y)}:Kn}function a(f){if(Rl(f)){const m=r.get(f);m&&(r.delete(f),n.splice(n.indexOf(m),1),m.children.forEach(a),m.alias.forEach(a))}else{const m=n.indexOf(f);m>-1&&(n.splice(m,1),f.record.name&&r.delete(f.record.name),f.children.forEach(a),f.alias.forEach(a))}}function i(){return n}function l(f){const m=Nd(f,n);n.splice(m,0,f),f.record.name&&!ga(f)&&r.set(f.record.name,f)}function c(f,m){let _,S={},T,I;if(\"name\"in f&&f.name){if(_=r.get(f.name),!_)throw On(1,{location:f});I=_.record.name,S=de(ha(m.params,_.keys.filter(y=>!y.optional).concat(_.parent?_.parent.keys.filter(y=>y.optional):[]).map(y=>y.name)),f.params&&ha(f.params,_.keys.map(y=>y.name))),T=_.stringify(S)}else if(f.path!=null)T=f.path,_=n.find(y=>y.re.test(T)),_&&(S=_.parse(T),I=_.record.name);else{if(_=m.name?r.get(m.name):n.find(y=>y.re.test(m.path)),!_)throw On(1,{location:f,currentLocation:m});I=_.record.name,S=de({},m.params,f.params),T=_.stringify(S)}const P=[];let F=_;for(;F;)P.unshift(F.record),F=F.parent;return{name:I,path:T,params:S,matched:P,meta:Cd(P)}}e.forEach(f=>o(f));function u(){n.length=0,r.clear()}return{addRoute:o,resolve:c,removeRoute:a,clearRoutes:u,getRoutes:i,getRecordMatcher:s}}function ha(e,t){const n={};for(const r of t)r in e&&(n[r]=e[r]);return n}function ma(e){const t={path:e.path,redirect:e.redirect,name:e.name,meta:e.meta||{},aliasOf:e.aliasOf,beforeEnter:e.beforeEnter,props:Rd(e),children:e.children||[],instances:{},leaveGuards:new Set,updateGuards:new Set,enterCallbacks:{},components:\"components\"in e?e.components||null:e.component&&{default:e.component}};return Object.defineProperty(t,\"mods\",{value:{}}),t}function Rd(e){const t={},n=e.props||!1;if(\"component\"in e)t.default=n;else for(const r in e.components)t[r]=typeof n==\"object\"?n[r]:n;return t}function ga(e){for(;e;){if(e.record.aliasOf)return!0;e=e.parent}return!1}function Cd(e){return e.reduce((t,n)=>de(t,n.meta),{})}function pa(e,t){const n={};for(const r in e)n[r]=r in t?t[r]:e[r];return n}function Nd(e,t){let n=0,r=t.length;for(;n!==r;){const o=n+r>>1;Nl(e,t[o])<0?r=o:n=o+1}const s=xd(e);return s&&(r=t.lastIndexOf(s,r-1)),r}function xd(e){let t=e;for(;t=t.parent;)if(xl(t)&&Nl(e,t)===0)return t}function xl({record:e}){return!!(e.name||e.components&&Object.keys(e.components).length||e.redirect)}function Md(e){const t={};if(e===\"\"||e===\"?\")return t;const r=(e[0]===\"?\"?e.slice(1):e).split(\"&\");for(let s=0;s<r.length;++s){const o=r[s].replace(Sl,\" \"),a=o.indexOf(\"=\"),i=er(a<0?o:o.slice(0,a)),l=a<0?null:er(o.slice(a+1));if(i in t){let c=t[i];ht(c)||(c=t[i]=[c]),c.push(l)}else t[i]=l}return t}function _a(e){let t=\"\";for(let n in e){const r=e[n];if(n=td(n),r==null){r!==void 0&&(t+=(t.length?\"&\":\"\")+n);continue}(ht(r)?r.map(o=>o&&Ms(o)):[r&&Ms(r)]).forEach(o=>{o!==void 0&&(t+=(t.length?\"&\":\"\")+n,o!=null&&(t+=\"=\"+o))})}return t}function Dd(e){const t={};for(const n in e){const r=e[n];r!==void 0&&(t[n]=ht(r)?r.map(s=>s==null?null:\"\"+s):r==null?r:\"\"+r)}return t}const Fd=Symbol(\"\"),ba=Symbol(\"\"),Jr=Symbol(\"\"),po=Symbol(\"\"),Fs=Symbol(\"\");function Mn(){let e=[];function t(r){return e.push(r),()=>{const s=e.indexOf(r);s>-1&&e.splice(s,1)}}function n(){e=[]}return{add:t,list:()=>e.slice(),reset:n}}function Ft(e,t,n,r,s,o=a=>a()){const a=r&&(r.enterCallbacks[s]=r.enterCallbacks[s]||[]);return()=>new Promise((i,l)=>{const c=m=>{m===!1?l(On(4,{from:n,to:t})):m instanceof Error?l(m):Ed(m)?l(On(2,{from:t,to:m})):(a&&r.enterCallbacks[s]===a&&typeof m==\"function\"&&a.push(m),i())},u=o(()=>e.call(r&&r.instances[s],t,n,c));let f=Promise.resolve(u);e.length<3&&(f=f.then(c)),f.catch(m=>l(m))})}function _s(e,t,n,r,s=o=>o()){const o=[];for(const a of e)for(const i in a.components){let l=a.components[i];if(!(t!==\"beforeRouteEnter\"&&!a.instances[i]))if(Tl(l)){const u=(l.__vccOpts||l)[t];u&&o.push(Ft(u,n,r,a,i,s))}else{let c=l();o.push(()=>c.then(u=>{if(!u)throw new Error(`Couldn't resolve component \"${i}\" at \"${a.path}\"`);const f=Kf(u)?u.default:u;a.mods[i]=u,a.components[i]=f;const _=(f.__vccOpts||f)[t];return _&&Ft(_,n,r,a,i,s)()}))}}return o}function ya(e){const t=rt(Jr),n=rt(po),r=we(()=>{const l=le(e.to);return t.resolve(l)}),s=we(()=>{const{matched:l}=r.value,{length:c}=l,u=l[c-1],f=n.matched;if(!u||!f.length)return-1;const m=f.findIndex(Ln.bind(null,u));if(m>-1)return m;const _=va(l[c-2]);return c>1&&va(u)===_&&f[f.length-1].path!==_?f.findIndex(Ln.bind(null,l[c-2])):m}),o=we(()=>s.value>-1&&Hd(n.params,r.value.params)),a=we(()=>s.value>-1&&s.value===n.matched.length-1&&Al(n.params,r.value.params));function i(l={}){if(Vd(l)){const c=t[le(e.replace)?\"replace\":\"push\"](le(e.to)).catch(Kn);return e.viewTransition&&typeof document<\"u\"&&\"startViewTransition\"in document&&document.startViewTransition(()=>c),c}return Promise.resolve()}return{route:r,href:we(()=>r.value.href),isActive:o,isExactActive:a,navigate:i}}function $d(e){return e.length===1?e[0]:e}const Ud=it({name:\"RouterLink\",compatConfig:{MODE:3},props:{to:{type:[String,Object],required:!0},replace:Boolean,activeClass:String,exactActiveClass:String,custom:Boolean,ariaCurrentValue:{type:String,default:\"page\"}},useLink:ya,setup(e,{slots:t}){const n=ar(ya(e)),{options:r}=rt(Jr),s=we(()=>({[Ea(e.activeClass,r.linkActiveClass,\"router-link-active\")]:n.isActive,[Ea(e.exactActiveClass,r.linkExactActiveClass,\"router-link-exact-active\")]:n.isExactActive}));return()=>{const o=t.default&&$d(t.default(n));return e.custom?o:Gr(\"a\",{\"aria-current\":n.isExactActive?e.ariaCurrentValue:null,href:n.href,onClick:n.navigate,class:s.value},o)}}}),Er=Ud;function Vd(e){if(!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)&&!e.defaultPrevented&&!(e.button!==void 0&&e.button!==0)){if(e.currentTarget&&e.currentTarget.getAttribute){const t=e.currentTarget.getAttribute(\"target\");if(/\\b_blank\\b/i.test(t))return}return e.preventDefault&&e.preventDefault(),!0}}function Hd(e,t){for(const n in t){const r=t[n],s=e[n];if(typeof r==\"string\"){if(r!==s)return!1}else if(!ht(s)||s.length!==r.length||r.some((o,a)=>o!==s[a]))return!1}return!0}function va(e){return e?e.aliasOf?e.aliasOf.path:e.path:\"\"}const Ea=(e,t,n)=>e??t??n,Wd=it({name:\"RouterView\",inheritAttrs:!1,props:{name:{type:String,default:\"default\"},route:Object},compatConfig:{MODE:3},setup(e,{attrs:t,slots:n}){const r=rt(Fs),s=we(()=>e.route||r.value),o=rt(ba,0),a=we(()=>{let c=le(o);const{matched:u}=s.value;let f;for(;(f=u[c])&&!f.components;)c++;return c}),i=we(()=>s.value.matched[a.value]);br(ba,we(()=>a.value+1)),br(Fd,i),br(Fs,s);const l=ge();return Vt(()=>[l.value,i.value,e.name],([c,u,f],[m,_,S])=>{u&&(u.instances[f]=c,_&&_!==u&&c&&c===m&&(u.leaveGuards.size||(u.leaveGuards=_.leaveGuards),u.updateGuards.size||(u.updateGuards=_.updateGuards))),c&&u&&(!_||!Ln(u,_)||!m)&&(u.enterCallbacks[f]||[]).forEach(T=>T(c))},{flush:\"post\"}),()=>{const c=s.value,u=e.name,f=i.value,m=f&&f.components[u];if(!m)return ka(n.default,{Component:m,route:c});const _=f.props[u],S=_?_===!0?c.params:typeof _==\"function\"?_(c):_:null,I=Gr(m,de({},S,t,{onVnodeUnmounted:P=>{P.component.isUnmounted&&(f.instances[u]=null)},ref:l}));return ka(n.default,{Component:I,route:c})||I}}});function ka(e,t){if(!e)return null;const n=e(t);return n.length===1?n[0]:n}const Ml=Wd;function Bd(e){const t=Pd(e.routes,e),n=e.parseQuery||Md,r=e.stringifyQuery||_a,s=e.history,o=Mn(),a=Mn(),i=Mn(),l=lo(Rt);let c=Rt;gn&&e.scrollBehavior&&\"scrollRestoration\"in history&&(history.scrollRestoration=\"manual\");const u=gs.bind(null,C=>\"\"+C),f=gs.bind(null,rd),m=gs.bind(null,er);function _(C,j){let B,q;return Rl(C)?(B=t.getRecordMatcher(C),q=j):q=C,t.addRoute(q,B)}function S(C){const j=t.getRecordMatcher(C);j&&t.removeRoute(j)}function T(){return t.getRoutes().map(C=>C.record)}function I(C){return!!t.getRecordMatcher(C)}function P(C,j){if(j=de({},j||l.value),typeof C==\"string\"){const g=ps(n,C,j.path),w=t.resolve({path:g.path},j),x=s.createHref(g.fullPath);return de(g,w,{params:m(w.params),hash:er(g.hash),redirectedFrom:void 0,href:x})}let B;if(C.path!=null)B=de({},C,{path:ps(n,C.path,j.path).path});else{const g=de({},C.params);for(const w in g)g[w]==null&&delete g[w];B=de({},C,{params:f(g)}),j.params=f(j.params)}const q=t.resolve(B,j),ae=C.hash||\"\";q.params=u(m(q.params));const p=ad(r,de({},C,{hash:ed(ae),path:q.path})),b=s.createHref(p);return de({fullPath:p,hash:ae,query:r===_a?Dd(C.query):C.query||{}},q,{redirectedFrom:void 0,href:b})}function F(C){return typeof C==\"string\"?ps(n,C,l.value.path):de({},C)}function y(C,j){if(c!==C)return On(8,{from:j,to:C})}function v(C){return D(C)}function L(C){return v(de(F(C),{replace:!0}))}function R(C){const j=C.matched[C.matched.length-1];if(j&&j.redirect){const{redirect:B}=j;let q=typeof B==\"function\"?B(C):B;return typeof q==\"string\"&&(q=q.includes(\"?\")||q.includes(\"#\")?q=F(q):{path:q},q.params={}),de({query:C.query,hash:C.hash,params:q.path!=null?{}:C.params},q)}}function D(C,j){const B=c=P(C),q=l.value,ae=C.state,p=C.force,b=C.replace===!0,g=R(B);if(g)return D(de(F(g),{state:typeof g==\"object\"?de({},ae,g.state):ae,force:p,replace:b}),j||B);const w=B;w.redirectedFrom=j;let x;return!p&&id(r,q,B)&&(x=On(16,{to:w,from:q}),Se(q,q,!0,!1)),(x?Promise.resolve(x):ee(w,q)).catch(N=>Tt(N)?Tt(N,2)?N:Xe(N):re(N,w,q)).then(N=>{if(N){if(Tt(N,2))return D(de({replace:b},F(N.to),{state:typeof N.to==\"object\"?de({},ae,N.to.state):ae,force:p}),j||w)}else N=z(w,q,!0,b,ae);return me(w,q,N),N})}function $(C,j){const B=y(C,j);return B?Promise.reject(B):Promise.resolve()}function M(C){const j=Ze.values().next().value;return j&&typeof j.runWithContext==\"function\"?j.runWithContext(C):C()}function ee(C,j){let B;const[q,ae,p]=Kd(C,j);B=_s(q.reverse(),\"beforeRouteLeave\",C,j);for(const g of q)g.leaveGuards.forEach(w=>{B.push(Ft(w,C,j))});const b=$.bind(null,C,j);return B.push(b),xe(B).then(()=>{B=[];for(const g of o.list())B.push(Ft(g,C,j));return B.push(b),xe(B)}).then(()=>{B=_s(ae,\"beforeRouteUpdate\",C,j);for(const g of ae)g.updateGuards.forEach(w=>{B.push(Ft(w,C,j))});return B.push(b),xe(B)}).then(()=>{B=[];for(const g of p)if(g.beforeEnter)if(ht(g.beforeEnter))for(const w of g.beforeEnter)B.push(Ft(w,C,j));else B.push(Ft(g.beforeEnter,C,j));return B.push(b),xe(B)}).then(()=>(C.matched.forEach(g=>g.enterCallbacks={}),B=_s(p,\"beforeRouteEnter\",C,j,M),B.push(b),xe(B))).then(()=>{B=[];for(const g of a.list())B.push(Ft(g,C,j));return B.push(b),xe(B)}).catch(g=>Tt(g,8)?g:Promise.reject(g))}function me(C,j,B){i.list().forEach(q=>M(()=>q(C,j,B)))}function z(C,j,B,q,ae){const p=y(C,j);if(p)return p;const b=j===Rt,g=gn?history.state:{};B&&(q||b?s.replace(C.fullPath,de({scroll:b&&g&&g.scroll},ae)):s.push(C.fullPath,ae)),l.value=C,Se(C,j,B,b),Xe()}let ue;function Qe(){ue||(ue=s.listen((C,j,B)=>{if(!mt.listening)return;const q=P(C),ae=R(q);if(ae){D(de(ae,{replace:!0,force:!0}),q).catch(Kn);return}c=q;const p=l.value;gn&&gd(la(p.fullPath,B.delta),Yr()),ee(q,p).catch(b=>Tt(b,12)?b:Tt(b,2)?(D(de(F(b.to),{force:!0}),q).then(g=>{Tt(g,20)&&!B.delta&&B.type===tr.pop&&s.go(-1,!1)}).catch(Kn),Promise.reject()):(B.delta&&s.go(-B.delta,!1),re(b,q,p))).then(b=>{b=b||z(q,p,!1),b&&(B.delta&&!Tt(b,8)?s.go(-B.delta,!1):B.type===tr.pop&&Tt(b,20)&&s.go(-1,!1)),me(q,p,b)}).catch(Kn)}))}let Je=Mn(),se=Mn(),Q;function re(C,j,B){Xe(C);const q=se.list();return q.length?q.forEach(ae=>ae(C,j,B)):console.error(C),Promise.reject(C)}function Be(){return Q&&l.value!==Rt?Promise.resolve():new Promise((C,j)=>{Je.add([C,j])})}function Xe(C){return Q||(Q=!C,Qe(),Je.list().forEach(([j,B])=>C?B(C):j()),Je.reset()),C}function Se(C,j,B,q){const{scrollBehavior:ae}=e;if(!gn||!ae)return Promise.resolve();const p=!B&&pd(la(C.fullPath,0))||(q||!B)&&history.state&&history.state.scroll||null;return co().then(()=>ae(C,j,p)).then(b=>b&&md(b)).catch(b=>re(b,C,j))}const Le=C=>s.go(C);let st;const Ze=new Set,mt={currentRoute:l,listening:!0,addRoute:_,removeRoute:S,clearRoutes:t.clearRoutes,hasRoute:I,getRoutes:T,resolve:P,options:e,push:v,replace:L,go:Le,back:()=>Le(-1),forward:()=>Le(1),beforeEach:o.add,beforeResolve:a.add,afterEach:i.add,onError:se.add,isReady:Be,install(C){const j=this;C.component(\"RouterLink\",Er),C.component(\"RouterView\",Ml),C.config.globalProperties.$router=j,Object.defineProperty(C.config.globalProperties,\"$route\",{enumerable:!0,get:()=>le(l)}),gn&&!st&&l.value===Rt&&(st=!0,v(s.location).catch(ae=>{}));const B={};for(const ae in Rt)Object.defineProperty(B,ae,{get:()=>l.value[ae],enumerable:!0});C.provide(Jr,j),C.provide(po,xi(B)),C.provide(Fs,l);const q=C.unmount;Ze.add(C),C.unmount=function(){Ze.delete(C),Ze.size<1&&(c=Rt,ue&&ue(),ue=null,l.value=Rt,st=!1,Q=!1),q()}}};function xe(C){return C.reduce((j,B)=>j.then(()=>M(B)),Promise.resolve())}return mt}function Kd(e,t){const n=[],r=[],s=[],o=Math.max(t.matched.length,e.matched.length);for(let a=0;a<o;a++){const i=t.matched[a];i&&(e.matched.find(c=>Ln(c,i))?r.push(i):n.push(i));const l=e.matched[a];l&&(t.matched.find(c=>Ln(c,l))||s.push(l))}return[n,r,s]}function Xr(){return rt(Jr)}function Gd(e){return rt(po)}const jd=\"http://localhost\".replace(/\\/+$/,\"\");class Dl{constructor(t={}){this.configuration=t}set config(t){this.configuration=t}get basePath(){return this.configuration.basePath!=null?this.configuration.basePath:jd}get fetchApi(){return this.configuration.fetchApi}get middleware(){return this.configuration.middleware||[]}get queryParamsStringify(){return this.configuration.queryParamsStringify||Fl}get username(){return this.configuration.username}get password(){return this.configuration.password}get apiKey(){const t=this.configuration.apiKey;if(t)return typeof t==\"function\"?t:()=>t}get accessToken(){const t=this.configuration.accessToken;if(t)return typeof t==\"function\"?t:async()=>t}get headers(){return this.configuration.headers}get credentials(){return this.configuration.credentials}}const qd=new Dl,Nr=class Nr{constructor(t=qd){Qt(this,\"middleware\");Qt(this,\"fetchApi\",async(t,n)=>{let r={url:t,init:n};for(const o of this.middleware)o.pre&&(r=await o.pre({fetch:this.fetchApi,...r})||r);let s;try{s=await(this.configuration.fetchApi||fetch)(r.url,r.init)}catch(o){for(const a of this.middleware)a.onError&&(s=await a.onError({fetch:this.fetchApi,url:r.url,init:r.init,error:o,response:s?s.clone():void 0})||s);if(s===void 0)throw o instanceof Error?new zd(o,\"The request failed and the interceptors did not return an alternative response\"):o}for(const o of this.middleware)o.post&&(s=await o.post({fetch:this.fetchApi,url:r.url,init:r.init,response:s.clone()})||s);return s});this.configuration=t,this.middleware=t.middleware}withMiddleware(...t){const n=this.clone();return n.middleware=n.middleware.concat(...t),n}withPreMiddleware(...t){const n=t.map(r=>({pre:r}));return this.withMiddleware(...n)}withPostMiddleware(...t){const n=t.map(r=>({post:r}));return this.withMiddleware(...n)}isJsonMime(t){return t?Nr.jsonRegex.test(t):!1}async request(t,n){const{url:r,init:s}=await this.createFetchParams(t,n),o=await this.fetchApi(r,s);if(o&&o.status>=200&&o.status<300)return o;throw new Xd(o,\"Response returned an error code\")}async createFetchParams(t,n){let r=this.configuration.basePath+t.path;t.query!==void 0&&Object.keys(t.query).length!==0&&(r+=\"?\"+this.configuration.queryParamsStringify(t.query));const s=Object.assign({},this.configuration.headers,t.headers);Object.keys(s).forEach(u=>s[u]===void 0?delete s[u]:{});const o=typeof n==\"function\"?n:async()=>n,a={method:t.method,headers:s,body:t.body,credentials:this.configuration.credentials},i={...a,...await o({init:a,context:t})};let l;Jd(i.body)||i.body instanceof URLSearchParams||Yd(i.body)?l=i.body:this.isJsonMime(s[\"Content-Type\"])?l=JSON.stringify(i.body):l=i.body;const c={...i,body:l};return{url:r,init:c}}clone(){const t=this.constructor,n=new t(this.configuration);return n.middleware=this.middleware.slice(),n}};Qt(Nr,\"jsonRegex\",new RegExp(\"^(:?application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(:?;.*)?$\",\"i\"));let $s=Nr;function Yd(e){return typeof Blob<\"u\"&&e instanceof Blob}function Jd(e){return typeof FormData<\"u\"&&e instanceof FormData}class Xd extends Error{constructor(n,r){super(r);Qt(this,\"name\",\"ResponseError\");this.response=n}}class zd extends Error{constructor(n,r){super(r);Qt(this,\"name\",\"FetchError\");this.cause=n}}class tn extends Error{constructor(n,r){super(r);Qt(this,\"name\",\"RequiredError\");this.field=n}}function Fl(e,t=\"\"){return Object.keys(e).map(n=>$l(n,e[n],t)).filter(n=>n.length>0).join(\"&\")}function $l(e,t,n=\"\"){const r=n+(n.length?`[${e}]`:e);if(t instanceof Array){const s=t.map(o=>encodeURIComponent(String(o))).join(`&${encodeURIComponent(r)}=`);return`${encodeURIComponent(r)}=${s}`}if(t instanceof Set){const s=Array.from(t);return $l(e,s,n)}return t instanceof Date?`${encodeURIComponent(r)}=${encodeURIComponent(t.toISOString())}`:t instanceof Object?Fl(t,r):`${encodeURIComponent(r)}=${encodeURIComponent(String(t))}`}class Ct{constructor(t,n=r=>r){this.raw=t,this.transformer=n}async value(){return this.transformer(await this.raw.json())}}class bs{constructor(t){this.raw=t}async value(){}}function Ta(e){return Qd(e,!1)}function Qd(e,t=!1){return e==null?e:{tag_id:e.tagId}}function Zd(e){return eh(e,!1)}function eh(e,t=!1){return e==null?e:{bookmark_ids:e.bookmarkIds,tag_ids:e.tagIds}}function th(e){return nh(e,!1)}function nh(e,t=!1){return e==null?e:{password:e.password,remember_me:e.rememberMe,username:e.username}}function wa(e){return rh(e)}function rh(e,t){return e==null?e:{expires:e.expires==null?void 0:e.expires,token:e.token==null?void 0:e.token}}function sh(e){return oh(e)}function oh(e,t){return e==null?e:{content:e.content==null?void 0:e.content,html:e.html==null?void 0:e.html}}function ah(e){return ih(e)}function ih(e,t){return e==null?e:{createEbook:e.createEbook==null?void 0:e.createEbook,hideExcerpt:e.hideExcerpt==null?void 0:e.hideExcerpt,hideThumbnail:e.hideThumbnail==null?void 0:e.hideThumbnail,keepMetadata:e.keepMetadata==null?void 0:e.keepMetadata,listMode:e.listMode==null?void 0:e.listMode,makePublic:e.makePublic==null?void 0:e.makePublic,showId:e.showId==null?void 0:e.showId,theme:e.theme==null?void 0:e.theme,useArchive:e.useArchive==null?void 0:e.useArchive}}function lh(e){return ch(e,!1)}function ch(e,t=!1){return e==null?e:{createEbook:e.createEbook,hideExcerpt:e.hideExcerpt,hideThumbnail:e.hideThumbnail,keepMetadata:e.keepMetadata,listMode:e.listMode,makePublic:e.makePublic,showId:e.showId,theme:e.theme,useArchive:e.useArchive}}function uh(e){return fh(e,!1)}function fh(e,t=!1){return e==null?e:{config:lh(e.config),new_password:e.newPassword,old_password:e.oldPassword,owner:e.owner,username:e.username}}function dh(e){return hh(e,!1)}function hh(e,t=!1){return e==null?e:{create_archive:e.createArchive,create_ebook:e.createEbook,ids:e.ids,keep_metadata:e.keepMetadata,skip_exist:e.skipExist}}function Sa(e){return mh(e)}function mh(e,t){return e==null?e:{config:e.config==null?void 0:ah(e.config),id:e.id==null?void 0:e.id,owner:e.owner==null?void 0:e.owner,password:e.password==null?void 0:e.password,username:e.username==null?void 0:e.username}}function Ul(e){return gh(e)}function gh(e,t){return e==null?e:{bookmarkCount:e.bookmark_count==null?void 0:e.bookmark_count,deleted:e.deleted==null?void 0:e.deleted,id:e.id==null?void 0:e.id,name:e.name==null?void 0:e.name}}function $_(e){return ph(e,!1)}function ph(e,t=!1){return e==null?e:{bookmark_count:e.bookmarkCount,deleted:e.deleted,id:e.id,name:e.name}}function La(e){return _h(e)}function _h(e,t){return e==null?e:{author:e.author==null?void 0:e.author,createArchive:e.create_archive==null?void 0:e.create_archive,createEbook:e.create_ebook==null?void 0:e.create_ebook,createdAt:e.createdAt==null?void 0:e.createdAt,excerpt:e.excerpt==null?void 0:e.excerpt,hasArchive:e.hasArchive==null?void 0:e.hasArchive,hasContent:e.hasContent==null?void 0:e.hasContent,hasEbook:e.hasEbook==null?void 0:e.hasEbook,html:e.html==null?void 0:e.html,id:e.id==null?void 0:e.id,imageURL:e.imageURL==null?void 0:e.imageURL,modifiedAt:e.modifiedAt==null?void 0:e.modifiedAt,_public:e.public==null?void 0:e.public,tags:e.tags==null?void 0:e.tags.map(Ul),title:e.title==null?void 0:e.title,url:e.url==null?void 0:e.url}}class bh extends $s{async apiV1AuthAccountPatchRaw(t,n){const r={},s={};s[\"Content-Type\"]=\"application/json\";const o=await this.request({path:\"/api/v1/auth/account\",method:\"PATCH\",headers:s,query:r,body:uh(t.payload)},n);return new Ct(o,a=>Sa(a))}async apiV1AuthAccountPatch(t={},n){return await(await this.apiV1AuthAccountPatchRaw(t,n)).value()}async apiV1AuthLoginPostRaw(t,n){const r={},s={};s[\"Content-Type\"]=\"application/json\";const o=await this.request({path:\"/api/v1/auth/login\",method:\"POST\",headers:s,query:r,body:th(t.payload)},n);return new Ct(o,a=>wa(a))}async apiV1AuthLoginPost(t={},n){return await(await this.apiV1AuthLoginPostRaw(t,n)).value()}async apiV1AuthLogoutPostRaw(t){const n={},r={},s=await this.request({path:\"/api/v1/auth/logout\",method:\"POST\",headers:r,query:n},t);return new bs(s)}async apiV1AuthLogoutPost(t){await this.apiV1AuthLogoutPostRaw(t)}async apiV1AuthMeGetRaw(t){const n={},r={},s=await this.request({path:\"/api/v1/auth/me\",method:\"GET\",headers:r,query:n},t);return new Ct(s,o=>Sa(o))}async apiV1AuthMeGet(t){return await(await this.apiV1AuthMeGetRaw(t)).value()}async apiV1AuthRefreshPostRaw(t){const n={},r={},s=await this.request({path:\"/api/v1/auth/refresh\",method:\"POST\",headers:r,query:n},t);return new Ct(s,o=>wa(o))}async apiV1AuthRefreshPost(t){return await(await this.apiV1AuthRefreshPostRaw(t)).value()}async apiV1BookmarksBulkTagsPutRaw(t,n){if(t.payload==null)throw new tn(\"payload\",'Required parameter \"payload\" was null or undefined when calling apiV1BookmarksBulkTagsPut().');const r={},s={};s[\"Content-Type\"]=\"application/json\";const o=await this.request({path:\"/api/v1/bookmarks/bulk/tags\",method:\"PUT\",headers:s,query:r,body:Zd(t.payload)},n);return new Ct(o,a=>a.map(La))}async apiV1BookmarksBulkTagsPut(t,n){return await(await this.apiV1BookmarksBulkTagsPutRaw(t,n)).value()}async apiV1BookmarksCachePutRaw(t,n){if(t.payload==null)throw new tn(\"payload\",'Required parameter \"payload\" was null or undefined when calling apiV1BookmarksCachePut().');const r={},s={};s[\"Content-Type\"]=\"application/json\";const o=await this.request({path:\"/api/v1/bookmarks/cache\",method:\"PUT\",headers:s,query:r,body:dh(t.payload)},n);return new Ct(o,a=>La(a))}async apiV1BookmarksCachePut(t,n){return await(await this.apiV1BookmarksCachePutRaw(t,n)).value()}async apiV1BookmarksIdReadableGetRaw(t){const n={},r={},s=await this.request({path:\"/api/v1/bookmarks/id/readable\",method:\"GET\",headers:r,query:n},t);return new Ct(s,o=>sh(o))}async apiV1BookmarksIdReadableGet(t){return await(await this.apiV1BookmarksIdReadableGetRaw(t)).value()}async apiV1BookmarksIdTagsDeleteRaw(t,n){if(t.id==null)throw new tn(\"id\",'Required parameter \"id\" was null or undefined when calling apiV1BookmarksIdTagsDelete().');if(t.payload==null)throw new tn(\"payload\",'Required parameter \"payload\" was null or undefined when calling apiV1BookmarksIdTagsDelete().');const r={},s={};s[\"Content-Type\"]=\"application/json\";const o=await this.request({path:\"/api/v1/bookmarks/{id}/tags\".replace(\"{id}\",encodeURIComponent(String(t.id))),method:\"DELETE\",headers:s,query:r,body:Ta(t.payload)},n);return new bs(o)}async apiV1BookmarksIdTagsDelete(t,n){await this.apiV1BookmarksIdTagsDeleteRaw(t,n)}async apiV1BookmarksIdTagsGetRaw(t,n){if(t.id==null)throw new tn(\"id\",'Required parameter \"id\" was null or undefined when calling apiV1BookmarksIdTagsGet().');const r={},s={},o=await this.request({path:\"/api/v1/bookmarks/{id}/tags\".replace(\"{id}\",encodeURIComponent(String(t.id))),method:\"GET\",headers:s,query:r},n);return new Ct(o,a=>a.map(Ul))}async apiV1BookmarksIdTagsGet(t,n){return await(await this.apiV1BookmarksIdTagsGetRaw(t,n)).value()}async apiV1BookmarksIdTagsPostRaw(t,n){if(t.id==null)throw new tn(\"id\",'Required parameter \"id\" was null or undefined when calling apiV1BookmarksIdTagsPost().');if(t.payload==null)throw new tn(\"payload\",'Required parameter \"payload\" was null or undefined when calling apiV1BookmarksIdTagsPost().');const r={},s={};s[\"Content-Type\"]=\"application/json\";const o=await this.request({path:\"/api/v1/bookmarks/{id}/tags\".replace(\"{id}\",encodeURIComponent(String(t.id))),method:\"POST\",headers:s,query:r,body:Ta(t.payload)},n);return new bs(o)}async apiV1BookmarksIdTagsPost(t,n){await this.apiV1BookmarksIdTagsPostRaw(t,n)}}const ur=Bf(\"auth\",()=>{const e=ge(localStorage.getItem(\"token\")),t=ge(Number(localStorage.getItem(\"expires\"))||null),n=ge(null),r=ge(!1),s=ge(null),o=ge(null),a=we(()=>!e.value||!t.value?!1:t.value>Date.now()),i=()=>{const I=new Dl({basePath:\"http://localhost:8080\",accessToken:e.value||void 0,headers:e.value?{Authorization:`Bearer ${e.value}`,\"X-Shiori-Response-Format\":\"new\"}:void 0});return new bh(I)},l=async()=>{if(!e.value)return!1;r.value=!0;try{const I=await u();return r.value=!1,!!I}catch{return r.value=!1,!1}},c=async(I,P,F=!1)=>{r.value=!0,s.value=null;try{const y={username:I,password:P,rememberMe:F},L=await i().apiV1AuthLoginPost({payload:y});if(L.token)return e.value=L.token,t.value=L.expires||0,localStorage.setItem(\"token\",L.token),localStorage.setItem(\"expires\",String(L.expires)),await u(),!0;throw new Error(\"Invalid response from server\")}catch(y){if(console.error(\"Login error:\",y),y.response)try{const v=await y.response.json();v&&v.message?s.value=v.message:v&&v.error?s.value=v.error:typeof v==\"string\"?s.value=v:s.value=`Server error: ${y.response.status}`}catch{s.value=y.response.statusText||`Server error: ${y.response.status}`}else s.value=y.message||\"Failed to login\";return!1}finally{r.value=!1}},u=async()=>{if(!e.value)return null;try{const P=await i().apiV1AuthMeGet();if(P)return n.value=P,n.value;throw new Error(\"Failed to fetch user info\")}catch(I){return console.error(\"Error fetching user info:\",I),I.response&&I.response.status===401&&f(),null}},f=()=>{e.value=null,t.value=null,n.value=null,localStorage.removeItem(\"token\"),localStorage.removeItem(\"expires\")};return{token:e,expires:t,user:n,loading:r,error:s,isAuthenticated:a,login:c,logout:async()=>{r.value=!0;try{e.value&&await i().apiV1AuthLogoutPost()}catch(I){console.error(\"Logout error:\",I)}finally{f(),r.value=!1}},fetchUserInfo:u,refreshToken:async()=>{if(!e.value)return!1;try{const P=await i().apiV1AuthRefreshPost();return P.token?(e.value=P.token,t.value=P.expires||0,localStorage.setItem(\"token\",P.token),localStorage.setItem(\"expires\",String(P.expires)),!0):!1}catch(I){return console.error(\"Token refresh error:\",I),!1}},validateToken:l,setRedirectDestination:I=>{o.value=I},getAndClearRedirectDestination:()=>{const I=o.value;return o.value=null,I},clearAuth:f}}),yh={class:\"min-h-screen h-full flex flex-col bg-[var(--background-color)] text-[var(--text-color)]\"},vh={key:0,class:\"fixed inset-0 flex items-center justify-center bg-white dark:bg-gray-900 bg-opacity-80 dark:bg-opacity-80 z-50\"},Eh=it({__name:\"App\",setup(e){const t=ur();Xr();const n=ge(!0);return qt(async()=>{if(t.token)try{await t.validateToken()}catch(r){console.error(\"Failed to validate token:\",r)}n.value=!1}),(r,s)=>(fe(),Ee(\"div\",yh,[n.value?(fe(),Ee(\"div\",vh,s[0]||(s[0]=[H(\"div\",{class:\"text-center\"},[H(\"div\",{class:\"animate-spin rounded-full h-12 w-12 border-b-2 border-red-500 mx-auto mb-2\"}),H(\"p\",{class:\"text-gray-700 dark:text-gray-300\"},\"Loading...\")],-1)]))):(fe(),wn(le(Ml),{key:1,class:\"flex-1\"}))]))}}),kh=\"modulepreload\",Th=function(e){return\"/\"+e},Oa={},mr=function(t,n,r){let s=Promise.resolve();if(n&&n.length>0){document.getElementsByTagName(\"link\");const a=document.querySelector(\"meta[property=csp-nonce]\"),i=(a==null?void 0:a.nonce)||(a==null?void 0:a.getAttribute(\"nonce\"));s=Promise.allSettled(n.map(l=>{if(l=Th(l),l in Oa)return;Oa[l]=!0;const c=l.endsWith(\".css\"),u=c?'[rel=\"stylesheet\"]':\"\";if(document.querySelector(`link[href=\"${l}\"]${u}`))return;const f=document.createElement(\"link\");if(f.rel=c?\"stylesheet\":kh,c||(f.as=\"script\"),f.crossOrigin=\"\",f.href=l,i&&f.setAttribute(\"nonce\",i),document.head.appendChild(f),c)return new Promise((m,_)=>{f.addEventListener(\"load\",m),f.addEventListener(\"error\",()=>_(new Error(`Unable to preload CSS for ${l}`)))})}))}function o(a){const i=new Event(\"vite:preloadError\",{cancelable:!0});if(i.payload=a,window.dispatchEvent(i),!i.defaultPrevented)throw a}return s.then(a=>{for(const i of a||[])i.status===\"rejected\"&&o(i.reason);return t().catch(o)})};/*!\n  * shared v9.14.3\n  * (c) 2025 kazuya kawaguchi\n  * Released under the MIT License.\n  */const Rr=typeof window<\"u\",Yt=(e,t=!1)=>t?Symbol.for(e):Symbol(e),wh=(e,t,n)=>Sh({l:e,k:t,s:n}),Sh=e=>JSON.stringify(e).replace(/\\u2028/g,\"\\\\u2028\").replace(/\\u2029/g,\"\\\\u2029\").replace(/\\u0027/g,\"\\\\u0027\"),Ae=e=>typeof e==\"number\"&&isFinite(e),Lh=e=>Hl(e)===\"[object Date]\",Kt=e=>Hl(e)===\"[object RegExp]\",zr=e=>Z(e)&&Object.keys(e).length===0,Ue=Object.assign,Oh=Object.create,pe=(e=null)=>Oh(e);let Ia;const At=()=>Ia||(Ia=typeof globalThis<\"u\"?globalThis:typeof self<\"u\"?self:typeof window<\"u\"?window:typeof global<\"u\"?global:pe());function Aa(e){return e.replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\").replace(/'/g,\"&apos;\")}const Ih=Object.prototype.hasOwnProperty;function ct(e,t){return Ih.call(e,t)}const ke=Array.isArray,ye=e=>typeof e==\"function\",G=e=>typeof e==\"string\",ne=e=>typeof e==\"boolean\",ce=e=>e!==null&&typeof e==\"object\",Ah=e=>ce(e)&&ye(e.then)&&ye(e.catch),Vl=Object.prototype.toString,Hl=e=>Vl.call(e),Z=e=>{if(!ce(e))return!1;const t=Object.getPrototypeOf(e);return t===null||t.constructor===Object},Ph=e=>e==null?\"\":ke(e)||Z(e)&&e.toString===Vl?JSON.stringify(e,null,2):String(e);function Rh(e,t=\"\"){return e.reduce((n,r,s)=>s===0?n+r:n+t+r,\"\")}function Qr(e){let t=e;return()=>++t}function Ch(e,t){typeof console<\"u\"&&(console.warn(\"[intlify] \"+e),t&&console.warn(t.stack))}const gr=e=>!ce(e)||ke(e);function kr(e,t){if(gr(e)||gr(t))throw new Error(\"Invalid value\");const n=[{src:e,des:t}];for(;n.length;){const{src:r,des:s}=n.pop();Object.keys(r).forEach(o=>{o!==\"__proto__\"&&(ce(r[o])&&!ce(s[o])&&(s[o]=Array.isArray(r[o])?[]:pe()),gr(s[o])||gr(r[o])?s[o]=r[o]:n.push({src:r[o],des:s[o]}))})}}/*!\n  * message-compiler v9.14.3\n  * (c) 2025 kazuya kawaguchi\n  * Released under the MIT License.\n  */function Nh(e,t,n){return{line:e,column:t,offset:n}}function Cr(e,t,n){return{start:e,end:t}}const xh=/\\{([0-9a-zA-Z]+)\\}/g;function Wl(e,...t){return t.length===1&&Mh(t[0])&&(t=t[0]),(!t||!t.hasOwnProperty)&&(t={}),e.replace(xh,(n,r)=>t.hasOwnProperty(r)?t[r]:\"\")}const Bl=Object.assign,Pa=e=>typeof e==\"string\",Mh=e=>e!==null&&typeof e==\"object\";function Kl(e,t=\"\"){return e.reduce((n,r,s)=>s===0?n+r:n+t+r,\"\")}const _o={USE_MODULO_SYNTAX:1,__EXTEND_POINT__:2},Dh={[_o.USE_MODULO_SYNTAX]:\"Use modulo before '{{0}}'.\"};function Fh(e,t,...n){const r=Wl(Dh[e],...n||[]),s={message:String(r),code:e};return t&&(s.location=t),s}const X={EXPECTED_TOKEN:1,INVALID_TOKEN_IN_PLACEHOLDER:2,UNTERMINATED_SINGLE_QUOTE_IN_PLACEHOLDER:3,UNKNOWN_ESCAPE_SEQUENCE:4,INVALID_UNICODE_ESCAPE_SEQUENCE:5,UNBALANCED_CLOSING_BRACE:6,UNTERMINATED_CLOSING_BRACE:7,EMPTY_PLACEHOLDER:8,NOT_ALLOW_NEST_PLACEHOLDER:9,INVALID_LINKED_FORMAT:10,MUST_HAVE_MESSAGES_IN_PLURAL:11,UNEXPECTED_EMPTY_LINKED_MODIFIER:12,UNEXPECTED_EMPTY_LINKED_KEY:13,UNEXPECTED_LEXICAL_ANALYSIS:14,UNHANDLED_CODEGEN_NODE_TYPE:15,UNHANDLED_MINIFIER_NODE_TYPE:16,__EXTEND_POINT__:17},$h={[X.EXPECTED_TOKEN]:\"Expected token: '{0}'\",[X.INVALID_TOKEN_IN_PLACEHOLDER]:\"Invalid token in placeholder: '{0}'\",[X.UNTERMINATED_SINGLE_QUOTE_IN_PLACEHOLDER]:\"Unterminated single quote in placeholder\",[X.UNKNOWN_ESCAPE_SEQUENCE]:\"Unknown escape sequence: \\\\{0}\",[X.INVALID_UNICODE_ESCAPE_SEQUENCE]:\"Invalid unicode escape sequence: {0}\",[X.UNBALANCED_CLOSING_BRACE]:\"Unbalanced closing brace\",[X.UNTERMINATED_CLOSING_BRACE]:\"Unterminated closing brace\",[X.EMPTY_PLACEHOLDER]:\"Empty placeholder\",[X.NOT_ALLOW_NEST_PLACEHOLDER]:\"Not allowed nest placeholder\",[X.INVALID_LINKED_FORMAT]:\"Invalid linked format\",[X.MUST_HAVE_MESSAGES_IN_PLURAL]:\"Plural must have messages\",[X.UNEXPECTED_EMPTY_LINKED_MODIFIER]:\"Unexpected empty linked modifier\",[X.UNEXPECTED_EMPTY_LINKED_KEY]:\"Unexpected empty linked key\",[X.UNEXPECTED_LEXICAL_ANALYSIS]:\"Unexpected lexical analysis in token: '{0}'\",[X.UNHANDLED_CODEGEN_NODE_TYPE]:\"unhandled codegen node type: '{0}'\",[X.UNHANDLED_MINIFIER_NODE_TYPE]:\"unhandled mimifier node type: '{0}'\"};function Cn(e,t,n={}){const{domain:r,messages:s,args:o}=n,a=Wl((s||$h)[e]||\"\",...o||[]),i=new SyntaxError(String(a));return i.code=e,t&&(i.location=t),i.domain=r,i}function Uh(e){throw e}const wt=\" \",Vh=\"\\r\",je=`\n`,Hh=\"\\u2028\",Wh=\"\\u2029\";function Bh(e){const t=e;let n=0,r=1,s=1,o=0;const a=D=>t[D]===Vh&&t[D+1]===je,i=D=>t[D]===je,l=D=>t[D]===Wh,c=D=>t[D]===Hh,u=D=>a(D)||i(D)||l(D)||c(D),f=()=>n,m=()=>r,_=()=>s,S=()=>o,T=D=>a(D)||l(D)||c(D)?je:t[D],I=()=>T(n),P=()=>T(n+o);function F(){return o=0,u(n)&&(r++,s=0),a(n)&&n++,n++,s++,t[n]}function y(){return a(n+o)&&o++,o++,t[n+o]}function v(){n=0,r=1,s=1,o=0}function L(D=0){o=D}function R(){const D=n+o;for(;D!==n;)F();o=0}return{index:f,line:m,column:_,peekOffset:S,charAt:T,currentChar:I,currentPeek:P,next:F,peek:y,reset:v,resetPeek:L,skipToPeek:R}}const Nt=void 0,Kh=\".\",Ra=\"'\",Gh=\"tokenizer\";function jh(e,t={}){const n=t.location!==!1,r=Bh(e),s=()=>r.index(),o=()=>Nh(r.line(),r.column(),r.index()),a=o(),i=s(),l={currentType:14,offset:i,startLoc:a,endLoc:a,lastType:14,lastOffset:i,lastStartLoc:a,lastEndLoc:a,braceNest:0,inLinked:!1,text:\"\"},c=()=>l,{onError:u}=t;function f(d,h,k,...O){const K=c();if(h.column+=k,h.offset+=k,u){const U=n?Cr(K.startLoc,h):null,E=Cn(d,U,{domain:Gh,args:O});u(E)}}function m(d,h,k){d.endLoc=o(),d.currentType=h;const O={type:h};return n&&(O.loc=Cr(d.startLoc,d.endLoc)),k!=null&&(O.value=k),O}const _=d=>m(d,14);function S(d,h){return d.currentChar()===h?(d.next(),h):(f(X.EXPECTED_TOKEN,o(),0,h),\"\")}function T(d){let h=\"\";for(;d.currentPeek()===wt||d.currentPeek()===je;)h+=d.currentPeek(),d.peek();return h}function I(d){const h=T(d);return d.skipToPeek(),h}function P(d){if(d===Nt)return!1;const h=d.charCodeAt(0);return h>=97&&h<=122||h>=65&&h<=90||h===95}function F(d){if(d===Nt)return!1;const h=d.charCodeAt(0);return h>=48&&h<=57}function y(d,h){const{currentType:k}=h;if(k!==2)return!1;T(d);const O=P(d.currentPeek());return d.resetPeek(),O}function v(d,h){const{currentType:k}=h;if(k!==2)return!1;T(d);const O=d.currentPeek()===\"-\"?d.peek():d.currentPeek(),K=F(O);return d.resetPeek(),K}function L(d,h){const{currentType:k}=h;if(k!==2)return!1;T(d);const O=d.currentPeek()===Ra;return d.resetPeek(),O}function R(d,h){const{currentType:k}=h;if(k!==8)return!1;T(d);const O=d.currentPeek()===\".\";return d.resetPeek(),O}function D(d,h){const{currentType:k}=h;if(k!==9)return!1;T(d);const O=P(d.currentPeek());return d.resetPeek(),O}function $(d,h){const{currentType:k}=h;if(!(k===8||k===12))return!1;T(d);const O=d.currentPeek()===\":\";return d.resetPeek(),O}function M(d,h){const{currentType:k}=h;if(k!==10)return!1;const O=()=>{const U=d.currentPeek();return U===\"{\"?P(d.peek()):U===\"@\"||U===\"%\"||U===\"|\"||U===\":\"||U===\".\"||U===wt||!U?!1:U===je?(d.peek(),O()):z(d,!1)},K=O();return d.resetPeek(),K}function ee(d){T(d);const h=d.currentPeek()===\"|\";return d.resetPeek(),h}function me(d){const h=T(d),k=d.currentPeek()===\"%\"&&d.peek()===\"{\";return d.resetPeek(),{isModulo:k,hasSpace:h.length>0}}function z(d,h=!0){const k=(K=!1,U=\"\",E=!1)=>{const A=d.currentPeek();return A===\"{\"?U===\"%\"?!1:K:A===\"@\"||!A?U===\"%\"?!0:K:A===\"%\"?(d.peek(),k(K,\"%\",!0)):A===\"|\"?U===\"%\"||E?!0:!(U===wt||U===je):A===wt?(d.peek(),k(!0,wt,E)):A===je?(d.peek(),k(!0,je,E)):!0},O=k();return h&&d.resetPeek(),O}function ue(d,h){const k=d.currentChar();return k===Nt?Nt:h(k)?(d.next(),k):null}function Qe(d){const h=d.charCodeAt(0);return h>=97&&h<=122||h>=65&&h<=90||h>=48&&h<=57||h===95||h===36}function Je(d){return ue(d,Qe)}function se(d){const h=d.charCodeAt(0);return h>=97&&h<=122||h>=65&&h<=90||h>=48&&h<=57||h===95||h===36||h===45}function Q(d){return ue(d,se)}function re(d){const h=d.charCodeAt(0);return h>=48&&h<=57}function Be(d){return ue(d,re)}function Xe(d){const h=d.charCodeAt(0);return h>=48&&h<=57||h>=65&&h<=70||h>=97&&h<=102}function Se(d){return ue(d,Xe)}function Le(d){let h=\"\",k=\"\";for(;h=Be(d);)k+=h;return k}function st(d){I(d);const h=d.currentChar();return h!==\"%\"&&f(X.EXPECTED_TOKEN,o(),0,h),d.next(),\"%\"}function Ze(d){let h=\"\";for(;;){const k=d.currentChar();if(k===\"{\"||k===\"}\"||k===\"@\"||k===\"|\"||!k)break;if(k===\"%\")if(z(d))h+=k,d.next();else break;else if(k===wt||k===je)if(z(d))h+=k,d.next();else{if(ee(d))break;h+=k,d.next()}else h+=k,d.next()}return h}function mt(d){I(d);let h=\"\",k=\"\";for(;h=Q(d);)k+=h;return d.currentChar()===Nt&&f(X.UNTERMINATED_CLOSING_BRACE,o(),0),k}function xe(d){I(d);let h=\"\";return d.currentChar()===\"-\"?(d.next(),h+=`-${Le(d)}`):h+=Le(d),d.currentChar()===Nt&&f(X.UNTERMINATED_CLOSING_BRACE,o(),0),h}function C(d){return d!==Ra&&d!==je}function j(d){I(d),S(d,\"'\");let h=\"\",k=\"\";for(;h=ue(d,C);)h===\"\\\\\"?k+=B(d):k+=h;const O=d.currentChar();return O===je||O===Nt?(f(X.UNTERMINATED_SINGLE_QUOTE_IN_PLACEHOLDER,o(),0),O===je&&(d.next(),S(d,\"'\")),k):(S(d,\"'\"),k)}function B(d){const h=d.currentChar();switch(h){case\"\\\\\":case\"'\":return d.next(),`\\\\${h}`;case\"u\":return q(d,h,4);case\"U\":return q(d,h,6);default:return f(X.UNKNOWN_ESCAPE_SEQUENCE,o(),0,h),\"\"}}function q(d,h,k){S(d,h);let O=\"\";for(let K=0;K<k;K++){const U=Se(d);if(!U){f(X.INVALID_UNICODE_ESCAPE_SEQUENCE,o(),0,`\\\\${h}${O}${d.currentChar()}`);break}O+=U}return`\\\\${h}${O}`}function ae(d){return d!==\"{\"&&d!==\"}\"&&d!==wt&&d!==je}function p(d){I(d);let h=\"\",k=\"\";for(;h=ue(d,ae);)k+=h;return k}function b(d){let h=\"\",k=\"\";for(;h=Je(d);)k+=h;return k}function g(d){const h=k=>{const O=d.currentChar();return O===\"{\"||O===\"%\"||O===\"@\"||O===\"|\"||O===\"(\"||O===\")\"||!O||O===wt?k:(k+=O,d.next(),h(k))};return h(\"\")}function w(d){I(d);const h=S(d,\"|\");return I(d),h}function x(d,h){let k=null;switch(d.currentChar()){case\"{\":return h.braceNest>=1&&f(X.NOT_ALLOW_NEST_PLACEHOLDER,o(),0),d.next(),k=m(h,2,\"{\"),I(d),h.braceNest++,k;case\"}\":return h.braceNest>0&&h.currentType===2&&f(X.EMPTY_PLACEHOLDER,o(),0),d.next(),k=m(h,3,\"}\"),h.braceNest--,h.braceNest>0&&I(d),h.inLinked&&h.braceNest===0&&(h.inLinked=!1),k;case\"@\":return h.braceNest>0&&f(X.UNTERMINATED_CLOSING_BRACE,o(),0),k=N(d,h)||_(h),h.braceNest=0,k;default:{let K=!0,U=!0,E=!0;if(ee(d))return h.braceNest>0&&f(X.UNTERMINATED_CLOSING_BRACE,o(),0),k=m(h,1,w(d)),h.braceNest=0,h.inLinked=!1,k;if(h.braceNest>0&&(h.currentType===5||h.currentType===6||h.currentType===7))return f(X.UNTERMINATED_CLOSING_BRACE,o(),0),h.braceNest=0,W(d,h);if(K=y(d,h))return k=m(h,5,mt(d)),I(d),k;if(U=v(d,h))return k=m(h,6,xe(d)),I(d),k;if(E=L(d,h))return k=m(h,7,j(d)),I(d),k;if(!K&&!U&&!E)return k=m(h,13,p(d)),f(X.INVALID_TOKEN_IN_PLACEHOLDER,o(),0,k.value),I(d),k;break}}return k}function N(d,h){const{currentType:k}=h;let O=null;const K=d.currentChar();switch((k===8||k===9||k===12||k===10)&&(K===je||K===wt)&&f(X.INVALID_LINKED_FORMAT,o(),0),K){case\"@\":return d.next(),O=m(h,8,\"@\"),h.inLinked=!0,O;case\".\":return I(d),d.next(),m(h,9,\".\");case\":\":return I(d),d.next(),m(h,10,\":\");default:return ee(d)?(O=m(h,1,w(d)),h.braceNest=0,h.inLinked=!1,O):R(d,h)||$(d,h)?(I(d),N(d,h)):D(d,h)?(I(d),m(h,12,b(d))):M(d,h)?(I(d),K===\"{\"?x(d,h)||O:m(h,11,g(d))):(k===8&&f(X.INVALID_LINKED_FORMAT,o(),0),h.braceNest=0,h.inLinked=!1,W(d,h))}}function W(d,h){let k={type:14};if(h.braceNest>0)return x(d,h)||_(h);if(h.inLinked)return N(d,h)||_(h);switch(d.currentChar()){case\"{\":return x(d,h)||_(h);case\"}\":return f(X.UNBALANCED_CLOSING_BRACE,o(),0),d.next(),m(h,3,\"}\");case\"@\":return N(d,h)||_(h);default:{if(ee(d))return k=m(h,1,w(d)),h.braceNest=0,h.inLinked=!1,k;const{isModulo:K,hasSpace:U}=me(d);if(K)return U?m(h,0,Ze(d)):m(h,4,st(d));if(z(d))return m(h,0,Ze(d));break}}return k}function V(){const{currentType:d,offset:h,startLoc:k,endLoc:O}=l;return l.lastType=d,l.lastOffset=h,l.lastStartLoc=k,l.lastEndLoc=O,l.offset=s(),l.startLoc=o(),r.currentChar()===Nt?m(l,14):W(r,l)}return{nextToken:V,currentOffset:s,currentPosition:o,context:c}}const qh=\"parser\",Yh=/(?:\\\\\\\\|\\\\'|\\\\u([0-9a-fA-F]{4})|\\\\U([0-9a-fA-F]{6}))/g;function Jh(e,t,n){switch(e){case\"\\\\\\\\\":return\"\\\\\";case\"\\\\'\":return\"'\";default:{const r=parseInt(t||n,16);return r<=55295||r>=57344?String.fromCodePoint(r):\"�\"}}}function Xh(e={}){const t=e.location!==!1,{onError:n,onWarn:r}=e;function s(y,v,L,R,...D){const $=y.currentPosition();if($.offset+=R,$.column+=R,n){const M=t?Cr(L,$):null,ee=Cn(v,M,{domain:qh,args:D});n(ee)}}function o(y,v,L,R,...D){const $=y.currentPosition();if($.offset+=R,$.column+=R,r){const M=t?Cr(L,$):null;r(Fh(v,M,D))}}function a(y,v,L){const R={type:y};return t&&(R.start=v,R.end=v,R.loc={start:L,end:L}),R}function i(y,v,L,R){t&&(y.end=v,y.loc&&(y.loc.end=L))}function l(y,v){const L=y.context(),R=a(3,L.offset,L.startLoc);return R.value=v,i(R,y.currentOffset(),y.currentPosition()),R}function c(y,v){const L=y.context(),{lastOffset:R,lastStartLoc:D}=L,$=a(5,R,D);return $.index=parseInt(v,10),y.nextToken(),i($,y.currentOffset(),y.currentPosition()),$}function u(y,v,L){const R=y.context(),{lastOffset:D,lastStartLoc:$}=R,M=a(4,D,$);return M.key=v,L===!0&&(M.modulo=!0),y.nextToken(),i(M,y.currentOffset(),y.currentPosition()),M}function f(y,v){const L=y.context(),{lastOffset:R,lastStartLoc:D}=L,$=a(9,R,D);return $.value=v.replace(Yh,Jh),y.nextToken(),i($,y.currentOffset(),y.currentPosition()),$}function m(y){const v=y.nextToken(),L=y.context(),{lastOffset:R,lastStartLoc:D}=L,$=a(8,R,D);return v.type!==12?(s(y,X.UNEXPECTED_EMPTY_LINKED_MODIFIER,L.lastStartLoc,0),$.value=\"\",i($,R,D),{nextConsumeToken:v,node:$}):(v.value==null&&s(y,X.UNEXPECTED_LEXICAL_ANALYSIS,L.lastStartLoc,0,lt(v)),$.value=v.value||\"\",i($,y.currentOffset(),y.currentPosition()),{node:$})}function _(y,v){const L=y.context(),R=a(7,L.offset,L.startLoc);return R.value=v,i(R,y.currentOffset(),y.currentPosition()),R}function S(y){const v=y.context(),L=a(6,v.offset,v.startLoc);let R=y.nextToken();if(R.type===9){const D=m(y);L.modifier=D.node,R=D.nextConsumeToken||y.nextToken()}switch(R.type!==10&&s(y,X.UNEXPECTED_LEXICAL_ANALYSIS,v.lastStartLoc,0,lt(R)),R=y.nextToken(),R.type===2&&(R=y.nextToken()),R.type){case 11:R.value==null&&s(y,X.UNEXPECTED_LEXICAL_ANALYSIS,v.lastStartLoc,0,lt(R)),L.key=_(y,R.value||\"\");break;case 5:R.value==null&&s(y,X.UNEXPECTED_LEXICAL_ANALYSIS,v.lastStartLoc,0,lt(R)),L.key=u(y,R.value||\"\");break;case 6:R.value==null&&s(y,X.UNEXPECTED_LEXICAL_ANALYSIS,v.lastStartLoc,0,lt(R)),L.key=c(y,R.value||\"\");break;case 7:R.value==null&&s(y,X.UNEXPECTED_LEXICAL_ANALYSIS,v.lastStartLoc,0,lt(R)),L.key=f(y,R.value||\"\");break;default:{s(y,X.UNEXPECTED_EMPTY_LINKED_KEY,v.lastStartLoc,0);const D=y.context(),$=a(7,D.offset,D.startLoc);return $.value=\"\",i($,D.offset,D.startLoc),L.key=$,i(L,D.offset,D.startLoc),{nextConsumeToken:R,node:L}}}return i(L,y.currentOffset(),y.currentPosition()),{node:L}}function T(y){const v=y.context(),L=v.currentType===1?y.currentOffset():v.offset,R=v.currentType===1?v.endLoc:v.startLoc,D=a(2,L,R);D.items=[];let $=null,M=null;do{const z=$||y.nextToken();switch($=null,z.type){case 0:z.value==null&&s(y,X.UNEXPECTED_LEXICAL_ANALYSIS,v.lastStartLoc,0,lt(z)),D.items.push(l(y,z.value||\"\"));break;case 6:z.value==null&&s(y,X.UNEXPECTED_LEXICAL_ANALYSIS,v.lastStartLoc,0,lt(z)),D.items.push(c(y,z.value||\"\"));break;case 4:M=!0;break;case 5:z.value==null&&s(y,X.UNEXPECTED_LEXICAL_ANALYSIS,v.lastStartLoc,0,lt(z)),D.items.push(u(y,z.value||\"\",!!M)),M&&(o(y,_o.USE_MODULO_SYNTAX,v.lastStartLoc,0,lt(z)),M=null);break;case 7:z.value==null&&s(y,X.UNEXPECTED_LEXICAL_ANALYSIS,v.lastStartLoc,0,lt(z)),D.items.push(f(y,z.value||\"\"));break;case 8:{const ue=S(y);D.items.push(ue.node),$=ue.nextConsumeToken||null;break}}}while(v.currentType!==14&&v.currentType!==1);const ee=v.currentType===1?v.lastOffset:y.currentOffset(),me=v.currentType===1?v.lastEndLoc:y.currentPosition();return i(D,ee,me),D}function I(y,v,L,R){const D=y.context();let $=R.items.length===0;const M=a(1,v,L);M.cases=[],M.cases.push(R);do{const ee=T(y);$||($=ee.items.length===0),M.cases.push(ee)}while(D.currentType!==14);return $&&s(y,X.MUST_HAVE_MESSAGES_IN_PLURAL,L,0),i(M,y.currentOffset(),y.currentPosition()),M}function P(y){const v=y.context(),{offset:L,startLoc:R}=v,D=T(y);return v.currentType===14?D:I(y,L,R,D)}function F(y){const v=jh(y,Bl({},e)),L=v.context(),R=a(0,L.offset,L.startLoc);return t&&R.loc&&(R.loc.source=y),R.body=P(v),e.onCacheKey&&(R.cacheKey=e.onCacheKey(y)),L.currentType!==14&&s(v,X.UNEXPECTED_LEXICAL_ANALYSIS,L.lastStartLoc,0,y[L.offset]||\"\"),i(R,v.currentOffset(),v.currentPosition()),R}return{parse:F}}function lt(e){if(e.type===14)return\"EOF\";const t=(e.value||\"\").replace(/\\r?\\n/gu,\"\\\\n\");return t.length>10?t.slice(0,9)+\"…\":t}function zh(e,t={}){const n={ast:e,helpers:new Set};return{context:()=>n,helper:o=>(n.helpers.add(o),o)}}function Ca(e,t){for(let n=0;n<e.length;n++)bo(e[n],t)}function bo(e,t){switch(e.type){case 1:Ca(e.cases,t),t.helper(\"plural\");break;case 2:Ca(e.items,t);break;case 6:{bo(e.key,t),t.helper(\"linked\"),t.helper(\"type\");break}case 5:t.helper(\"interpolate\"),t.helper(\"list\");break;case 4:t.helper(\"interpolate\"),t.helper(\"named\");break}}function Qh(e,t={}){const n=zh(e);n.helper(\"normalize\"),e.body&&bo(e.body,n);const r=n.context();e.helpers=Array.from(r.helpers)}function Zh(e){const t=e.body;return t.type===2?Na(t):t.cases.forEach(n=>Na(n)),e}function Na(e){if(e.items.length===1){const t=e.items[0];(t.type===3||t.type===9)&&(e.static=t.value,delete t.value)}else{const t=[];for(let n=0;n<e.items.length;n++){const r=e.items[n];if(!(r.type===3||r.type===9)||r.value==null)break;t.push(r.value)}if(t.length===e.items.length){e.static=Kl(t);for(let n=0;n<e.items.length;n++){const r=e.items[n];(r.type===3||r.type===9)&&delete r.value}}}}const em=\"minifier\";function pn(e){switch(e.t=e.type,e.type){case 0:{const t=e;pn(t.body),t.b=t.body,delete t.body;break}case 1:{const t=e,n=t.cases;for(let r=0;r<n.length;r++)pn(n[r]);t.c=n,delete t.cases;break}case 2:{const t=e,n=t.items;for(let r=0;r<n.length;r++)pn(n[r]);t.i=n,delete t.items,t.static&&(t.s=t.static,delete t.static);break}case 3:case 9:case 8:case 7:{const t=e;t.value&&(t.v=t.value,delete t.value);break}case 6:{const t=e;pn(t.key),t.k=t.key,delete t.key,t.modifier&&(pn(t.modifier),t.m=t.modifier,delete t.modifier);break}case 5:{const t=e;t.i=t.index,delete t.index;break}case 4:{const t=e;t.k=t.key,delete t.key;break}default:throw Cn(X.UNHANDLED_MINIFIER_NODE_TYPE,null,{domain:em,args:[e.type]})}delete e.type}const tm=\"parser\";function nm(e,t){const{filename:n,breakLineCode:r,needIndent:s}=t,o=t.location!==!1,a={filename:n,code:\"\",column:1,line:1,offset:0,map:void 0,breakLineCode:r,needIndent:s,indentLevel:0};o&&e.loc&&(a.source=e.loc.source);const i=()=>a;function l(T,I){a.code+=T}function c(T,I=!0){const P=I?r:\"\";l(s?P+\"  \".repeat(T):P)}function u(T=!0){const I=++a.indentLevel;T&&c(I)}function f(T=!0){const I=--a.indentLevel;T&&c(I)}function m(){c(a.indentLevel)}return{context:i,push:l,indent:u,deindent:f,newline:m,helper:T=>`_${T}`,needIndent:()=>a.needIndent}}function rm(e,t){const{helper:n}=e;e.push(`${n(\"linked\")}(`),In(e,t.key),t.modifier?(e.push(\", \"),In(e,t.modifier),e.push(\", _type\")):e.push(\", undefined, _type\"),e.push(\")\")}function sm(e,t){const{helper:n,needIndent:r}=e;e.push(`${n(\"normalize\")}([`),e.indent(r());const s=t.items.length;for(let o=0;o<s&&(In(e,t.items[o]),o!==s-1);o++)e.push(\", \");e.deindent(r()),e.push(\"])\")}function om(e,t){const{helper:n,needIndent:r}=e;if(t.cases.length>1){e.push(`${n(\"plural\")}([`),e.indent(r());const s=t.cases.length;for(let o=0;o<s&&(In(e,t.cases[o]),o!==s-1);o++)e.push(\", \");e.deindent(r()),e.push(\"])\")}}function am(e,t){t.body?In(e,t.body):e.push(\"null\")}function In(e,t){const{helper:n}=e;switch(t.type){case 0:am(e,t);break;case 1:om(e,t);break;case 2:sm(e,t);break;case 6:rm(e,t);break;case 8:e.push(JSON.stringify(t.value),t);break;case 7:e.push(JSON.stringify(t.value),t);break;case 5:e.push(`${n(\"interpolate\")}(${n(\"list\")}(${t.index}))`,t);break;case 4:e.push(`${n(\"interpolate\")}(${n(\"named\")}(${JSON.stringify(t.key)}))`,t);break;case 9:e.push(JSON.stringify(t.value),t);break;case 3:e.push(JSON.stringify(t.value),t);break;default:throw Cn(X.UNHANDLED_CODEGEN_NODE_TYPE,null,{domain:tm,args:[t.type]})}}const im=(e,t={})=>{const n=Pa(t.mode)?t.mode:\"normal\",r=Pa(t.filename)?t.filename:\"message.intl\";t.sourceMap;const s=t.breakLineCode!=null?t.breakLineCode:n===\"arrow\"?\";\":`\n`,o=t.needIndent?t.needIndent:n!==\"arrow\",a=e.helpers||[],i=nm(e,{filename:r,breakLineCode:s,needIndent:o});i.push(n===\"normal\"?\"function __msg__ (ctx) {\":\"(ctx) => {\"),i.indent(o),a.length>0&&(i.push(`const { ${Kl(a.map(u=>`${u}: _${u}`),\", \")} } = ctx`),i.newline()),i.push(\"return \"),In(i,e),i.deindent(o),i.push(\"}\"),delete e.helpers;const{code:l,map:c}=i.context();return{ast:e,code:l,map:c?c.toJSON():void 0}};function lm(e,t={}){const n=Bl({},t),r=!!n.jit,s=!!n.minify,o=n.optimize==null?!0:n.optimize,i=Xh(n).parse(e);return r?(o&&Zh(i),s&&pn(i),{ast:i,code:\"\"}):(Qh(i,n),im(i,n))}/*!\n  * core-base v9.14.3\n  * (c) 2025 kazuya kawaguchi\n  * Released under the MIT License.\n  */function cm(){typeof __INTLIFY_PROD_DEVTOOLS__!=\"boolean\"&&(At().__INTLIFY_PROD_DEVTOOLS__=!1),typeof __INTLIFY_JIT_COMPILATION__!=\"boolean\"&&(At().__INTLIFY_JIT_COMPILATION__=!1),typeof __INTLIFY_DROP_MESSAGE_COMPILER__!=\"boolean\"&&(At().__INTLIFY_DROP_MESSAGE_COMPILER__=!1)}const Jt=[];Jt[0]={w:[0],i:[3,0],\"[\":[4],o:[7]};Jt[1]={w:[1],\".\":[2],\"[\":[4],o:[7]};Jt[2]={w:[2],i:[3,0],0:[3,0]};Jt[3]={i:[3,0],0:[3,0],w:[1,1],\".\":[2,1],\"[\":[4,1],o:[7,1]};Jt[4]={\"'\":[5,0],'\"':[6,0],\"[\":[4,2],\"]\":[1,3],o:8,l:[4,0]};Jt[5]={\"'\":[4,0],o:8,l:[5,0]};Jt[6]={'\"':[4,0],o:8,l:[6,0]};const um=/^\\s?(?:true|false|-?[\\d.]+|'[^']*'|\"[^\"]*\")\\s?$/;function fm(e){return um.test(e)}function dm(e){const t=e.charCodeAt(0),n=e.charCodeAt(e.length-1);return t===n&&(t===34||t===39)?e.slice(1,-1):e}function hm(e){if(e==null)return\"o\";switch(e.charCodeAt(0)){case 91:case 93:case 46:case 34:case 39:return e;case 95:case 36:case 45:return\"i\";case 9:case 10:case 13:case 160:case 65279:case 8232:case 8233:return\"w\"}return\"i\"}function mm(e){const t=e.trim();return e.charAt(0)===\"0\"&&isNaN(parseInt(e))?!1:fm(t)?dm(t):\"*\"+t}function gm(e){const t=[];let n=-1,r=0,s=0,o,a,i,l,c,u,f;const m=[];m[0]=()=>{a===void 0?a=i:a+=i},m[1]=()=>{a!==void 0&&(t.push(a),a=void 0)},m[2]=()=>{m[0](),s++},m[3]=()=>{if(s>0)s--,r=4,m[0]();else{if(s=0,a===void 0||(a=mm(a),a===!1))return!1;m[1]()}};function _(){const S=e[n+1];if(r===5&&S===\"'\"||r===6&&S==='\"')return n++,i=\"\\\\\"+S,m[0](),!0}for(;r!==null;)if(n++,o=e[n],!(o===\"\\\\\"&&_())){if(l=hm(o),f=Jt[r],c=f[l]||f.l||8,c===8||(r=c[0],c[1]!==void 0&&(u=m[c[1]],u&&(i=o,u()===!1))))return;if(r===7)return t}}const xa=new Map;function pm(e,t){return ce(e)?e[t]:null}function _m(e,t){if(!ce(e))return null;let n=xa.get(t);if(n||(n=gm(t),n&&xa.set(t,n)),!n)return null;const r=n.length;let s=e,o=0;for(;o<r;){const a=s[n[o]];if(a===void 0||ye(s))return null;s=a,o++}return s}const bm=e=>e,ym=e=>\"\",vm=\"text\",Em=e=>e.length===0?\"\":Rh(e),km=Ph;function Ma(e,t){return e=Math.abs(e),t===2?e?e>1?1:0:1:e?Math.min(e,2):0}function Tm(e){const t=Ae(e.pluralIndex)?e.pluralIndex:-1;return e.named&&(Ae(e.named.count)||Ae(e.named.n))?Ae(e.named.count)?e.named.count:Ae(e.named.n)?e.named.n:t:t}function wm(e,t){t.count||(t.count=e),t.n||(t.n=e)}function Sm(e={}){const t=e.locale,n=Tm(e),r=ce(e.pluralRules)&&G(t)&&ye(e.pluralRules[t])?e.pluralRules[t]:Ma,s=ce(e.pluralRules)&&G(t)&&ye(e.pluralRules[t])?Ma:void 0,o=P=>P[r(n,P.length,s)],a=e.list||[],i=P=>a[P],l=e.named||pe();Ae(e.pluralIndex)&&wm(n,l);const c=P=>l[P];function u(P){const F=ye(e.messages)?e.messages(P):ce(e.messages)?e.messages[P]:!1;return F||(e.parent?e.parent.message(P):ym)}const f=P=>e.modifiers?e.modifiers[P]:bm,m=Z(e.processor)&&ye(e.processor.normalize)?e.processor.normalize:Em,_=Z(e.processor)&&ye(e.processor.interpolate)?e.processor.interpolate:km,S=Z(e.processor)&&G(e.processor.type)?e.processor.type:vm,I={list:i,named:c,plural:o,linked:(P,...F)=>{const[y,v]=F;let L=\"text\",R=\"\";F.length===1?ce(y)?(R=y.modifier||R,L=y.type||L):G(y)&&(R=y||R):F.length===2&&(G(y)&&(R=y||R),G(v)&&(L=v||L));const D=u(P)(I),$=L===\"vnode\"&&ke(D)&&R?D[0]:D;return R?f(R)($,L):$},message:u,type:S,interpolate:_,normalize:m,values:Ue(pe(),a,l)};return I}let nr=null;function Lm(e){nr=e}function Om(e,t,n){nr&&nr.emit(\"i18n:init\",{timestamp:Date.now(),i18n:e,version:t,meta:n})}const Im=Am(\"function:translate\");function Am(e){return t=>nr&&nr.emit(e,t)}const Pm=_o.__EXTEND_POINT__,nn=Qr(Pm),Rm={FALLBACK_TO_TRANSLATE:nn(),CANNOT_FORMAT_NUMBER:nn(),FALLBACK_TO_NUMBER_FORMAT:nn(),CANNOT_FORMAT_DATE:nn(),FALLBACK_TO_DATE_FORMAT:nn(),EXPERIMENTAL_CUSTOM_MESSAGE_COMPILER:nn(),__EXTEND_POINT__:nn()},Gl=X.__EXTEND_POINT__,rn=Qr(Gl),ut={INVALID_ARGUMENT:Gl,INVALID_DATE_ARGUMENT:rn(),INVALID_ISO_DATE_ARGUMENT:rn(),NOT_SUPPORT_NON_STRING_MESSAGE:rn(),NOT_SUPPORT_LOCALE_PROMISE_VALUE:rn(),NOT_SUPPORT_LOCALE_ASYNC_FUNCTION:rn(),NOT_SUPPORT_LOCALE_TYPE:rn(),__EXTEND_POINT__:rn()};function yt(e){return Cn(e,null,void 0)}function yo(e,t){return t.locale!=null?Da(t.locale):Da(e.locale)}let ys;function Da(e){if(G(e))return e;if(ye(e)){if(e.resolvedOnce&&ys!=null)return ys;if(e.constructor.name===\"Function\"){const t=e();if(Ah(t))throw yt(ut.NOT_SUPPORT_LOCALE_PROMISE_VALUE);return ys=t}else throw yt(ut.NOT_SUPPORT_LOCALE_ASYNC_FUNCTION)}else throw yt(ut.NOT_SUPPORT_LOCALE_TYPE)}function Cm(e,t,n){return[...new Set([n,...ke(t)?t:ce(t)?Object.keys(t):G(t)?[t]:[n]])]}function jl(e,t,n){const r=G(n)?n:An,s=e;s.__localeChainCache||(s.__localeChainCache=new Map);let o=s.__localeChainCache.get(r);if(!o){o=[];let a=[n];for(;ke(a);)a=Fa(o,a,t);const i=ke(t)||!Z(t)?t:t.default?t.default:null;a=G(i)?[i]:i,ke(a)&&Fa(o,a,!1),s.__localeChainCache.set(r,o)}return o}function Fa(e,t,n){let r=!0;for(let s=0;s<t.length&&ne(r);s++){const o=t[s];G(o)&&(r=Nm(e,t[s],n))}return r}function Nm(e,t,n){let r;const s=t.split(\"-\");do{const o=s.join(\"-\");r=xm(e,o,n),s.splice(-1,1)}while(s.length&&r===!0);return r}function xm(e,t,n){let r=!1;if(!e.includes(t)&&(r=!0,t)){r=t[t.length-1]!==\"!\";const s=t.replace(/!/g,\"\");e.push(s),(ke(n)||Z(n))&&n[s]&&(r=n[s])}return r}const Mm=\"9.14.3\",Zr=-1,An=\"en-US\",$a=\"\",Ua=e=>`${e.charAt(0).toLocaleUpperCase()}${e.substr(1)}`;function Dm(){return{upper:(e,t)=>t===\"text\"&&G(e)?e.toUpperCase():t===\"vnode\"&&ce(e)&&\"__v_isVNode\"in e?e.children.toUpperCase():e,lower:(e,t)=>t===\"text\"&&G(e)?e.toLowerCase():t===\"vnode\"&&ce(e)&&\"__v_isVNode\"in e?e.children.toLowerCase():e,capitalize:(e,t)=>t===\"text\"&&G(e)?Ua(e):t===\"vnode\"&&ce(e)&&\"__v_isVNode\"in e?Ua(e.children):e}}let ql;function Va(e){ql=e}let Yl;function Fm(e){Yl=e}let Jl;function $m(e){Jl=e}let Xl=null;const Um=e=>{Xl=e},Vm=()=>Xl;let zl=null;const Ha=e=>{zl=e},Hm=()=>zl;let Wa=0;function Wm(e={}){const t=ye(e.onWarn)?e.onWarn:Ch,n=G(e.version)?e.version:Mm,r=G(e.locale)||ye(e.locale)?e.locale:An,s=ye(r)?An:r,o=ke(e.fallbackLocale)||Z(e.fallbackLocale)||G(e.fallbackLocale)||e.fallbackLocale===!1?e.fallbackLocale:s,a=Z(e.messages)?e.messages:vs(s),i=Z(e.datetimeFormats)?e.datetimeFormats:vs(s),l=Z(e.numberFormats)?e.numberFormats:vs(s),c=Ue(pe(),e.modifiers,Dm()),u=e.pluralRules||pe(),f=ye(e.missing)?e.missing:null,m=ne(e.missingWarn)||Kt(e.missingWarn)?e.missingWarn:!0,_=ne(e.fallbackWarn)||Kt(e.fallbackWarn)?e.fallbackWarn:!0,S=!!e.fallbackFormat,T=!!e.unresolving,I=ye(e.postTranslation)?e.postTranslation:null,P=Z(e.processor)?e.processor:null,F=ne(e.warnHtmlMessage)?e.warnHtmlMessage:!0,y=!!e.escapeParameter,v=ye(e.messageCompiler)?e.messageCompiler:ql,L=ye(e.messageResolver)?e.messageResolver:Yl||pm,R=ye(e.localeFallbacker)?e.localeFallbacker:Jl||Cm,D=ce(e.fallbackContext)?e.fallbackContext:void 0,$=e,M=ce($.__datetimeFormatters)?$.__datetimeFormatters:new Map,ee=ce($.__numberFormatters)?$.__numberFormatters:new Map,me=ce($.__meta)?$.__meta:{};Wa++;const z={version:n,cid:Wa,locale:r,fallbackLocale:o,messages:a,modifiers:c,pluralRules:u,missing:f,missingWarn:m,fallbackWarn:_,fallbackFormat:S,unresolving:T,postTranslation:I,processor:P,warnHtmlMessage:F,escapeParameter:y,messageCompiler:v,messageResolver:L,localeFallbacker:R,fallbackContext:D,onWarn:t,__meta:me};return z.datetimeFormats=i,z.numberFormats=l,z.__datetimeFormatters=M,z.__numberFormatters=ee,__INTLIFY_PROD_DEVTOOLS__&&Om(z,n,me),z}const vs=e=>({[e]:pe()});function vo(e,t,n,r,s){const{missing:o,onWarn:a}=e;if(o!==null){const i=o(e,n,t,s);return G(i)?i:t}else return t}function Dn(e,t,n){const r=e;r.__localeChainCache=new Map,e.localeFallbacker(e,n,t)}function Bm(e,t){return e===t?!1:e.split(\"-\")[0]===t.split(\"-\")[0]}function Km(e,t){const n=t.indexOf(e);if(n===-1)return!1;for(let r=n+1;r<t.length;r++)if(Bm(e,t[r]))return!0;return!1}function Es(e){return n=>Gm(n,e)}function Gm(e,t){const n=qm(t);if(n==null)throw rr(0);if(Eo(n)===1){const o=Jm(n);return e.plural(o.reduce((a,i)=>[...a,Ba(e,i)],[]))}else return Ba(e,n)}const jm=[\"b\",\"body\"];function qm(e){return Xt(e,jm)}const Ym=[\"c\",\"cases\"];function Jm(e){return Xt(e,Ym,[])}function Ba(e,t){const n=zm(t);if(n!=null)return e.type===\"text\"?n:e.normalize([n]);{const r=Zm(t).reduce((s,o)=>[...s,Us(e,o)],[]);return e.normalize(r)}}const Xm=[\"s\",\"static\"];function zm(e){return Xt(e,Xm)}const Qm=[\"i\",\"items\"];function Zm(e){return Xt(e,Qm,[])}function Us(e,t){const n=Eo(t);switch(n){case 3:return pr(t,n);case 9:return pr(t,n);case 4:{const r=t;if(ct(r,\"k\")&&r.k)return e.interpolate(e.named(r.k));if(ct(r,\"key\")&&r.key)return e.interpolate(e.named(r.key));throw rr(n)}case 5:{const r=t;if(ct(r,\"i\")&&Ae(r.i))return e.interpolate(e.list(r.i));if(ct(r,\"index\")&&Ae(r.index))return e.interpolate(e.list(r.index));throw rr(n)}case 6:{const r=t,s=rg(r),o=og(r);return e.linked(Us(e,o),s?Us(e,s):void 0,e.type)}case 7:return pr(t,n);case 8:return pr(t,n);default:throw new Error(`unhandled node on format message part: ${n}`)}}const eg=[\"t\",\"type\"];function Eo(e){return Xt(e,eg)}const tg=[\"v\",\"value\"];function pr(e,t){const n=Xt(e,tg);if(n)return n;throw rr(t)}const ng=[\"m\",\"modifier\"];function rg(e){return Xt(e,ng)}const sg=[\"k\",\"key\"];function og(e){const t=Xt(e,sg);if(t)return t;throw rr(6)}function Xt(e,t,n){for(let r=0;r<t.length;r++){const s=t[r];if(ct(e,s)&&e[s]!=null)return e[s]}return n}function rr(e){return new Error(`unhandled node type: ${e}`)}const Ql=e=>e;let _n=pe();function Pn(e){return ce(e)&&Eo(e)===0&&(ct(e,\"b\")||ct(e,\"body\"))}function Zl(e,t={}){let n=!1;const r=t.onError||Uh;return t.onError=s=>{n=!0,r(s)},{...lm(e,t),detectError:n}}const ag=(e,t)=>{if(!G(e))throw yt(ut.NOT_SUPPORT_NON_STRING_MESSAGE);{ne(t.warnHtmlMessage)&&t.warnHtmlMessage;const r=(t.onCacheKey||Ql)(e),s=_n[r];if(s)return s;const{code:o,detectError:a}=Zl(e,t),i=new Function(`return ${o}`)();return a?i:_n[r]=i}};function ig(e,t){if(__INTLIFY_JIT_COMPILATION__&&!__INTLIFY_DROP_MESSAGE_COMPILER__&&G(e)){ne(t.warnHtmlMessage)&&t.warnHtmlMessage;const r=(t.onCacheKey||Ql)(e),s=_n[r];if(s)return s;const{ast:o,detectError:a}=Zl(e,{...t,location:!1,jit:!0}),i=Es(o);return a?i:_n[r]=i}else{const n=e.cacheKey;if(n){const r=_n[n];return r||(_n[n]=Es(e))}else return Es(e)}}const Ka=()=>\"\",ot=e=>ye(e);function Ga(e,...t){const{fallbackFormat:n,postTranslation:r,unresolving:s,messageCompiler:o,fallbackLocale:a,messages:i}=e,[l,c]=Vs(...t),u=ne(c.missingWarn)?c.missingWarn:e.missingWarn,f=ne(c.fallbackWarn)?c.fallbackWarn:e.fallbackWarn,m=ne(c.escapeParameter)?c.escapeParameter:e.escapeParameter,_=!!c.resolvedMessage,S=G(c.default)||ne(c.default)?ne(c.default)?o?l:()=>l:c.default:n?o?l:()=>l:\"\",T=n||S!==\"\",I=yo(e,c);m&&lg(c);let[P,F,y]=_?[l,I,i[I]||pe()]:ec(e,l,I,a,f,u),v=P,L=l;if(!_&&!(G(v)||Pn(v)||ot(v))&&T&&(v=S,L=v),!_&&(!(G(v)||Pn(v)||ot(v))||!G(F)))return s?Zr:l;let R=!1;const D=()=>{R=!0},$=ot(v)?v:tc(e,l,F,v,L,D);if(R)return v;const M=fg(e,F,y,c),ee=Sm(M),me=cg(e,$,ee),z=r?r(me,l):me;if(__INTLIFY_PROD_DEVTOOLS__){const ue={timestamp:Date.now(),key:G(l)?l:ot(v)?v.key:\"\",locale:F||(ot(v)?v.locale:\"\"),format:G(v)?v:ot(v)?v.source:\"\",message:z};ue.meta=Ue({},e.__meta,Vm()||{}),Im(ue)}return z}function lg(e){ke(e.list)?e.list=e.list.map(t=>G(t)?Aa(t):t):ce(e.named)&&Object.keys(e.named).forEach(t=>{G(e.named[t])&&(e.named[t]=Aa(e.named[t]))})}function ec(e,t,n,r,s,o){const{messages:a,onWarn:i,messageResolver:l,localeFallbacker:c}=e,u=c(e,r,n);let f=pe(),m,_=null;const S=\"translate\";for(let T=0;T<u.length&&(m=u[T],f=a[m]||pe(),(_=l(f,t))===null&&(_=f[t]),!(G(_)||Pn(_)||ot(_)));T++)if(!Km(m,u)){const I=vo(e,t,m,o,S);I!==t&&(_=I)}return[_,m,f]}function tc(e,t,n,r,s,o){const{messageCompiler:a,warnHtmlMessage:i}=e;if(ot(r)){const c=r;return c.locale=c.locale||n,c.key=c.key||t,c}if(a==null){const c=()=>r;return c.locale=n,c.key=t,c}const l=a(r,ug(e,n,s,r,i,o));return l.locale=n,l.key=t,l.source=r,l}function cg(e,t,n){return t(n)}function Vs(...e){const[t,n,r]=e,s=pe();if(!G(t)&&!Ae(t)&&!ot(t)&&!Pn(t))throw yt(ut.INVALID_ARGUMENT);const o=Ae(t)?String(t):(ot(t),t);return Ae(n)?s.plural=n:G(n)?s.default=n:Z(n)&&!zr(n)?s.named=n:ke(n)&&(s.list=n),Ae(r)?s.plural=r:G(r)?s.default=r:Z(r)&&Ue(s,r),[o,s]}function ug(e,t,n,r,s,o){return{locale:t,key:n,warnHtmlMessage:s,onError:a=>{throw o&&o(a),a},onCacheKey:a=>wh(t,n,a)}}function fg(e,t,n,r){const{modifiers:s,pluralRules:o,messageResolver:a,fallbackLocale:i,fallbackWarn:l,missingWarn:c,fallbackContext:u}=e,m={locale:t,modifiers:s,pluralRules:o,messages:_=>{let S=a(n,_);if(S==null&&u){const[,,T]=ec(u,_,t,i,l,c);S=a(T,_)}if(G(S)||Pn(S)){let T=!1;const P=tc(e,_,t,S,_,()=>{T=!0});return T?Ka:P}else return ot(S)?S:Ka}};return e.processor&&(m.processor=e.processor),r.list&&(m.list=r.list),r.named&&(m.named=r.named),Ae(r.plural)&&(m.pluralIndex=r.plural),m}function ja(e,...t){const{datetimeFormats:n,unresolving:r,fallbackLocale:s,onWarn:o,localeFallbacker:a}=e,{__datetimeFormatters:i}=e,[l,c,u,f]=Hs(...t),m=ne(u.missingWarn)?u.missingWarn:e.missingWarn;ne(u.fallbackWarn)?u.fallbackWarn:e.fallbackWarn;const _=!!u.part,S=yo(e,u),T=a(e,s,S);if(!G(l)||l===\"\")return new Intl.DateTimeFormat(S,f).format(c);let I={},P,F=null;const y=\"datetime format\";for(let R=0;R<T.length&&(P=T[R],I=n[P]||{},F=I[l],!Z(F));R++)vo(e,l,P,m,y);if(!Z(F)||!G(P))return r?Zr:l;let v=`${P}__${l}`;zr(f)||(v=`${v}__${JSON.stringify(f)}`);let L=i.get(v);return L||(L=new Intl.DateTimeFormat(P,Ue({},F,f)),i.set(v,L)),_?L.formatToParts(c):L.format(c)}const nc=[\"localeMatcher\",\"weekday\",\"era\",\"year\",\"month\",\"day\",\"hour\",\"minute\",\"second\",\"timeZoneName\",\"formatMatcher\",\"hour12\",\"timeZone\",\"dateStyle\",\"timeStyle\",\"calendar\",\"dayPeriod\",\"numberingSystem\",\"hourCycle\",\"fractionalSecondDigits\"];function Hs(...e){const[t,n,r,s]=e,o=pe();let a=pe(),i;if(G(t)){const l=t.match(/(\\d{4}-\\d{2}-\\d{2})(T|\\s)?(.*)/);if(!l)throw yt(ut.INVALID_ISO_DATE_ARGUMENT);const c=l[3]?l[3].trim().startsWith(\"T\")?`${l[1].trim()}${l[3].trim()}`:`${l[1].trim()}T${l[3].trim()}`:l[1].trim();i=new Date(c);try{i.toISOString()}catch{throw yt(ut.INVALID_ISO_DATE_ARGUMENT)}}else if(Lh(t)){if(isNaN(t.getTime()))throw yt(ut.INVALID_DATE_ARGUMENT);i=t}else if(Ae(t))i=t;else throw yt(ut.INVALID_ARGUMENT);return G(n)?o.key=n:Z(n)&&Object.keys(n).forEach(l=>{nc.includes(l)?a[l]=n[l]:o[l]=n[l]}),G(r)?o.locale=r:Z(r)&&(a=r),Z(s)&&(a=s),[o.key||\"\",i,o,a]}function qa(e,t,n){const r=e;for(const s in n){const o=`${t}__${s}`;r.__datetimeFormatters.has(o)&&r.__datetimeFormatters.delete(o)}}function Ya(e,...t){const{numberFormats:n,unresolving:r,fallbackLocale:s,onWarn:o,localeFallbacker:a}=e,{__numberFormatters:i}=e,[l,c,u,f]=Ws(...t),m=ne(u.missingWarn)?u.missingWarn:e.missingWarn;ne(u.fallbackWarn)?u.fallbackWarn:e.fallbackWarn;const _=!!u.part,S=yo(e,u),T=a(e,s,S);if(!G(l)||l===\"\")return new Intl.NumberFormat(S,f).format(c);let I={},P,F=null;const y=\"number format\";for(let R=0;R<T.length&&(P=T[R],I=n[P]||{},F=I[l],!Z(F));R++)vo(e,l,P,m,y);if(!Z(F)||!G(P))return r?Zr:l;let v=`${P}__${l}`;zr(f)||(v=`${v}__${JSON.stringify(f)}`);let L=i.get(v);return L||(L=new Intl.NumberFormat(P,Ue({},F,f)),i.set(v,L)),_?L.formatToParts(c):L.format(c)}const rc=[\"localeMatcher\",\"style\",\"currency\",\"currencyDisplay\",\"currencySign\",\"useGrouping\",\"minimumIntegerDigits\",\"minimumFractionDigits\",\"maximumFractionDigits\",\"minimumSignificantDigits\",\"maximumSignificantDigits\",\"compactDisplay\",\"notation\",\"signDisplay\",\"unit\",\"unitDisplay\",\"roundingMode\",\"roundingPriority\",\"roundingIncrement\",\"trailingZeroDisplay\"];function Ws(...e){const[t,n,r,s]=e,o=pe();let a=pe();if(!Ae(t))throw yt(ut.INVALID_ARGUMENT);const i=t;return G(n)?o.key=n:Z(n)&&Object.keys(n).forEach(l=>{rc.includes(l)?a[l]=n[l]:o[l]=n[l]}),G(r)?o.locale=r:Z(r)&&(a=r),Z(s)&&(a=s),[o.key||\"\",i,o,a]}function Ja(e,t,n){const r=e;for(const s in n){const o=`${t}__${s}`;r.__numberFormatters.has(o)&&r.__numberFormatters.delete(o)}}cm();/*!\n  * vue-i18n v9.14.3\n  * (c) 2025 kazuya kawaguchi\n  * Released under the MIT License.\n  */const dg=\"9.14.3\";function hg(){typeof __VUE_I18N_FULL_INSTALL__!=\"boolean\"&&(At().__VUE_I18N_FULL_INSTALL__=!0),typeof __VUE_I18N_LEGACY_API__!=\"boolean\"&&(At().__VUE_I18N_LEGACY_API__=!0),typeof __INTLIFY_JIT_COMPILATION__!=\"boolean\"&&(At().__INTLIFY_JIT_COMPILATION__=!1),typeof __INTLIFY_DROP_MESSAGE_COMPILER__!=\"boolean\"&&(At().__INTLIFY_DROP_MESSAGE_COMPILER__=!1),typeof __INTLIFY_PROD_DEVTOOLS__!=\"boolean\"&&(At().__INTLIFY_PROD_DEVTOOLS__=!1)}const mg=Rm.__EXTEND_POINT__,St=Qr(mg);St(),St(),St(),St(),St(),St(),St(),St(),St();const sc=ut.__EXTEND_POINT__,ze=Qr(sc),Ne={UNEXPECTED_RETURN_TYPE:sc,INVALID_ARGUMENT:ze(),MUST_BE_CALL_SETUP_TOP:ze(),NOT_INSTALLED:ze(),NOT_AVAILABLE_IN_LEGACY_MODE:ze(),REQUIRED_VALUE:ze(),INVALID_VALUE:ze(),CANNOT_SETUP_VUE_DEVTOOLS_PLUGIN:ze(),NOT_INSTALLED_WITH_PROVIDE:ze(),UNEXPECTED_ERROR:ze(),NOT_COMPATIBLE_LEGACY_VUE_I18N:ze(),BRIDGE_SUPPORT_VUE_2_ONLY:ze(),MUST_DEFINE_I18N_OPTION_IN_ALLOW_COMPOSITION:ze(),NOT_AVAILABLE_COMPOSITION_IN_LEGACY:ze(),__EXTEND_POINT__:ze()};function De(e,...t){return Cn(e,null,void 0)}const Bs=Yt(\"__translateVNode\"),Ks=Yt(\"__datetimeParts\"),Gs=Yt(\"__numberParts\"),oc=Yt(\"__setPluralRules\"),ac=Yt(\"__injectWithOption\"),js=Yt(\"__dispose\");function sr(e){if(!ce(e))return e;for(const t in e)if(ct(e,t))if(!t.includes(\".\"))ce(e[t])&&sr(e[t]);else{const n=t.split(\".\"),r=n.length-1;let s=e,o=!1;for(let a=0;a<r;a++){if(n[a]===\"__proto__\")throw new Error(`unsafe key: ${n[a]}`);if(n[a]in s||(s[n[a]]=pe()),!ce(s[n[a]])){o=!0;break}s=s[n[a]]}o||(s[n[r]]=e[t],delete e[t]),ce(s[n[r]])&&sr(s[n[r]])}return e}function es(e,t){const{messages:n,__i18n:r,messageResolver:s,flatJson:o}=t,a=Z(n)?n:ke(r)?pe():{[e]:pe()};if(ke(r)&&r.forEach(i=>{if(\"locale\"in i&&\"resource\"in i){const{locale:l,resource:c}=i;l?(a[l]=a[l]||pe(),kr(c,a[l])):kr(c,a)}else G(i)&&kr(JSON.parse(i),a)}),s==null&&o)for(const i in a)ct(a,i)&&sr(a[i]);return a}function ic(e){return e.type}function lc(e,t,n){let r=ce(t.messages)?t.messages:pe();\"__i18nGlobal\"in n&&(r=es(e.locale.value,{messages:r,__i18n:n.__i18nGlobal}));const s=Object.keys(r);s.length&&s.forEach(o=>{e.mergeLocaleMessage(o,r[o])});{if(ce(t.datetimeFormats)){const o=Object.keys(t.datetimeFormats);o.length&&o.forEach(a=>{e.mergeDateTimeFormat(a,t.datetimeFormats[a])})}if(ce(t.numberFormats)){const o=Object.keys(t.numberFormats);o.length&&o.forEach(a=>{e.mergeNumberFormat(a,t.numberFormats[a])})}}}function Xa(e){return Pe(lr,null,e,0)}const za=\"__INTLIFY_META__\",Qa=()=>[],gg=()=>!1;let Za=0;function ei(e){return(t,n,r,s)=>e(n,r,Qn()||void 0,s)}const pg=()=>{const e=Qn();let t=null;return e&&(t=ic(e)[za])?{[za]:t}:null};function ko(e={},t){const{__root:n,__injectWithOption:r}=e,s=n===void 0,o=e.flatJson,a=Rr?ge:lo,i=!!e.translateExistCompatible;let l=ne(e.inheritLocale)?e.inheritLocale:!0;const c=a(n&&l?n.locale.value:G(e.locale)?e.locale:An),u=a(n&&l?n.fallbackLocale.value:G(e.fallbackLocale)||ke(e.fallbackLocale)||Z(e.fallbackLocale)||e.fallbackLocale===!1?e.fallbackLocale:c.value),f=a(es(c.value,e)),m=a(Z(e.datetimeFormats)?e.datetimeFormats:{[c.value]:{}}),_=a(Z(e.numberFormats)?e.numberFormats:{[c.value]:{}});let S=n?n.missingWarn:ne(e.missingWarn)||Kt(e.missingWarn)?e.missingWarn:!0,T=n?n.fallbackWarn:ne(e.fallbackWarn)||Kt(e.fallbackWarn)?e.fallbackWarn:!0,I=n?n.fallbackRoot:ne(e.fallbackRoot)?e.fallbackRoot:!0,P=!!e.fallbackFormat,F=ye(e.missing)?e.missing:null,y=ye(e.missing)?ei(e.missing):null,v=ye(e.postTranslation)?e.postTranslation:null,L=n?n.warnHtmlMessage:ne(e.warnHtmlMessage)?e.warnHtmlMessage:!0,R=!!e.escapeParameter;const D=n?n.modifiers:Z(e.modifiers)?e.modifiers:{};let $=e.pluralRules||n&&n.pluralRules,M;M=(()=>{s&&Ha(null);const E={version:dg,locale:c.value,fallbackLocale:u.value,messages:f.value,modifiers:D,pluralRules:$,missing:y===null?void 0:y,missingWarn:S,fallbackWarn:T,fallbackFormat:P,unresolving:!0,postTranslation:v===null?void 0:v,warnHtmlMessage:L,escapeParameter:R,messageResolver:e.messageResolver,messageCompiler:e.messageCompiler,__meta:{framework:\"vue\"}};E.datetimeFormats=m.value,E.numberFormats=_.value,E.__datetimeFormatters=Z(M)?M.__datetimeFormatters:void 0,E.__numberFormatters=Z(M)?M.__numberFormatters:void 0;const A=Wm(E);return s&&Ha(A),A})(),Dn(M,c.value,u.value);function me(){return[c.value,u.value,f.value,m.value,_.value]}const z=we({get:()=>c.value,set:E=>{c.value=E,M.locale=c.value}}),ue=we({get:()=>u.value,set:E=>{u.value=E,M.fallbackLocale=u.value,Dn(M,c.value,E)}}),Qe=we(()=>f.value),Je=we(()=>m.value),se=we(()=>_.value);function Q(){return ye(v)?v:null}function re(E){v=E,M.postTranslation=E}function Be(){return F}function Xe(E){E!==null&&(y=ei(E)),F=E,M.missing=y}const Se=(E,A,Y,oe,Te,Ke)=>{me();let Fe;try{__INTLIFY_PROD_DEVTOOLS__,s||(M.fallbackContext=n?Hm():void 0),Fe=E(M)}finally{__INTLIFY_PROD_DEVTOOLS__,s||(M.fallbackContext=void 0)}if(Y!==\"translate exists\"&&Ae(Fe)&&Fe===Zr||Y===\"translate exists\"&&!Fe){const[zt,ns]=A();return n&&I?oe(n):Te(zt)}else{if(Ke(Fe))return Fe;throw De(Ne.UNEXPECTED_RETURN_TYPE)}};function Le(...E){return Se(A=>Reflect.apply(Ga,null,[A,...E]),()=>Vs(...E),\"translate\",A=>Reflect.apply(A.t,A,[...E]),A=>A,A=>G(A))}function st(...E){const[A,Y,oe]=E;if(oe&&!ce(oe))throw De(Ne.INVALID_ARGUMENT);return Le(A,Y,Ue({resolvedMessage:!0},oe||{}))}function Ze(...E){return Se(A=>Reflect.apply(ja,null,[A,...E]),()=>Hs(...E),\"datetime format\",A=>Reflect.apply(A.d,A,[...E]),()=>$a,A=>G(A))}function mt(...E){return Se(A=>Reflect.apply(Ya,null,[A,...E]),()=>Ws(...E),\"number format\",A=>Reflect.apply(A.n,A,[...E]),()=>$a,A=>G(A))}function xe(E){return E.map(A=>G(A)||Ae(A)||ne(A)?Xa(String(A)):A)}const j={normalize:xe,interpolate:E=>E,type:\"vnode\"};function B(...E){return Se(A=>{let Y;const oe=A;try{oe.processor=j,Y=Reflect.apply(Ga,null,[oe,...E])}finally{oe.processor=null}return Y},()=>Vs(...E),\"translate\",A=>A[Bs](...E),A=>[Xa(A)],A=>ke(A))}function q(...E){return Se(A=>Reflect.apply(Ya,null,[A,...E]),()=>Ws(...E),\"number format\",A=>A[Gs](...E),Qa,A=>G(A)||ke(A))}function ae(...E){return Se(A=>Reflect.apply(ja,null,[A,...E]),()=>Hs(...E),\"datetime format\",A=>A[Ks](...E),Qa,A=>G(A)||ke(A))}function p(E){$=E,M.pluralRules=$}function b(E,A){return Se(()=>{if(!E)return!1;const Y=G(A)?A:c.value,oe=x(Y),Te=M.messageResolver(oe,E);return i?Te!=null:Pn(Te)||ot(Te)||G(Te)},()=>[E],\"translate exists\",Y=>Reflect.apply(Y.te,Y,[E,A]),gg,Y=>ne(Y))}function g(E){let A=null;const Y=jl(M,u.value,c.value);for(let oe=0;oe<Y.length;oe++){const Te=f.value[Y[oe]]||{},Ke=M.messageResolver(Te,E);if(Ke!=null){A=Ke;break}}return A}function w(E){const A=g(E);return A??(n?n.tm(E)||{}:{})}function x(E){return f.value[E]||{}}function N(E,A){if(o){const Y={[E]:A};for(const oe in Y)ct(Y,oe)&&sr(Y[oe]);A=Y[E]}f.value[E]=A,M.messages=f.value}function W(E,A){f.value[E]=f.value[E]||{};const Y={[E]:A};if(o)for(const oe in Y)ct(Y,oe)&&sr(Y[oe]);A=Y[E],kr(A,f.value[E]),M.messages=f.value}function V(E){return m.value[E]||{}}function d(E,A){m.value[E]=A,M.datetimeFormats=m.value,qa(M,E,A)}function h(E,A){m.value[E]=Ue(m.value[E]||{},A),M.datetimeFormats=m.value,qa(M,E,A)}function k(E){return _.value[E]||{}}function O(E,A){_.value[E]=A,M.numberFormats=_.value,Ja(M,E,A)}function K(E,A){_.value[E]=Ue(_.value[E]||{},A),M.numberFormats=_.value,Ja(M,E,A)}Za++,n&&Rr&&(Vt(n.locale,E=>{l&&(c.value=E,M.locale=E,Dn(M,c.value,u.value))}),Vt(n.fallbackLocale,E=>{l&&(u.value=E,M.fallbackLocale=E,Dn(M,c.value,u.value))}));const U={id:Za,locale:z,fallbackLocale:ue,get inheritLocale(){return l},set inheritLocale(E){l=E,E&&n&&(c.value=n.locale.value,u.value=n.fallbackLocale.value,Dn(M,c.value,u.value))},get availableLocales(){return Object.keys(f.value).sort()},messages:Qe,get modifiers(){return D},get pluralRules(){return $||{}},get isGlobal(){return s},get missingWarn(){return S},set missingWarn(E){S=E,M.missingWarn=S},get fallbackWarn(){return T},set fallbackWarn(E){T=E,M.fallbackWarn=T},get fallbackRoot(){return I},set fallbackRoot(E){I=E},get fallbackFormat(){return P},set fallbackFormat(E){P=E,M.fallbackFormat=P},get warnHtmlMessage(){return L},set warnHtmlMessage(E){L=E,M.warnHtmlMessage=E},get escapeParameter(){return R},set escapeParameter(E){R=E,M.escapeParameter=E},t:Le,getLocaleMessage:x,setLocaleMessage:N,mergeLocaleMessage:W,getPostTranslationHandler:Q,setPostTranslationHandler:re,getMissingHandler:Be,setMissingHandler:Xe,[oc]:p};return U.datetimeFormats=Je,U.numberFormats=se,U.rt=st,U.te=b,U.tm=w,U.d=Ze,U.n=mt,U.getDateTimeFormat=V,U.setDateTimeFormat=d,U.mergeDateTimeFormat=h,U.getNumberFormat=k,U.setNumberFormat=O,U.mergeNumberFormat=K,U[ac]=r,U[Bs]=B,U[Ks]=ae,U[Gs]=q,U}function _g(e){const t=G(e.locale)?e.locale:An,n=G(e.fallbackLocale)||ke(e.fallbackLocale)||Z(e.fallbackLocale)||e.fallbackLocale===!1?e.fallbackLocale:t,r=ye(e.missing)?e.missing:void 0,s=ne(e.silentTranslationWarn)||Kt(e.silentTranslationWarn)?!e.silentTranslationWarn:!0,o=ne(e.silentFallbackWarn)||Kt(e.silentFallbackWarn)?!e.silentFallbackWarn:!0,a=ne(e.fallbackRoot)?e.fallbackRoot:!0,i=!!e.formatFallbackMessages,l=Z(e.modifiers)?e.modifiers:{},c=e.pluralizationRules,u=ye(e.postTranslation)?e.postTranslation:void 0,f=G(e.warnHtmlInMessage)?e.warnHtmlInMessage!==\"off\":!0,m=!!e.escapeParameterHtml,_=ne(e.sync)?e.sync:!0;let S=e.messages;if(Z(e.sharedMessages)){const R=e.sharedMessages;S=Object.keys(R).reduce(($,M)=>{const ee=$[M]||($[M]={});return Ue(ee,R[M]),$},S||{})}const{__i18n:T,__root:I,__injectWithOption:P}=e,F=e.datetimeFormats,y=e.numberFormats,v=e.flatJson,L=e.translateExistCompatible;return{locale:t,fallbackLocale:n,messages:S,flatJson:v,datetimeFormats:F,numberFormats:y,missing:r,missingWarn:s,fallbackWarn:o,fallbackRoot:a,fallbackFormat:i,modifiers:l,pluralRules:c,postTranslation:u,warnHtmlMessage:f,escapeParameter:m,messageResolver:e.messageResolver,inheritLocale:_,translateExistCompatible:L,__i18n:T,__root:I,__injectWithOption:P}}function qs(e={},t){{const n=ko(_g(e)),{__extender:r}=e,s={id:n.id,get locale(){return n.locale.value},set locale(o){n.locale.value=o},get fallbackLocale(){return n.fallbackLocale.value},set fallbackLocale(o){n.fallbackLocale.value=o},get messages(){return n.messages.value},get datetimeFormats(){return n.datetimeFormats.value},get numberFormats(){return n.numberFormats.value},get availableLocales(){return n.availableLocales},get formatter(){return{interpolate(){return[]}}},set formatter(o){},get missing(){return n.getMissingHandler()},set missing(o){n.setMissingHandler(o)},get silentTranslationWarn(){return ne(n.missingWarn)?!n.missingWarn:n.missingWarn},set silentTranslationWarn(o){n.missingWarn=ne(o)?!o:o},get silentFallbackWarn(){return ne(n.fallbackWarn)?!n.fallbackWarn:n.fallbackWarn},set silentFallbackWarn(o){n.fallbackWarn=ne(o)?!o:o},get modifiers(){return n.modifiers},get formatFallbackMessages(){return n.fallbackFormat},set formatFallbackMessages(o){n.fallbackFormat=o},get postTranslation(){return n.getPostTranslationHandler()},set postTranslation(o){n.setPostTranslationHandler(o)},get sync(){return n.inheritLocale},set sync(o){n.inheritLocale=o},get warnHtmlInMessage(){return n.warnHtmlMessage?\"warn\":\"off\"},set warnHtmlInMessage(o){n.warnHtmlMessage=o!==\"off\"},get escapeParameterHtml(){return n.escapeParameter},set escapeParameterHtml(o){n.escapeParameter=o},get preserveDirectiveContent(){return!0},set preserveDirectiveContent(o){},get pluralizationRules(){return n.pluralRules||{}},__composer:n,t(...o){const[a,i,l]=o,c={};let u=null,f=null;if(!G(a))throw De(Ne.INVALID_ARGUMENT);const m=a;return G(i)?c.locale=i:ke(i)?u=i:Z(i)&&(f=i),ke(l)?u=l:Z(l)&&(f=l),Reflect.apply(n.t,n,[m,u||f||{},c])},rt(...o){return Reflect.apply(n.rt,n,[...o])},tc(...o){const[a,i,l]=o,c={plural:1};let u=null,f=null;if(!G(a))throw De(Ne.INVALID_ARGUMENT);const m=a;return G(i)?c.locale=i:Ae(i)?c.plural=i:ke(i)?u=i:Z(i)&&(f=i),G(l)?c.locale=l:ke(l)?u=l:Z(l)&&(f=l),Reflect.apply(n.t,n,[m,u||f||{},c])},te(o,a){return n.te(o,a)},tm(o){return n.tm(o)},getLocaleMessage(o){return n.getLocaleMessage(o)},setLocaleMessage(o,a){n.setLocaleMessage(o,a)},mergeLocaleMessage(o,a){n.mergeLocaleMessage(o,a)},d(...o){return Reflect.apply(n.d,n,[...o])},getDateTimeFormat(o){return n.getDateTimeFormat(o)},setDateTimeFormat(o,a){n.setDateTimeFormat(o,a)},mergeDateTimeFormat(o,a){n.mergeDateTimeFormat(o,a)},n(...o){return Reflect.apply(n.n,n,[...o])},getNumberFormat(o){return n.getNumberFormat(o)},setNumberFormat(o,a){n.setNumberFormat(o,a)},mergeNumberFormat(o,a){n.mergeNumberFormat(o,a)},getChoiceIndex(o,a){return-1}};return s.__extender=r,s}}const To={tag:{type:[String,Object]},locale:{type:String},scope:{type:String,validator:e=>e===\"parent\"||e===\"global\",default:\"parent\"},i18n:{type:Object}};function bg({slots:e},t){return t.length===1&&t[0]===\"default\"?(e.default?e.default():[]).reduce((r,s)=>[...r,...s.type===Ce?s.children:[s]],[]):t.reduce((n,r)=>{const s=e[r];return s&&(n[r]=s()),n},pe())}function cc(e){return Ce}const yg=it({name:\"i18n-t\",props:Ue({keypath:{type:String,required:!0},plural:{type:[Number,String],validator:e=>Ae(e)||!isNaN(e)}},To),setup(e,t){const{slots:n,attrs:r}=t,s=e.i18n||fn({useScope:e.scope,__useComponent:!0});return()=>{const o=Object.keys(n).filter(f=>f!==\"_\"),a=pe();e.locale&&(a.locale=e.locale),e.plural!==void 0&&(a.plural=G(e.plural)?+e.plural:e.plural);const i=bg(t,o),l=s[Bs](e.keypath,i,a),c=Ue(pe(),r),u=G(e.tag)||ce(e.tag)?e.tag:cc();return Gr(u,c,l)}}}),ti=yg;function vg(e){return ke(e)&&!G(e[0])}function uc(e,t,n,r){const{slots:s,attrs:o}=t;return()=>{const a={part:!0};let i=pe();e.locale&&(a.locale=e.locale),G(e.format)?a.key=e.format:ce(e.format)&&(G(e.format.key)&&(a.key=e.format.key),i=Object.keys(e.format).reduce((m,_)=>n.includes(_)?Ue(pe(),m,{[_]:e.format[_]}):m,pe()));const l=r(e.value,a,i);let c=[a.key];ke(l)?c=l.map((m,_)=>{const S=s[m.type],T=S?S({[m.type]:m.value,index:_,parts:l}):[m.value];return vg(T)&&(T[0].key=`${m.type}-${_}`),T}):G(l)&&(c=[l]);const u=Ue(pe(),o),f=G(e.tag)||ce(e.tag)?e.tag:cc();return Gr(f,u,c)}}const Eg=it({name:\"i18n-n\",props:Ue({value:{type:Number,required:!0},format:{type:[String,Object]}},To),setup(e,t){const n=e.i18n||fn({useScope:e.scope,__useComponent:!0});return uc(e,t,rc,(...r)=>n[Gs](...r))}}),ni=Eg,kg=it({name:\"i18n-d\",props:Ue({value:{type:[Number,Date],required:!0},format:{type:[String,Object]}},To),setup(e,t){const n=e.i18n||fn({useScope:e.scope,__useComponent:!0});return uc(e,t,nc,(...r)=>n[Ks](...r))}}),ri=kg;function Tg(e,t){const n=e;if(e.mode===\"composition\")return n.__getInstance(t)||e.global;{const r=n.__getInstance(t);return r!=null?r.__composer:e.global.__composer}}function wg(e){const t=a=>{const{instance:i,modifiers:l,value:c}=a;if(!i||!i.$)throw De(Ne.UNEXPECTED_ERROR);const u=Tg(e,i.$),f=si(c);return[Reflect.apply(u.t,u,[...oi(f)]),u]};return{created:(a,i)=>{const[l,c]=t(i);Rr&&e.global===c&&(a.__i18nWatcher=Vt(c.locale,()=>{i.instance&&i.instance.$forceUpdate()})),a.__composer=c,a.textContent=l},unmounted:a=>{Rr&&a.__i18nWatcher&&(a.__i18nWatcher(),a.__i18nWatcher=void 0,delete a.__i18nWatcher),a.__composer&&(a.__composer=void 0,delete a.__composer)},beforeUpdate:(a,{value:i})=>{if(a.__composer){const l=a.__composer,c=si(i);a.textContent=Reflect.apply(l.t,l,[...oi(c)])}},getSSRProps:a=>{const[i]=t(a);return{textContent:i}}}}function si(e){if(G(e))return{path:e};if(Z(e)){if(!(\"path\"in e))throw De(Ne.REQUIRED_VALUE,\"path\");return e}else throw De(Ne.INVALID_VALUE)}function oi(e){const{path:t,locale:n,args:r,choice:s,plural:o}=e,a={},i=r||{};return G(n)&&(a.locale=n),Ae(s)&&(a.plural=s),Ae(o)&&(a.plural=o),[t,i,a]}function Sg(e,t,...n){const r=Z(n[0])?n[0]:{},s=!!r.useI18nComponentName;(ne(r.globalInstall)?r.globalInstall:!0)&&([s?\"i18n\":ti.name,\"I18nT\"].forEach(a=>e.component(a,ti)),[ni.name,\"I18nN\"].forEach(a=>e.component(a,ni)),[ri.name,\"I18nD\"].forEach(a=>e.component(a,ri))),e.directive(\"t\",wg(t))}function Lg(e,t,n){return{beforeCreate(){const r=Qn();if(!r)throw De(Ne.UNEXPECTED_ERROR);const s=this.$options;if(s.i18n){const o=s.i18n;if(s.__i18n&&(o.__i18n=s.__i18n),o.__root=t,this===this.$root)this.$i18n=ai(e,o);else{o.__injectWithOption=!0,o.__extender=n.__vueI18nExtend,this.$i18n=qs(o);const a=this.$i18n;a.__extender&&(a.__disposer=a.__extender(this.$i18n))}}else if(s.__i18n)if(this===this.$root)this.$i18n=ai(e,s);else{this.$i18n=qs({__i18n:s.__i18n,__injectWithOption:!0,__extender:n.__vueI18nExtend,__root:t});const o=this.$i18n;o.__extender&&(o.__disposer=o.__extender(this.$i18n))}else this.$i18n=e;s.__i18nGlobal&&lc(t,s,s),this.$t=(...o)=>this.$i18n.t(...o),this.$rt=(...o)=>this.$i18n.rt(...o),this.$tc=(...o)=>this.$i18n.tc(...o),this.$te=(o,a)=>this.$i18n.te(o,a),this.$d=(...o)=>this.$i18n.d(...o),this.$n=(...o)=>this.$i18n.n(...o),this.$tm=o=>this.$i18n.tm(o),n.__setInstance(r,this.$i18n)},mounted(){},unmounted(){const r=Qn();if(!r)throw De(Ne.UNEXPECTED_ERROR);const s=this.$i18n;delete this.$t,delete this.$rt,delete this.$tc,delete this.$te,delete this.$d,delete this.$n,delete this.$tm,s.__disposer&&(s.__disposer(),delete s.__disposer,delete s.__extender),n.__deleteInstance(r),delete this.$i18n}}}function ai(e,t){e.locale=t.locale||e.locale,e.fallbackLocale=t.fallbackLocale||e.fallbackLocale,e.missing=t.missing||e.missing,e.silentTranslationWarn=t.silentTranslationWarn||e.silentFallbackWarn,e.silentFallbackWarn=t.silentFallbackWarn||e.silentFallbackWarn,e.formatFallbackMessages=t.formatFallbackMessages||e.formatFallbackMessages,e.postTranslation=t.postTranslation||e.postTranslation,e.warnHtmlInMessage=t.warnHtmlInMessage||e.warnHtmlInMessage,e.escapeParameterHtml=t.escapeParameterHtml||e.escapeParameterHtml,e.sync=t.sync||e.sync,e.__composer[oc](t.pluralizationRules||e.pluralizationRules);const n=es(e.locale,{messages:t.messages,__i18n:t.__i18n});return Object.keys(n).forEach(r=>e.mergeLocaleMessage(r,n[r])),t.datetimeFormats&&Object.keys(t.datetimeFormats).forEach(r=>e.mergeDateTimeFormat(r,t.datetimeFormats[r])),t.numberFormats&&Object.keys(t.numberFormats).forEach(r=>e.mergeNumberFormat(r,t.numberFormats[r])),e}const Og=Yt(\"global-vue-i18n\");function Ig(e={},t){const n=__VUE_I18N_LEGACY_API__&&ne(e.legacy)?e.legacy:__VUE_I18N_LEGACY_API__,r=ne(e.globalInjection)?e.globalInjection:!0,s=__VUE_I18N_LEGACY_API__&&n?!!e.allowComposition:!0,o=new Map,[a,i]=Ag(e,n),l=Yt(\"\");function c(m){return o.get(m)||null}function u(m,_){o.set(m,_)}function f(m){o.delete(m)}{const m={get mode(){return __VUE_I18N_LEGACY_API__&&n?\"legacy\":\"composition\"},get allowComposition(){return s},async install(_,...S){if(_.__VUE_I18N_SYMBOL__=l,_.provide(_.__VUE_I18N_SYMBOL__,m),Z(S[0])){const P=S[0];m.__composerExtend=P.__composerExtend,m.__vueI18nExtend=P.__vueI18nExtend}let T=null;!n&&r&&(T=$g(_,m.global)),__VUE_I18N_FULL_INSTALL__&&Sg(_,m,...S),__VUE_I18N_LEGACY_API__&&n&&_.mixin(Lg(i,i.__composer,m));const I=_.unmount;_.unmount=()=>{T&&T(),m.dispose(),I()}},get global(){return i},dispose(){a.stop()},__instances:o,__getInstance:c,__setInstance:u,__deleteInstance:f};return m}}function fn(e={}){const t=Qn();if(t==null)throw De(Ne.MUST_BE_CALL_SETUP_TOP);if(!t.isCE&&t.appContext.app!=null&&!t.appContext.app.__VUE_I18N_SYMBOL__)throw De(Ne.NOT_INSTALLED);const n=Pg(t),r=Cg(n),s=ic(t),o=Rg(e,s);if(__VUE_I18N_LEGACY_API__&&n.mode===\"legacy\"&&!e.__useComponent){if(!n.allowComposition)throw De(Ne.NOT_AVAILABLE_IN_LEGACY_MODE);return Dg(t,o,r,e)}if(o===\"global\")return lc(r,e,s),r;if(o===\"parent\"){let l=Ng(n,t,e.__useComponent);return l==null&&(l=r),l}const a=n;let i=a.__getInstance(t);if(i==null){const l=Ue({},e);\"__i18n\"in s&&(l.__i18n=s.__i18n),r&&(l.__root=r),i=ko(l),a.__composerExtend&&(i[js]=a.__composerExtend(i)),Mg(a,t,i),a.__setInstance(t,i)}return i}function Ag(e,t,n){const r=Zs();{const s=__VUE_I18N_LEGACY_API__&&t?r.run(()=>qs(e)):r.run(()=>ko(e));if(s==null)throw De(Ne.UNEXPECTED_ERROR);return[r,s]}}function Pg(e){{const t=rt(e.isCE?Og:e.appContext.app.__VUE_I18N_SYMBOL__);if(!t)throw De(e.isCE?Ne.NOT_INSTALLED_WITH_PROVIDE:Ne.UNEXPECTED_ERROR);return t}}function Rg(e,t){return zr(e)?\"__i18n\"in t?\"local\":\"global\":e.useScope?e.useScope:\"local\"}function Cg(e){return e.mode===\"composition\"?e.global:e.global.__composer}function Ng(e,t,n=!1){let r=null;const s=t.root;let o=xg(t,n);for(;o!=null;){const a=e;if(e.mode===\"composition\")r=a.__getInstance(o);else if(__VUE_I18N_LEGACY_API__){const i=a.__getInstance(o);i!=null&&(r=i.__composer,n&&r&&!r[ac]&&(r=null))}if(r!=null||s===o)break;o=o.parent}return r}function xg(e,t=!1){return e==null?null:t&&e.vnode.ctx||e.parent}function Mg(e,t,n){qt(()=>{},t),Rn(()=>{const r=n;e.__deleteInstance(t);const s=r[js];s&&(s(),delete r[js])},t)}function Dg(e,t,n,r={}){const s=t===\"local\",o=lo(null);if(s&&e.proxy&&!(e.proxy.$options.i18n||e.proxy.$options.__i18n))throw De(Ne.MUST_DEFINE_I18N_OPTION_IN_ALLOW_COMPOSITION);const a=ne(r.inheritLocale)?r.inheritLocale:!G(r.locale),i=ge(!s||a?n.locale.value:G(r.locale)?r.locale:An),l=ge(!s||a?n.fallbackLocale.value:G(r.fallbackLocale)||ke(r.fallbackLocale)||Z(r.fallbackLocale)||r.fallbackLocale===!1?r.fallbackLocale:i.value),c=ge(es(i.value,r)),u=ge(Z(r.datetimeFormats)?r.datetimeFormats:{[i.value]:{}}),f=ge(Z(r.numberFormats)?r.numberFormats:{[i.value]:{}}),m=s?n.missingWarn:ne(r.missingWarn)||Kt(r.missingWarn)?r.missingWarn:!0,_=s?n.fallbackWarn:ne(r.fallbackWarn)||Kt(r.fallbackWarn)?r.fallbackWarn:!0,S=s?n.fallbackRoot:ne(r.fallbackRoot)?r.fallbackRoot:!0,T=!!r.fallbackFormat,I=ye(r.missing)?r.missing:null,P=ye(r.postTranslation)?r.postTranslation:null,F=s?n.warnHtmlMessage:ne(r.warnHtmlMessage)?r.warnHtmlMessage:!0,y=!!r.escapeParameter,v=s?n.modifiers:Z(r.modifiers)?r.modifiers:{},L=r.pluralRules||s&&n.pluralRules;function R(){return[i.value,l.value,c.value,u.value,f.value]}const D=we({get:()=>o.value?o.value.locale.value:i.value,set:g=>{o.value&&(o.value.locale.value=g),i.value=g}}),$=we({get:()=>o.value?o.value.fallbackLocale.value:l.value,set:g=>{o.value&&(o.value.fallbackLocale.value=g),l.value=g}}),M=we(()=>o.value?o.value.messages.value:c.value),ee=we(()=>u.value),me=we(()=>f.value);function z(){return o.value?o.value.getPostTranslationHandler():P}function ue(g){o.value&&o.value.setPostTranslationHandler(g)}function Qe(){return o.value?o.value.getMissingHandler():I}function Je(g){o.value&&o.value.setMissingHandler(g)}function se(g){return R(),g()}function Q(...g){return o.value?se(()=>Reflect.apply(o.value.t,null,[...g])):se(()=>\"\")}function re(...g){return o.value?Reflect.apply(o.value.rt,null,[...g]):\"\"}function Be(...g){return o.value?se(()=>Reflect.apply(o.value.d,null,[...g])):se(()=>\"\")}function Xe(...g){return o.value?se(()=>Reflect.apply(o.value.n,null,[...g])):se(()=>\"\")}function Se(g){return o.value?o.value.tm(g):{}}function Le(g,w){return o.value?o.value.te(g,w):!1}function st(g){return o.value?o.value.getLocaleMessage(g):{}}function Ze(g,w){o.value&&(o.value.setLocaleMessage(g,w),c.value[g]=w)}function mt(g,w){o.value&&o.value.mergeLocaleMessage(g,w)}function xe(g){return o.value?o.value.getDateTimeFormat(g):{}}function C(g,w){o.value&&(o.value.setDateTimeFormat(g,w),u.value[g]=w)}function j(g,w){o.value&&o.value.mergeDateTimeFormat(g,w)}function B(g){return o.value?o.value.getNumberFormat(g):{}}function q(g,w){o.value&&(o.value.setNumberFormat(g,w),f.value[g]=w)}function ae(g,w){o.value&&o.value.mergeNumberFormat(g,w)}const p={get id(){return o.value?o.value.id:-1},locale:D,fallbackLocale:$,messages:M,datetimeFormats:ee,numberFormats:me,get inheritLocale(){return o.value?o.value.inheritLocale:a},set inheritLocale(g){o.value&&(o.value.inheritLocale=g)},get availableLocales(){return o.value?o.value.availableLocales:Object.keys(c.value)},get modifiers(){return o.value?o.value.modifiers:v},get pluralRules(){return o.value?o.value.pluralRules:L},get isGlobal(){return o.value?o.value.isGlobal:!1},get missingWarn(){return o.value?o.value.missingWarn:m},set missingWarn(g){o.value&&(o.value.missingWarn=g)},get fallbackWarn(){return o.value?o.value.fallbackWarn:_},set fallbackWarn(g){o.value&&(o.value.missingWarn=g)},get fallbackRoot(){return o.value?o.value.fallbackRoot:S},set fallbackRoot(g){o.value&&(o.value.fallbackRoot=g)},get fallbackFormat(){return o.value?o.value.fallbackFormat:T},set fallbackFormat(g){o.value&&(o.value.fallbackFormat=g)},get warnHtmlMessage(){return o.value?o.value.warnHtmlMessage:F},set warnHtmlMessage(g){o.value&&(o.value.warnHtmlMessage=g)},get escapeParameter(){return o.value?o.value.escapeParameter:y},set escapeParameter(g){o.value&&(o.value.escapeParameter=g)},t:Q,getPostTranslationHandler:z,setPostTranslationHandler:ue,getMissingHandler:Qe,setMissingHandler:Je,rt:re,d:Be,n:Xe,tm:Se,te:Le,getLocaleMessage:st,setLocaleMessage:Ze,mergeLocaleMessage:mt,getDateTimeFormat:xe,setDateTimeFormat:C,mergeDateTimeFormat:j,getNumberFormat:B,setNumberFormat:q,mergeNumberFormat:ae};function b(g){g.locale.value=i.value,g.fallbackLocale.value=l.value,Object.keys(c.value).forEach(w=>{g.mergeLocaleMessage(w,c.value[w])}),Object.keys(u.value).forEach(w=>{g.mergeDateTimeFormat(w,u.value[w])}),Object.keys(f.value).forEach(w=>{g.mergeNumberFormat(w,f.value[w])}),g.escapeParameter=y,g.fallbackFormat=T,g.fallbackRoot=S,g.fallbackWarn=_,g.missingWarn=m,g.warnHtmlMessage=F}return ji(()=>{if(e.proxy==null||e.proxy.$i18n==null)throw De(Ne.NOT_AVAILABLE_COMPOSITION_IN_LEGACY);const g=o.value=e.proxy.$i18n.__composer;t===\"global\"?(i.value=g.locale.value,l.value=g.fallbackLocale.value,c.value=g.messages.value,u.value=g.datetimeFormats.value,f.value=g.numberFormats.value):s&&b(g)}),p}const Fg=[\"locale\",\"fallbackLocale\",\"availableLocales\"],ii=[\"t\",\"rt\",\"d\",\"n\",\"tm\",\"te\"];function $g(e,t){const n=Object.create(null);return Fg.forEach(s=>{const o=Object.getOwnPropertyDescriptor(t,s);if(!o)throw De(Ne.UNEXPECTED_ERROR);const a=Oe(o.value)?{get(){return o.value.value},set(i){o.value.value=i}}:{get(){return o.get&&o.get()}};Object.defineProperty(n,s,a)}),e.config.globalProperties.$i18n=n,ii.forEach(s=>{const o=Object.getOwnPropertyDescriptor(t,s);if(!o||!o.value)throw De(Ne.UNEXPECTED_ERROR);Object.defineProperty(e.config.globalProperties,`$${s}`,o)}),()=>{delete e.config.globalProperties.$i18n,ii.forEach(s=>{delete e.config.globalProperties[`$${s}`]})}}hg();__INTLIFY_JIT_COMPILATION__?Va(ig):Va(ag);Fm(_m);$m(jl);if(__INTLIFY_PROD_DEVTOOLS__){const e=At();e.__INTLIFY__=!0,Lm(e.__INTLIFY_DEVTOOLS_GLOBAL_HOOK__)}const Ug={key:0,class:\"w-20 h-screen bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col items-center py-6 sticky top-0\"},Vg={class:\"flex flex-col items-center space-y-6 flex-1\"},Hg=[\"innerHTML\"],Wg={class:\"text-xs mt-1 dark:text-gray-300\"},Bg=[\"title\"],Kg=[\"innerHTML\"],Gg={class:\"text-xs mt-1 dark:text-gray-300\"},jg={key:0,class:\"absolute left-20 bottom-0 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg py-1 z-50 border border-gray-200 dark:border-gray-700\"},qg={class:\"px-4 py-2 text-sm text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700\"},Yg={class:\"font-medium dark:text-gray-300\"},Jg={key:1,class:\"fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 flex justify-around py-2 z-10\"},Xg=[\"innerHTML\"],zg={class:\"text-xs mt-1 dark:text-gray-300\"},Qg=it({__name:\"Sidebar\",props:{isMobile:{type:Boolean}},setup(e){const{t}=fn(),n=ur(),r=Xr(),s=ge(!1),o=ge(null),a=[{nameKey:\"navigation.home\",icon:\"home\",route:\"/home\"},{nameKey:\"navigation.tags\",icon:\"tag\",route:\"/tags\"},{nameKey:\"navigation.folders\",icon:\"folder\",route:\"/folders\"},{nameKey:\"navigation.archive\",icon:\"archive\",route:\"/archive\"},{nameKey:\"navigation.settings\",icon:\"settings\",route:\"/settings\"}],i=()=>{s.value=!s.value},l=async()=>{await n.logout(),s.value=!1,r.push(\"/login\")},c=f=>{o.value&&!o.value.contains(f.target)&&(s.value=!1)};qt(()=>{document.addEventListener(\"click\",c)}),Rn(()=>{document.removeEventListener(\"click\",c)});const u={home:`<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-6 w-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6\" />\n  </svg>`,tag:`<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-6 w-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z\" />\n  </svg>`,folder:`<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-6 w-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z\" />\n  </svg>`,archive:`<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-6 w-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4\" />\n  </svg>`,settings:`<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-6 w-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\" />\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\n  </svg>`,user:`<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-6 w-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n      d=\"M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z\" />\n  </svg>`};return(f,m)=>{var _,S;return fe(),Ee(\"div\",null,[f.isMobile?(fe(),Ee(\"nav\",Jg,[(fe(),Ee(Ce,null,Jn(a,T=>Pe(le(Er),{key:T.nameKey,to:T.route,class:\"text-gray-500 dark:text-gray-400 hover:text-red-500 p-2 flex flex-col items-center\"},{default:En(()=>[H(\"div\",{innerHTML:u[T.icon]},null,8,Xg),H(\"span\",zg,Ie(le(t)(T.nameKey)),1)]),_:2},1032,[\"to\"])),64))])):(fe(),Ee(\"aside\",Ug,[m[1]||(m[1]=H(\"div\",{class:\"mb-8 flex flex-col items-center\"},[H(\"div\",{class:\"text-red-500 font-bold text-2xl\"},\"栞\"),H(\"span\",{class:\"text-xs mt-1 dark:text-gray-300\"},\"shiori\")],-1)),H(\"nav\",Vg,[(fe(),Ee(Ce,null,Jn(a,T=>Pe(le(Er),{key:T.nameKey,to:T.route,class:\"text-gray-500 dark:text-gray-400 hover:text-red-500 p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex flex-col items-center\",title:le(t)(T.nameKey)},{default:En(()=>[H(\"div\",{innerHTML:u[T.icon]},null,8,Hg),H(\"span\",Wg,Ie(le(t)(T.nameKey)),1)]),_:2},1032,[\"to\",\"title\"])),64)),m[0]||(m[0]=H(\"div\",{class:\"flex-1\"},null,-1)),H(\"div\",{class:\"relative mt-auto\",ref_key:\"menuRef\",ref:o},[H(\"button\",{onClick:jr(i,[\"stop\"]),class:\"text-gray-500 dark:text-gray-400 hover:text-red-500 p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex flex-col items-center\",title:le(t)(\"auth.user\")},[H(\"div\",{innerHTML:u.user},null,8,Kg),H(\"span\",Gg,Ie(((_=le(n).user)==null?void 0:_.username)||le(t)(\"auth.user\")),1)],8,Bg),s.value?(fe(),Ee(\"div\",jg,[H(\"div\",qg,[H(\"div\",Yg,Ie(((S=le(n).user)==null?void 0:S.username)||le(t)(\"auth.user\")),1)]),Pe(le(Er),{to:\"/settings\",class:\"block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700\"},{default:En(()=>[ml(Ie(le(t)(\"navigation.settings\")),1)]),_:1}),H(\"button\",{onClick:l,class:\"block w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700\"},Ie(le(t)(\"auth.logout\")),1)])):Ht(\"\",!0)],512)])]))])}}}),wo=(e,t)=>{const n=e.__vccOpts||e;for(const[r,s]of t)n[r]=s;return n},Zg=wo(Qg,[[\"__scopeId\",\"data-v-4fa04b46\"]]),ep={loading:\"Loading...\",save:\"Save\",cancel:\"Cancel\",delete:\"Delete\",edit:\"Edit\",search:\"Search\",add:\"Add\",remove:\"Remove\",close:\"Close\"},tp={login:\"Login\",logout:\"Logout\",username:\"Username\",password:\"Password\",remember_me:\"Remember me\",login_failed:\"Login failed. Please check your credentials.\"},np={home:\"Home\",archive:\"Archive\",tags:\"Tags\",folders:\"Folders\",settings:\"Settings\",about:\"About\"},rp={title:\"Title\",add_bookmark:\"Add Bookmark\",edit_bookmark:\"Edit Bookmark\",delete_bookmark:\"Delete Bookmark\",url:\"URL\",excerpt:\"Excerpt\",tags:\"Tags\",add_tag:\"Add Tag\",no_bookmarks:\"No bookmarks found\"},sp={title:\"Tags\",add_tag:\"Add Tag\",edit_tag:\"Edit Tag\",delete_tag:\"Delete Tag\",name:\"Name\",no_tags:\"No tags found\"},op={title:\"Folders\",add_folder:\"Add Folder\",edit_folder:\"Edit Folder\",delete_folder:\"Delete Folder\",name:\"Name\",no_folders:\"No folders found\"},ap={title:\"Settings\",language:\"Language\",theme:\"Theme\",light:\"Light\",dark:\"Dark\",system:\"System\"},ip={common:ep,auth:tp,navigation:np,bookmarks:rp,tags:sp,folders:op,settings:ap},lp={loading:\"Cargando...\",save:\"Guardar\",cancel:\"Cancelar\",delete:\"Eliminar\",edit:\"Editar\",search:\"Buscar\",add:\"Añadir\",remove:\"Eliminar\",close:\"Cerrar\"},cp={login:\"Iniciar sesión\",logout:\"Cerrar sesión\",username:\"Usuario\",password:\"Contraseña\",remember_me:\"Recordarme\",login_failed:\"Error al iniciar sesión. Por favor, verifica tus credenciales.\"},up={home:\"Inicio\",archive:\"Archivo\",tags:\"Etiquetas\",folders:\"Carpetas\",settings:\"Configuración\",about:\"Acerca de\"},fp={title:\"Título\",add_bookmark:\"Añadir marcador\",edit_bookmark:\"Editar marcador\",delete_bookmark:\"Eliminar marcador\",url:\"URL\",excerpt:\"Extracto\",tags:\"Etiquetas\",add_tag:\"Añadir etiqueta\",no_bookmarks:\"No se encontraron marcadores\"},dp={title:\"Etiquetas\",add_tag:\"Añadir etiqueta\",edit_tag:\"Editar etiqueta\",delete_tag:\"Eliminar etiqueta\",name:\"Nombre\",no_tags:\"No se encontraron etiquetas\"},hp={title:\"Carpetas\",add_folder:\"Añadir carpeta\",edit_folder:\"Editar carpeta\",delete_folder:\"Eliminar carpeta\",name:\"Nombre\",no_folders:\"No se encontraron carpetas\"},mp={title:\"Configuración\",language:\"Idioma\",theme:\"Tema\",light:\"Claro\",dark:\"Oscuro\",system:\"Sistema\"},gp={common:lp,auth:cp,navigation:up,bookmarks:fp,tags:dp,folders:hp,settings:mp},pp={loading:\"Chargement...\",save:\"Enregistrer\",cancel:\"Annuler\",delete:\"Supprimer\",edit:\"Modifier\",search:\"Rechercher\",add:\"Ajouter\",remove:\"Supprimer\",close:\"Fermer\"},_p={login:\"Connexion\",logout:\"Déconnexion\",username:\"Nom d'utilisateur\",password:\"Mot de passe\",remember_me:\"Se souvenir de moi\",login_failed:\"Échec de la connexion. Veuillez vérifier vos identifiants.\"},bp={home:\"Accueil\",archive:\"Archives\",tags:\"Tags\",folders:\"Dossiers\",settings:\"Paramètres\",about:\"À propos\"},yp={title:\"Titre\",add_bookmark:\"Ajouter un favori\",edit_bookmark:\"Modifier le favori\",delete_bookmark:\"Supprimer le favori\",url:\"URL\",excerpt:\"Extrait\",tags:\"Tags\",add_tag:\"Ajouter un tag\",no_bookmarks:\"Aucun favori trouvé\"},vp={title:\"Tags\",add_tag:\"Ajouter un tag\",edit_tag:\"Modifier le tag\",delete_tag:\"Supprimer le tag\",name:\"Nom\",no_tags:\"Aucun tag trouvé\"},Ep={title:\"Dossiers\",add_folder:\"Ajouter un dossier\",edit_folder:\"Modifier le dossier\",delete_folder:\"Supprimer le dossier\",name:\"Nom\",no_folders:\"Aucun dossier trouvé\"},kp={title:\"Paramètres\",language:\"Langue\",theme:\"Thème\",light:\"Clair\",dark:\"Sombre\",system:\"Système\"},Tp={common:pp,auth:_p,navigation:bp,bookmarks:yp,tags:vp,folders:Ep,settings:kp},wp={loading:\"Wird geladen...\",save:\"Speichern\",cancel:\"Abbrechen\",delete:\"Löschen\",edit:\"Bearbeiten\",search:\"Suchen\",add:\"Hinzufügen\",remove:\"Entfernen\",close:\"Schließen\"},Sp={login:\"Anmelden\",logout:\"Abmelden\",username:\"Benutzername\",password:\"Passwort\",remember_me:\"Angemeldet bleiben\",login_failed:\"Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten.\"},Lp={home:\"Startseite\",archive:\"Archiv\",tags:\"Tags\",folders:\"Ordner\",settings:\"Einstellungen\",about:\"Über\"},Op={title:\"Titel\",add_bookmark:\"Lesezeichen hinzufügen\",edit_bookmark:\"Lesezeichen bearbeiten\",delete_bookmark:\"Lesezeichen löschen\",url:\"URL\",excerpt:\"Auszug\",tags:\"Tags\",add_tag:\"Tag hinzufügen\",no_bookmarks:\"Keine Lesezeichen gefunden\"},Ip={title:\"Tags\",add_tag:\"Tag hinzufügen\",edit_tag:\"Tag bearbeiten\",delete_tag:\"Tag löschen\",name:\"Name\",no_tags:\"Keine Tags gefunden\"},Ap={title:\"Ordner\",add_folder:\"Ordner hinzufügen\",edit_folder:\"Ordner bearbeiten\",delete_folder:\"Ordner löschen\",name:\"Name\",no_folders:\"Keine Ordner gefunden\"},Pp={title:\"Einstellungen\",language:\"Sprache\",theme:\"Thema\",light:\"Hell\",dark:\"Dunkel\",system:\"System\"},Rp={common:wp,auth:Sp,navigation:Lp,bookmarks:Op,tags:Ip,folders:Ap,settings:Pp},Cp={loading:\"読み込み中...\",save:\"保存\",cancel:\"キャンセル\",delete:\"削除\",edit:\"編集\",search:\"検索\",add:\"追加\",remove:\"削除\",close:\"閉じる\"},Np={login:\"ログイン\",logout:\"ログアウト\",username:\"ユーザー名\",password:\"パスワード\",remember_me:\"ログイン状態を保持する\",login_failed:\"ログインに失敗しました。認証情報を確認してください。\"},xp={home:\"ホーム\",archive:\"アーカイブ\",tags:\"タグ\",folders:\"フォルダ\",settings:\"設定\",about:\"概要\"},Mp={title:\"タイトル\",add_bookmark:\"ブックマークを追加\",edit_bookmark:\"ブックマークを編集\",delete_bookmark:\"ブックマークを削除\",url:\"URL\",excerpt:\"抜粋\",tags:\"タグ\",add_tag:\"タグを追加\",no_bookmarks:\"ブックマークが見つかりません\"},Dp={title:\"タグ\",add_tag:\"タグを追加\",edit_tag:\"タグを編集\",delete_tag:\"タグを削除\",name:\"名前\",no_tags:\"タグが見つかりません\"},Fp={title:\"フォルダ\",add_folder:\"フォルダを追加\",edit_folder:\"フォルダを編集\",delete_folder:\"フォルダを削除\",name:\"名前\",no_folders:\"フォルダが見つかりません\"},$p={title:\"設定\",language:\"言語\",theme:\"テーマ\",light:\"ライト\",dark:\"ダーク\",system:\"システム\"},Up={common:Cp,auth:Np,navigation:xp,bookmarks:Mp,tags:Dp,folders:Fp,settings:$p},Vp=()=>{const e=navigator.language.split(\"-\")[0];return[\"en\",\"es\",\"fr\",\"de\",\"ja\"].includes(e)?e:\"en\"},fc=()=>{const e=localStorage.getItem(\"shiori-language\");return e&&[\"en\",\"es\",\"fr\",\"de\",\"ja\"].includes(e)?e:Vp()},dc=Ig({legacy:!1,locale:fc(),fallbackLocale:\"en\",messages:{en:ip,es:gp,fr:Tp,de:Rp,ja:Up}}),Hp=e=>{var t;dc.global.locale.value=e,localStorage.setItem(\"shiori-language\",e),(t=document.querySelector(\"html\"))==null||t.setAttribute(\"lang\",e)};var li;(li=document.querySelector(\"html\"))==null||li.setAttribute(\"lang\",fc());const Wp={class:\"language-selector relative\"},Bp=[\"aria-expanded\"],Kp={class:\"mr-1\"},Gp={key:0,class:\"absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg z-10 py-1\"},jp=[\"onClick\"],qp=it({__name:\"LanguageSelector\",setup(e){const{t,locale:n}=fn(),r=[{code:\"en\",name:\"English\"},{code:\"es\",name:\"Español\"},{code:\"fr\",name:\"Français\"},{code:\"de\",name:\"Deutsch\"},{code:\"ja\",name:\"日本語\"}],s=ge(!1),o=ge(n.value),a=()=>{s.value=!s.value},i=()=>{s.value=!1},l=c=>{o.value=c,Hp(c),i()};return qt(()=>{document.addEventListener(\"click\",c=>{c.target.closest(\".language-selector\")||i()})}),(c,u)=>{var f;return fe(),Ee(\"div\",Wp,[H(\"button\",{onClick:jr(a,[\"stop\"]),class:\"flex items-center px-3 py-2 text-sm rounded-md hover:bg-gray-100 dark:hover:bg-gray-700\",\"aria-haspopup\":\"true\",\"aria-expanded\":s.value},[H(\"span\",Kp,Ie((f=r.find(m=>m.code===o.value))==null?void 0:f.name),1),u[0]||(u[0]=H(\"svg\",{class:\"w-4 h-4\",fill:\"none\",stroke:\"currentColor\",viewBox:\"0 0 24 24\",xmlns:\"http://www.w3.org/2000/svg\"},[H(\"path\",{\"stroke-linecap\":\"round\",\"stroke-linejoin\":\"round\",\"stroke-width\":\"2\",d:\"M19 9l-7 7-7-7\"})],-1))],8,Bp),s.value?(fe(),Ee(\"div\",Gp,[(fe(),Ee(Ce,null,Jn(r,m=>H(\"button\",{key:m.code,onClick:_=>l(m.code),class:$r([\"block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700\",{\"bg-gray-100 dark:bg-gray-700\":o.value===m.code}])},Ie(m.name),11,jp)),64))])):Ht(\"\",!0)])}}}),Yp={class:\"bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between\"},Jp={class:\"flex-1 mx-4\"},Xp={class:\"relative\"},zp=[\"placeholder\"],Qp={class:\"flex items-center space-x-2\"},Zp={key:0,class:\"absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg py-1 z-10 border border-gray-200 dark:border-gray-700\"},e_={class:\"px-4 py-2 text-sm text-gray-700 dark:text-gray-300\"},t_=it({__name:\"TopBar\",setup(e){const{t}=fn(),n=ge(!1),r=ur(),s=Xr(),o=ge(null),a=c=>{c.stopPropagation(),n.value=!n.value},i=async()=>{await r.logout(),n.value=!1,s.push(\"/login\")},l=c=>{o.value&&!o.value.contains(c.target)&&(n.value=!1)};return qt(()=>{document.addEventListener(\"click\",l)}),Rn(()=>{document.removeEventListener(\"click\",l)}),(c,u)=>{var f;return fe(),Ee(\"header\",Yp,[u[2]||(u[2]=H(\"div\",{class:\"flex items-center\"},[H(\"div\",{class:\"text-red-500 font-bold text-xl\"},\"栞\")],-1)),H(\"div\",Jp,[H(\"div\",Xp,[H(\"input\",{type:\"text\",placeholder:le(t)(\"common.search\"),class:\"w-full border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-md px-3 py-1 focus:outline-none focus:ring-2 focus:ring-red-500\"},null,8,zp)])]),H(\"div\",Qp,[Pe(qp),H(\"div\",{class:\"relative\",ref_key:\"menuRef\",ref:o},[H(\"button\",{onClick:a,class:\"text-gray-500 dark:text-gray-300 p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full\"},u[0]||(u[0]=[H(\"svg\",{xmlns:\"http://www.w3.org/2000/svg\",class:\"h-6 w-6\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\"},[H(\"path\",{\"stroke-linecap\":\"round\",\"stroke-linejoin\":\"round\",\"stroke-width\":\"2\",d:\"M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z\"})],-1)])),n.value?(fe(),Ee(\"div\",Zp,[H(\"div\",e_,Ie(((f=le(r).user)==null?void 0:f.username)||\"User\"),1),u[1]||(u[1]=H(\"hr\",{class:\"border-gray-200 dark:border-gray-700\"},null,-1)),H(\"a\",{href:\"#\",onClick:jr(i,[\"prevent\"]),class:\"block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700\"},Ie(le(t)(\"auth.logout\")),1)])):Ht(\"\",!0)],512)])])}}}),n_=wo(t_,[[\"__scopeId\",\"data-v-37e7f8fb\"]]),r_={class:\"min-h-screen flex flex-col bg-gray-100 dark:bg-gray-900\"},s_={class:\"flex flex-1\"},o_={class:\"flex-1 p-6 pb-24 md:pb-6 overflow-auto\"},a_={key:0,class:\"mb-6\"},i_={key:1,class:\"bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 fixed bottom-0 left-0 right-0 z-10\"},l_=it({__name:\"AppLayout\",setup(e){const t=ge(!1),n=()=>{t.value=window.innerWidth<768};return qt(()=>{n(),window.addEventListener(\"resize\",n)}),Rn(()=>{window.removeEventListener(\"resize\",n)}),(r,s)=>(fe(),Ee(\"div\",r_,[t.value?(fe(),wn(n_,{key:0})):Ht(\"\",!0),H(\"div\",s_,[Pe(Zg,{\"is-mobile\":t.value},null,8,[\"is-mobile\"]),H(\"main\",o_,[r.$slots.header?(fe(),Ee(\"header\",a_,[Ro(r.$slots,\"header\")])):Ht(\"\",!0),Ro(r.$slots,\"default\")])]),t.value?(fe(),Ee(\"nav\",i_)):Ht(\"\",!0)]))}}),c_={class:\"mt-6\"},u_={class:\"space-y-4\"},f_={class:\"flex justify-between\"},d_=[\"href\"],h_={class:\"text-gray-500 dark:text-gray-400 text-sm mt-1 truncate\"},m_={class:\"mt-2 flex flex-wrap gap-1\"},g_=it({__name:\"HomeView\",setup(e){const t=ge([{id:1,title:\"Example Bookmark 1\",url:\"https://example.com/1\",tags:[\"example\",\"first\"]},{id:2,title:\"Example Bookmark 2\",url:\"https://example.com/2\",tags:[\"example\",\"second\"]},{id:3,title:\"Example Bookmark 3\",url:\"https://example.com/3\",tags:[\"example\",\"third\"]}]);return(n,r)=>(fe(),wn(l_,null,{header:En(()=>r[0]||(r[0]=[H(\"div\",{class:\"flex justify-between items-center\"},[H(\"h1\",{class:\"text-xl font-bold text-gray-800 dark:text-white\"},\"My Bookmarks\"),H(\"div\",{class:\"flex space-x-2\"},[H(\"button\",{class:\"bg-red-500 text-white px-3 py-1 rounded-md hover:bg-red-600\"},\" Add Bookmark \"),H(\"div\",{class:\"relative\"},[H(\"input\",{type:\"text\",placeholder:\"Search...\",class:\"border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-md px-3 py-1 focus:outline-none focus:ring-2 focus:ring-red-500\"})])])],-1)])),default:En(()=>[H(\"div\",c_,[H(\"ul\",u_,[(fe(!0),Ee(Ce,null,Jn(t.value,s=>(fe(),Ee(\"li\",{key:s.id,class:\"bg-white dark:bg-gray-800 p-4 rounded-md shadow-sm hover:shadow-md transition-shadow\"},[H(\"div\",f_,[H(\"a\",{href:s.url,target:\"_blank\",class:\"text-blue-600 dark:text-blue-400 hover:underline font-medium\"},Ie(s.title),9,d_),r[1]||(r[1]=H(\"div\",{class:\"flex space-x-2\"},[H(\"button\",{class:\"text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300\"},[H(\"span\",{class:\"sr-only\"},\"Edit\"),H(\"svg\",{xmlns:\"http://www.w3.org/2000/svg\",class:\"h-5 w-5\",viewBox:\"0 0 20 20\",fill:\"currentColor\"},[H(\"path\",{d:\"M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z\"})])]),H(\"button\",{class:\"text-gray-500 dark:text-gray-400 hover:text-red-500\"},[H(\"span\",{class:\"sr-only\"},\"Delete\"),H(\"svg\",{xmlns:\"http://www.w3.org/2000/svg\",class:\"h-5 w-5\",viewBox:\"0 0 20 20\",fill:\"currentColor\"},[H(\"path\",{\"fill-rule\":\"evenodd\",d:\"M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z\",\"clip-rule\":\"evenodd\"})])])],-1))]),H(\"div\",h_,Ie(s.url),1),H(\"div\",m_,[(fe(!0),Ee(Ce,null,Jn(s.tags,o=>(fe(),Ee(\"span\",{key:o,class:\"bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-xs px-2 py-1 rounded-full\"},Ie(o),1))),128))])]))),128))])])]),_:1}))}}),p_={class:\"min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900\"},__={class:\"w-full max-w-md bg-white dark:bg-gray-800 shadow-lg rounded-md overflow-hidden\"},b_={class:\"p-8\"},y_={key:0,class:\"mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-md text-sm text-center\"},v_={key:1,class:\"mb-4 p-3 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-md text-sm text-center\"},E_={class:\"mb-6\"},k_={class:\"flex items-center mb-4\"},T_={class:\"w-28 text-right mr-4 text-gray-700 dark:text-gray-300\"},w_=[\"placeholder\"],S_={class:\"flex items-center\"},L_={class:\"w-28 text-right mr-4 text-gray-700 dark:text-gray-300\"},O_=[\"placeholder\"],I_={class:\"flex justify-center items-center mb-6\"},A_={for:\"remember-me\",class:\"ml-2 block text-sm text-gray-700 dark:text-gray-300\"},P_={class:\"flex justify-center\"},R_=[\"disabled\"],C_={key:0},N_={key:1},x_=it({__name:\"LoginView\",props:{dst:{}},setup(e){const t=e,{t:n}=fn(),r=ge(\"\"),s=ge(\"\"),o=ge(!1),a=ge(\"\"),i=ge(!1),l=Xr(),c=Gd(),u=ur();qt(async()=>{if(u.token){i.value=!0;const _=await u.validateToken();i.value=!1,_&&m()}});const f=async()=>{if(!r.value||!s.value){a.value=n(\"auth.login_failed\");return}i.value=!0,a.value=\"\";try{await u.login(r.value,s.value,o.value)?m():a.value=u.error||n(\"auth.login_failed\")}catch(_){console.error(\"Login error:\",_),a.value=_.message||n(\"auth.login_failed\")}finally{i.value=!1}},m=()=>{let _=u.getAndClearRedirectDestination();_||(_=t.dst||c.query.dst||\"/home\"),l.push(_)};return(_,S)=>(fe(),Ee(\"div\",p_,[H(\"div\",__,[S[3]||(S[3]=H(\"div\",{class:\"bg-red-500 text-white py-6 px-4 text-center\"},[H(\"div\",{class:\"text-4xl font-bold mb-1\"},\"栞 shiori\"),H(\"div\",{class:\"text-sm\"},\"simple bookmark manager\")],-1)),H(\"div\",b_,[a.value?(fe(),Ee(\"div\",y_,Ie(a.value),1)):Ht(\"\",!0),i.value&&le(u).token?(fe(),Ee(\"div\",v_,Ie(le(n)(\"common.loading\")),1)):Ht(\"\",!0),H(\"form\",{onSubmit:jr(f,[\"prevent\"])},[H(\"div\",E_,[H(\"div\",k_,[H(\"div\",T_,Ie(le(n)(\"auth.username\"))+\":\",1),ls(H(\"input\",{\"onUpdate:modelValue\":S[0]||(S[0]=T=>r.value=T),type:\"text\",class:\"flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500\",placeholder:le(n)(\"auth.username\"),required:\"\"},null,8,w_),[[ta,r.value]])]),H(\"div\",S_,[H(\"div\",L_,Ie(le(n)(\"auth.password\"))+\":\",1),ls(H(\"input\",{\"onUpdate:modelValue\":S[1]||(S[1]=T=>s.value=T),type:\"password\",class:\"flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500\",placeholder:le(n)(\"auth.password\"),required:\"\"},null,8,O_),[[ta,s.value]])])]),H(\"div\",I_,[ls(H(\"input\",{id:\"remember-me\",\"onUpdate:modelValue\":S[2]||(S[2]=T=>o.value=T),type:\"checkbox\",class:\"h-4 w-4 text-red-500 focus:ring-red-500 border-gray-300 dark:border-gray-600 rounded\"},null,512),[[If,o.value]]),H(\"label\",A_,Ie(le(n)(\"auth.remember_me\")),1)]),H(\"div\",P_,[H(\"button\",{type:\"submit\",class:\"w-full bg-gray-800 dark:bg-gray-700 text-white py-2 px-4 rounded-md hover:bg-gray-700 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 uppercase font-medium\",disabled:i.value},[i.value?(fe(),Ee(\"span\",C_,Ie(le(n)(\"common.loading\")),1)):(fe(),Ee(\"span\",N_,Ie(le(n)(\"auth.login\")),1))],8,R_)])],32)])])]))}}),M_=wo(x_,[[\"__scopeId\",\"data-v-0227928c\"]]),D_=[{path:\"/\",redirect:\"/home\"},{path:\"/home\",name:\"home\",component:g_,meta:{requiresAuth:!0}},{path:\"/login\",name:\"login\",component:M_,props:e=>({dst:e.query.dst})},{path:\"/tags\",name:\"tags\",component:()=>mr(()=>import(\"./TagsView-CmDnarVi.js\"),[]),meta:{requiresAuth:!0}},{path:\"/folders\",name:\"folders\",component:()=>mr(()=>import(\"./FoldersView-B-TWh6ac.js\"),__vite__mapDeps([0,1])),meta:{requiresAuth:!0}},{path:\"/archive\",name:\"archive\",component:()=>mr(()=>import(\"./ArchiveView-DZOySksr.js\"),[]),meta:{requiresAuth:!0}},{path:\"/settings\",name:\"settings\",component:()=>mr(()=>import(\"./SettingsView-BWJgD3kk.js\"),[]),meta:{requiresAuth:!0}},{path:\"/:pathMatch(.*)*\",redirect:\"/home\"}],hc=Bd({history:vd(\"/\"),routes:D_});hc.beforeEach(async(e,t,n)=>{const r=ur();if(e.matched.some(s=>s.meta.requiresAuth))if(r.token)if(await r.validateToken())n();else{const o=e.fullPath;r.setRedirectDestination(o),n({name:\"login\",query:{dst:o}})}else{const s=e.fullPath;r.setRedirectDestination(s),n({name:\"login\",query:{dst:s}})}else n()});const ts=xf(Eh);ts.use(Ff());ts.use(hc);ts.use(dc);ts.mount(\"#app\");export{$s as B,Dl as C,Ce as F,Ct as J,Ul as M,tn as R,bs as V,l_ as _,$_ as a,it as b,Xr as c,Bf as d,wn as e,fe as f,Ee as g,Ht as h,H as i,ls as j,jr as k,le as l,Jn as m,wo as n,qt as o,fn as p,$r as q,ge as r,Hp as s,Ie as t,ur as u,ta as v,En as w};\n"
  },
  {
    "path": "webapp/dist/assets/index-DoBsnBZ2.css",
    "content": "/*! tailwindcss v4.0.14 | MIT License | https://tailwindcss.com */@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\",\"Noto Color Emoji\";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace;--color-red-50:oklch(.971 .013 17.38);--color-red-100:oklch(.936 .032 17.717);--color-red-200:oklch(.885 .062 18.334);--color-red-300:oklch(.808 .114 19.571);--color-red-500:oklch(.637 .237 25.331);--color-red-600:oklch(.577 .245 27.325);--color-red-700:oklch(.505 .213 27.518);--color-red-900:oklch(.396 .141 25.723);--color-blue-100:oklch(.932 .032 255.585);--color-blue-300:oklch(.809 .105 251.813);--color-blue-400:oklch(.707 .165 254.624);--color-blue-500:oklch(.623 .214 259.815);--color-blue-600:oklch(.546 .245 262.881);--color-blue-700:oklch(.488 .243 264.376);--color-blue-900:oklch(.379 .146 265.522);--color-gray-50:oklch(.985 .002 247.839);--color-gray-100:oklch(.967 .003 264.542);--color-gray-200:oklch(.928 .006 264.531);--color-gray-300:oklch(.872 .01 258.338);--color-gray-400:oklch(.707 .022 261.325);--color-gray-500:oklch(.551 .027 264.364);--color-gray-600:oklch(.446 .03 256.802);--color-gray-700:oklch(.373 .034 259.733);--color-gray-800:oklch(.278 .033 256.848);--color-gray-900:oklch(.21 .034 264.665);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--radius-md:.375rem;--radius-lg:.5rem;--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-font-feature-settings:var(--font-sans--font-feature-settings);--default-font-variation-settings:var(--font-sans--font-variation-settings);--default-mono-font-family:var(--font-mono);--default-mono-font-feature-settings:var(--font-mono--font-feature-settings);--default-mono-font-variation-settings:var(--font-mono--font-variation-settings)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\",\"Noto Color Emoji\");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:color-mix(in oklab,currentColor 50%,transparent)}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing)*0)}.top-0{top:calc(var(--spacing)*0)}.right-0{right:calc(var(--spacing)*0)}.bottom-0{bottom:calc(var(--spacing)*0)}.left-0{left:calc(var(--spacing)*0)}.left-20{left:calc(var(--spacing)*20)}.z-10{z-index:10}.z-50{z-index:50}.mx-4{margin-inline:calc(var(--spacing)*4)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-auto{margin-top:auto}.mr-1{margin-right:calc(var(--spacing)*1)}.mr-3{margin-right:calc(var(--spacing)*3)}.mr-4{margin-right:calc(var(--spacing)*4)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-2{margin-left:calc(var(--spacing)*2)}.block{display:block}.flex{display:flex}.grid{display:grid}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-8{height:calc(var(--spacing)*8)}.h-12{height:calc(var(--spacing)*12)}.h-full{height:100%}.h-screen{height:100vh}.min-h-screen{min-height:100vh}.w-4{width:calc(var(--spacing)*4)}.w-5{width:calc(var(--spacing)*5)}.w-6{width:calc(var(--spacing)*6)}.w-8{width:calc(var(--spacing)*8)}.w-12{width:calc(var(--spacing)*12)}.w-20{width:calc(var(--spacing)*20)}.w-28{width:calc(var(--spacing)*28)}.w-48{width:calc(var(--spacing)*48)}.w-full{width:100%}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing)*1)}.gap-4{gap:calc(var(--spacing)*4)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*1)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*2)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-3>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*3)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-x-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-red-200{border-color:var(--color-red-200)}.border-red-500{border-color:var(--color-red-500)}.bg-\\[var\\(--background-color\\)\\]{background-color:var(--background-color)}.bg-black{background-color:var(--color-black)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-800{background-color:var(--color-gray-800)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-white{background-color:var(--color-white)}.p-1{padding:calc(var(--spacing)*1)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.py-1{padding-block:calc(var(--spacing)*1)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.pb-24{padding-bottom:calc(var(--spacing)*24)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.text-\\[var\\(--text-color\\)\\]{color:var(--text-color)}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\\:bg-blue-600:hover{background-color:var(--color-blue-600)}.hover\\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\\:bg-gray-700:hover{background-color:var(--color-gray-700)}.hover\\:bg-red-600:hover{background-color:var(--color-red-600)}.hover\\:text-blue-700:hover{color:var(--color-blue-700)}.hover\\:text-gray-600:hover{color:var(--color-gray-600)}.hover\\:text-gray-700:hover{color:var(--color-gray-700)}.hover\\:text-red-500:hover{color:var(--color-red-500)}.hover\\:underline:hover{text-decoration-line:underline}.hover\\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentColor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\\:ring-gray-500:focus{--tw-ring-color:var(--color-gray-500)}.focus\\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\\:opacity-50:disabled{opacity:.5}@media (width>=40rem){.sm\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (width>=48rem){.md\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\\:pb-6{padding-bottom:calc(var(--spacing)*6)}}@media (width>=64rem){.lg\\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (prefers-color-scheme:dark){.dark\\:border-gray-600{border-color:var(--color-gray-600)}.dark\\:border-gray-700{border-color:var(--color-gray-700)}.dark\\:bg-blue-900\\/30{background-color:color-mix(in oklab,var(--color-blue-900)30%,transparent)}.dark\\:bg-gray-700{background-color:var(--color-gray-700)}.dark\\:bg-gray-800{background-color:var(--color-gray-800)}.dark\\:bg-gray-900{background-color:var(--color-gray-900)}.dark\\:bg-red-900\\/20{background-color:color-mix(in oklab,var(--color-red-900)20%,transparent)}.dark\\:bg-red-900\\/30{background-color:color-mix(in oklab,var(--color-red-900)30%,transparent)}.dark\\:text-blue-300{color:var(--color-blue-300)}.dark\\:text-blue-400{color:var(--color-blue-400)}.dark\\:text-gray-100{color:var(--color-gray-100)}.dark\\:text-gray-300{color:var(--color-gray-300)}.dark\\:text-gray-400{color:var(--color-gray-400)}.dark\\:text-red-300{color:var(--color-red-300)}.dark\\:text-white{color:var(--color-white)}@media (hover:hover){.dark\\:hover\\:bg-gray-600:hover{background-color:var(--color-gray-600)}.dark\\:hover\\:bg-gray-700:hover{background-color:var(--color-gray-700)}.dark\\:hover\\:text-gray-300:hover{color:var(--color-gray-300)}}}}:root{--primary-color:#f44336;--secondary-color:#fff;--text-color:#333;--background-color:#f5f5f5;--card-background:#fff;--border-color:#e0e0e0}@media (prefers-color-scheme:dark){:root{--primary-color:#f44336;--secondary-color:#1f1f1f;--text-color:#f5f5f5;--background-color:#121212;--card-background:#1e1e1e;--border-color:#333}}html,body{height:100%;margin:0;padding:0}body{color:var(--text-color);background-color:var(--background-color);min-height:100vh;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Open Sans,Helvetica Neue,sans-serif}#app{flex-direction:column;min-height:100vh;display:flex}@property --tw-space-y-reverse{syntax:\"*\";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:\"*\";inherits:false;initial-value:0}@property --tw-border-style{syntax:\"*\";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:\"*\";inherits:false}@property --tw-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:\"*\";inherits:false}@property --tw-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:\"*\";inherits:false}@property --tw-ring-color{syntax:\"*\";inherits:false}@property --tw-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:\"*\";inherits:false}@property --tw-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:\"*\";inherits:false}@property --tw-ring-offset-width{syntax:\"<length>\";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:\"*\";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:\"*\";inherits:false}@property --tw-brightness{syntax:\"*\";inherits:false}@property --tw-contrast{syntax:\"*\";inherits:false}@property --tw-grayscale{syntax:\"*\";inherits:false}@property --tw-hue-rotate{syntax:\"*\";inherits:false}@property --tw-invert{syntax:\"*\";inherits:false}@property --tw-opacity{syntax:\"*\";inherits:false}@property --tw-saturate{syntax:\"*\";inherits:false}@property --tw-sepia{syntax:\"*\";inherits:false}@property --tw-drop-shadow{syntax:\"*\";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}html,body,#app{height:100%;min-height:100vh;margin:0;padding:0}body{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Open Sans,Helvetica Neue,sans-serif;color:var(--text-color);background-color:var(--background-color)}.relative[data-v-4fa04b46],.relative[data-v-37e7f8fb]{position:relative}html,body,#app{height:100%;min-height:100vh}\n"
  },
  {
    "path": "webapp/dist/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\">\n    <link rel=\"icon\" href=\"/favicon.ico\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Shiori - Simple Bookmark Manager</title>\n    <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n    <script type=\"module\" crossorigin src=\"/assets/index-C8c580-n.js\"></script>\n    <link rel=\"stylesheet\" crossorigin href=\"/assets/index-DoBsnBZ2.css\">\n  </head>\n  <body>\n    <div id=\"app\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "webapp/embed.go",
    "content": "package webapp\n\nimport (\n\t\"embed\"\n)\n\n//go:embed dist/index.html\nvar Templates embed.FS\n\n//go:embed dist/assets dist/*.ico\nvar Assets embed.FS\n"
  },
  {
    "path": "webapp/env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "webapp/eslint.config.ts",
    "content": "import pluginVue from 'eslint-plugin-vue'\nimport { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'\nimport pluginVitest from '@vitest/eslint-plugin'\nimport skipFormatting from '@vue/eslint-config-prettier/skip-formatting'\n\n// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:\n// import { configureVueProject } from '@vue/eslint-config-typescript'\n// configureVueProject({ scriptLangs: ['ts', 'tsx'] })\n// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup\n\nexport default defineConfigWithVueTs(\n  {\n    name: 'app/files-to-lint',\n    files: ['**/*.{ts,mts,tsx,vue}'],\n  },\n\n  {\n    name: 'app/files-to-ignore',\n    ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],\n  },\n\n  pluginVue.configs['flat/essential'],\n  vueTsConfigs.recommended,\n  \n  {\n    ...pluginVitest.configs.recommended,\n    files: ['src/**/__tests__/*'],\n  },\n  skipFormatting,\n)\n"
  },
  {
    "path": "webapp/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\">\n    <link rel=\"icon\" href=\"/favicon.ico\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Shiori - Simple Bookmark Manager</title>\n    <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/main.ts\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "webapp/package.json",
    "content": "{\n  \"name\": \"shiori\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"run-p type-check \\\"build-only {@}\\\" --\",\n    \"preview\": \"vite preview\",\n    \"test:unit\": \"vitest\",\n    \"build-only\": \"vite build\",\n    \"type-check\": \"vue-tsc --build\",\n    \"lint\": \"eslint . --fix\",\n    \"format\": \"prettier --write src/\"\n  },\n  \"dependencies\": {\n    \"@tailwindcss/vite\": \"^4.1.13\",\n    \"@vueuse/core\": \"^13.9.0\",\n    \"pinia\": \"^3.0.3\",\n    \"vue\": \"^3.5.22\",\n    \"vue-i18n\": \"^9.14.5\",\n    \"vue-router\": \"^4.5.1\"\n  },\n  \"devDependencies\": {\n    \"@tsconfig/node22\": \"^22.0.2\",\n    \"@types/jsdom\": \"^21.1.7\",\n    \"@types/node\": \"^22.18.6\",\n    \"@types/vue-router\": \"^2.0.0\",\n    \"@vitejs/plugin-vue\": \"^5.2.4\",\n    \"@vitest/eslint-plugin\": \"^1.3.13\",\n    \"@vue/eslint-config-prettier\": \"^10.2.0\",\n    \"@vue/eslint-config-typescript\": \"^14.6.0\",\n    \"@vue/test-utils\": \"^2.4.6\",\n    \"@vue/tsconfig\": \"^0.7.0\",\n    \"eslint\": \"^9.36.0\",\n    \"eslint-plugin-vue\": \"~10.0.1\",\n    \"jiti\": \"^2.6.0\",\n    \"jsdom\": \"^26.1.0\",\n    \"npm-run-all2\": \"^7.0.2\",\n    \"prettier\": \"3.5.3\",\n    \"tailwindcss\": \"^4.1.13\",\n    \"typescript\": \"~5.8.3\",\n    \"vite\": \"^6.3.6\",\n    \"vite-plugin-vue-devtools\": \"^7.7.7\",\n    \"vitest\": \"^3.2.4\",\n    \"vue-tsc\": \"^2.2.12\"\n  }\n}\n"
  },
  {
    "path": "webapp/src/App.vue",
    "content": "<script setup lang=\"ts\">\nimport { RouterView } from 'vue-router'\nimport { onMounted, ref } from 'vue'\nimport { useAuthStore } from './stores/auth'\nimport { useRouter } from 'vue-router'\n\nconst authStore = useAuthStore()\nconst router = useRouter()\nconst isInitializing = ref(true)\n\nonMounted(async () => {\n  // If we have a token, validate it\n  if (authStore.token) {\n    try {\n      // Validate the token by fetching user info\n      await authStore.validateToken()\n    } catch (error) {\n      console.error('Failed to validate token:', error)\n    }\n  }\n  isInitializing.value = false\n})\n</script>\n\n<template>\n  <div class=\"min-h-screen h-full flex flex-col bg-[var(--background-color)] text-[var(--text-color)]\">\n    <div v-if=\"isInitializing\"\n      class=\"fixed inset-0 flex items-center justify-center bg-white dark:bg-gray-900 bg-opacity-80 dark:bg-opacity-80 z-50\">\n      <div class=\"text-center\">\n        <div class=\"animate-spin rounded-full h-12 w-12 border-b-2 border-red-500 mx-auto mb-2\"></div>\n        <p class=\"text-gray-700 dark:text-gray-300\">Loading...</p>\n      </div>\n    </div>\n    <RouterView v-else class=\"flex-1\" />\n  </div>\n</template>\n\n<style>\n/* Global styles */\nhtml,\nbody,\n#app {\n  height: 100%;\n  min-height: 100vh;\n  margin: 0;\n  padding: 0;\n}\n\nbody {\n  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;\n  color: var(--text-color);\n  background-color: var(--background-color);\n}\n</style>\n"
  },
  {
    "path": "webapp/src/assets/main.css",
    "content": "@import \"tailwindcss\";\n\n/* Custom styles */\n:root {\n  --primary-color: #f44336;\n  --secondary-color: #ffffff;\n  --text-color: #333333;\n  --background-color: #f5f5f5;\n  --card-background: #ffffff;\n  --border-color: #e0e0e0;\n}\n\n/* Dark mode variables */\n@media (prefers-color-scheme: dark) {\n  :root {\n    --primary-color: #f44336;\n    --secondary-color: #1f1f1f;\n    --text-color: #f5f5f5;\n    --background-color: #121212;\n    --card-background: #1e1e1e;\n    --border-color: #333333;\n  }\n}\n\n/* Base styles for full height layout */\nhtml, body {\n  height: 100%;\n  margin: 0;\n  padding: 0;\n}\n\nbody {\n  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;\n  color: var(--text-color);\n  background-color: var(--background-color);\n  min-height: 100vh;\n}\n\n#app {\n  display: flex;\n  flex-direction: column;\n  min-height: 100vh;\n}\n"
  },
  {
    "path": "webapp/src/client/.openapi-generator/FILES",
    "content": ".openapi-generator-ignore\napis/AccountsApi.ts\napis/AuthApi.ts\napis/SystemApi.ts\napis/TagsApi.ts\napis/index.ts\nindex.ts\nmodels/ApiV1BookmarkTagPayload.ts\nmodels/ApiV1BulkUpdateBookmarkTagsPayload.ts\nmodels/ApiV1InfoResponse.ts\nmodels/ApiV1InfoResponseVersion.ts\nmodels/ApiV1LoginRequestPayload.ts\nmodels/ApiV1LoginResponseMessage.ts\nmodels/ApiV1ReadableResponseMessage.ts\nmodels/ApiV1UpdateAccountPayload.ts\nmodels/ApiV1UpdateCachePayload.ts\nmodels/ModelAccount.ts\nmodels/ModelAccountDTO.ts\nmodels/ModelBookmarkDTO.ts\nmodels/ModelTagDTO.ts\nmodels/ModelUserConfig.ts\nmodels/index.ts\nruntime.ts\n"
  },
  {
    "path": "webapp/src/client/.openapi-generator/VERSION",
    "content": "7.12.0\n"
  },
  {
    "path": "webapp/src/client/.openapi-generator-ignore",
    "content": "# OpenAPI Generator Ignore\n# Generated by openapi-generator https://github.com/openapitools/openapi-generator\n\n# Use this file to prevent files from being overwritten by the generator.\n# The patterns follow closely to .gitignore or .dockerignore.\n\n# As an example, the C# client generator defines ApiClient.cs.\n# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:\n#ApiClient.cs\n\n# You can match any string of characters against a directory, file or extension with a single asterisk (*):\n#foo/*/qux\n# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux\n\n# You can recursively match patterns against a directory, file or extension with a double asterisk (**):\n#foo/**/qux\n# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux\n\n# You can also negate patterns with an exclamation (!).\n# For example, you can ignore all files in a docs folder with the file extension .md:\n#docs/*.md\n# Then explicitly reverse the ignore rule for a single file:\n#!docs/README.md\n"
  },
  {
    "path": "webapp/src/client/apis/AccountsApi.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\n\nimport * as runtime from '../runtime';\nimport type {\n  ApiV1UpdateAccountPayload,\n  ModelAccountDTO,\n} from '../models/index';\nimport {\n    ApiV1UpdateAccountPayloadFromJSON,\n    ApiV1UpdateAccountPayloadToJSON,\n    ModelAccountDTOFromJSON,\n    ModelAccountDTOToJSON,\n} from '../models/index';\n\nexport interface ApiV1AccountsIdDeleteRequest {\n    id: number;\n}\n\nexport interface ApiV1AccountsIdPatchRequest {\n    id: number;\n    account: ApiV1UpdateAccountPayload;\n}\n\n/**\n * \n */\nexport class AccountsApi extends runtime.BaseAPI {\n\n    /**\n     * List accounts\n     * List accounts\n     */\n    async apiV1AccountsGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Array<ModelAccountDTO>>> {\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        const response = await this.request({\n            path: `/api/v1/accounts`,\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => jsonValue.map(ModelAccountDTOFromJSON));\n    }\n\n    /**\n     * List accounts\n     * List accounts\n     */\n    async apiV1AccountsGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Array<ModelAccountDTO>> {\n        const response = await this.apiV1AccountsGetRaw(initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Delete an account\n     */\n    async apiV1AccountsIdDeleteRaw(requestParameters: ApiV1AccountsIdDeleteRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<void>> {\n        if (requestParameters['id'] == null) {\n            throw new runtime.RequiredError(\n                'id',\n                'Required parameter \"id\" was null or undefined when calling apiV1AccountsIdDelete().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        const response = await this.request({\n            path: `/api/v1/accounts/{id}`.replace(`{${\"id\"}}`, encodeURIComponent(String(requestParameters['id']))),\n            method: 'DELETE',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.VoidApiResponse(response);\n    }\n\n    /**\n     * Delete an account\n     */\n    async apiV1AccountsIdDelete(requestParameters: ApiV1AccountsIdDeleteRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<void> {\n        await this.apiV1AccountsIdDeleteRaw(requestParameters, initOverrides);\n    }\n\n    /**\n     * Update an account\n     */\n    async apiV1AccountsIdPatchRaw(requestParameters: ApiV1AccountsIdPatchRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ModelAccountDTO>> {\n        if (requestParameters['id'] == null) {\n            throw new runtime.RequiredError(\n                'id',\n                'Required parameter \"id\" was null or undefined when calling apiV1AccountsIdPatch().'\n            );\n        }\n\n        if (requestParameters['account'] == null) {\n            throw new runtime.RequiredError(\n                'account',\n                'Required parameter \"account\" was null or undefined when calling apiV1AccountsIdPatch().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        headerParameters['Content-Type'] = 'application/json';\n\n        const response = await this.request({\n            path: `/api/v1/accounts/{id}`.replace(`{${\"id\"}}`, encodeURIComponent(String(requestParameters['id']))),\n            method: 'PATCH',\n            headers: headerParameters,\n            query: queryParameters,\n            body: ApiV1UpdateAccountPayloadToJSON(requestParameters['account']),\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => ModelAccountDTOFromJSON(jsonValue));\n    }\n\n    /**\n     * Update an account\n     */\n    async apiV1AccountsIdPatch(requestParameters: ApiV1AccountsIdPatchRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ModelAccountDTO> {\n        const response = await this.apiV1AccountsIdPatchRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Create an account\n     */\n    async apiV1AccountsPostRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ModelAccountDTO>> {\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        const response = await this.request({\n            path: `/api/v1/accounts`,\n            method: 'POST',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => ModelAccountDTOFromJSON(jsonValue));\n    }\n\n    /**\n     * Create an account\n     */\n    async apiV1AccountsPost(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ModelAccountDTO> {\n        const response = await this.apiV1AccountsPostRaw(initOverrides);\n        return await response.value();\n    }\n\n}\n"
  },
  {
    "path": "webapp/src/client/apis/AuthApi.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\n\nimport * as runtime from '../runtime';\nimport type {\n  ApiV1BookmarkTagPayload,\n  ApiV1BulkUpdateBookmarkTagsPayload,\n  ApiV1LoginRequestPayload,\n  ApiV1LoginResponseMessage,\n  ApiV1ReadableResponseMessage,\n  ApiV1UpdateAccountPayload,\n  ApiV1UpdateCachePayload,\n  ModelAccount,\n  ModelBookmarkDTO,\n  ModelTagDTO,\n} from '../models/index';\nimport {\n    ApiV1BookmarkTagPayloadFromJSON,\n    ApiV1BookmarkTagPayloadToJSON,\n    ApiV1BulkUpdateBookmarkTagsPayloadFromJSON,\n    ApiV1BulkUpdateBookmarkTagsPayloadToJSON,\n    ApiV1LoginRequestPayloadFromJSON,\n    ApiV1LoginRequestPayloadToJSON,\n    ApiV1LoginResponseMessageFromJSON,\n    ApiV1LoginResponseMessageToJSON,\n    ApiV1ReadableResponseMessageFromJSON,\n    ApiV1ReadableResponseMessageToJSON,\n    ApiV1UpdateAccountPayloadFromJSON,\n    ApiV1UpdateAccountPayloadToJSON,\n    ApiV1UpdateCachePayloadFromJSON,\n    ApiV1UpdateCachePayloadToJSON,\n    ModelAccountFromJSON,\n    ModelAccountToJSON,\n    ModelBookmarkDTOFromJSON,\n    ModelBookmarkDTOToJSON,\n    ModelTagDTOFromJSON,\n    ModelTagDTOToJSON,\n} from '../models/index';\n\nexport interface ApiV1AuthAccountPatchRequest {\n    payload?: ApiV1UpdateAccountPayload;\n}\n\nexport interface ApiV1AuthLoginPostRequest {\n    payload?: ApiV1LoginRequestPayload;\n}\n\nexport interface ApiV1BookmarksBulkTagsPutRequest {\n    payload: ApiV1BulkUpdateBookmarkTagsPayload;\n}\n\nexport interface ApiV1BookmarksCachePutRequest {\n    payload: ApiV1UpdateCachePayload;\n}\n\nexport interface ApiV1BookmarksIdTagsDeleteRequest {\n    id: number;\n    payload: ApiV1BookmarkTagPayload;\n}\n\nexport interface ApiV1BookmarksIdTagsGetRequest {\n    id: number;\n}\n\nexport interface ApiV1BookmarksIdTagsPostRequest {\n    id: number;\n    payload: ApiV1BookmarkTagPayload;\n}\n\n/**\n * \n */\nexport class AuthApi extends runtime.BaseAPI {\n\n    /**\n     * Update account information\n     */\n    async apiV1AuthAccountPatchRaw(requestParameters: ApiV1AuthAccountPatchRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ModelAccount>> {\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        headerParameters['Content-Type'] = 'application/json';\n\n        const response = await this.request({\n            path: `/api/v1/auth/account`,\n            method: 'PATCH',\n            headers: headerParameters,\n            query: queryParameters,\n            body: ApiV1UpdateAccountPayloadToJSON(requestParameters['payload']),\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => ModelAccountFromJSON(jsonValue));\n    }\n\n    /**\n     * Update account information\n     */\n    async apiV1AuthAccountPatch(requestParameters: ApiV1AuthAccountPatchRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ModelAccount> {\n        const response = await this.apiV1AuthAccountPatchRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Login to an account using username and password\n     */\n    async apiV1AuthLoginPostRaw(requestParameters: ApiV1AuthLoginPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ApiV1LoginResponseMessage>> {\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        headerParameters['Content-Type'] = 'application/json';\n\n        const response = await this.request({\n            path: `/api/v1/auth/login`,\n            method: 'POST',\n            headers: headerParameters,\n            query: queryParameters,\n            body: ApiV1LoginRequestPayloadToJSON(requestParameters['payload']),\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => ApiV1LoginResponseMessageFromJSON(jsonValue));\n    }\n\n    /**\n     * Login to an account using username and password\n     */\n    async apiV1AuthLoginPost(requestParameters: ApiV1AuthLoginPostRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ApiV1LoginResponseMessage> {\n        const response = await this.apiV1AuthLoginPostRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Logout from the current session\n     */\n    async apiV1AuthLogoutPostRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<void>> {\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        const response = await this.request({\n            path: `/api/v1/auth/logout`,\n            method: 'POST',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.VoidApiResponse(response);\n    }\n\n    /**\n     * Logout from the current session\n     */\n    async apiV1AuthLogoutPost(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<void> {\n        await this.apiV1AuthLogoutPostRaw(initOverrides);\n    }\n\n    /**\n     * Get information for the current logged in user\n     */\n    async apiV1AuthMeGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ModelAccount>> {\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        const response = await this.request({\n            path: `/api/v1/auth/me`,\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => ModelAccountFromJSON(jsonValue));\n    }\n\n    /**\n     * Get information for the current logged in user\n     */\n    async apiV1AuthMeGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ModelAccount> {\n        const response = await this.apiV1AuthMeGetRaw(initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Refresh a token for an account\n     */\n    async apiV1AuthRefreshPostRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ApiV1LoginResponseMessage>> {\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        const response = await this.request({\n            path: `/api/v1/auth/refresh`,\n            method: 'POST',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => ApiV1LoginResponseMessageFromJSON(jsonValue));\n    }\n\n    /**\n     * Refresh a token for an account\n     */\n    async apiV1AuthRefreshPost(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ApiV1LoginResponseMessage> {\n        const response = await this.apiV1AuthRefreshPostRaw(initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Bulk update tags for multiple bookmarks.\n     */\n    async apiV1BookmarksBulkTagsPutRaw(requestParameters: ApiV1BookmarksBulkTagsPutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Array<ModelBookmarkDTO>>> {\n        if (requestParameters['payload'] == null) {\n            throw new runtime.RequiredError(\n                'payload',\n                'Required parameter \"payload\" was null or undefined when calling apiV1BookmarksBulkTagsPut().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        headerParameters['Content-Type'] = 'application/json';\n\n        const response = await this.request({\n            path: `/api/v1/bookmarks/bulk/tags`,\n            method: 'PUT',\n            headers: headerParameters,\n            query: queryParameters,\n            body: ApiV1BulkUpdateBookmarkTagsPayloadToJSON(requestParameters['payload']),\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => jsonValue.map(ModelBookmarkDTOFromJSON));\n    }\n\n    /**\n     * Bulk update tags for multiple bookmarks.\n     */\n    async apiV1BookmarksBulkTagsPut(requestParameters: ApiV1BookmarksBulkTagsPutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Array<ModelBookmarkDTO>> {\n        const response = await this.apiV1BookmarksBulkTagsPutRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Update Cache and Ebook on server.\n     */\n    async apiV1BookmarksCachePutRaw(requestParameters: ApiV1BookmarksCachePutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ModelBookmarkDTO>> {\n        if (requestParameters['payload'] == null) {\n            throw new runtime.RequiredError(\n                'payload',\n                'Required parameter \"payload\" was null or undefined when calling apiV1BookmarksCachePut().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        headerParameters['Content-Type'] = 'application/json';\n\n        const response = await this.request({\n            path: `/api/v1/bookmarks/cache`,\n            method: 'PUT',\n            headers: headerParameters,\n            query: queryParameters,\n            body: ApiV1UpdateCachePayloadToJSON(requestParameters['payload']),\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => ModelBookmarkDTOFromJSON(jsonValue));\n    }\n\n    /**\n     * Update Cache and Ebook on server.\n     */\n    async apiV1BookmarksCachePut(requestParameters: ApiV1BookmarksCachePutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ModelBookmarkDTO> {\n        const response = await this.apiV1BookmarksCachePutRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Get readable version of bookmark.\n     */\n    async apiV1BookmarksIdReadableGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ApiV1ReadableResponseMessage>> {\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        const response = await this.request({\n            path: `/api/v1/bookmarks/id/readable`,\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => ApiV1ReadableResponseMessageFromJSON(jsonValue));\n    }\n\n    /**\n     * Get readable version of bookmark.\n     */\n    async apiV1BookmarksIdReadableGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ApiV1ReadableResponseMessage> {\n        const response = await this.apiV1BookmarksIdReadableGetRaw(initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Remove a tag from a bookmark.\n     */\n    async apiV1BookmarksIdTagsDeleteRaw(requestParameters: ApiV1BookmarksIdTagsDeleteRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<void>> {\n        if (requestParameters['id'] == null) {\n            throw new runtime.RequiredError(\n                'id',\n                'Required parameter \"id\" was null or undefined when calling apiV1BookmarksIdTagsDelete().'\n            );\n        }\n\n        if (requestParameters['payload'] == null) {\n            throw new runtime.RequiredError(\n                'payload',\n                'Required parameter \"payload\" was null or undefined when calling apiV1BookmarksIdTagsDelete().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        headerParameters['Content-Type'] = 'application/json';\n\n        const response = await this.request({\n            path: `/api/v1/bookmarks/{id}/tags`.replace(`{${\"id\"}}`, encodeURIComponent(String(requestParameters['id']))),\n            method: 'DELETE',\n            headers: headerParameters,\n            query: queryParameters,\n            body: ApiV1BookmarkTagPayloadToJSON(requestParameters['payload']),\n        }, initOverrides);\n\n        return new runtime.VoidApiResponse(response);\n    }\n\n    /**\n     * Remove a tag from a bookmark.\n     */\n    async apiV1BookmarksIdTagsDelete(requestParameters: ApiV1BookmarksIdTagsDeleteRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<void> {\n        await this.apiV1BookmarksIdTagsDeleteRaw(requestParameters, initOverrides);\n    }\n\n    /**\n     * Get tags for a bookmark.\n     */\n    async apiV1BookmarksIdTagsGetRaw(requestParameters: ApiV1BookmarksIdTagsGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Array<ModelTagDTO>>> {\n        if (requestParameters['id'] == null) {\n            throw new runtime.RequiredError(\n                'id',\n                'Required parameter \"id\" was null or undefined when calling apiV1BookmarksIdTagsGet().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        const response = await this.request({\n            path: `/api/v1/bookmarks/{id}/tags`.replace(`{${\"id\"}}`, encodeURIComponent(String(requestParameters['id']))),\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => jsonValue.map(ModelTagDTOFromJSON));\n    }\n\n    /**\n     * Get tags for a bookmark.\n     */\n    async apiV1BookmarksIdTagsGet(requestParameters: ApiV1BookmarksIdTagsGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Array<ModelTagDTO>> {\n        const response = await this.apiV1BookmarksIdTagsGetRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Add a tag to a bookmark.\n     */\n    async apiV1BookmarksIdTagsPostRaw(requestParameters: ApiV1BookmarksIdTagsPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<void>> {\n        if (requestParameters['id'] == null) {\n            throw new runtime.RequiredError(\n                'id',\n                'Required parameter \"id\" was null or undefined when calling apiV1BookmarksIdTagsPost().'\n            );\n        }\n\n        if (requestParameters['payload'] == null) {\n            throw new runtime.RequiredError(\n                'payload',\n                'Required parameter \"payload\" was null or undefined when calling apiV1BookmarksIdTagsPost().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        headerParameters['Content-Type'] = 'application/json';\n\n        const response = await this.request({\n            path: `/api/v1/bookmarks/{id}/tags`.replace(`{${\"id\"}}`, encodeURIComponent(String(requestParameters['id']))),\n            method: 'POST',\n            headers: headerParameters,\n            query: queryParameters,\n            body: ApiV1BookmarkTagPayloadToJSON(requestParameters['payload']),\n        }, initOverrides);\n\n        return new runtime.VoidApiResponse(response);\n    }\n\n    /**\n     * Add a tag to a bookmark.\n     */\n    async apiV1BookmarksIdTagsPost(requestParameters: ApiV1BookmarksIdTagsPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<void> {\n        await this.apiV1BookmarksIdTagsPostRaw(requestParameters, initOverrides);\n    }\n\n}\n"
  },
  {
    "path": "webapp/src/client/apis/SystemApi.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\n\nimport * as runtime from '../runtime';\nimport type {\n  ApiV1InfoResponse,\n} from '../models/index';\nimport {\n    ApiV1InfoResponseFromJSON,\n    ApiV1InfoResponseToJSON,\n} from '../models/index';\n\n/**\n * \n */\nexport class SystemApi extends runtime.BaseAPI {\n\n    /**\n     * Get general system information like Shiori version, database, and OS\n     * Get general system information\n     */\n    async apiV1SystemInfoGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ApiV1InfoResponse>> {\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        const response = await this.request({\n            path: `/api/v1/system/info`,\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => ApiV1InfoResponseFromJSON(jsonValue));\n    }\n\n    /**\n     * Get general system information like Shiori version, database, and OS\n     * Get general system information\n     */\n    async apiV1SystemInfoGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ApiV1InfoResponse> {\n        const response = await this.apiV1SystemInfoGetRaw(initOverrides);\n        return await response.value();\n    }\n\n}\n"
  },
  {
    "path": "webapp/src/client/apis/TagsApi.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\n\nimport * as runtime from '../runtime';\nimport type {\n  ModelTagDTO,\n} from '../models/index';\nimport {\n    ModelTagDTOFromJSON,\n    ModelTagDTOToJSON,\n} from '../models/index';\n\nexport interface ApiV1TagsGetRequest {\n    withBookmarkCount?: boolean;\n    bookmarkId?: number;\n    search?: string;\n}\n\nexport interface ApiV1TagsIdDeleteRequest {\n    id: number;\n}\n\nexport interface ApiV1TagsIdGetRequest {\n    id: number;\n}\n\nexport interface ApiV1TagsIdPutRequest {\n    id: number;\n    tag: ModelTagDTO;\n}\n\nexport interface ApiV1TagsPostRequest {\n    tag: ModelTagDTO;\n}\n\n/**\n * \n */\nexport class TagsApi extends runtime.BaseAPI {\n\n    /**\n     * List all tags\n     * List tags\n     */\n    async apiV1TagsGetRaw(requestParameters: ApiV1TagsGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Array<ModelTagDTO>>> {\n        const queryParameters: any = {};\n\n        if (requestParameters['withBookmarkCount'] != null) {\n            queryParameters['with_bookmark_count'] = requestParameters['withBookmarkCount'];\n        }\n\n        if (requestParameters['bookmarkId'] != null) {\n            queryParameters['bookmark_id'] = requestParameters['bookmarkId'];\n        }\n\n        if (requestParameters['search'] != null) {\n            queryParameters['search'] = requestParameters['search'];\n        }\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        const response = await this.request({\n            path: `/api/v1/tags`,\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => jsonValue.map(ModelTagDTOFromJSON));\n    }\n\n    /**\n     * List all tags\n     * List tags\n     */\n    async apiV1TagsGet(requestParameters: ApiV1TagsGetRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Array<ModelTagDTO>> {\n        const response = await this.apiV1TagsGetRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Delete a tag\n     * Delete tag\n     */\n    async apiV1TagsIdDeleteRaw(requestParameters: ApiV1TagsIdDeleteRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<void>> {\n        if (requestParameters['id'] == null) {\n            throw new runtime.RequiredError(\n                'id',\n                'Required parameter \"id\" was null or undefined when calling apiV1TagsIdDelete().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        const response = await this.request({\n            path: `/api/v1/tags/{id}`.replace(`{${\"id\"}}`, encodeURIComponent(String(requestParameters['id']))),\n            method: 'DELETE',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.VoidApiResponse(response);\n    }\n\n    /**\n     * Delete a tag\n     * Delete tag\n     */\n    async apiV1TagsIdDelete(requestParameters: ApiV1TagsIdDeleteRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<void> {\n        await this.apiV1TagsIdDeleteRaw(requestParameters, initOverrides);\n    }\n\n    /**\n     * Get a tag by ID\n     * Get tag\n     */\n    async apiV1TagsIdGetRaw(requestParameters: ApiV1TagsIdGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ModelTagDTO>> {\n        if (requestParameters['id'] == null) {\n            throw new runtime.RequiredError(\n                'id',\n                'Required parameter \"id\" was null or undefined when calling apiV1TagsIdGet().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        const response = await this.request({\n            path: `/api/v1/tags/{id}`.replace(`{${\"id\"}}`, encodeURIComponent(String(requestParameters['id']))),\n            method: 'GET',\n            headers: headerParameters,\n            query: queryParameters,\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => ModelTagDTOFromJSON(jsonValue));\n    }\n\n    /**\n     * Get a tag by ID\n     * Get tag\n     */\n    async apiV1TagsIdGet(requestParameters: ApiV1TagsIdGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ModelTagDTO> {\n        const response = await this.apiV1TagsIdGetRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Update an existing tag\n     * Update tag\n     */\n    async apiV1TagsIdPutRaw(requestParameters: ApiV1TagsIdPutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ModelTagDTO>> {\n        if (requestParameters['id'] == null) {\n            throw new runtime.RequiredError(\n                'id',\n                'Required parameter \"id\" was null or undefined when calling apiV1TagsIdPut().'\n            );\n        }\n\n        if (requestParameters['tag'] == null) {\n            throw new runtime.RequiredError(\n                'tag',\n                'Required parameter \"tag\" was null or undefined when calling apiV1TagsIdPut().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        headerParameters['Content-Type'] = 'application/json';\n\n        const response = await this.request({\n            path: `/api/v1/tags/{id}`.replace(`{${\"id\"}}`, encodeURIComponent(String(requestParameters['id']))),\n            method: 'PUT',\n            headers: headerParameters,\n            query: queryParameters,\n            body: ModelTagDTOToJSON(requestParameters['tag']),\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => ModelTagDTOFromJSON(jsonValue));\n    }\n\n    /**\n     * Update an existing tag\n     * Update tag\n     */\n    async apiV1TagsIdPut(requestParameters: ApiV1TagsIdPutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ModelTagDTO> {\n        const response = await this.apiV1TagsIdPutRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n    /**\n     * Create a new tag\n     * Create tag\n     */\n    async apiV1TagsPostRaw(requestParameters: ApiV1TagsPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ModelTagDTO>> {\n        if (requestParameters['tag'] == null) {\n            throw new runtime.RequiredError(\n                'tag',\n                'Required parameter \"tag\" was null or undefined when calling apiV1TagsPost().'\n            );\n        }\n\n        const queryParameters: any = {};\n\n        const headerParameters: runtime.HTTPHeaders = {};\n\n        headerParameters['Content-Type'] = 'application/json';\n\n        const response = await this.request({\n            path: `/api/v1/tags`,\n            method: 'POST',\n            headers: headerParameters,\n            query: queryParameters,\n            body: ModelTagDTOToJSON(requestParameters['tag']),\n        }, initOverrides);\n\n        return new runtime.JSONApiResponse(response, (jsonValue) => ModelTagDTOFromJSON(jsonValue));\n    }\n\n    /**\n     * Create a new tag\n     * Create tag\n     */\n    async apiV1TagsPost(requestParameters: ApiV1TagsPostRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ModelTagDTO> {\n        const response = await this.apiV1TagsPostRaw(requestParameters, initOverrides);\n        return await response.value();\n    }\n\n}\n"
  },
  {
    "path": "webapp/src/client/apis/index.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\nexport * from './AccountsApi';\nexport * from './AuthApi';\nexport * from './SystemApi';\nexport * from './TagsApi';\n"
  },
  {
    "path": "webapp/src/client/index.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\nexport * from './runtime';\nexport * from './apis/index';\nexport * from './models/index';\n"
  },
  {
    "path": "webapp/src/client/models/ApiV1BookmarkTagPayload.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * \n * @export\n * @interface ApiV1BookmarkTagPayload\n */\nexport interface ApiV1BookmarkTagPayload {\n    /**\n     * \n     * @type {number}\n     * @memberof ApiV1BookmarkTagPayload\n     */\n    tagId: number;\n}\n\n/**\n * Check if a given object implements the ApiV1BookmarkTagPayload interface.\n */\nexport function instanceOfApiV1BookmarkTagPayload(value: object): value is ApiV1BookmarkTagPayload {\n    if (!('tagId' in value) || value['tagId'] === undefined) return false;\n    return true;\n}\n\nexport function ApiV1BookmarkTagPayloadFromJSON(json: any): ApiV1BookmarkTagPayload {\n    return ApiV1BookmarkTagPayloadFromJSONTyped(json, false);\n}\n\nexport function ApiV1BookmarkTagPayloadFromJSONTyped(json: any, ignoreDiscriminator: boolean): ApiV1BookmarkTagPayload {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'tagId': json['tag_id'],\n    };\n}\n\nexport function ApiV1BookmarkTagPayloadToJSON(json: any): ApiV1BookmarkTagPayload {\n    return ApiV1BookmarkTagPayloadToJSONTyped(json, false);\n}\n\nexport function ApiV1BookmarkTagPayloadToJSONTyped(value?: ApiV1BookmarkTagPayload | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'tag_id': value['tagId'],\n    };\n}\n\n"
  },
  {
    "path": "webapp/src/client/models/ApiV1BulkUpdateBookmarkTagsPayload.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * \n * @export\n * @interface ApiV1BulkUpdateBookmarkTagsPayload\n */\nexport interface ApiV1BulkUpdateBookmarkTagsPayload {\n    /**\n     * \n     * @type {Array<number>}\n     * @memberof ApiV1BulkUpdateBookmarkTagsPayload\n     */\n    bookmarkIds: Array<number>;\n    /**\n     * \n     * @type {Array<number>}\n     * @memberof ApiV1BulkUpdateBookmarkTagsPayload\n     */\n    tagIds: Array<number>;\n}\n\n/**\n * Check if a given object implements the ApiV1BulkUpdateBookmarkTagsPayload interface.\n */\nexport function instanceOfApiV1BulkUpdateBookmarkTagsPayload(value: object): value is ApiV1BulkUpdateBookmarkTagsPayload {\n    if (!('bookmarkIds' in value) || value['bookmarkIds'] === undefined) return false;\n    if (!('tagIds' in value) || value['tagIds'] === undefined) return false;\n    return true;\n}\n\nexport function ApiV1BulkUpdateBookmarkTagsPayloadFromJSON(json: any): ApiV1BulkUpdateBookmarkTagsPayload {\n    return ApiV1BulkUpdateBookmarkTagsPayloadFromJSONTyped(json, false);\n}\n\nexport function ApiV1BulkUpdateBookmarkTagsPayloadFromJSONTyped(json: any, ignoreDiscriminator: boolean): ApiV1BulkUpdateBookmarkTagsPayload {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'bookmarkIds': json['bookmark_ids'],\n        'tagIds': json['tag_ids'],\n    };\n}\n\nexport function ApiV1BulkUpdateBookmarkTagsPayloadToJSON(json: any): ApiV1BulkUpdateBookmarkTagsPayload {\n    return ApiV1BulkUpdateBookmarkTagsPayloadToJSONTyped(json, false);\n}\n\nexport function ApiV1BulkUpdateBookmarkTagsPayloadToJSONTyped(value?: ApiV1BulkUpdateBookmarkTagsPayload | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'bookmark_ids': value['bookmarkIds'],\n        'tag_ids': value['tagIds'],\n    };\n}\n\n"
  },
  {
    "path": "webapp/src/client/models/ApiV1InfoResponse.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\nimport type { ApiV1InfoResponseVersion } from './ApiV1InfoResponseVersion';\nimport {\n    ApiV1InfoResponseVersionFromJSON,\n    ApiV1InfoResponseVersionFromJSONTyped,\n    ApiV1InfoResponseVersionToJSON,\n    ApiV1InfoResponseVersionToJSONTyped,\n} from './ApiV1InfoResponseVersion';\n\n/**\n * \n * @export\n * @interface ApiV1InfoResponse\n */\nexport interface ApiV1InfoResponse {\n    /**\n     * \n     * @type {string}\n     * @memberof ApiV1InfoResponse\n     */\n    database?: string;\n    /**\n     * \n     * @type {string}\n     * @memberof ApiV1InfoResponse\n     */\n    os?: string;\n    /**\n     * \n     * @type {ApiV1InfoResponseVersion}\n     * @memberof ApiV1InfoResponse\n     */\n    version?: ApiV1InfoResponseVersion;\n}\n\n/**\n * Check if a given object implements the ApiV1InfoResponse interface.\n */\nexport function instanceOfApiV1InfoResponse(value: object): value is ApiV1InfoResponse {\n    return true;\n}\n\nexport function ApiV1InfoResponseFromJSON(json: any): ApiV1InfoResponse {\n    return ApiV1InfoResponseFromJSONTyped(json, false);\n}\n\nexport function ApiV1InfoResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): ApiV1InfoResponse {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'database': json['database'] == null ? undefined : json['database'],\n        'os': json['os'] == null ? undefined : json['os'],\n        'version': json['version'] == null ? undefined : ApiV1InfoResponseVersionFromJSON(json['version']),\n    };\n}\n\nexport function ApiV1InfoResponseToJSON(json: any): ApiV1InfoResponse {\n    return ApiV1InfoResponseToJSONTyped(json, false);\n}\n\nexport function ApiV1InfoResponseToJSONTyped(value?: ApiV1InfoResponse | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'database': value['database'],\n        'os': value['os'],\n        'version': ApiV1InfoResponseVersionToJSON(value['version']),\n    };\n}\n\n"
  },
  {
    "path": "webapp/src/client/models/ApiV1InfoResponseVersion.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * \n * @export\n * @interface ApiV1InfoResponseVersion\n */\nexport interface ApiV1InfoResponseVersion {\n    /**\n     * \n     * @type {string}\n     * @memberof ApiV1InfoResponseVersion\n     */\n    commit?: string;\n    /**\n     * \n     * @type {string}\n     * @memberof ApiV1InfoResponseVersion\n     */\n    date?: string;\n    /**\n     * \n     * @type {string}\n     * @memberof ApiV1InfoResponseVersion\n     */\n    tag?: string;\n}\n\n/**\n * Check if a given object implements the ApiV1InfoResponseVersion interface.\n */\nexport function instanceOfApiV1InfoResponseVersion(value: object): value is ApiV1InfoResponseVersion {\n    return true;\n}\n\nexport function ApiV1InfoResponseVersionFromJSON(json: any): ApiV1InfoResponseVersion {\n    return ApiV1InfoResponseVersionFromJSONTyped(json, false);\n}\n\nexport function ApiV1InfoResponseVersionFromJSONTyped(json: any, ignoreDiscriminator: boolean): ApiV1InfoResponseVersion {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'commit': json['commit'] == null ? undefined : json['commit'],\n        'date': json['date'] == null ? undefined : json['date'],\n        'tag': json['tag'] == null ? undefined : json['tag'],\n    };\n}\n\nexport function ApiV1InfoResponseVersionToJSON(json: any): ApiV1InfoResponseVersion {\n    return ApiV1InfoResponseVersionToJSONTyped(json, false);\n}\n\nexport function ApiV1InfoResponseVersionToJSONTyped(value?: ApiV1InfoResponseVersion | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'commit': value['commit'],\n        'date': value['date'],\n        'tag': value['tag'],\n    };\n}\n\n"
  },
  {
    "path": "webapp/src/client/models/ApiV1LoginRequestPayload.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * \n * @export\n * @interface ApiV1LoginRequestPayload\n */\nexport interface ApiV1LoginRequestPayload {\n    /**\n     * \n     * @type {string}\n     * @memberof ApiV1LoginRequestPayload\n     */\n    password?: string;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ApiV1LoginRequestPayload\n     */\n    rememberMe?: boolean;\n    /**\n     * \n     * @type {string}\n     * @memberof ApiV1LoginRequestPayload\n     */\n    username?: string;\n}\n\n/**\n * Check if a given object implements the ApiV1LoginRequestPayload interface.\n */\nexport function instanceOfApiV1LoginRequestPayload(value: object): value is ApiV1LoginRequestPayload {\n    return true;\n}\n\nexport function ApiV1LoginRequestPayloadFromJSON(json: any): ApiV1LoginRequestPayload {\n    return ApiV1LoginRequestPayloadFromJSONTyped(json, false);\n}\n\nexport function ApiV1LoginRequestPayloadFromJSONTyped(json: any, ignoreDiscriminator: boolean): ApiV1LoginRequestPayload {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'password': json['password'] == null ? undefined : json['password'],\n        'rememberMe': json['remember_me'] == null ? undefined : json['remember_me'],\n        'username': json['username'] == null ? undefined : json['username'],\n    };\n}\n\nexport function ApiV1LoginRequestPayloadToJSON(json: any): ApiV1LoginRequestPayload {\n    return ApiV1LoginRequestPayloadToJSONTyped(json, false);\n}\n\nexport function ApiV1LoginRequestPayloadToJSONTyped(value?: ApiV1LoginRequestPayload | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'password': value['password'],\n        'remember_me': value['rememberMe'],\n        'username': value['username'],\n    };\n}\n\n"
  },
  {
    "path": "webapp/src/client/models/ApiV1LoginResponseMessage.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * \n * @export\n * @interface ApiV1LoginResponseMessage\n */\nexport interface ApiV1LoginResponseMessage {\n    /**\n     * \n     * @type {number}\n     * @memberof ApiV1LoginResponseMessage\n     */\n    expires?: number;\n    /**\n     * \n     * @type {string}\n     * @memberof ApiV1LoginResponseMessage\n     */\n    token?: string;\n}\n\n/**\n * Check if a given object implements the ApiV1LoginResponseMessage interface.\n */\nexport function instanceOfApiV1LoginResponseMessage(value: object): value is ApiV1LoginResponseMessage {\n    return true;\n}\n\nexport function ApiV1LoginResponseMessageFromJSON(json: any): ApiV1LoginResponseMessage {\n    return ApiV1LoginResponseMessageFromJSONTyped(json, false);\n}\n\nexport function ApiV1LoginResponseMessageFromJSONTyped(json: any, ignoreDiscriminator: boolean): ApiV1LoginResponseMessage {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'expires': json['expires'] == null ? undefined : json['expires'],\n        'token': json['token'] == null ? undefined : json['token'],\n    };\n}\n\nexport function ApiV1LoginResponseMessageToJSON(json: any): ApiV1LoginResponseMessage {\n    return ApiV1LoginResponseMessageToJSONTyped(json, false);\n}\n\nexport function ApiV1LoginResponseMessageToJSONTyped(value?: ApiV1LoginResponseMessage | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'expires': value['expires'],\n        'token': value['token'],\n    };\n}\n\n"
  },
  {
    "path": "webapp/src/client/models/ApiV1ReadableResponseMessage.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * \n * @export\n * @interface ApiV1ReadableResponseMessage\n */\nexport interface ApiV1ReadableResponseMessage {\n    /**\n     * \n     * @type {string}\n     * @memberof ApiV1ReadableResponseMessage\n     */\n    content?: string;\n    /**\n     * \n     * @type {string}\n     * @memberof ApiV1ReadableResponseMessage\n     */\n    html?: string;\n}\n\n/**\n * Check if a given object implements the ApiV1ReadableResponseMessage interface.\n */\nexport function instanceOfApiV1ReadableResponseMessage(value: object): value is ApiV1ReadableResponseMessage {\n    return true;\n}\n\nexport function ApiV1ReadableResponseMessageFromJSON(json: any): ApiV1ReadableResponseMessage {\n    return ApiV1ReadableResponseMessageFromJSONTyped(json, false);\n}\n\nexport function ApiV1ReadableResponseMessageFromJSONTyped(json: any, ignoreDiscriminator: boolean): ApiV1ReadableResponseMessage {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'content': json['content'] == null ? undefined : json['content'],\n        'html': json['html'] == null ? undefined : json['html'],\n    };\n}\n\nexport function ApiV1ReadableResponseMessageToJSON(json: any): ApiV1ReadableResponseMessage {\n    return ApiV1ReadableResponseMessageToJSONTyped(json, false);\n}\n\nexport function ApiV1ReadableResponseMessageToJSONTyped(value?: ApiV1ReadableResponseMessage | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'content': value['content'],\n        'html': value['html'],\n    };\n}\n\n"
  },
  {
    "path": "webapp/src/client/models/ApiV1UpdateAccountPayload.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\nimport type { ModelUserConfig } from './ModelUserConfig';\nimport {\n    ModelUserConfigFromJSON,\n    ModelUserConfigFromJSONTyped,\n    ModelUserConfigToJSON,\n    ModelUserConfigToJSONTyped,\n} from './ModelUserConfig';\n\n/**\n * \n * @export\n * @interface ApiV1UpdateAccountPayload\n */\nexport interface ApiV1UpdateAccountPayload {\n    /**\n     * \n     * @type {ModelUserConfig}\n     * @memberof ApiV1UpdateAccountPayload\n     */\n    config?: ModelUserConfig;\n    /**\n     * \n     * @type {string}\n     * @memberof ApiV1UpdateAccountPayload\n     */\n    newPassword?: string;\n    /**\n     * \n     * @type {string}\n     * @memberof ApiV1UpdateAccountPayload\n     */\n    oldPassword?: string;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ApiV1UpdateAccountPayload\n     */\n    owner?: boolean;\n    /**\n     * \n     * @type {string}\n     * @memberof ApiV1UpdateAccountPayload\n     */\n    username?: string;\n}\n\n/**\n * Check if a given object implements the ApiV1UpdateAccountPayload interface.\n */\nexport function instanceOfApiV1UpdateAccountPayload(value: object): value is ApiV1UpdateAccountPayload {\n    return true;\n}\n\nexport function ApiV1UpdateAccountPayloadFromJSON(json: any): ApiV1UpdateAccountPayload {\n    return ApiV1UpdateAccountPayloadFromJSONTyped(json, false);\n}\n\nexport function ApiV1UpdateAccountPayloadFromJSONTyped(json: any, ignoreDiscriminator: boolean): ApiV1UpdateAccountPayload {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'config': json['config'] == null ? undefined : ModelUserConfigFromJSON(json['config']),\n        'newPassword': json['new_password'] == null ? undefined : json['new_password'],\n        'oldPassword': json['old_password'] == null ? undefined : json['old_password'],\n        'owner': json['owner'] == null ? undefined : json['owner'],\n        'username': json['username'] == null ? undefined : json['username'],\n    };\n}\n\nexport function ApiV1UpdateAccountPayloadToJSON(json: any): ApiV1UpdateAccountPayload {\n    return ApiV1UpdateAccountPayloadToJSONTyped(json, false);\n}\n\nexport function ApiV1UpdateAccountPayloadToJSONTyped(value?: ApiV1UpdateAccountPayload | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'config': ModelUserConfigToJSON(value['config']),\n        'new_password': value['newPassword'],\n        'old_password': value['oldPassword'],\n        'owner': value['owner'],\n        'username': value['username'],\n    };\n}\n\n"
  },
  {
    "path": "webapp/src/client/models/ApiV1UpdateCachePayload.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * \n * @export\n * @interface ApiV1UpdateCachePayload\n */\nexport interface ApiV1UpdateCachePayload {\n    /**\n     * \n     * @type {boolean}\n     * @memberof ApiV1UpdateCachePayload\n     */\n    createArchive?: boolean;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ApiV1UpdateCachePayload\n     */\n    createEbook?: boolean;\n    /**\n     * \n     * @type {Array<number>}\n     * @memberof ApiV1UpdateCachePayload\n     */\n    ids: Array<number>;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ApiV1UpdateCachePayload\n     */\n    keepMetadata?: boolean;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ApiV1UpdateCachePayload\n     */\n    skipExist?: boolean;\n}\n\n/**\n * Check if a given object implements the ApiV1UpdateCachePayload interface.\n */\nexport function instanceOfApiV1UpdateCachePayload(value: object): value is ApiV1UpdateCachePayload {\n    if (!('ids' in value) || value['ids'] === undefined) return false;\n    return true;\n}\n\nexport function ApiV1UpdateCachePayloadFromJSON(json: any): ApiV1UpdateCachePayload {\n    return ApiV1UpdateCachePayloadFromJSONTyped(json, false);\n}\n\nexport function ApiV1UpdateCachePayloadFromJSONTyped(json: any, ignoreDiscriminator: boolean): ApiV1UpdateCachePayload {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'createArchive': json['create_archive'] == null ? undefined : json['create_archive'],\n        'createEbook': json['create_ebook'] == null ? undefined : json['create_ebook'],\n        'ids': json['ids'],\n        'keepMetadata': json['keep_metadata'] == null ? undefined : json['keep_metadata'],\n        'skipExist': json['skip_exist'] == null ? undefined : json['skip_exist'],\n    };\n}\n\nexport function ApiV1UpdateCachePayloadToJSON(json: any): ApiV1UpdateCachePayload {\n    return ApiV1UpdateCachePayloadToJSONTyped(json, false);\n}\n\nexport function ApiV1UpdateCachePayloadToJSONTyped(value?: ApiV1UpdateCachePayload | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'create_archive': value['createArchive'],\n        'create_ebook': value['createEbook'],\n        'ids': value['ids'],\n        'keep_metadata': value['keepMetadata'],\n        'skip_exist': value['skipExist'],\n    };\n}\n\n"
  },
  {
    "path": "webapp/src/client/models/ModelAccount.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\nimport type { ModelUserConfig } from './ModelUserConfig';\nimport {\n    ModelUserConfigFromJSON,\n    ModelUserConfigFromJSONTyped,\n    ModelUserConfigToJSON,\n    ModelUserConfigToJSONTyped,\n} from './ModelUserConfig';\n\n/**\n * \n * @export\n * @interface ModelAccount\n */\nexport interface ModelAccount {\n    /**\n     * \n     * @type {ModelUserConfig}\n     * @memberof ModelAccount\n     */\n    config?: ModelUserConfig;\n    /**\n     * \n     * @type {number}\n     * @memberof ModelAccount\n     */\n    id?: number;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ModelAccount\n     */\n    owner?: boolean;\n    /**\n     * \n     * @type {string}\n     * @memberof ModelAccount\n     */\n    password?: string;\n    /**\n     * \n     * @type {string}\n     * @memberof ModelAccount\n     */\n    username?: string;\n}\n\n/**\n * Check if a given object implements the ModelAccount interface.\n */\nexport function instanceOfModelAccount(value: object): value is ModelAccount {\n    return true;\n}\n\nexport function ModelAccountFromJSON(json: any): ModelAccount {\n    return ModelAccountFromJSONTyped(json, false);\n}\n\nexport function ModelAccountFromJSONTyped(json: any, ignoreDiscriminator: boolean): ModelAccount {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'config': json['config'] == null ? undefined : ModelUserConfigFromJSON(json['config']),\n        'id': json['id'] == null ? undefined : json['id'],\n        'owner': json['owner'] == null ? undefined : json['owner'],\n        'password': json['password'] == null ? undefined : json['password'],\n        'username': json['username'] == null ? undefined : json['username'],\n    };\n}\n\nexport function ModelAccountToJSON(json: any): ModelAccount {\n    return ModelAccountToJSONTyped(json, false);\n}\n\nexport function ModelAccountToJSONTyped(value?: ModelAccount | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'config': ModelUserConfigToJSON(value['config']),\n        'id': value['id'],\n        'owner': value['owner'],\n        'password': value['password'],\n        'username': value['username'],\n    };\n}\n\n"
  },
  {
    "path": "webapp/src/client/models/ModelAccountDTO.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\nimport type { ModelUserConfig } from './ModelUserConfig';\nimport {\n    ModelUserConfigFromJSON,\n    ModelUserConfigFromJSONTyped,\n    ModelUserConfigToJSON,\n    ModelUserConfigToJSONTyped,\n} from './ModelUserConfig';\n\n/**\n * \n * @export\n * @interface ModelAccountDTO\n */\nexport interface ModelAccountDTO {\n    /**\n     * \n     * @type {ModelUserConfig}\n     * @memberof ModelAccountDTO\n     */\n    config?: ModelUserConfig;\n    /**\n     * \n     * @type {number}\n     * @memberof ModelAccountDTO\n     */\n    id?: number;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ModelAccountDTO\n     */\n    owner?: boolean;\n    /**\n     * Used only to store, not to retrieve\n     * @type {string}\n     * @memberof ModelAccountDTO\n     */\n    passowrd?: string;\n    /**\n     * \n     * @type {string}\n     * @memberof ModelAccountDTO\n     */\n    username?: string;\n}\n\n/**\n * Check if a given object implements the ModelAccountDTO interface.\n */\nexport function instanceOfModelAccountDTO(value: object): value is ModelAccountDTO {\n    return true;\n}\n\nexport function ModelAccountDTOFromJSON(json: any): ModelAccountDTO {\n    return ModelAccountDTOFromJSONTyped(json, false);\n}\n\nexport function ModelAccountDTOFromJSONTyped(json: any, ignoreDiscriminator: boolean): ModelAccountDTO {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'config': json['config'] == null ? undefined : ModelUserConfigFromJSON(json['config']),\n        'id': json['id'] == null ? undefined : json['id'],\n        'owner': json['owner'] == null ? undefined : json['owner'],\n        'passowrd': json['passowrd'] == null ? undefined : json['passowrd'],\n        'username': json['username'] == null ? undefined : json['username'],\n    };\n}\n\nexport function ModelAccountDTOToJSON(json: any): ModelAccountDTO {\n    return ModelAccountDTOToJSONTyped(json, false);\n}\n\nexport function ModelAccountDTOToJSONTyped(value?: ModelAccountDTO | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'config': ModelUserConfigToJSON(value['config']),\n        'id': value['id'],\n        'owner': value['owner'],\n        'passowrd': value['passowrd'],\n        'username': value['username'],\n    };\n}\n\n"
  },
  {
    "path": "webapp/src/client/models/ModelBookmarkDTO.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\nimport type { ModelTagDTO } from './ModelTagDTO';\nimport {\n    ModelTagDTOFromJSON,\n    ModelTagDTOFromJSONTyped,\n    ModelTagDTOToJSON,\n    ModelTagDTOToJSONTyped,\n} from './ModelTagDTO';\n\n/**\n * \n * @export\n * @interface ModelBookmarkDTO\n */\nexport interface ModelBookmarkDTO {\n    /**\n     * \n     * @type {string}\n     * @memberof ModelBookmarkDTO\n     */\n    author?: string;\n    /**\n     * TODO: migrate outside the DTO\n     * @type {boolean}\n     * @memberof ModelBookmarkDTO\n     */\n    createArchive?: boolean;\n    /**\n     * TODO: migrate outside the DTO\n     * @type {boolean}\n     * @memberof ModelBookmarkDTO\n     */\n    createEbook?: boolean;\n    /**\n     * \n     * @type {string}\n     * @memberof ModelBookmarkDTO\n     */\n    createdAt?: string;\n    /**\n     * \n     * @type {string}\n     * @memberof ModelBookmarkDTO\n     */\n    excerpt?: string;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ModelBookmarkDTO\n     */\n    hasArchive?: boolean;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ModelBookmarkDTO\n     */\n    hasContent?: boolean;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ModelBookmarkDTO\n     */\n    hasEbook?: boolean;\n    /**\n     * \n     * @type {string}\n     * @memberof ModelBookmarkDTO\n     */\n    html?: string;\n    /**\n     * \n     * @type {number}\n     * @memberof ModelBookmarkDTO\n     */\n    id?: number;\n    /**\n     * \n     * @type {string}\n     * @memberof ModelBookmarkDTO\n     */\n    imageURL?: string;\n    /**\n     * \n     * @type {string}\n     * @memberof ModelBookmarkDTO\n     */\n    modifiedAt?: string;\n    /**\n     * \n     * @type {number}\n     * @memberof ModelBookmarkDTO\n     */\n    _public?: number;\n    /**\n     * \n     * @type {Array<ModelTagDTO>}\n     * @memberof ModelBookmarkDTO\n     */\n    tags?: Array<ModelTagDTO>;\n    /**\n     * \n     * @type {string}\n     * @memberof ModelBookmarkDTO\n     */\n    title?: string;\n    /**\n     * \n     * @type {string}\n     * @memberof ModelBookmarkDTO\n     */\n    url?: string;\n}\n\n/**\n * Check if a given object implements the ModelBookmarkDTO interface.\n */\nexport function instanceOfModelBookmarkDTO(value: object): value is ModelBookmarkDTO {\n    return true;\n}\n\nexport function ModelBookmarkDTOFromJSON(json: any): ModelBookmarkDTO {\n    return ModelBookmarkDTOFromJSONTyped(json, false);\n}\n\nexport function ModelBookmarkDTOFromJSONTyped(json: any, ignoreDiscriminator: boolean): ModelBookmarkDTO {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'author': json['author'] == null ? undefined : json['author'],\n        'createArchive': json['create_archive'] == null ? undefined : json['create_archive'],\n        'createEbook': json['create_ebook'] == null ? undefined : json['create_ebook'],\n        'createdAt': json['createdAt'] == null ? undefined : json['createdAt'],\n        'excerpt': json['excerpt'] == null ? undefined : json['excerpt'],\n        'hasArchive': json['hasArchive'] == null ? undefined : json['hasArchive'],\n        'hasContent': json['hasContent'] == null ? undefined : json['hasContent'],\n        'hasEbook': json['hasEbook'] == null ? undefined : json['hasEbook'],\n        'html': json['html'] == null ? undefined : json['html'],\n        'id': json['id'] == null ? undefined : json['id'],\n        'imageURL': json['imageURL'] == null ? undefined : json['imageURL'],\n        'modifiedAt': json['modifiedAt'] == null ? undefined : json['modifiedAt'],\n        '_public': json['public'] == null ? undefined : json['public'],\n        'tags': json['tags'] == null ? undefined : ((json['tags'] as Array<any>).map(ModelTagDTOFromJSON)),\n        'title': json['title'] == null ? undefined : json['title'],\n        'url': json['url'] == null ? undefined : json['url'],\n    };\n}\n\nexport function ModelBookmarkDTOToJSON(json: any): ModelBookmarkDTO {\n    return ModelBookmarkDTOToJSONTyped(json, false);\n}\n\nexport function ModelBookmarkDTOToJSONTyped(value?: ModelBookmarkDTO | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'author': value['author'],\n        'create_archive': value['createArchive'],\n        'create_ebook': value['createEbook'],\n        'createdAt': value['createdAt'],\n        'excerpt': value['excerpt'],\n        'hasArchive': value['hasArchive'],\n        'hasContent': value['hasContent'],\n        'hasEbook': value['hasEbook'],\n        'html': value['html'],\n        'id': value['id'],\n        'imageURL': value['imageURL'],\n        'modifiedAt': value['modifiedAt'],\n        'public': value['_public'],\n        'tags': value['tags'] == null ? undefined : ((value['tags'] as Array<any>).map(ModelTagDTOToJSON)),\n        'title': value['title'],\n        'url': value['url'],\n    };\n}\n\n"
  },
  {
    "path": "webapp/src/client/models/ModelTagDTO.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * \n * @export\n * @interface ModelTagDTO\n */\nexport interface ModelTagDTO {\n    /**\n     * Number of bookmarks with this tag\n     * @type {number}\n     * @memberof ModelTagDTO\n     */\n    bookmarkCount?: number;\n    /**\n     * Marks when a tag is deleted from a bookmark\n     * @type {boolean}\n     * @memberof ModelTagDTO\n     */\n    deleted?: boolean;\n    /**\n     * \n     * @type {number}\n     * @memberof ModelTagDTO\n     */\n    id?: number;\n    /**\n     * \n     * @type {string}\n     * @memberof ModelTagDTO\n     */\n    name?: string;\n}\n\n/**\n * Check if a given object implements the ModelTagDTO interface.\n */\nexport function instanceOfModelTagDTO(value: object): value is ModelTagDTO {\n    return true;\n}\n\nexport function ModelTagDTOFromJSON(json: any): ModelTagDTO {\n    return ModelTagDTOFromJSONTyped(json, false);\n}\n\nexport function ModelTagDTOFromJSONTyped(json: any, ignoreDiscriminator: boolean): ModelTagDTO {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'bookmarkCount': json['bookmark_count'] == null ? undefined : json['bookmark_count'],\n        'deleted': json['deleted'] == null ? undefined : json['deleted'],\n        'id': json['id'] == null ? undefined : json['id'],\n        'name': json['name'] == null ? undefined : json['name'],\n    };\n}\n\nexport function ModelTagDTOToJSON(json: any): ModelTagDTO {\n    return ModelTagDTOToJSONTyped(json, false);\n}\n\nexport function ModelTagDTOToJSONTyped(value?: ModelTagDTO | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'bookmark_count': value['bookmarkCount'],\n        'deleted': value['deleted'],\n        'id': value['id'],\n        'name': value['name'],\n    };\n}\n\n"
  },
  {
    "path": "webapp/src/client/models/ModelUserConfig.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n * \n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\nimport { mapValues } from '../runtime';\n/**\n * \n * @export\n * @interface ModelUserConfig\n */\nexport interface ModelUserConfig {\n    /**\n     * \n     * @type {boolean}\n     * @memberof ModelUserConfig\n     */\n    createEbook?: boolean;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ModelUserConfig\n     */\n    hideExcerpt?: boolean;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ModelUserConfig\n     */\n    hideThumbnail?: boolean;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ModelUserConfig\n     */\n    keepMetadata?: boolean;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ModelUserConfig\n     */\n    listMode?: boolean;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ModelUserConfig\n     */\n    makePublic?: boolean;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ModelUserConfig\n     */\n    showId?: boolean;\n    /**\n     * \n     * @type {string}\n     * @memberof ModelUserConfig\n     */\n    theme?: string;\n    /**\n     * \n     * @type {boolean}\n     * @memberof ModelUserConfig\n     */\n    useArchive?: boolean;\n}\n\n/**\n * Check if a given object implements the ModelUserConfig interface.\n */\nexport function instanceOfModelUserConfig(value: object): value is ModelUserConfig {\n    return true;\n}\n\nexport function ModelUserConfigFromJSON(json: any): ModelUserConfig {\n    return ModelUserConfigFromJSONTyped(json, false);\n}\n\nexport function ModelUserConfigFromJSONTyped(json: any, ignoreDiscriminator: boolean): ModelUserConfig {\n    if (json == null) {\n        return json;\n    }\n    return {\n        \n        'createEbook': json['createEbook'] == null ? undefined : json['createEbook'],\n        'hideExcerpt': json['hideExcerpt'] == null ? undefined : json['hideExcerpt'],\n        'hideThumbnail': json['hideThumbnail'] == null ? undefined : json['hideThumbnail'],\n        'keepMetadata': json['keepMetadata'] == null ? undefined : json['keepMetadata'],\n        'listMode': json['listMode'] == null ? undefined : json['listMode'],\n        'makePublic': json['makePublic'] == null ? undefined : json['makePublic'],\n        'showId': json['showId'] == null ? undefined : json['showId'],\n        'theme': json['theme'] == null ? undefined : json['theme'],\n        'useArchive': json['useArchive'] == null ? undefined : json['useArchive'],\n    };\n}\n\nexport function ModelUserConfigToJSON(json: any): ModelUserConfig {\n    return ModelUserConfigToJSONTyped(json, false);\n}\n\nexport function ModelUserConfigToJSONTyped(value?: ModelUserConfig | null, ignoreDiscriminator: boolean = false): any {\n    if (value == null) {\n        return value;\n    }\n\n    return {\n        \n        'createEbook': value['createEbook'],\n        'hideExcerpt': value['hideExcerpt'],\n        'hideThumbnail': value['hideThumbnail'],\n        'keepMetadata': value['keepMetadata'],\n        'listMode': value['listMode'],\n        'makePublic': value['makePublic'],\n        'showId': value['showId'],\n        'theme': value['theme'],\n        'useArchive': value['useArchive'],\n    };\n}\n\n"
  },
  {
    "path": "webapp/src/client/models/index.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\nexport * from './ApiV1BookmarkTagPayload';\nexport * from './ApiV1BulkUpdateBookmarkTagsPayload';\nexport * from './ApiV1InfoResponse';\nexport * from './ApiV1InfoResponseVersion';\nexport * from './ApiV1LoginRequestPayload';\nexport * from './ApiV1LoginResponseMessage';\nexport * from './ApiV1ReadableResponseMessage';\nexport * from './ApiV1UpdateAccountPayload';\nexport * from './ApiV1UpdateCachePayload';\nexport * from './ModelAccount';\nexport * from './ModelAccountDTO';\nexport * from './ModelBookmarkDTO';\nexport * from './ModelTagDTO';\nexport * from './ModelUserConfig';\n"
  },
  {
    "path": "webapp/src/client/runtime.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\n/**\n * Shiori API\n * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)\n *\n * The version of the OpenAPI document: 1.0.0\n *\n *\n * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n * https://openapi-generator.tech\n * Do not edit the class manually.\n */\n\n\nexport const BASE_PATH = \"http://localhost\".replace(/\\/+$/, \"\");\n\nexport interface ConfigurationParameters {\n    basePath?: string; // override base path\n    fetchApi?: FetchAPI; // override for fetch implementation\n    middleware?: Middleware[]; // middleware to apply before/after fetch requests\n    queryParamsStringify?: (params: HTTPQuery) => string; // stringify function for query strings\n    username?: string; // parameter for basic security\n    password?: string; // parameter for basic security\n    apiKey?: string | Promise<string> | ((name: string) => string | Promise<string>); // parameter for apiKey security\n    accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string | Promise<string>); // parameter for oauth2 security\n    headers?: HTTPHeaders; //header params we want to use on every request\n    credentials?: RequestCredentials; //value for the credentials param we want to use on each request\n}\n\nexport class Configuration {\n    constructor(private configuration: ConfigurationParameters = {}) {}\n\n    set config(configuration: Configuration) {\n        this.configuration = configuration;\n    }\n\n    get basePath(): string {\n        return this.configuration.basePath != null ? this.configuration.basePath : BASE_PATH;\n    }\n\n    get fetchApi(): FetchAPI | undefined {\n        return this.configuration.fetchApi;\n    }\n\n    get middleware(): Middleware[] {\n        return this.configuration.middleware || [];\n    }\n\n    get queryParamsStringify(): (params: HTTPQuery) => string {\n        return this.configuration.queryParamsStringify || querystring;\n    }\n\n    get username(): string | undefined {\n        return this.configuration.username;\n    }\n\n    get password(): string | undefined {\n        return this.configuration.password;\n    }\n\n    get apiKey(): ((name: string) => string | Promise<string>) | undefined {\n        const apiKey = this.configuration.apiKey;\n        if (apiKey) {\n            return typeof apiKey === 'function' ? apiKey : () => apiKey;\n        }\n        return undefined;\n    }\n\n    get accessToken(): ((name?: string, scopes?: string[]) => string | Promise<string>) | undefined {\n        const accessToken = this.configuration.accessToken;\n        if (accessToken) {\n            return typeof accessToken === 'function' ? accessToken : async () => accessToken;\n        }\n        return undefined;\n    }\n\n    get headers(): HTTPHeaders | undefined {\n        return this.configuration.headers;\n    }\n\n    get credentials(): RequestCredentials | undefined {\n        return this.configuration.credentials;\n    }\n}\n\nexport const DefaultConfig = new Configuration();\n\n/**\n * This is the base class for all generated API classes.\n */\nexport class BaseAPI {\n\n    private static readonly jsonRegex = new RegExp('^(:?application\\/json|[^;/ \\t]+\\/[^;/ \\t]+[+]json)[ \\t]*(:?;.*)?$', 'i');\n    private middleware: Middleware[];\n\n    constructor(protected configuration = DefaultConfig) {\n        this.middleware = configuration.middleware;\n    }\n\n    withMiddleware<T extends BaseAPI>(this: T, ...middlewares: Middleware[]) {\n        const next = this.clone<T>();\n        next.middleware = next.middleware.concat(...middlewares);\n        return next;\n    }\n\n    withPreMiddleware<T extends BaseAPI>(this: T, ...preMiddlewares: Array<Middleware['pre']>) {\n        const middlewares = preMiddlewares.map((pre) => ({ pre }));\n        return this.withMiddleware<T>(...middlewares);\n    }\n\n    withPostMiddleware<T extends BaseAPI>(this: T, ...postMiddlewares: Array<Middleware['post']>) {\n        const middlewares = postMiddlewares.map((post) => ({ post }));\n        return this.withMiddleware<T>(...middlewares);\n    }\n\n    /**\n     * Check if the given MIME is a JSON MIME.\n     * JSON MIME examples:\n     *   application/json\n     *   application/json; charset=UTF8\n     *   APPLICATION/JSON\n     *   application/vnd.company+json\n     * @param mime - MIME (Multipurpose Internet Mail Extensions)\n     * @return True if the given MIME is JSON, false otherwise.\n     */\n    protected isJsonMime(mime: string | null | undefined): boolean {\n        if (!mime) {\n            return false;\n        }\n        return BaseAPI.jsonRegex.test(mime);\n    }\n\n    protected async request(context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction): Promise<Response> {\n        const { url, init } = await this.createFetchParams(context, initOverrides);\n        const response = await this.fetchApi(url, init);\n        if (response && (response.status >= 200 && response.status < 300)) {\n            return response;\n        }\n        throw new ResponseError(response, 'Response returned an error code');\n    }\n\n    private async createFetchParams(context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction) {\n        let url = this.configuration.basePath + context.path;\n        if (context.query !== undefined && Object.keys(context.query).length !== 0) {\n            // only add the querystring to the URL if there are query parameters.\n            // this is done to avoid urls ending with a \"?\" character which buggy webservers\n            // do not handle correctly sometimes.\n            url += '?' + this.configuration.queryParamsStringify(context.query);\n        }\n\n        const headers = Object.assign({}, this.configuration.headers, context.headers);\n        Object.keys(headers).forEach(key => headers[key] === undefined ? delete headers[key] : {});\n\n        const initOverrideFn =\n            typeof initOverrides === \"function\"\n                ? initOverrides\n                : async () => initOverrides;\n\n        const initParams = {\n            method: context.method,\n            headers,\n            body: context.body,\n            credentials: this.configuration.credentials,\n        };\n\n        const overriddenInit: RequestInit = {\n            ...initParams,\n            ...(await initOverrideFn({\n                init: initParams,\n                context,\n            }))\n        };\n\n        let body: any;\n        if (isFormData(overriddenInit.body)\n            || (overriddenInit.body instanceof URLSearchParams)\n            || isBlob(overriddenInit.body)) {\n          body = overriddenInit.body;\n        } else if (this.isJsonMime(headers['Content-Type'])) {\n          body = JSON.stringify(overriddenInit.body);\n        } else {\n          body = overriddenInit.body;\n        }\n\n        const init: RequestInit = {\n            ...overriddenInit,\n            body\n        };\n\n        return { url, init };\n    }\n\n    private fetchApi = async (url: string, init: RequestInit) => {\n        let fetchParams = { url, init };\n        for (const middleware of this.middleware) {\n            if (middleware.pre) {\n                fetchParams = await middleware.pre({\n                    fetch: this.fetchApi,\n                    ...fetchParams,\n                }) || fetchParams;\n            }\n        }\n        let response: Response | undefined = undefined;\n        try {\n            response = await (this.configuration.fetchApi || fetch)(fetchParams.url, fetchParams.init);\n        } catch (e) {\n            for (const middleware of this.middleware) {\n                if (middleware.onError) {\n                    response = await middleware.onError({\n                        fetch: this.fetchApi,\n                        url: fetchParams.url,\n                        init: fetchParams.init,\n                        error: e,\n                        response: response ? response.clone() : undefined,\n                    }) || response;\n                }\n            }\n            if (response === undefined) {\n              if (e instanceof Error) {\n                throw new FetchError(e, 'The request failed and the interceptors did not return an alternative response');\n              } else {\n                throw e;\n              }\n            }\n        }\n        for (const middleware of this.middleware) {\n            if (middleware.post) {\n                response = await middleware.post({\n                    fetch: this.fetchApi,\n                    url: fetchParams.url,\n                    init: fetchParams.init,\n                    response: response.clone(),\n                }) || response;\n            }\n        }\n        return response;\n    }\n\n    /**\n     * Create a shallow clone of `this` by constructing a new instance\n     * and then shallow cloning data members.\n     */\n    private clone<T extends BaseAPI>(this: T): T {\n        const constructor = this.constructor as any;\n        const next = new constructor(this.configuration);\n        next.middleware = this.middleware.slice();\n        return next;\n    }\n};\n\nfunction isBlob(value: any): value is Blob {\n    return typeof Blob !== 'undefined' && value instanceof Blob;\n}\n\nfunction isFormData(value: any): value is FormData {\n    return typeof FormData !== \"undefined\" && value instanceof FormData;\n}\n\nexport class ResponseError extends Error {\n    override name: \"ResponseError\" = \"ResponseError\";\n    constructor(public response: Response, msg?: string) {\n        super(msg);\n    }\n}\n\nexport class FetchError extends Error {\n    override name: \"FetchError\" = \"FetchError\";\n    constructor(public cause: Error, msg?: string) {\n        super(msg);\n    }\n}\n\nexport class RequiredError extends Error {\n    override name: \"RequiredError\" = \"RequiredError\";\n    constructor(public field: string, msg?: string) {\n        super(msg);\n    }\n}\n\nexport const COLLECTION_FORMATS = {\n    csv: \",\",\n    ssv: \" \",\n    tsv: \"\\t\",\n    pipes: \"|\",\n};\n\nexport type FetchAPI = WindowOrWorkerGlobalScope['fetch'];\n\nexport type Json = any;\nexport type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD';\nexport type HTTPHeaders = { [key: string]: string };\nexport type HTTPQuery = { [key: string]: string | number | null | boolean | Array<string | number | null | boolean> | Set<string | number | null | boolean> | HTTPQuery };\nexport type HTTPBody = Json | FormData | URLSearchParams;\nexport type HTTPRequestInit = { headers?: HTTPHeaders; method: HTTPMethod; credentials?: RequestCredentials; body?: HTTPBody };\nexport type ModelPropertyNaming = 'camelCase' | 'snake_case' | 'PascalCase' | 'original';\n\nexport type InitOverrideFunction = (requestContext: { init: HTTPRequestInit, context: RequestOpts }) => Promise<RequestInit>\n\nexport interface FetchParams {\n    url: string;\n    init: RequestInit;\n}\n\nexport interface RequestOpts {\n    path: string;\n    method: HTTPMethod;\n    headers: HTTPHeaders;\n    query?: HTTPQuery;\n    body?: HTTPBody;\n}\n\nexport function querystring(params: HTTPQuery, prefix: string = ''): string {\n    return Object.keys(params)\n        .map(key => querystringSingleKey(key, params[key], prefix))\n        .filter(part => part.length > 0)\n        .join('&');\n}\n\nfunction querystringSingleKey(key: string, value: string | number | null | undefined | boolean | Array<string | number | null | boolean> | Set<string | number | null | boolean> | HTTPQuery, keyPrefix: string = ''): string {\n    const fullKey = keyPrefix + (keyPrefix.length ? `[${key}]` : key);\n    if (value instanceof Array) {\n        const multiValue = value.map(singleValue => encodeURIComponent(String(singleValue)))\n            .join(`&${encodeURIComponent(fullKey)}=`);\n        return `${encodeURIComponent(fullKey)}=${multiValue}`;\n    }\n    if (value instanceof Set) {\n        const valueAsArray = Array.from(value);\n        return querystringSingleKey(key, valueAsArray, keyPrefix);\n    }\n    if (value instanceof Date) {\n        return `${encodeURIComponent(fullKey)}=${encodeURIComponent(value.toISOString())}`;\n    }\n    if (value instanceof Object) {\n        return querystring(value as HTTPQuery, fullKey);\n    }\n    return `${encodeURIComponent(fullKey)}=${encodeURIComponent(String(value))}`;\n}\n\nexport function exists(json: any, key: string) {\n    const value = json[key];\n    return value !== null && value !== undefined;\n}\n\nexport function mapValues(data: any, fn: (item: any) => any) {\n  return Object.keys(data).reduce(\n    (acc, key) => ({ ...acc, [key]: fn(data[key]) }),\n    {}\n  );\n}\n\nexport function canConsumeForm(consumes: Consume[]): boolean {\n    for (const consume of consumes) {\n        if ('multipart/form-data' === consume.contentType) {\n            return true;\n        }\n    }\n    return false;\n}\n\nexport interface Consume {\n    contentType: string;\n}\n\nexport interface RequestContext {\n    fetch: FetchAPI;\n    url: string;\n    init: RequestInit;\n}\n\nexport interface ResponseContext {\n    fetch: FetchAPI;\n    url: string;\n    init: RequestInit;\n    response: Response;\n}\n\nexport interface ErrorContext {\n    fetch: FetchAPI;\n    url: string;\n    init: RequestInit;\n    error: unknown;\n    response?: Response;\n}\n\nexport interface Middleware {\n    pre?(context: RequestContext): Promise<FetchParams | void>;\n    post?(context: ResponseContext): Promise<Response | void>;\n    onError?(context: ErrorContext): Promise<Response | void>;\n}\n\nexport interface ApiResponse<T> {\n    raw: Response;\n    value(): Promise<T>;\n}\n\nexport interface ResponseTransformer<T> {\n    (json: any): T;\n}\n\nexport class JSONApiResponse<T> {\n    constructor(public raw: Response, private transformer: ResponseTransformer<T> = (jsonValue: any) => jsonValue) {}\n\n    async value(): Promise<T> {\n        return this.transformer(await this.raw.json());\n    }\n}\n\nexport class VoidApiResponse {\n    constructor(public raw: Response) {}\n\n    async value(): Promise<void> {\n        return undefined;\n    }\n}\n\nexport class BlobApiResponse {\n    constructor(public raw: Response) {}\n\n    async value(): Promise<Blob> {\n        return await this.raw.blob();\n    };\n}\n\nexport class TextApiResponse {\n    constructor(public raw: Response) {}\n\n    async value(): Promise<string> {\n        return await this.raw.text();\n    };\n}\n"
  },
  {
    "path": "webapp/src/components/layout/AppLayout.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, onMounted, onUnmounted } from 'vue';\nimport Sidebar from './Sidebar.vue';\nimport TopBar from './TopBar.vue';\n\nconst isMobile = ref(false);\n\nconst checkMobile = () => {\n  isMobile.value = window.innerWidth < 768;\n};\n\nonMounted(() => {\n  checkMobile();\n  window.addEventListener('resize', checkMobile);\n});\n\nonUnmounted(() => {\n  window.removeEventListener('resize', checkMobile);\n});\n</script>\n\n<template>\n  <div class=\"min-h-screen flex flex-col bg-gray-100 dark:bg-gray-900\">\n    <!-- Mobile Top Bar (only visible on mobile) -->\n    <TopBar v-if=\"isMobile\" />\n\n    <div class=\"flex flex-1\">\n      <!-- Sidebar (left on desktop, bottom on mobile) -->\n      <Sidebar :is-mobile=\"isMobile\" />\n\n      <!-- Main Content -->\n      <main class=\"flex-1 p-6 pb-24 md:pb-6 overflow-auto\">\n        <!-- Header slot for page-specific headers -->\n        <header v-if=\"$slots.header\" class=\"mb-6\">\n          <slot name=\"header\"></slot>\n        </header>\n\n        <!-- Default slot for page content -->\n        <slot></slot>\n      </main>\n    </div>\n\n    <!-- Mobile Navigation (only visible on mobile) -->\n    <nav v-if=\"isMobile\"\n      class=\"bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 fixed bottom-0 left-0 right-0 z-10\">\n      <!-- Mobile navigation content will be rendered by Sidebar component -->\n    </nav>\n  </div>\n</template>\n\n<style>\n/* Ensure the layout takes up the full viewport height */\nhtml,\nbody,\n#app {\n  height: 100%;\n  min-height: 100vh;\n}\n</style>\n"
  },
  {
    "path": "webapp/src/components/layout/LanguageSelector.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, onMounted } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { setLanguage } from '@/utils/i18n'\nimport type { SupportedLocale } from '@/utils/i18n'\n\nconst { t, locale } = useI18n()\n\nconst languages = [\n    { code: 'en' as SupportedLocale, name: 'English' },\n    { code: 'es' as SupportedLocale, name: 'Español' },\n    { code: 'fr' as SupportedLocale, name: 'Français' },\n    { code: 'de' as SupportedLocale, name: 'Deutsch' },\n    { code: 'ja' as SupportedLocale, name: '日本語' }\n]\n\nconst isOpen = ref(false)\nconst selectedLanguage = ref(locale.value as SupportedLocale)\n\nconst toggleDropdown = () => {\n    isOpen.value = !isOpen.value\n}\n\nconst closeDropdown = () => {\n    isOpen.value = false\n}\n\nconst changeLanguage = (langCode: SupportedLocale) => {\n    selectedLanguage.value = langCode\n    setLanguage(langCode)\n    closeDropdown()\n}\n\n// Close dropdown when clicking outside\nonMounted(() => {\n    document.addEventListener('click', (event) => {\n        const target = event.target as HTMLElement\n        if (!target.closest('.language-selector')) {\n            closeDropdown()\n        }\n    })\n})\n</script>\n\n<template>\n    <div class=\"language-selector relative\">\n        <button @click.stop=\"toggleDropdown\"\n            class=\"flex items-center px-3 py-2 text-sm rounded-md hover:bg-gray-100 dark:hover:bg-gray-700\"\n            aria-haspopup=\"true\" :aria-expanded=\"isOpen\">\n            <span class=\"mr-1\">{{languages.find(lang => lang.code === selectedLanguage)?.name}}</span>\n            <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"\n                xmlns=\"http://www.w3.org/2000/svg\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path>\n            </svg>\n        </button>\n\n        <div v-if=\"isOpen\" class=\"absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg z-10 py-1\">\n            <button v-for=\"language in languages\" :key=\"language.code\" @click=\"changeLanguage(language.code)\"\n                class=\"block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700\"\n                :class=\"{ 'bg-gray-100 dark:bg-gray-700': selectedLanguage === language.code }\">\n                {{ language.name }}\n            </button>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "webapp/src/components/layout/Sidebar.vue",
    "content": "<script setup lang=\"ts\">\nimport { RouterLink } from 'vue-router';\nimport { useAuthStore } from '@/stores/auth';\nimport { useRouter } from 'vue-router';\nimport { ref, onMounted, onUnmounted } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\n// Define props using the compiler macro (no import needed)\ndefineProps<{\n    isMobile: boolean;\n}>();\n\nconst { t } = useI18n();\nconst authStore = useAuthStore();\nconst router = useRouter();\nconst isMenuOpen = ref(false);\nconst menuRef = ref<HTMLElement | null>(null);\n\ninterface NavItem {\n    nameKey: string;\n    icon: 'home' | 'tag' | 'folder' | 'archive' | 'settings';\n    route: string;\n}\n\nconst navItems: NavItem[] = [\n    { nameKey: 'navigation.home', icon: 'home', route: '/home' },\n    { nameKey: 'navigation.tags', icon: 'tag', route: '/tags' },\n    { nameKey: 'navigation.folders', icon: 'folder', route: '/folders' },\n    { nameKey: 'navigation.archive', icon: 'archive', route: '/archive' },\n    { nameKey: 'navigation.settings', icon: 'settings', route: '/settings' },\n];\n\n// Toggle menu\nconst toggleMenu = () => {\n    isMenuOpen.value = !isMenuOpen.value;\n};\n\n// Handle logout\nconst handleLogout = async () => {\n    await authStore.logout();\n    isMenuOpen.value = false;\n    router.push('/login');\n};\n\n// Close menu when clicking outside\nconst handleClickOutside = (event: MouseEvent) => {\n    if (menuRef.value && !menuRef.value.contains(event.target as Node)) {\n        isMenuOpen.value = false;\n    }\n};\n\n// Add and remove event listeners\nonMounted(() => {\n    document.addEventListener('click', handleClickOutside);\n});\n\nonUnmounted(() => {\n    document.removeEventListener('click', handleClickOutside);\n});\n\n// SVG icons mapping\nconst icons = {\n    home: `<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-6 w-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6\" />\n  </svg>`,\n    tag: `<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-6 w-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z\" />\n  </svg>`,\n    folder: `<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-6 w-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z\" />\n  </svg>`,\n    archive: `<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-6 w-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4\" />\n  </svg>`,\n    settings: `<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-6 w-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\" />\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\n  </svg>`,\n    user: `<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-6 w-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n      d=\"M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z\" />\n  </svg>`,\n};\n</script>\n\n<template>\n    <div>\n        <template v-if=\"!isMobile\">\n            <!-- Desktop Sidebar -->\n            <aside\n                class=\"w-20 h-screen bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col items-center py-6 sticky top-0\">\n                <!-- Logo -->\n                <div class=\"mb-8 flex flex-col items-center\">\n                    <div class=\"text-red-500 font-bold text-2xl\">栞</div>\n                    <span class=\"text-xs mt-1 dark:text-gray-300\">shiori</span>\n                </div>\n\n                <!-- Navigation -->\n                <nav class=\"flex flex-col items-center space-y-6 flex-1\">\n                    <RouterLink v-for=\"item in navItems\" :key=\"item.nameKey\" :to=\"item.route\"\n                        class=\"text-gray-500 dark:text-gray-400 hover:text-red-500 p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex flex-col items-center\"\n                        :title=\"t(item.nameKey)\">\n                        <div v-html=\"icons[item.icon]\"></div>\n                        <span class=\"text-xs mt-1 dark:text-gray-300\">{{ t(item.nameKey) }}</span>\n                    </RouterLink>\n\n                    <!-- Spacer -->\n                    <div class=\"flex-1\"></div>\n\n                    <!-- User Menu -->\n                    <div class=\"relative mt-auto\" ref=\"menuRef\">\n                        <button @click.stop=\"toggleMenu\"\n                            class=\"text-gray-500 dark:text-gray-400 hover:text-red-500 p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex flex-col items-center\"\n                            :title=\"t('auth.user')\">\n                            <div v-html=\"icons.user\"></div>\n                            <span class=\"text-xs mt-1 dark:text-gray-300\">{{ authStore.user?.username || t('auth.user')\n                                }}</span>\n                        </button>\n\n                        <!-- Dropdown Menu -->\n                        <div v-if=\"isMenuOpen\"\n                            class=\"absolute left-20 bottom-0 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg py-1 z-50 border border-gray-200 dark:border-gray-700\">\n                            <div\n                                class=\"px-4 py-2 text-sm text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700\">\n                                <div class=\"font-medium dark:text-gray-300\">{{ authStore.user?.username ||\n                                    t('auth.user') }}\n                                </div>\n                            </div>\n                            <router-link to=\"/settings\"\n                                class=\"block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700\">\n                                {{ t('navigation.settings') }}\n                            </router-link>\n                            <button @click=\"handleLogout\"\n                                class=\"block w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700\">\n                                {{ t('auth.logout') }}\n                            </button>\n                        </div>\n                    </div>\n                </nav>\n            </aside>\n        </template>\n\n        <template v-else>\n            <!-- Mobile Bottom Navigation -->\n            <nav\n                class=\"fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 flex justify-around py-2 z-10\">\n                <RouterLink v-for=\"item in navItems\" :key=\"item.nameKey\" :to=\"item.route\"\n                    class=\"text-gray-500 dark:text-gray-400 hover:text-red-500 p-2 flex flex-col items-center\">\n                    <div v-html=\"icons[item.icon]\"></div>\n                    <span class=\"text-xs mt-1 dark:text-gray-300\">{{ t(item.nameKey) }}</span>\n                </RouterLink>\n            </nav>\n        </template>\n    </div>\n</template>\n\n<style scoped>\n/* Ensure the dropdown is visible and positioned correctly */\n.relative {\n    position: relative;\n}\n</style>\n"
  },
  {
    "path": "webapp/src/components/layout/TopBar.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, onMounted, onUnmounted } from 'vue';\nimport { useRouter } from 'vue-router';\nimport { useAuthStore } from '@/stores/auth';\nimport { useI18n } from 'vue-i18n';\nimport LanguageSelector from './LanguageSelector.vue';\n\nconst { t } = useI18n();\nconst isMenuOpen = ref(false);\nconst authStore = useAuthStore();\nconst router = useRouter();\nconst menuRef = ref<HTMLElement | null>(null);\n\n// Toggle menu\nconst toggleMenu = (event: MouseEvent) => {\n  event.stopPropagation(); // Prevent event from bubbling up\n  isMenuOpen.value = !isMenuOpen.value;\n};\n\n// Handle logout\nconst handleLogout = async () => {\n  await authStore.logout();\n  isMenuOpen.value = false;\n  router.push('/login');\n};\n\n// Close menu when clicking outside\nconst handleClickOutside = (event: MouseEvent) => {\n  if (menuRef.value && !menuRef.value.contains(event.target as Node)) {\n    isMenuOpen.value = false;\n  }\n};\n\n// Add and remove event listeners\nonMounted(() => {\n  document.addEventListener('click', handleClickOutside);\n});\n\nonUnmounted(() => {\n  document.removeEventListener('click', handleClickOutside);\n});\n</script>\n\n<template>\n  <header\n    class=\"bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between\">\n    <!-- Logo -->\n    <div class=\"flex items-center\">\n      <div class=\"text-red-500 font-bold text-xl\">栞</div>\n    </div>\n\n    <!-- Search -->\n    <div class=\"flex-1 mx-4\">\n      <div class=\"relative\">\n        <input type=\"text\" :placeholder=\"t('common.search')\"\n          class=\"w-full border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-md px-3 py-1 focus:outline-none focus:ring-2 focus:ring-red-500\" />\n      </div>\n    </div>\n\n    <!-- Actions -->\n    <div class=\"flex items-center space-x-2\">\n      <!-- Language Selector -->\n      <LanguageSelector />\n\n      <!-- User Menu -->\n      <div class=\"relative\" ref=\"menuRef\">\n        <button @click=\"toggleMenu\"\n          class=\"text-gray-500 dark:text-gray-300 p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full\">\n          <!-- User menu icon (consistent across mobile and desktop) -->\n          <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-6 w-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n              d=\"M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z\" />\n          </svg>\n        </button>\n\n        <!-- Dropdown menu -->\n        <div v-if=\"isMenuOpen\"\n          class=\"absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg py-1 z-10 border border-gray-200 dark:border-gray-700\">\n          <div class=\"px-4 py-2 text-sm text-gray-700 dark:text-gray-300\">\n            {{ authStore.user?.username || 'User' }}\n          </div>\n          <hr class=\"border-gray-200 dark:border-gray-700\">\n          <a href=\"#\" @click.prevent=\"handleLogout\"\n            class=\"block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700\">\n            {{ t('auth.logout') }}\n          </a>\n        </div>\n      </div>\n    </div>\n  </header>\n</template>\n\n<style scoped>\n/* Ensure the dropdown is visible and positioned correctly */\n.relative {\n  position: relative;\n}\n</style>\n"
  },
  {
    "path": "webapp/src/locales/de.json",
    "content": "{\n  \"common\": {\n    \"loading\": \"Wird geladen...\",\n    \"save\": \"Speichern\",\n    \"cancel\": \"Abbrechen\",\n    \"delete\": \"Löschen\",\n    \"edit\": \"Bearbeiten\",\n    \"search\": \"Suchen\",\n    \"add\": \"Hinzufügen\",\n    \"remove\": \"Entfernen\",\n    \"close\": \"Schließen\"\n  },\n  \"auth\": {\n    \"login\": \"Anmelden\",\n    \"logout\": \"Abmelden\",\n    \"username\": \"Benutzername\",\n    \"password\": \"Passwort\",\n    \"remember_me\": \"Angemeldet bleiben\",\n    \"login_failed\": \"Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten.\"\n  },\n  \"navigation\": {\n    \"home\": \"Startseite\",\n    \"archive\": \"Archiv\",\n    \"tags\": \"Tags\",\n    \"folders\": \"Ordner\",\n    \"settings\": \"Einstellungen\",\n    \"about\": \"Über\"\n  },\n  \"bookmarks\": {\n    \"title\": \"Lesezeichen\",\n    \"add_bookmark\": \"Lesezeichen hinzufügen\",\n    \"edit_bookmark\": \"Lesezeichen bearbeiten\",\n    \"delete_bookmark\": \"Lesezeichen löschen\",\n    \"url\": \"URL\",\n    \"title\": \"Titel\",\n    \"excerpt\": \"Auszug\",\n    \"tags\": \"Tags\",\n    \"add_tag\": \"Tag hinzufügen\",\n    \"no_bookmarks\": \"Keine Lesezeichen gefunden\"\n  },\n  \"tags\": {\n    \"title\": \"Tags\",\n    \"add_tag\": \"Tag hinzufügen\",\n    \"edit_tag\": \"Tag bearbeiten\",\n    \"delete_tag\": \"Tag löschen\",\n    \"name\": \"Name\",\n    \"no_tags\": \"Keine Tags gefunden\"\n  },\n  \"folders\": {\n    \"title\": \"Ordner\",\n    \"add_folder\": \"Ordner hinzufügen\",\n    \"edit_folder\": \"Ordner bearbeiten\",\n    \"delete_folder\": \"Ordner löschen\",\n    \"name\": \"Name\",\n    \"no_folders\": \"Keine Ordner gefunden\"\n  },\n  \"settings\": {\n    \"title\": \"Einstellungen\",\n    \"language\": \"Sprache\",\n    \"theme\": \"Thema\",\n    \"light\": \"Hell\",\n    \"dark\": \"Dunkel\",\n    \"system\": \"System\"\n  }\n}\n"
  },
  {
    "path": "webapp/src/locales/en.json",
    "content": "{\n  \"common\": {\n    \"loading\": \"Loading...\",\n    \"save\": \"Save\",\n    \"cancel\": \"Cancel\",\n    \"delete\": \"Delete\",\n    \"edit\": \"Edit\",\n    \"search\": \"Search\",\n    \"add\": \"Add\",\n    \"remove\": \"Remove\",\n    \"close\": \"Close\"\n  },\n  \"auth\": {\n    \"login\": \"Login\",\n    \"logout\": \"Logout\",\n    \"username\": \"Username\",\n    \"password\": \"Password\",\n    \"remember_me\": \"Remember me\",\n    \"login_failed\": \"Login failed. Please check your credentials.\"\n  },\n  \"navigation\": {\n    \"home\": \"Home\",\n    \"archive\": \"Archive\",\n    \"tags\": \"Tags\",\n    \"folders\": \"Folders\",\n    \"settings\": \"Settings\",\n    \"about\": \"About\"\n  },\n  \"bookmarks\": {\n    \"title\": \"Bookmarks\",\n    \"add_bookmark\": \"Add Bookmark\",\n    \"edit_bookmark\": \"Edit Bookmark\",\n    \"delete_bookmark\": \"Delete Bookmark\",\n    \"url\": \"URL\",\n    \"title\": \"Title\",\n    \"excerpt\": \"Excerpt\",\n    \"tags\": \"Tags\",\n    \"add_tag\": \"Add Tag\",\n    \"no_bookmarks\": \"No bookmarks found\"\n  },\n  \"tags\": {\n    \"title\": \"Tags\",\n    \"add_tag\": \"Add Tag\",\n    \"edit_tag\": \"Edit Tag\",\n    \"delete_tag\": \"Delete Tag\",\n    \"name\": \"Name\",\n    \"no_tags\": \"No tags found\"\n  },\n  \"folders\": {\n    \"title\": \"Folders\",\n    \"add_folder\": \"Add Folder\",\n    \"edit_folder\": \"Edit Folder\",\n    \"delete_folder\": \"Delete Folder\",\n    \"name\": \"Name\",\n    \"no_folders\": \"No folders found\"\n  },\n  \"settings\": {\n    \"title\": \"Settings\",\n    \"language\": \"Language\",\n    \"theme\": \"Theme\",\n    \"light\": \"Light\",\n    \"dark\": \"Dark\",\n    \"system\": \"System\"\n  }\n}\n"
  },
  {
    "path": "webapp/src/locales/es.json",
    "content": "{\n  \"common\": {\n    \"loading\": \"Cargando...\",\n    \"save\": \"Guardar\",\n    \"cancel\": \"Cancelar\",\n    \"delete\": \"Eliminar\",\n    \"edit\": \"Editar\",\n    \"search\": \"Buscar\",\n    \"add\": \"Añadir\",\n    \"remove\": \"Eliminar\",\n    \"close\": \"Cerrar\"\n  },\n  \"auth\": {\n    \"login\": \"Iniciar sesión\",\n    \"logout\": \"Cerrar sesión\",\n    \"username\": \"Usuario\",\n    \"password\": \"Contraseña\",\n    \"remember_me\": \"Recordarme\",\n    \"login_failed\": \"Error al iniciar sesión. Por favor, verifica tus credenciales.\"\n  },\n  \"navigation\": {\n    \"home\": \"Inicio\",\n    \"archive\": \"Archivo\",\n    \"tags\": \"Etiquetas\",\n    \"folders\": \"Carpetas\",\n    \"settings\": \"Configuración\",\n    \"about\": \"Acerca de\"\n  },\n  \"bookmarks\": {\n    \"title\": \"Marcadores\",\n    \"add_bookmark\": \"Añadir marcador\",\n    \"edit_bookmark\": \"Editar marcador\",\n    \"delete_bookmark\": \"Eliminar marcador\",\n    \"url\": \"URL\",\n    \"title\": \"Título\",\n    \"excerpt\": \"Extracto\",\n    \"tags\": \"Etiquetas\",\n    \"add_tag\": \"Añadir etiqueta\",\n    \"no_bookmarks\": \"No se encontraron marcadores\"\n  },\n  \"tags\": {\n    \"title\": \"Etiquetas\",\n    \"add_tag\": \"Añadir etiqueta\",\n    \"edit_tag\": \"Editar etiqueta\",\n    \"delete_tag\": \"Eliminar etiqueta\",\n    \"name\": \"Nombre\",\n    \"no_tags\": \"No se encontraron etiquetas\"\n  },\n  \"folders\": {\n    \"title\": \"Carpetas\",\n    \"add_folder\": \"Añadir carpeta\",\n    \"edit_folder\": \"Editar carpeta\",\n    \"delete_folder\": \"Eliminar carpeta\",\n    \"name\": \"Nombre\",\n    \"no_folders\": \"No se encontraron carpetas\"\n  },\n  \"settings\": {\n    \"title\": \"Configuración\",\n    \"language\": \"Idioma\",\n    \"theme\": \"Tema\",\n    \"light\": \"Claro\",\n    \"dark\": \"Oscuro\",\n    \"system\": \"Sistema\"\n  }\n}\n"
  },
  {
    "path": "webapp/src/locales/fr.json",
    "content": "{\n  \"common\": {\n    \"loading\": \"Chargement...\",\n    \"save\": \"Enregistrer\",\n    \"cancel\": \"Annuler\",\n    \"delete\": \"Supprimer\",\n    \"edit\": \"Modifier\",\n    \"search\": \"Rechercher\",\n    \"add\": \"Ajouter\",\n    \"remove\": \"Supprimer\",\n    \"close\": \"Fermer\"\n  },\n  \"auth\": {\n    \"login\": \"Connexion\",\n    \"logout\": \"Déconnexion\",\n    \"username\": \"Nom d'utilisateur\",\n    \"password\": \"Mot de passe\",\n    \"remember_me\": \"Se souvenir de moi\",\n    \"login_failed\": \"Échec de la connexion. Veuillez vérifier vos identifiants.\"\n  },\n  \"navigation\": {\n    \"home\": \"Accueil\",\n    \"archive\": \"Archives\",\n    \"tags\": \"Tags\",\n    \"folders\": \"Dossiers\",\n    \"settings\": \"Paramètres\",\n    \"about\": \"À propos\"\n  },\n  \"bookmarks\": {\n    \"title\": \"Favoris\",\n    \"add_bookmark\": \"Ajouter un favori\",\n    \"edit_bookmark\": \"Modifier le favori\",\n    \"delete_bookmark\": \"Supprimer le favori\",\n    \"url\": \"URL\",\n    \"title\": \"Titre\",\n    \"excerpt\": \"Extrait\",\n    \"tags\": \"Tags\",\n    \"add_tag\": \"Ajouter un tag\",\n    \"no_bookmarks\": \"Aucun favori trouvé\"\n  },\n  \"tags\": {\n    \"title\": \"Tags\",\n    \"add_tag\": \"Ajouter un tag\",\n    \"edit_tag\": \"Modifier le tag\",\n    \"delete_tag\": \"Supprimer le tag\",\n    \"name\": \"Nom\",\n    \"no_tags\": \"Aucun tag trouvé\"\n  },\n  \"folders\": {\n    \"title\": \"Dossiers\",\n    \"add_folder\": \"Ajouter un dossier\",\n    \"edit_folder\": \"Modifier le dossier\",\n    \"delete_folder\": \"Supprimer le dossier\",\n    \"name\": \"Nom\",\n    \"no_folders\": \"Aucun dossier trouvé\"\n  },\n  \"settings\": {\n    \"title\": \"Paramètres\",\n    \"language\": \"Langue\",\n    \"theme\": \"Thème\",\n    \"light\": \"Clair\",\n    \"dark\": \"Sombre\",\n    \"system\": \"Système\"\n  }\n}\n"
  },
  {
    "path": "webapp/src/locales/ja.json",
    "content": "{\n  \"common\": {\n    \"loading\": \"読み込み中...\",\n    \"save\": \"保存\",\n    \"cancel\": \"キャンセル\",\n    \"delete\": \"削除\",\n    \"edit\": \"編集\",\n    \"search\": \"検索\",\n    \"add\": \"追加\",\n    \"remove\": \"削除\",\n    \"close\": \"閉じる\"\n  },\n  \"auth\": {\n    \"login\": \"ログイン\",\n    \"logout\": \"ログアウト\",\n    \"username\": \"ユーザー名\",\n    \"password\": \"パスワード\",\n    \"remember_me\": \"ログイン状態を保持する\",\n    \"login_failed\": \"ログインに失敗しました。認証情報を確認してください。\"\n  },\n  \"navigation\": {\n    \"home\": \"ホーム\",\n    \"archive\": \"アーカイブ\",\n    \"tags\": \"タグ\",\n    \"folders\": \"フォルダ\",\n    \"settings\": \"設定\",\n    \"about\": \"概要\"\n  },\n  \"bookmarks\": {\n    \"title\": \"ブックマーク\",\n    \"add_bookmark\": \"ブックマークを追加\",\n    \"edit_bookmark\": \"ブックマークを編集\",\n    \"delete_bookmark\": \"ブックマークを削除\",\n    \"url\": \"URL\",\n    \"title\": \"タイトル\",\n    \"excerpt\": \"抜粋\",\n    \"tags\": \"タグ\",\n    \"add_tag\": \"タグを追加\",\n    \"no_bookmarks\": \"ブックマークが見つかりません\"\n  },\n  \"tags\": {\n    \"title\": \"タグ\",\n    \"add_tag\": \"タグを追加\",\n    \"edit_tag\": \"タグを編集\",\n    \"delete_tag\": \"タグを削除\",\n    \"name\": \"名前\",\n    \"no_tags\": \"タグが見つかりません\"\n  },\n  \"folders\": {\n    \"title\": \"フォルダ\",\n    \"add_folder\": \"フォルダを追加\",\n    \"edit_folder\": \"フォルダを編集\",\n    \"delete_folder\": \"フォルダを削除\",\n    \"name\": \"名前\",\n    \"no_folders\": \"フォルダが見つかりません\"\n  },\n  \"settings\": {\n    \"title\": \"設定\",\n    \"language\": \"言語\",\n    \"theme\": \"テーマ\",\n    \"light\": \"ライト\",\n    \"dark\": \"ダーク\",\n    \"system\": \"システム\"\n  }\n}\n"
  },
  {
    "path": "webapp/src/main.ts",
    "content": "import './assets/main.css'\n\nimport { createApp } from 'vue'\nimport { createPinia } from 'pinia'\n\nimport App from './App.vue'\nimport router from './router'\nimport i18n from './utils/i18n'\n\nconst app = createApp(App)\n\napp.use(createPinia())\napp.use(router)\napp.use(i18n)\n\napp.mount('#app')\n"
  },
  {
    "path": "webapp/src/router/index.ts",
    "content": "import { createRouter, createWebHistory } from 'vue-router'\nimport type { RouteRecordRaw, NavigationGuardNext as NavigationGuard, RouteLocationNormalized } from 'vue-router'\nimport HomeView from '../views/HomeView.vue'\nimport LoginView from '../views/LoginView.vue'\nimport { useAuthStore } from '@/stores/auth'\n\nconst routes: Array<RouteRecordRaw> = [\n  {\n    path: '/',\n    redirect: '/home'\n  },\n  {\n    path: '/home',\n    name: 'home',\n    component: HomeView,\n    meta: { requiresAuth: true }\n  },\n  {\n    path: '/login',\n    name: 'login',\n    component: LoginView,\n    props: (route) => ({ dst: route.query.dst })\n  },\n  {\n    path: '/tags',\n    name: 'tags',\n    component: () => import('../views/TagsView.vue'),\n    meta: { requiresAuth: true }\n  },\n  {\n    path: '/folders',\n    name: 'folders',\n    component: () => import('../views/FoldersView.vue'),\n    meta: { requiresAuth: true }\n  },\n  {\n    path: '/archive',\n    name: 'archive',\n    component: () => import('../views/ArchiveView.vue'),\n    meta: { requiresAuth: true }\n  },\n  {\n    path: '/settings',\n    name: 'settings',\n    component: () => import('../views/SettingsView.vue'),\n    meta: { requiresAuth: true }\n  },\n  // Redirect any unmatched routes to home (which will redirect to login if not authenticated)\n  {\n    path: '/:pathMatch(.*)*',\n    redirect: '/home'\n  }\n]\n\nconst router = createRouter({\n  history: createWebHistory(import.meta.env.BASE_URL),\n  routes\n})\n\n// Navigation guard\nrouter.beforeEach(async (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuard) => {\n  const authStore = useAuthStore()\n\n  // Check if the route requires authentication\n  if (to.matched.some((record) => record.meta.requiresAuth)) {\n    // If we have a token, validate it\n    if (authStore.token) {\n      const isValid = await authStore.validateToken()\n\n      if (isValid) {\n        // Token is valid, proceed to the requested route\n        next()\n      } else {\n        // Token is invalid, redirect to login with destination\n        const destination = to.fullPath\n        authStore.setRedirectDestination(destination)\n        next({\n          name: 'login',\n          query: { dst: destination }\n        })\n      }\n    } else {\n      // No token, redirect to login with destination\n      const destination = to.fullPath\n      authStore.setRedirectDestination(destination)\n      next({\n        name: 'login',\n        query: { dst: destination }\n      })\n    }\n  } else {\n    // Route doesn't require auth, proceed\n    next()\n  }\n})\n\nexport default router\n"
  },
  {
    "path": "webapp/src/stores/auth.ts",
    "content": "import { defineStore } from 'pinia'\nimport { ref, computed } from 'vue'\nimport { AuthApi } from '@/client/apis/AuthApi'\nimport type { ApiV1LoginRequestPayload } from '@/client/models/ApiV1LoginRequestPayload'\nimport { Configuration } from '@/client/runtime'\n\nexport const useAuthStore = defineStore('auth', () => {\n  const token = ref<string | null>(localStorage.getItem('token'))\n  const expires = ref<number | null>(Number(localStorage.getItem('expires')) || null)\n  const user = ref<any | null>(null)\n  const loading = ref(false)\n  const error = ref<string | null>(null)\n  const redirectDestination = ref<string | null>(null)\n\n  const isAuthenticated = computed(() => {\n    if (!token.value) return false\n    if (!expires.value) return false\n    return expires.value > Date.now()\n  })\n\n  // Create API client with auth token\n  const getApiClient = () => {\n    const config = new Configuration({\n      basePath: 'http://localhost:8080',\n      accessToken: token.value || undefined,\n      headers: token.value ? {\n        'Authorization': `Bearer ${token.value}`,\n        'X-Shiori-Response-Format': 'new'\n      } : undefined\n    })\n    return new AuthApi(config)\n  }\n\n  // Validate token by fetching user info\n  const validateToken = async (): Promise<boolean> => {\n    if (!token.value) return false\n\n    loading.value = true\n    try {\n      const result = await fetchUserInfo()\n      loading.value = false\n      return !!result\n    } catch (err) {\n      loading.value = false\n      return false\n    }\n  }\n\n  // Login function\n  const login = async (username: string, password: string, rememberMe: boolean = false) => {\n    loading.value = true\n    error.value = null\n\n    try {\n      const payload: ApiV1LoginRequestPayload = {\n        username,\n        password,\n        rememberMe,\n      }\n\n      const api = getApiClient()\n      const response = await api.apiV1AuthLoginPost({ payload })\n\n      if (response.token) {\n        token.value = response.token\n        expires.value = response.expires || 0\n\n        // Store in localStorage\n        localStorage.setItem('token', response.token)\n        localStorage.setItem('expires', String(response.expires))\n\n        // Get user info\n        await fetchUserInfo()\n        return true\n      } else {\n        throw new Error('Invalid response from server')\n      }\n    } catch (err: any) {\n      console.error('Login error:', err)\n\n      // Extract error message from response if available\n      if (err.response) {\n        try {\n          // Try to parse the response body as JSON\n          const responseBody = await err.response.json()\n          if (responseBody && responseBody.message) {\n            error.value = responseBody.message\n          } else if (responseBody && responseBody.error) {\n            error.value = responseBody.error\n          } else if (typeof responseBody === 'string') {\n            error.value = responseBody\n          } else {\n            error.value = `Server error: ${err.response.status}`\n          }\n        } catch (jsonError) {\n          // If response is not JSON, use status text\n          error.value = err.response.statusText || `Server error: ${err.response.status}`\n        }\n      } else {\n        // If no response object, use the error message\n        error.value = err.message || 'Failed to login'\n      }\n\n      return false\n    } finally {\n      loading.value = false\n    }\n  }\n\n  // Fetch user info\n  const fetchUserInfo = async () => {\n    if (!token.value) return null\n\n    try {\n      // Create a new API client with the current token\n      const api = getApiClient()\n\n      // Make the API request with the token in the headers\n      const response = await api.apiV1AuthMeGet()\n\n      if (response) {\n        user.value = response\n        return user.value\n      } else {\n        throw new Error('Failed to fetch user info')\n      }\n    } catch (err: any) {\n      console.error('Error fetching user info:', err)\n\n      // If we get a 401 Unauthorized, the token is invalid\n      if (err.response && err.response.status === 401) {\n        // Clear the invalid token\n        clearAuth()\n      }\n\n      return null\n    }\n  }\n\n  // Clear authentication data\n  const clearAuth = () => {\n    token.value = null\n    expires.value = null\n    user.value = null\n    localStorage.removeItem('token')\n    localStorage.removeItem('expires')\n  }\n\n  // Logout function\n  const logout = async () => {\n    loading.value = true\n\n    try {\n      if (token.value) {\n        const api = getApiClient()\n        await api.apiV1AuthLogoutPost()\n      }\n    } catch (err) {\n      console.error('Logout error:', err)\n    } finally {\n      // Clear state regardless of API success\n      clearAuth()\n      loading.value = false\n    }\n  }\n\n  // Refresh token\n  const refreshToken = async () => {\n    if (!token.value) return false\n\n    try {\n      const api = getApiClient()\n      const response = await api.apiV1AuthRefreshPost()\n\n      if (response.token) {\n        token.value = response.token\n        expires.value = response.expires || 0\n\n        localStorage.setItem('token', response.token)\n        localStorage.setItem('expires', String(response.expires))\n        return true\n      }\n      return false\n    } catch (err) {\n      console.error('Token refresh error:', err)\n      return false\n    }\n  }\n\n  // Set redirect destination\n  const setRedirectDestination = (destination: string | null) => {\n    redirectDestination.value = destination\n  }\n\n  // Get and clear redirect destination\n  const getAndClearRedirectDestination = () => {\n    const destination = redirectDestination.value\n    redirectDestination.value = null\n    return destination\n  }\n\n  return {\n    token,\n    expires,\n    user,\n    loading,\n    error,\n    isAuthenticated,\n    login,\n    logout,\n    fetchUserInfo,\n    refreshToken,\n    validateToken,\n    setRedirectDestination,\n    getAndClearRedirectDestination,\n    clearAuth\n  }\n})\n"
  },
  {
    "path": "webapp/src/stores/tags.ts",
    "content": "import { defineStore } from 'pinia'\nimport { ref } from 'vue'\nimport { Configuration, TagsApi } from '@/client'\nimport type { ModelTagDTO } from '@/client/models'\nimport { useAuthStore } from './auth'\n\nexport const useTagsStore = defineStore('tags', () => {\n  const tags = ref<ModelTagDTO[]>([])\n  const isLoading = ref(false)\n  const error = ref<string | null>(null)\n\n  // API client\n  const getTagsApi = () => {\n    const authStore = useAuthStore()\n    const token = authStore.token\n\n    const config = new Configuration({\n      basePath: 'http://localhost:8080',\n      accessToken: token || undefined,\n      headers: token ? {\n        'Authorization': `Bearer ${token}`,\n        'X-Shiori-Response-Format': 'new'\n      } : undefined\n    })\n    return new TagsApi(config)\n  }\n\n  // Get all tags\n  const fetchTags = async (withBookmarkCount = true) => {\n    isLoading.value = true\n    error.value = null\n\n    try {\n      const api = getTagsApi()\n      const response = await api.apiV1TagsGet({ withBookmarkCount })\n\n      // Ensure response is an array before assigning\n      if (Array.isArray(response)) {\n        tags.value = response\n      } else {\n        console.error('Expected array response but got:', typeof response)\n        tags.value = []\n      }\n\n      return tags.value\n    } catch (err) {\n      console.error('Failed to fetch tags:', err)\n      if (err instanceof Error && err.message.includes('401')) {\n        error.value = 'Authentication error. Please log in again.'\n      } else {\n        error.value = 'Failed to load tags. Please try again.'\n      }\n      throw err\n    } finally {\n      isLoading.value = false\n    }\n\n  }\n\n  // Create a new tag\n  const createTag = async (name: string) => {\n    isLoading.value = true\n    error.value = null\n\n    try {\n      const api = getTagsApi()\n      const newTag = await api.apiV1TagsPost({ tag: { name } })\n      tags.value.push(newTag)\n      return newTag\n    } catch (err) {\n      console.error('Failed to create tag:', err)\n      if (err instanceof Error && err.message.includes('401')) {\n        error.value = 'Authentication error. Please log in again.'\n      } else {\n        error.value = 'Failed to create tag. Please try again.'\n      }\n      throw err\n    } finally {\n      isLoading.value = false\n    }\n  }\n\n  // Update a tag\n  const updateTag = async (id: number, name: string) => {\n    isLoading.value = true\n    error.value = null\n\n    try {\n      const api = getTagsApi()\n      const updatedTag = await api.apiV1TagsIdPut({ id, tag: { id, name } })\n\n      const index = tags.value.findIndex(tag => tag.id === id)\n      if (index !== -1) {\n        tags.value[index] = updatedTag\n      }\n\n      return updatedTag\n    } catch (err) {\n      console.error('Failed to update tag:', err)\n      if (err instanceof Error && err.message.includes('401')) {\n        error.value = 'Authentication error. Please log in again.'\n      } else {\n        error.value = 'Failed to update tag. Please try again.'\n      }\n      throw err\n    } finally {\n      isLoading.value = false\n    }\n  }\n\n  // Delete a tag\n  const deleteTag = async (id: number) => {\n    isLoading.value = true\n    error.value = null\n\n    try {\n      const api = getTagsApi()\n      await api.apiV1TagsIdDelete({ id })\n      tags.value = tags.value.filter(tag => tag.id !== id)\n    } catch (err) {\n      console.error('Failed to delete tag:', err)\n      if (err instanceof Error && err.message.includes('401')) {\n        error.value = 'Authentication error. Please log in again.'\n      } else {\n        error.value = 'Failed to delete tag. Please try again.'\n      }\n      throw err\n    } finally {\n      isLoading.value = false\n    }\n  }\n\n  return {\n    tags,\n    isLoading,\n    error,\n    fetchTags,\n    createTag,\n    updateTag,\n    deleteTag\n  }\n})\n"
  },
  {
    "path": "webapp/src/utils/i18n.ts",
    "content": "import { createI18n } from 'vue-i18n'\nimport en from '@/locales/en.json'\nimport es from '@/locales/es.json'\nimport fr from '@/locales/fr.json'\nimport de from '@/locales/de.json'\nimport ja from '@/locales/ja.json'\n\n// Define supported languages\nexport type SupportedLocale = 'en' | 'es' | 'fr' | 'de' | 'ja';\n\n// Get the browser language or use English as fallback\nconst getBrowserLanguage = (): SupportedLocale => {\n  const browserLang = navigator.language.split('-')[0]\n  return ['en', 'es', 'fr', 'de', 'ja'].includes(browserLang) ? browserLang as SupportedLocale : 'en'\n}\n\n// Get the stored language preference or use browser language\nconst getStoredLanguage = (): SupportedLocale => {\n  const storedLang = localStorage.getItem('shiori-language')\n  return (storedLang && ['en', 'es', 'fr', 'de', 'ja'].includes(storedLang))\n    ? storedLang as SupportedLocale\n    : getBrowserLanguage()\n}\n\n// Create the i18n instance\nconst i18n = createI18n({\n  legacy: false, // Use Composition API\n  locale: getStoredLanguage(),\n  fallbackLocale: 'en',\n  messages: {\n    en,\n    es,\n    fr,\n    de,\n    ja\n  }\n})\n\n// Function to change the language\nexport const setLanguage = (lang: SupportedLocale): void => {\n  i18n.global.locale.value = lang\n  localStorage.setItem('shiori-language', lang)\n  document.querySelector('html')?.setAttribute('lang', lang)\n}\n\n// Initialize HTML lang attribute\ndocument.querySelector('html')?.setAttribute('lang', getStoredLanguage())\n\nexport default i18n\n"
  },
  {
    "path": "webapp/src/views/AboutView.vue",
    "content": "<template>\n  <div class=\"about\">\n    <h1>This is an about page</h1>\n  </div>\n</template>\n\n<style>\n@media (min-width: 1024px) {\n  .about {\n    min-height: 100vh;\n    display: flex;\n    align-items: center;\n  }\n}\n</style>\n"
  },
  {
    "path": "webapp/src/views/ArchiveView.vue",
    "content": "<script setup lang=\"ts\">\nimport AppLayout from '@/components/layout/AppLayout.vue';\n</script>\n\n<template>\n  <AppLayout>\n    <template #header>\n      <div class=\"flex justify-between items-center\">\n        <h1 class=\"text-xl font-bold\">Archive</h1>\n      </div>\n    </template>\n\n    <div class=\"bg-white p-6 rounded-md shadow-sm\">\n      <p>Archive view - Coming soon</p>\n    </div>\n  </AppLayout>\n</template>\n"
  },
  {
    "path": "webapp/src/views/FoldersView.vue",
    "content": "<script setup lang=\"ts\">\nimport AppLayout from '@/components/layout/AppLayout.vue';\nimport { ref } from 'vue';\n\n// Sample folders data (to be replaced with actual API data later)\nconst folders = ref([\n    { id: 1, name: 'Work', bookmarkCount: 12 },\n    { id: 2, name: 'Personal', bookmarkCount: 8 },\n    { id: 3, name: 'Research', bookmarkCount: 15 },\n    { id: 4, name: 'Reading List', bookmarkCount: 23 },\n]);\n</script>\n\n<template>\n    <AppLayout>\n        <template #header>\n            <div class=\"flex justify-between items-center\">\n                <h1 class=\"text-xl font-bold\">Folders</h1>\n                <div class=\"flex space-x-2\">\n                    <button class=\"bg-red-500 text-white px-3 py-1 rounded-md hover:bg-red-600\">\n                        New Folder\n                    </button>\n                </div>\n            </div>\n        </template>\n\n        <div class=\"mt-6\">\n            <div v-if=\"folders.length === 0\" class=\"text-center py-8 text-gray-500\">\n                No folders yet. Create your first folder to organize your bookmarks.\n            </div>\n\n            <ul v-else class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4\">\n                <li v-for=\"folder in folders\" :key=\"folder.id\"\n                    class=\"bg-white p-4 rounded-md shadow-sm hover:shadow-md transition-shadow border border-gray-200\">\n                    <div class=\"flex items-center\">\n                        <div class=\"mr-3 text-gray-400\">\n                            <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-8 w-8\" fill=\"none\" viewBox=\"0 0 24 24\"\n                                stroke=\"currentColor\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                                    d=\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z\" />\n                            </svg>\n                        </div>\n                        <div class=\"flex-1\">\n                            <h3 class=\"font-medium text-lg\">{{ folder.name }}</h3>\n                            <p class=\"text-sm text-gray-500\">{{ folder.bookmarkCount }} bookmarks</p>\n                        </div>\n                        <div class=\"flex space-x-1\">\n                            <button class=\"text-gray-400 hover:text-gray-600 p-1\" title=\"Edit\">\n                                <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-5 w-5\" viewBox=\"0 0 20 20\"\n                                    fill=\"currentColor\">\n                                    <path\n                                        d=\"M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z\" />\n                                </svg>\n                            </button>\n                            <button class=\"text-gray-400 hover:text-red-500 p-1\" title=\"Delete\">\n                                <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-5 w-5\" viewBox=\"0 0 20 20\"\n                                    fill=\"currentColor\">\n                                    <path fill-rule=\"evenodd\"\n                                        d=\"M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z\"\n                                        clip-rule=\"evenodd\" />\n                                </svg>\n                            </button>\n                        </div>\n                    </div>\n                </li>\n            </ul>\n        </div>\n    </AppLayout>\n</template>\n\n<style scoped>\n/* Additional custom styles if needed */\n</style>\n"
  },
  {
    "path": "webapp/src/views/HomeView.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport AppLayout from '@/components/layout/AppLayout.vue';\n\nconst bookmarks = ref([\n  { id: 1, title: 'Example Bookmark 1', url: 'https://example.com/1', tags: ['example', 'first'] },\n  { id: 2, title: 'Example Bookmark 2', url: 'https://example.com/2', tags: ['example', 'second'] },\n  { id: 3, title: 'Example Bookmark 3', url: 'https://example.com/3', tags: ['example', 'third'] },\n]);\n</script>\n\n<template>\n  <AppLayout>\n    <template #header>\n      <div class=\"flex justify-between items-center\">\n        <h1 class=\"text-xl font-bold text-gray-800 dark:text-white\">My Bookmarks</h1>\n        <div class=\"flex space-x-2\">\n          <button class=\"bg-red-500 text-white px-3 py-1 rounded-md hover:bg-red-600\">\n            Add Bookmark\n          </button>\n          <div class=\"relative\">\n            <input type=\"text\" placeholder=\"Search...\"\n              class=\"border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-md px-3 py-1 focus:outline-none focus:ring-2 focus:ring-red-500\" />\n          </div>\n        </div>\n      </div>\n    </template>\n\n    <div class=\"mt-6\">\n      <ul class=\"space-y-4\">\n        <li v-for=\"bookmark in bookmarks\" :key=\"bookmark.id\"\n          class=\"bg-white dark:bg-gray-800 p-4 rounded-md shadow-sm hover:shadow-md transition-shadow\">\n          <div class=\"flex justify-between\">\n            <a :href=\"bookmark.url\" target=\"_blank\"\n              class=\"text-blue-600 dark:text-blue-400 hover:underline font-medium\">{{\n                bookmark.title }}</a>\n            <div class=\"flex space-x-2\">\n              <button class=\"text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300\">\n                <span class=\"sr-only\">Edit</span>\n                <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-5 w-5\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n                  <path\n                    d=\"M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z\" />\n                </svg>\n              </button>\n              <button class=\"text-gray-500 dark:text-gray-400 hover:text-red-500\">\n                <span class=\"sr-only\">Delete</span>\n                <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-5 w-5\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n                  <path fill-rule=\"evenodd\"\n                    d=\"M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z\"\n                    clip-rule=\"evenodd\" />\n                </svg>\n              </button>\n            </div>\n          </div>\n          <div class=\"text-gray-500 dark:text-gray-400 text-sm mt-1 truncate\">{{ bookmark.url }}</div>\n          <div class=\"mt-2 flex flex-wrap gap-1\">\n            <span v-for=\"tag in bookmark.tags\" :key=\"tag\"\n              class=\"bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-xs px-2 py-1 rounded-full\">\n              {{ tag }}\n            </span>\n          </div>\n        </li>\n      </ul>\n    </div>\n  </AppLayout>\n</template>\n"
  },
  {
    "path": "webapp/src/views/LoginView.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, onMounted } from 'vue';\nimport { useRouter, useRoute } from 'vue-router';\nimport { useAuthStore } from '@/stores/auth';\nimport { useI18n } from 'vue-i18n';\n\n// Props for destination\nconst props = defineProps<{\n  dst?: string\n}>();\n\nconst { t } = useI18n();\nconst username = ref('');\nconst password = ref('');\nconst rememberMe = ref(false);\nconst errorMessage = ref('');\nconst isLoading = ref(false);\nconst router = useRouter();\nconst route = useRoute();\nconst authStore = useAuthStore();\n\n// Check if already authenticated on mount\nonMounted(async () => {\n  // If we already have a token, validate it\n  if (authStore.token) {\n    isLoading.value = true;\n    const isValid = await authStore.validateToken();\n    isLoading.value = false;\n\n    if (isValid) {\n      // If valid, redirect to destination or home\n      redirectAfterLogin();\n    }\n  }\n});\n\nconst login = async () => {\n  if (!username.value || !password.value) {\n    errorMessage.value = t('auth.login_failed');\n    return;\n  }\n\n  isLoading.value = true;\n  errorMessage.value = '';\n\n  try {\n    const success = await authStore.login(username.value, password.value, rememberMe.value);\n\n    if (success) {\n      // Redirect to destination or home\n      redirectAfterLogin();\n    } else {\n      // Display the error message from the auth store\n      errorMessage.value = authStore.error || t('auth.login_failed');\n    }\n  } catch (error: any) {\n    console.error('Login error:', error);\n    errorMessage.value = error.message || t('auth.login_failed');\n  } finally {\n    isLoading.value = false;\n  }\n};\n\n// Helper function to redirect after successful login\nconst redirectAfterLogin = () => {\n  // First check the store for a destination\n  let destination = authStore.getAndClearRedirectDestination();\n\n  // If no destination in store, check props and route query\n  if (!destination) {\n    destination = props.dst || route.query.dst as string || '/home';\n  }\n\n  // Redirect to the destination\n  router.push(destination);\n};\n</script>\n\n<template>\n  <div class=\"min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900\">\n    <div class=\"w-full max-w-md bg-white dark:bg-gray-800 shadow-lg rounded-md overflow-hidden\">\n      <!-- Logo and Header -->\n      <div class=\"bg-red-500 text-white py-6 px-4 text-center\">\n        <div class=\"text-4xl font-bold mb-1\">栞 shiori</div>\n        <div class=\"text-sm\">simple bookmark manager</div>\n      </div>\n\n      <!-- Login Form -->\n      <div class=\"p-8\">\n        <div v-if=\"errorMessage\"\n          class=\"mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-md text-sm text-center\">\n          {{ errorMessage }}\n        </div>\n\n        <div v-if=\"isLoading && authStore.token\"\n          class=\"mb-4 p-3 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-md text-sm text-center\">\n          {{ t('common.loading') }}\n        </div>\n\n        <form @submit.prevent=\"login\">\n          <div class=\"mb-6\">\n            <div class=\"flex items-center mb-4\">\n              <div class=\"w-28 text-right mr-4 text-gray-700 dark:text-gray-300\">{{ t('auth.username') }}:</div>\n              <input v-model=\"username\" type=\"text\"\n                class=\"flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500\"\n                :placeholder=\"t('auth.username')\" required />\n            </div>\n\n            <div class=\"flex items-center\">\n              <div class=\"w-28 text-right mr-4 text-gray-700 dark:text-gray-300\">{{ t('auth.password') }}:</div>\n              <input v-model=\"password\" type=\"password\"\n                class=\"flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500\"\n                :placeholder=\"t('auth.password')\" required />\n            </div>\n          </div>\n\n          <div class=\"flex justify-center items-center mb-6\">\n            <input id=\"remember-me\" v-model=\"rememberMe\" type=\"checkbox\"\n              class=\"h-4 w-4 text-red-500 focus:ring-red-500 border-gray-300 dark:border-gray-600 rounded\" />\n            <label for=\"remember-me\" class=\"ml-2 block text-sm text-gray-700 dark:text-gray-300\">{{\n              t('auth.remember_me') }}</label>\n          </div>\n\n          <div class=\"flex justify-center\">\n            <button type=\"submit\"\n              class=\"w-full bg-gray-800 dark:bg-gray-700 text-white py-2 px-4 rounded-md hover:bg-gray-700 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 uppercase font-medium\"\n              :disabled=\"isLoading\">\n              <span v-if=\"isLoading\">{{ t('common.loading') }}</span>\n              <span v-else>{{ t('auth.login') }}</span>\n            </button>\n          </div>\n        </form>\n      </div>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n/* Additional custom styles if needed */\n</style>\n"
  },
  {
    "path": "webapp/src/views/SettingsView.vue",
    "content": "<script setup lang=\"ts\">\nimport AppLayout from '@/components/layout/AppLayout.vue';\nimport { useI18n } from 'vue-i18n';\nimport { ref } from 'vue';\nimport { setLanguage } from '@/utils/i18n';\nimport type { SupportedLocale } from '@/utils/i18n';\n\nconst { t, locale } = useI18n();\n\nconst languages = [\n  { code: 'en' as SupportedLocale, name: 'English' },\n  { code: 'es' as SupportedLocale, name: 'Español' },\n  { code: 'fr' as SupportedLocale, name: 'Français' },\n  { code: 'de' as SupportedLocale, name: 'Deutsch' },\n  { code: 'ja' as SupportedLocale, name: '日本語' }\n];\n\nconst selectedLanguage = ref(locale.value as SupportedLocale);\n\nconst changeLanguage = (langCode: SupportedLocale) => {\n  selectedLanguage.value = langCode;\n  setLanguage(langCode);\n};\n</script>\n\n<template>\n  <AppLayout>\n    <template #header>\n      <div class=\"flex justify-between items-center\">\n        <h1 class=\"text-xl font-bold\">{{ t('settings.title') }}</h1>\n      </div>\n    </template>\n\n    <div class=\"bg-white dark:bg-gray-800 p-6 rounded-md shadow-sm\">\n      <div class=\"mb-6\">\n        <h2 class=\"text-lg font-semibold mb-4\">{{ t('settings.language') }}</h2>\n        <div class=\"grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4\">\n          <div v-for=\"language in languages\" :key=\"language.code\" @click=\"changeLanguage(language.code)\"\n            class=\"border rounded-md p-4 cursor-pointer transition-colors\" :class=\"selectedLanguage === language.code ?\n              'border-red-500 bg-red-50 dark:bg-red-900/20' :\n              'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700'\">\n            <div class=\"flex items-center\">\n              <div class=\"flex-1\">\n                <div class=\"font-medium\">{{ language.name }}</div>\n              </div>\n              <div v-if=\"selectedLanguage === language.code\" class=\"text-red-500\">\n                <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-5 w-5\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n                  <path fill-rule=\"evenodd\"\n                    d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z\"\n                    clip-rule=\"evenodd\" />\n                </svg>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </AppLayout>\n</template>\n"
  },
  {
    "path": "webapp/src/views/TagsView.vue",
    "content": "<script setup lang=\"ts\">\nimport AppLayout from '@/components/layout/AppLayout.vue';\nimport { useTagsStore } from '@/stores/tags';\nimport { useAuthStore } from '@/stores/auth';\nimport { ref, onMounted } from 'vue';\nimport { useRouter } from 'vue-router';\n\nconst tagsStore = useTagsStore();\nconst authStore = useAuthStore();\nconst router = useRouter();\nconst { tags, isLoading, error, fetchTags, createTag, updateTag, deleteTag } = tagsStore;\n\n// New tag form\nconst showNewTagForm = ref(false);\nconst newTagName = ref('');\nconst isSubmitting = ref(false);\nconst formError = ref<string | null>(null);\n\n// Edit tag form\nconst editingTagId = ref<number | null>(null);\nconst editTagName = ref('');\n\n// Load tags on component mount\nonMounted(async () => {\n  try {\n    if (authStore.isAuthenticated) {\n      await fetchTags();\n    } else {\n      const isValid = await authStore.validateToken();\n      if (isValid) {\n        await fetchTags();\n        console.log(\"tags\", tags);\n      } else {\n        authStore.setRedirectDestination('/tags');\n        router.push('/login');\n      }\n    }\n  } catch (err) {\n    // If we get an authentication error, redirect to login\n    if (err instanceof Error && err.message.includes('401')) {\n      authStore.setRedirectDestination('/tags');\n      router.push('/login');\n    }\n  }\n});\n\n// Handle API errors and authentication\nconst handleApiError = (err: any) => {\n  if (err instanceof Error && err.message.includes('401')) {\n    authStore.setRedirectDestination('/tags');\n    router.push('/login');\n  }\n};\n\n// Handle new tag submission\nconst handleCreateTag = async () => {\n  if (!newTagName.value.trim()) {\n    formError.value = 'Tag name cannot be empty';\n    return;\n  }\n\n  formError.value = null;\n  isSubmitting.value = true;\n\n  try {\n    await createTag(newTagName.value.trim());\n    newTagName.value = '';\n    showNewTagForm.value = false;\n  } catch (err) {\n    // Check for authentication errors\n    handleApiError(err);\n  } finally {\n    isSubmitting.value = false;\n  }\n};\n\n// Start editing a tag\nconst startEditTag = (id: number, name: string) => {\n  editingTagId.value = id;\n  editTagName.value = name;\n};\n\n// Cancel editing\nconst cancelEdit = () => {\n  editingTagId.value = null;\n  editTagName.value = '';\n};\n\n// Save edited tag\nconst handleUpdateTag = async (id: number) => {\n  if (!editTagName.value.trim()) {\n    return;\n  }\n\n  isSubmitting.value = true;\n\n  try {\n    await updateTag(id, editTagName.value.trim());\n    editingTagId.value = null;\n  } catch (err) {\n    // Check for authentication errors\n    handleApiError(err);\n  } finally {\n    isSubmitting.value = false;\n  }\n};\n\n// Delete tag confirmation\nconst tagToDelete = ref<number | null>(null);\nconst confirmDeleteTag = (id: number) => {\n  tagToDelete.value = id;\n};\n\n// Handle tag deletion\nconst handleDeleteTag = async () => {\n  if (tagToDelete.value === null) return;\n\n  try {\n    await deleteTag(tagToDelete.value);\n    tagToDelete.value = null;\n  } catch (err) {\n    // Check for authentication errors\n    handleApiError(err);\n  }\n};\n</script>\n\n<template>\n  <AppLayout>\n    <template #header>\n      <div class=\"flex justify-between items-center\">\n        <h1 class=\"text-xl font-bold\">Tags</h1>\n        <div class=\"flex space-x-2\">\n          <button @click=\"showNewTagForm = !showNewTagForm\"\n            class=\"bg-blue-500 text-white px-3 py-1 rounded-md hover:bg-blue-600 transition\">\n            {{ showNewTagForm ? 'Cancel' : 'New Tag' }}\n          </button>\n        </div>\n      </div>\n    </template>\n\n    <!-- New Tag Form -->\n    <div v-if=\"showNewTagForm\" class=\"bg-white p-4 rounded-md shadow-sm mb-6\">\n      <h2 class=\"text-lg font-medium mb-3\">Create New Tag</h2>\n      <form @submit.prevent=\"handleCreateTag\" class=\"flex flex-col space-y-3\">\n        <div>\n          <label for=\"tagName\" class=\"block text-sm font-medium text-gray-700 mb-1\">Tag Name</label>\n          <input id=\"tagName\" v-model=\"newTagName\" type=\"text\"\n            class=\"w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500\"\n            placeholder=\"Enter tag name\" :disabled=\"isSubmitting\" />\n          <p v-if=\"formError\" class=\"mt-1 text-sm text-red-600\">{{ formError }}</p>\n        </div>\n        <div class=\"flex justify-end space-x-2\">\n          <button type=\"button\" @click=\"showNewTagForm = false\"\n            class=\"px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50\" :disabled=\"isSubmitting\">\n            Cancel\n          </button>\n          <button type=\"submit\"\n            class=\"px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50\"\n            :disabled=\"isSubmitting\">\n            Create\n          </button>\n        </div>\n      </form>\n    </div>\n\n    <!-- Error Message -->\n    <div v-if=\"error\" class=\"bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-6\">\n      {{ error }}\n    </div>\n\n    <!-- Loading State -->\n    <div v-if=\"isLoading && !tags.length\" class=\"bg-white p-6 rounded-md shadow-sm flex justify-center\">\n      <div class=\"animate-pulse text-gray-500\">Loading tags...</div>\n    </div>\n\n    <!-- Empty State -->\n    <div v-else-if=\"!isLoading && !tags.length\" class=\"bg-white p-6 rounded-md shadow-sm text-center\">\n      <p class=\"text-gray-500 mb-4\">No tags found. Create your first tag to organize your bookmarks.</p>\n      <button v-if=\"!showNewTagForm\" @click=\"showNewTagForm = true\"\n        class=\"px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600\">\n        Create Tag\n      </button>\n    </div>\n\n    <!-- Tag List -->\n    <div v-else class=\"mt-6\">\n      <ul class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4\">\n        <li v-for=\"tag in tags\" :key=\"tag.id\"\n          class=\"bg-white p-4 rounded-md shadow-sm hover:shadow-md transition-shadow border border-gray-200\">\n          <!-- Edit Mode -->\n          <div v-if=\"editingTagId === tag.id\" class=\"flex items-center\">\n            <input v-model=\"editTagName\" type=\"text\"\n              class=\"flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500\"\n              :disabled=\"isSubmitting\" />\n            <div class=\"flex ml-2 space-x-1\">\n              <button @click=\"handleUpdateTag(tag.id!)\" class=\"text-blue-500 hover:text-blue-700 p-1\"\n                :disabled=\"isSubmitting\" title=\"Save\">\n                <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-5 w-5\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n                  <path fill-rule=\"evenodd\"\n                    d=\"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z\"\n                    clip-rule=\"evenodd\" />\n                </svg>\n              </button>\n              <button @click=\"cancelEdit\" class=\"text-gray-500 hover:text-gray-700 p-1\" :disabled=\"isSubmitting\"\n                title=\"Cancel\">\n                <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-5 w-5\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n                  <path fill-rule=\"evenodd\"\n                    d=\"M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z\"\n                    clip-rule=\"evenodd\" />\n                </svg>\n              </button>\n            </div>\n          </div>\n\n          <!-- View Mode -->\n          <div v-else class=\"flex items-center\">\n            <div class=\"mr-3 text-blue-400\">\n              <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-6 w-6\" fill=\"none\" viewBox=\"0 0 24 24\"\n                stroke=\"currentColor\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                  d=\"M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z\" />\n              </svg>\n            </div>\n            <div class=\"flex-1\">\n              <h3 class=\"font-medium text-lg\">{{ tag.name }}</h3>\n              <p class=\"text-sm text-gray-500\">{{ tag.bookmarkCount || 0 }} bookmarks</p>\n            </div>\n            <div class=\"flex space-x-1\">\n              <button @click=\"startEditTag(tag.id!, tag.name!)\" class=\"text-gray-400 hover:text-gray-600 p-1\"\n                title=\"Edit\">\n                <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-5 w-5\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n                  <path\n                    d=\"M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z\" />\n                </svg>\n              </button>\n              <button @click=\"confirmDeleteTag(tag.id!)\" class=\"text-gray-400 hover:text-red-500 p-1\" title=\"Delete\">\n                <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-5 w-5\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n                  <path fill-rule=\"evenodd\"\n                    d=\"M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z\"\n                    clip-rule=\"evenodd\" />\n                </svg>\n              </button>\n            </div>\n          </div>\n        </li>\n      </ul>\n    </div>\n\n    <!-- Delete Confirmation Modal -->\n    <div v-if=\"tagToDelete !== null\" class=\"fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50\">\n      <div class=\"bg-white rounded-lg p-6 max-w-md w-full\">\n        <h3 class=\"text-lg font-medium mb-4\">Confirm Delete</h3>\n        <p class=\"mb-6\">Are you sure you want to delete this tag? This action cannot be undone.</p>\n        <div class=\"flex justify-end space-x-3\">\n          <button @click=\"tagToDelete = null\"\n            class=\"px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50\">\n            Cancel\n          </button>\n          <button @click=\"handleDeleteTag\" class=\"px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600\">\n            Delete\n          </button>\n        </div>\n      </div>\n    </div>\n  </AppLayout>\n</template>\n"
  },
  {
    "path": "webapp/tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\nexport default {\n  content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],\n  darkMode: 'media',\n  theme: {\n    extend: {\n      colors: {\n        primary: '#f44336',\n      },\n    },\n  },\n  plugins: [],\n}\n"
  },
  {
    "path": "webapp/tsconfig.app.json",
    "content": "{\n  \"extends\": \"@vue/tsconfig/tsconfig.dom.json\",\n  \"include\": [\"env.d.ts\", \"src/**/*\", \"src/**/*.vue\"],\n  \"exclude\": [\"src/**/__tests__/*\"],\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "webapp/tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.node.json\"\n    },\n    {\n      \"path\": \"./tsconfig.app.json\"\n    },\n    {\n      \"path\": \"./tsconfig.vitest.json\"\n    }\n  ]\n}\n"
  },
  {
    "path": "webapp/tsconfig.node.json",
    "content": "{\n  \"extends\": \"@tsconfig/node22/tsconfig.json\",\n  \"include\": [\n    \"vite.config.*\",\n    \"vitest.config.*\",\n    \"cypress.config.*\",\n    \"nightwatch.conf.*\",\n    \"playwright.config.*\",\n    \"eslint.config.*\"\n  ],\n  \"compilerOptions\": {\n    \"noEmit\": true,\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Bundler\",\n    \"types\": [\"node\"]\n  }\n}\n"
  },
  {
    "path": "webapp/tsconfig.vitest.json",
    "content": "{\n  \"extends\": \"./tsconfig.app.json\",\n  \"include\": [\"src/**/__tests__/*\", \"env.d.ts\"],\n  \"exclude\": [],\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.vitest.tsbuildinfo\",\n\n    \"lib\": [],\n    \"types\": [\"node\", \"jsdom\"]\n  }\n}\n"
  },
  {
    "path": "webapp/vite.config.ts",
    "content": "import { fileURLToPath, URL } from 'node:url'\n\nimport { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\nimport vueDevTools from 'vite-plugin-vue-devtools'\nimport tailwindcss from '@tailwindcss/vite'\n\n// https://vite.dev/config/\nexport default defineConfig({\n  plugins: [\n    vue(),\n    vueDevTools(),\n    tailwindcss(),\n  ],\n  resolve: {\n    alias: {\n      '@': fileURLToPath(new URL('./src', import.meta.url))\n    },\n  },\n  css: {\n    devSourcemap: true,\n  },\n})\n"
  },
  {
    "path": "webapp/vitest.config.ts",
    "content": "import { fileURLToPath } from 'node:url'\nimport { mergeConfig, defineConfig, configDefaults } from 'vitest/config'\nimport viteConfig from './vite.config'\n\nexport default mergeConfig(\n  viteConfig,\n  defineConfig({\n    test: {\n      environment: 'jsdom',\n      exclude: [...configDefaults.exclude, 'e2e/**'],\n      root: fileURLToPath(new URL('./', import.meta.url)),\n    },\n  }),\n)\n"
  }
]